From 24a2c8ab531a2987bcbc9457f5710e13fabd4129 Mon Sep 17 00:00:00 2001 From: CalJosKos <120157396+CalJosKos@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:52:13 -0800 Subject: [PATCH 1/5] Allow inviting members after project creation (#699) --- utils/languoidUtils.ts | 65 +++++++++++++------------------ views/new/NextGenProjectsView.tsx | 12 +++--- views/new/OnboardingFlow.tsx | 10 ++--- 3 files changed, 37 insertions(+), 50 deletions(-) diff --git a/utils/languoidUtils.ts b/utils/languoidUtils.ts index a9bb4af2d..1736851ee 100644 --- a/utils/languoidUtils.ts +++ b/utils/languoidUtils.ts @@ -22,8 +22,8 @@ export interface CreateLanguoidResult { } /** - * Creates a new languoid in local storage (for offline use) - * When the user comes online, PowerSync will upload it to the server + * Creates a new languoid in synced storage + * When offline, PowerSync queues for upload when user comes online * * @param params - Languoid creation parameters * @returns The created languoid ID @@ -39,16 +39,16 @@ export async function createLanguoidOffline( ui_ready = false } = params; - // Check if a languoid with this name already exists locally - const languoidLocal = resolveTable('languoid', { localOverride: true }); + // Check if a languoid with this name already exists in synced table + const languoidSynced = resolveTable('languoid', { localOverride: false }); const existing = await system.db .select() - .from(languoidLocal) - .where(eq(languoidLocal.name, name)) + .from(languoidSynced) + .where(eq(languoidSynced.name, name)) .limit(1); if (existing.length > 0 && existing[0]) { - // Languoid already exists locally + // Languoid already exists return { languoid_id: existing[0].id, created: false @@ -58,10 +58,10 @@ export async function createLanguoidOffline( // Generate a new ID for the languoid const languoidId = uuid.v4() as string; - // Create the languoid in local storage + // Create the languoid in synced storage await system.db.transaction(async (tx) => { // Insert languoid - await tx.insert(languoidLocal).values({ + await tx.insert(languoidSynced).values({ id: languoidId, name: name.trim(), level, @@ -73,12 +73,12 @@ export async function createLanguoidOffline( // If iso639_3 code is provided, create languoid_source record if (iso639_3 && iso639_3.trim() !== '') { - const languoidSourceLocal = resolveTable('languoid_source', { - localOverride: true + const languoidSourceSynced = resolveTable('languoid_source', { + localOverride: false }); const sourceId = uuid.v4() as string; - await tx.insert(languoidSourceLocal).values({ + await tx.insert(languoidSourceSynced).values({ id: sourceId, name: 'iso639-3', languoid_id: languoidId, @@ -98,8 +98,7 @@ export async function createLanguoidOffline( /** * Finds or creates a languoid by name - * First checks if a languoid with the given name exists (locally or synced) - * If not found, creates a new one offline + * Checks synced table, creates in synced table if not found * * @param name - The languoid name to find or create * @param creator_id - The user creating the languoid @@ -115,31 +114,19 @@ export async function findOrCreateLanguoidByName( const trimmedName = name.trim(); - // First check synced table (might already exist from server) + // Check synced table (all languoids are now created in synced) const languoidSynced = resolveTable('languoid', { localOverride: false }); - const [existingSynced] = await system.db + const [existing] = await system.db .select() .from(languoidSynced) .where(eq(languoidSynced.name, trimmedName)) .limit(1); - if (existingSynced) { - return existingSynced.id; + if (existing) { + return existing.id; } - // Check local table - const languoidLocal = resolveTable('languoid', { localOverride: true }); - const [existingLocal] = await system.db - .select() - .from(languoidLocal) - .where(eq(languoidLocal.name, trimmedName)) - .limit(1); - - if (existingLocal) { - return existingLocal.id; - } - - // Not found - create new languoid offline + // Not found - create new languoid in synced table const result = await createLanguoidOffline({ name: trimmedName, level: 'language', @@ -151,7 +138,7 @@ export async function findOrCreateLanguoidByName( /** * Creates a project_language_link with languoid_id - * Handles both offline and online scenarios + * Creates in synced table for immediate project publishing * * PK is now (project_id, languoid_id, language_type) - languoid_id is required * @@ -168,19 +155,19 @@ export async function createProjectLanguageLinkWithLanguoid( creator_id: string, language_id?: string // Optional for backward compatibility ): Promise { - const projectLanguageLinkLocal = resolveTable('project_language_link', { - localOverride: true + const projectLanguageLinkSynced = resolveTable('project_language_link', { + localOverride: false }); // Check if link already exists using new PK (project_id, languoid_id, language_type) const existing = await system.db .select() - .from(projectLanguageLinkLocal) + .from(projectLanguageLinkSynced) .where( and( - eq(projectLanguageLinkLocal.project_id, project_id), - eq(projectLanguageLinkLocal.languoid_id, languoid_id), - eq(projectLanguageLinkLocal.language_type, language_type) + eq(projectLanguageLinkSynced.project_id, project_id), + eq(projectLanguageLinkSynced.languoid_id, languoid_id), + eq(projectLanguageLinkSynced.language_type, language_type) ) ) .limit(1); @@ -191,7 +178,7 @@ export async function createProjectLanguageLinkWithLanguoid( } // Create new link - languoid_id is required (part of PK), language_id is optional - await system.db.insert(projectLanguageLinkLocal).values({ + await system.db.insert(projectLanguageLinkSynced).values({ project_id, language_id: language_id || null, // Optional - for backward compatibility languoid_id, diff --git a/views/new/NextGenProjectsView.tsx b/views/new/NextGenProjectsView.tsx index 0e5798370..13b85eb5d 100644 --- a/views/new/NextGenProjectsView.tsx +++ b/views/new/NextGenProjectsView.tsx @@ -115,12 +115,12 @@ export default function NextGenProjectsView() { throw new Error('Must be logged in to create projects'); } - // insert into local storage + // Insert into synced tables (project is published immediately for invites) await db.transaction(async (tx) => { // Create project (target_language_id is deprecated but still required by schema) const { target_languoid_id, ...projectValues } = values; const [newProject] = await tx - .insert(resolveTable('project', { localOverride: true })) + .insert(resolveTable('project', { localOverride: false })) .values({ ...projectValues, template: projectValues.template, @@ -134,7 +134,7 @@ export default function NextGenProjectsView() { // Create profile_project_link await tx .insert( - resolveTable('profile_project_link', { localOverride: true }) + resolveTable('profile_project_link', { localOverride: false }) ) .values({ id: `${currentUser.id}_${newProject.id}`, @@ -146,13 +146,13 @@ export default function NextGenProjectsView() { // Create project_language_link with languoid_id // PK is (project_id, languoid_id, language_type) - language_id is optional - const projectLanguageLinkLocal = resolveTable( + const projectLanguageLinkSynced = resolveTable( 'project_language_link', { - localOverride: true + localOverride: false } ); - await tx.insert(projectLanguageLinkLocal).values({ + await tx.insert(projectLanguageLinkSynced).values({ project_id: newProject.id, language_id: null, // Optional - for backward compatibility languoid_id: target_languoid_id, // Required - part of PK diff --git a/views/new/OnboardingFlow.tsx b/views/new/OnboardingFlow.tsx index 571cdaa65..57e2b7916 100644 --- a/views/new/OnboardingFlow.tsx +++ b/views/new/OnboardingFlow.tsx @@ -130,12 +130,12 @@ export function OnboardingFlow({ visible, onClose }: OnboardingFlowProps) { } }); - // Create language mutation + // Create language mutation (uses synced table for immediate project publishing) const { mutateAsync: createLanguage, isPending: isCreatingLanguage } = useMutation({ mutationFn: async (values: LanguageFormData) => { const newLanguage = await db - .insert(resolveTable('language', { localOverride: true })) + .insert(resolveTable('language', { localOverride: false })) .values({ id: uuid.v4(), native_name: values.native_name, @@ -153,7 +153,7 @@ export function OnboardingFlow({ visible, onClose }: OnboardingFlowProps) { } }); - // Create project mutation + // Create project mutation (uses synced tables for immediate project publishing) const { mutateAsync: createProject, isPending: isCreatingProject } = useMutation({ mutationFn: async (languageId: string) => { @@ -171,7 +171,7 @@ export function OnboardingFlow({ visible, onClose }: OnboardingFlowProps) { | undefined; await db.transaction(async (tx) => { const [project] = await tx - .insert(resolveTable('project', { localOverride: true })) + .insert(resolveTable('project', { localOverride: false })) .values({ name: projectName, template: projectType!, @@ -187,7 +187,7 @@ export function OnboardingFlow({ visible, onClose }: OnboardingFlowProps) { await tx .insert( - resolveTable('profile_project_link', { localOverride: true }) + resolveTable('profile_project_link', { localOverride: false }) ) .values({ id: `${currentUser!.id}_${project.id}`, From 0ad4fae4cdf57c04fd8ccdbaf8ce236b52fc089b Mon Sep 17 00:00:00 2001 From: Rafael Winter Date: Sat, 24 Jan 2026 12:01:36 -0800 Subject: [PATCH 2/5] Introducing Verse Labels to Bible Projects (#701) --- app/_layout.tsx | 5 + components/AddVerseLabelButton.tsx | 37 + components/ArrayInsertionWheel.tsx | 136 +- components/AssetsDeletionDrawer.tsx | 122 + components/QuestionModal.tsx | 78 + components/SectionSeparator.tsx | 42 + components/SortListCombo.tsx | 158 + components/TagModal.tsx | 296 ++ components/VerseAssigner.tsx | 378 ++ components/VersePill.tsx | 53 + components/VerseRangeSelector.tsx | 241 ++ components/VerseSeparator.tsx | 179 + components/ui/select.tsx | 32 +- constants/bibleStructure.ts | 191 +- database_services/assetService.ts | 138 +- database_services/tagCache.ts | 8 + database_services/tagService.ts | 107 +- db/drizzleSchemaColumns.ts | 1 + hooks/db/useAssets.ts | 398 +- hooks/db/useSearchTags.ts | 32 + hooks/useAppNavigation.ts | 18 +- hooks/useTagStore.ts | 86 + package-lock.json | 35 +- package.json | 1 + services/localizations.ts | 60 +- store/localStore.ts | 10 + ...0251214120000_add_asset_metadata_field.sql | 11 + views/AppView.tsx | 18 + views/SettingsView.tsx | 18 + views/new/AssetListItem.tsx | 109 +- views/new/BibleAssetListItem.tsx | 460 ++ views/new/BibleAssetsView.tsx | 3730 +++++++++++++++++ views/new/BibleBookList.tsx | 31 + views/new/NextGenAssetsView.tsx | 43 +- views/new/recording/components/AssetCard.tsx | 4 +- .../components/BibleRecordingView.tsx | 3059 ++++++++++++++ .../components/BibleSelectionControls.tsx | 71 + .../recording/components/LabeledAssetCard.tsx | 438 ++ .../components/SelectionControls.tsx | 15 +- .../recording/services/recordingService.ts | 15 +- 40 files changed, 10735 insertions(+), 129 deletions(-) create mode 100644 components/AddVerseLabelButton.tsx create mode 100644 components/AssetsDeletionDrawer.tsx create mode 100644 components/QuestionModal.tsx create mode 100644 components/SectionSeparator.tsx create mode 100644 components/SortListCombo.tsx create mode 100644 components/TagModal.tsx create mode 100644 components/VerseAssigner.tsx create mode 100644 components/VersePill.tsx create mode 100644 components/VerseRangeSelector.tsx create mode 100644 components/VerseSeparator.tsx create mode 100644 database_services/tagCache.ts create mode 100644 hooks/db/useSearchTags.ts create mode 100644 hooks/useTagStore.ts create mode 100644 supabase/migrations/20251214120000_add_asset_metadata_field.sql create mode 100644 views/new/BibleAssetListItem.tsx create mode 100644 views/new/BibleAssetsView.tsx create mode 100644 views/new/recording/components/BibleRecordingView.tsx create mode 100644 views/new/recording/components/BibleSelectionControls.tsx create mode 100644 views/new/recording/components/LabeledAssetCard.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index d717c04f7..0f8c4ab12 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -78,6 +78,11 @@ export default function RootLayout() { }); useEffect(() => { + // async function init() { + // await tagService.preloadTagsIntoCache(); + // } + // void init(); + if (Platform.OS === 'web') return; console.log('[_layout] Setting up deep link handler'); diff --git a/components/AddVerseLabelButton.tsx b/components/AddVerseLabelButton.tsx new file mode 100644 index 000000000..d9c5e466a --- /dev/null +++ b/components/AddVerseLabelButton.tsx @@ -0,0 +1,37 @@ +import { PlusCircleIcon } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface AddVerseLabelButtonProps { + onPress: () => void; + disabled?: boolean; + className?: string; +} + +export function AddVerseLabelButton({ + onPress, + disabled = false, + className = '' +}: AddVerseLabelButtonProps) { + return ( + + {/* */} + + + + + Add verse + + + {/* */} + + ); +} diff --git a/components/ArrayInsertionWheel.tsx b/components/ArrayInsertionWheel.tsx index 47c4e7a8e..9680fd038 100644 --- a/components/ArrayInsertionWheel.tsx +++ b/components/ArrayInsertionWheel.tsx @@ -12,47 +12,66 @@ export interface ArrayInsertionWheelHandle { scrollItemToTop: (index: number, animated?: boolean) => void; } -interface ArrayInsertionWheelProps { - children: React.ReactNode[]; +interface ArrayInsertionWheelPropsBase { value: number; // 0..N insertion boundary onChange?: (index: number) => void; rowHeight: number; className?: string; topInset?: number; // unused in native wheel, kept for API parity bottomInset?: number; // unused in native wheel, kept for API parity + boundaryComponent?: React.ReactNode; +} + +// API 1: Eager rendering with children (backward compatible) +interface ArrayInsertionWheelPropsEager extends ArrayInsertionWheelPropsBase { + children: React.ReactNode[]; + data?: never; + renderItem?: never; } -function ArrayInsertionWheelInternal( - { - children, +// API 2: Lazy rendering with data + renderItem (optimized) +interface ArrayInsertionWheelPropsLazy extends ArrayInsertionWheelPropsBase { + children?: never; + data: T[]; + renderItem: (item: T, index: number) => React.ReactElement; +} + +type ArrayInsertionWheelProps = + | ArrayInsertionWheelPropsEager + | ArrayInsertionWheelPropsLazy; + +function ArrayInsertionWheelInternal( + props: ArrayInsertionWheelProps, + ref: React.Ref +) { + const { value, onChange, rowHeight, className, topInset = 0, - bottomInset = 0 - }: ArrayInsertionWheelProps, - ref: React.Ref -) { - const itemCount = children.length + 1; // extra end boundary + bottomInset = 0, + boundaryComponent + } = props; + + // Determine which API is being used + const isLazyMode = 'data' in props && props.data !== undefined; + + // Calculate item count based on mode + let itemCount: number; + if (isLazyMode) { + itemCount = props.data.length + 1; + } else { + // Eager mode - children is guaranteed by type + itemCount = props.children.length + 1; + } + const clampedValue = Math.max(0, Math.min(itemCount - 1, value)); // Stabilize clampedValue to prevent unnecessary WheelPicker updates - const prevClampedRef = React.useRef(clampedValue); const stableClampedValue = React.useMemo(() => { - if (prevClampedRef.current !== clampedValue) { - console.log( - '๐Ÿ“Š Wheel value changed:', - prevClampedRef.current, - 'โ†’', - clampedValue, - '| itemCount:', - itemCount - ); - prevClampedRef.current = clampedValue; - } return clampedValue; - }, [clampedValue, itemCount]); + }, [clampedValue]); // Debug logging to trace clamping React.useEffect(() => { @@ -67,7 +86,7 @@ function ArrayInsertionWheelInternal( } }, [value, clampedValue, itemCount]); - const data = React.useMemo[]>( + const pickerData = React.useMemo[]>( () => Array.from({ length: itemCount }, (_, i) => ({ value: i })), [itemCount] ); @@ -116,22 +135,50 @@ function ArrayInsertionWheelInternal( [stableClampedValue, itemCount, onChange] ); - const renderItem = React.useCallback( - ({ item }: { item: PickerItem }) => { + const renderItemInternal = React.useCallback( + ({ item }: { item: PickerItem }): React.ReactElement => { const i = item.value; + // Calculate data length based on mode + let dataLength: number; + if (isLazyMode) { + dataLength = props.data.length; + } else { + dataLength = props.children.length; + } + // Render actual items (not the final boundary) - if (i < children.length) { - return ( - - {children[i]} - - ); + if (i < dataLength) { + if (isLazyMode) { + // Lazy mode: call renderItem with data item + const dataItem = props.data[i]; + if (!dataItem) { + // Defensive: should never happen, but TypeScript needs this + return ; + } + return ( + + {props.renderItem(dataItem, i)} + + ); + } else { + // Eager mode: use pre-created children + const child = props.children[i]; + return ( + + {child} + + ); + } } - // Final boundary (i === children.length) + // Final boundary (i === dataLength) // When empty (0 items), this is position 0 - the only insertion point // When non-empty, this is position N - insert after all items + if (boundaryComponent) { + return <>{boundaryComponent}; + } + return ( ); }, - [children, rowHeight] + [isLazyMode, props, rowHeight, boundaryComponent] ); return ( @@ -174,7 +221,7 @@ function ArrayInsertionWheelInternal( onLayout={onContainerLayout} > onChange?.(item.value)} // Constrain height to an exact multiple of rowHeight so overlay aligns style={{ height: wheelHeight }} - renderItem={renderItem} + renderItem={renderItemInternal} renderItemContainer={({ key, ...props }) => ( )} @@ -209,13 +256,12 @@ function ArrayInsertionWheelInternal( ); } -export default React.forwardRef< - ArrayInsertionWheelHandle, - ArrayInsertionWheelProps +const ArrayInsertionWheel = React.forwardRef(ArrayInsertionWheelInternal) as < + T = unknown >( - ArrayInsertionWheelInternal as unknown as ( - props: ArrayInsertionWheelProps & { - ref?: React.Ref; - } - ) => React.ReactElement -); + props: ArrayInsertionWheelProps & { + ref?: React.Ref; + } +) => React.ReactElement; + +export default ArrayInsertionWheel; diff --git a/components/AssetsDeletionDrawer.tsx b/components/AssetsDeletionDrawer.tsx new file mode 100644 index 000000000..6852a6c1d --- /dev/null +++ b/components/AssetsDeletionDrawer.tsx @@ -0,0 +1,122 @@ +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { Input } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import { useLocalization } from '@/hooks/useLocalization'; +import { AlertTriangleIcon } from 'lucide-react-native'; +import React from 'react'; +import { View } from 'react-native'; +import { Icon } from './ui/icon'; + +interface AssetsDeletionDrawerProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void | Promise; + title: string; + description: string; + confirmationString: string; // String that user must type to confirm deletion +} + +export const AssetsDeletionDrawer: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmationString +}) => { + const { t } = useLocalization(); + const [inputValue, setInputValue] = React.useState(''); + const [isExecuting, setIsExecuting] = React.useState(false); + + // Reset input when drawer opens + React.useEffect(() => { + if (isOpen) { + setInputValue(''); + setIsExecuting(false); + } + }, [isOpen]); + + const handleConfirm = async () => { + if (inputValue !== confirmationString || isExecuting) return; + + setIsExecuting(true); + try { + await onConfirm(); + onClose(); + } catch (error) { + console.error('Error executing deletion:', error); + } finally { + setIsExecuting(false); + } + }; + + const isButtonDisabled = inputValue !== confirmationString || isExecuting; + + return ( + !open && onClose()}> + + + + + + {title} + + {description} + + + + + + {t('typeToConfirm').replace('{text}', `"${confirmationString}"`)} + + + + + + + + + + + + + + ); +}; diff --git a/components/QuestionModal.tsx b/components/QuestionModal.tsx new file mode 100644 index 000000000..1ce0e1f60 --- /dev/null +++ b/components/QuestionModal.tsx @@ -0,0 +1,78 @@ +import { Button } from '@/components/ui/button'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle +} from '@/components/ui/drawer'; +import { Text } from '@/components/ui/text'; +import React from 'react'; +import { View } from 'react-native'; + +interface QuestionModalProps { + visible: boolean; + title: string; + description: string; + onYes: () => void; + onNo: () => void; + onClose?: () => void; +} + +export function QuestionModal({ + visible, + title, + description, + onYes, + onNo, + onClose +}: QuestionModalProps) { + const [isOpen, setIsOpen] = React.useState(visible); + + // Sync internal state with prop + React.useEffect(() => { + setIsOpen(visible); + }, [visible]); + + const handleYes = () => { + onYes(); + setIsOpen(false); + onClose?.(); + }; + + const handleNo = () => { + onNo(); + setIsOpen(false); + onClose?.(); + }; + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (!open) { + onClose?.(); + } + }; + + return ( + + + + {title} + {description} + + + + + + + + + + + ); +} diff --git a/components/SectionSeparator.tsx b/components/SectionSeparator.tsx new file mode 100644 index 000000000..6599302c1 --- /dev/null +++ b/components/SectionSeparator.tsx @@ -0,0 +1,42 @@ +import { Text, View } from 'react-native'; + +type SectionSeparatorVariant = 'default' | 'small' | 'xs'; + +interface SectionSeparatorProps { + text: string; + variant?: SectionSeparatorVariant; + className?: string; + textClassName?: string; +} + +const variantStyles = { + default: { + line: 'bg-primary', + text: 'text-base text-primary' + }, + small: { + line: 'bg-primary/60', + text: 'text-sm text-primary/60' + }, + xs: { + line: 'bg-primary/40', + text: 'text-xs text-primary/40' + } +}; + +export function SectionSeparator({ + text, + variant = 'default', + className = '', + textClassName = '' +}: SectionSeparatorProps) { + const styles = variantStyles[variant]; + + return ( + + + {text} + + + ); +} diff --git a/components/SortListCombo.tsx b/components/SortListCombo.tsx new file mode 100644 index 000000000..bb85dbd68 --- /dev/null +++ b/components/SortListCombo.tsx @@ -0,0 +1,158 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + getOptionFromValue +} from '@/components/ui/select'; +import { Text } from '@/components/ui/text'; +import React from 'react'; +import { View } from 'react-native'; + +export interface SortListComboProps { + options: string[]; + onSelectionChange?: (selection: { + first: string | undefined; + second: string | undefined; + third: string | undefined; + }) => void; + className?: string; + placeholder1?: string; + placeholder2?: string; + placeholder3?: string; +} + +export function SortListCombo({ + options, + onSelectionChange, + className = '', + placeholder1 = 'Select first option', + placeholder2 = 'Select second option', + placeholder3 = 'Select third option' +}: SortListComboProps) { + const [selected1, setSelected1] = React.useState(); + const [selected2, setSelected2] = React.useState(); + const [selected3, setSelected3] = React.useState(); + + // Notify parent when selection changes + React.useEffect(() => { + onSelectionChange?.({ + first: selected1, + second: selected2, + third: selected3 + }); + }, [selected1, selected2, selected3, onSelectionChange]); + + // Filter options for second combobox (exclude selected1) + const options2 = React.useMemo(() => { + return options.filter((option) => option !== selected1); + }, [options, selected1]); + + // Filter options for third combobox (exclude selected1 and selected2) + const options3 = React.useMemo(() => { + return options.filter( + (option) => option !== selected1 && option !== selected2 + ); + }, [options, selected1, selected2]); + + const handleFirstChange = (value: string) => { + setSelected1(value); + setSelected2(undefined); + setSelected3(undefined); + }; + + const handleSecondChange = (value: string) => { + setSelected2(value); + setSelected3(undefined); + }; + + return ( + + Group by + + + + + + + + + + + + + ); +} diff --git a/components/TagModal.tsx b/components/TagModal.tsx new file mode 100644 index 000000000..8edd0646b --- /dev/null +++ b/components/TagModal.tsx @@ -0,0 +1,296 @@ +/** + * TagModal - Modal for assigning tags + */ + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Text } from '@/components/ui/text'; +import type { Tag } from '@/database_services/tagCache'; +import { useSearchTags } from '@/hooks/db/useSearchTags'; +import React from 'react'; +import type { TextInput } from 'react-native'; +import { Modal, Pressable, ScrollView, View } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming +} from 'react-native-reanimated'; + +interface TagModalProps { + isVisible: boolean; + selectedTag?: Tag; + initialSelectedTags?: Tag[]; + searchTerm?: string; + limit?: number; + onClose: () => void; + onAssignTags: (tags: Tag[]) => void; +} + +export function TagModal({ + isVisible, + selectedTag, + initialSelectedTags = [], + searchTerm = '', + limit = 20, + onClose, + onAssignTags +}: TagModalProps) { + const [localSearchTerm, setLocalSearchTerm] = React.useState(searchTerm); + const [selectedTags, setSelectedTags] = React.useState(() => { + if (selectedTag) return [selectedTag]; + return initialSelectedTags; + }); + const [modalVisible, setModalVisible] = React.useState(false); + const inputRef = React.useRef(null); + + const { tags = [], isTagsLoading } = useSearchTags({ + searchTerm: searchTerm || localSearchTerm, + maxResults: limit, + enabled: isVisible + }); + const opacity = useSharedValue(0); + const scale = useSharedValue(0.9); + + // Reset state when modal opens or searchTerm changes + React.useEffect(() => { + if (isVisible) { + setLocalSearchTerm(searchTerm); + if (selectedTag) { + setSelectedTags([selectedTag]); + } else { + setSelectedTags(initialSelectedTags); + } + } + }, [isVisible, searchTerm, selectedTag, initialSelectedTags]); + + // Handle modal visibility with exit animation + React.useEffect(() => { + if (isVisible) { + // Show modal immediately + setModalVisible(true); + // Quick, snappy animation (Emil Kowalski style) + opacity.value = withTiming(1, { + duration: 150, + easing: Easing.out(Easing.ease) + }); + scale.value = withTiming(1, { + duration: 150, + easing: Easing.out(Easing.ease) + }); + + // Focus after animation completes + const focusTimer = setTimeout(() => { + inputRef.current?.focus(); + }, 150); + + // Backup focus attempt + const backupTimer = setTimeout(() => { + inputRef.current?.focus(); + }, 200); + + return () => { + clearTimeout(focusTimer); + clearTimeout(backupTimer); + }; + } else { + // Exit animation - quick fade out (ease-out for responsiveness) + opacity.value = withTiming(0, { + duration: 100, + easing: Easing.out(Easing.ease) + }); + scale.value = withTiming(0.9, { + duration: 100, + easing: Easing.out(Easing.ease) + }); + + // Hide modal after exit animation completes + const hideTimer = setTimeout(() => { + setModalVisible(false); + }, 100); + + return () => { + clearTimeout(hideTimer); + }; + } + }, [isVisible, opacity, scale]); + + const handleAssign = () => { + onAssignTags(selectedTags); + onClose(); + }; + + const handleTagToggle = (tag: Tag) => { + setSelectedTags((prev) => { + const isSelected = prev.some((t) => t.id === tag.id); + if (isSelected) { + return prev.filter((t) => t.id !== tag.id); + } else { + return [...prev, tag]; + } + }); + }; + + const handleCancel = () => { + setLocalSearchTerm(searchTerm); + if (selectedTag) { + setSelectedTags([selectedTag]); + } else { + setSelectedTags(initialSelectedTags); + } + onClose(); + }; + + const formatTagText = (tag: Tag) => { + return tag.value ? `${tag.key}:${tag.value}` : tag.key; + }; + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }] + })); + + if (!modalVisible) return null; + + return ( + + + + e.stopPropagation()}> + + + Assign Tags + + + {/* Show search input only if no initial search term */} + {!searchTerm && ( + + )} + + {/* Selected tags display */} + {selectedTags.length > 0 && ( + + + Selected Tags ({selectedTags.length}): + + + {selectedTags.map((tag, index) => { + const isFirstTag = index === 0; + return ( + handleTagToggle(tag)} + className={`mr-2 rounded-full px-3 py-1 ${ + isFirstTag ? 'bg-primary' : 'bg-primary/90' + }`} + > + + {formatTagText(tag)} โœ• + + + ); + })} + + + )} + + {/* Tags list */} + + + Available Tags: + + {isTagsLoading ? ( + Loading... + ) : tags.length === 0 ? ( + + {localSearchTerm ? 'No tags found' : 'No tags available'} + + ) : ( + + + {tags.map((tag) => { + const isSelected = selectedTags.some( + (t) => t.id === tag.id + ); + const isFirstSelected = + selectedTags.length > 0 && + selectedTags[0]?.id === tag.id; + return ( + handleTagToggle(tag)} + className={`mb-2 mr-2 rounded-full px-3 py-1 ${ + isFirstSelected + ? 'bg-primary' + : isSelected + ? 'bg-primary/90' + : 'border border-border bg-background' + }`} + > + + {formatTagText(tag)} + {isSelected && ' โœ“'} + + + ); + })} + + + )} + + + + + + + + + + + + ); +} diff --git a/components/VerseAssigner.tsx b/components/VerseAssigner.tsx new file mode 100644 index 000000000..0418d47fa --- /dev/null +++ b/components/VerseAssigner.tsx @@ -0,0 +1,378 @@ +import { XIcon } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { Button } from './ui/button'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +export interface ExistingLabel { + from: number; + to: number; +} + +// Unified list item type +type ListItem = + | { type: 'available'; verse: number } + | { type: 'existing'; from: number; to: number }; + +interface VerseAssignerProps { + // Either provide availableVerses array OR from/to range (for backward compatibility) + availableVerses?: number[]; + from?: number; + to?: number; + selectedFrom?: number; + selectedTo?: number; + onApply: (from: number, to: number) => void; + onCancel: () => void; + onRemove?: () => void; // Optional callback to remove labels from selected assets + hasSelectedAssetsWithLabels?: boolean; // Whether selected assets have labels to remove + className?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ScrollViewComponent?: React.ComponentType; + // Optional function to limit the maximum "to" value based on selected "from" + getMaxToForFrom?: (selectedFrom: number) => number; + // Existing verse labels to display for quick selection + existingLabels?: ExistingLabel[]; + // Total verse count for the chapter (to show all verses in unified list) + verseCount?: number; +} + +export function VerseAssigner({ + availableVerses, + from, + to, + selectedFrom: initialFrom, + selectedTo: initialTo, + onApply, + onCancel, + onRemove, + hasSelectedAssetsWithLabels = false, + className = '', + ScrollViewComponent = ScrollView, + getMaxToForFrom, + existingLabels = [], + verseCount +}: VerseAssignerProps) { + const [selectedFrom, setSelectedFrom] = React.useState( + initialFrom + ); + const [selectedTo, setSelectedTo] = React.useState( + initialTo + ); + + // Track if selection came from an existing label (to prevent range editing) + const [isExistingSelected, setIsExistingSelected] = React.useState(false); + + // Generate array of available numbers + const availableSet = React.useMemo(() => { + const set = new Set(); + if (availableVerses && availableVerses.length > 0) { + availableVerses.forEach((v) => set.add(v)); + } else if (from !== undefined && to !== undefined) { + for (let i = from; i <= to; i++) { + set.add(i); + } + } + return set; + }, [availableVerses, from, to]); + + // Determine total verse range + const totalVerseCount = React.useMemo(() => { + if (verseCount) return verseCount; + // Calculate from available verses and existing labels + let max = 0; + availableSet.forEach((v) => { + if (v > max) max = v; + }); + existingLabels.forEach((label) => { + if (label.to > max) max = label.to; + }); + return max || to || 1; + }, [verseCount, availableSet, existingLabels, to]); + + // Build unified list: interleave available verses with existing labels + const unifiedList = React.useMemo(() => { + const items: ListItem[] = []; + const existingMap = new Map(); // Maps 'from' to label + + // Index existing labels by their starting verse + for (const label of existingLabels) { + existingMap.set(label.from, label); + } + + // Build a set of verses covered by existing labels + const coveredByExisting = new Set(); + for (const label of existingLabels) { + for (let v = label.from; v <= label.to; v++) { + coveredByExisting.add(v); + } + } + + let verse = 1; + while (verse <= totalVerseCount) { + // Check if this verse starts an existing label + const existingLabel = existingMap.get(verse); + if (existingLabel) { + items.push({ + type: 'existing', + from: existingLabel.from, + to: existingLabel.to + }); + // Skip to after the label's range + verse = existingLabel.to + 1; + } else if (availableSet.has(verse)) { + // Available verse (not part of any existing label) + items.push({ type: 'available', verse }); + verse++; + } else { + // Verse is not available and not in existing label - skip it + verse++; + } + } + + return items; + }, [totalVerseCount, existingLabels, availableSet]); + + // Calculate max "to" value when "from" is selected + const maxTo: number = React.useMemo(() => { + if (selectedFrom === undefined) { + return totalVerseCount; + } + if (getMaxToForFrom) { + return getMaxToForFrom(selectedFrom); + } + return totalVerseCount; + }, [selectedFrom, getMaxToForFrom, totalVerseCount]); + + // Check if a verse is selectable for range selection + const isVerseSelectable = React.useCallback( + (verse: number) => { + if (!availableSet.has(verse)) return false; + + // If nothing selected, all available are selectable + if (selectedFrom === undefined) return true; + + // If existing label is selected, nothing else is selectable + if (isExistingSelected) return false; + + // If only "from" selected, only verses >= selectedFrom and <= maxTo are selectable + if (selectedTo === undefined) { + return verse >= selectedFrom && verse <= maxTo; + } + + // If both selected, nothing is selectable + return false; + }, + [selectedFrom, selectedTo, maxTo, availableSet, isExistingSelected] + ); + + const handleAvailablePress = (verse: number) => { + if (!isVerseSelectable(verse)) return; + + if (selectedFrom === undefined) { + // First selection + setSelectedFrom(verse); + setSelectedTo(undefined); + setIsExistingSelected(false); + } else if (selectedTo === undefined && !isExistingSelected) { + // Second selection for range + setSelectedTo(Math.min(verse, maxTo)); + } + }; + + const handleExistingPress = (label: ExistingLabel) => { + // If already selected, deselect + if (selectedFrom === label.from && selectedTo === label.to) { + setSelectedFrom(undefined); + setSelectedTo(undefined); + setIsExistingSelected(false); + } else { + // Select this existing label + setSelectedFrom(label.from); + setSelectedTo(label.to); + setIsExistingSelected(true); + } + }; + + const handleClear = () => { + setSelectedFrom(undefined); + setSelectedTo(undefined); + setIsExistingSelected(false); + }; + + const handleApply = () => { + if (selectedFrom !== undefined && selectedTo !== undefined) { + onApply(selectedFrom, selectedTo); + } else if (selectedFrom !== undefined) { + onApply(selectedFrom, selectedFrom); + } + }; + + const canApply = selectedFrom !== undefined; + + return ( + + {/* Unified verse list */} + + {unifiedList.map((item) => { + if (item.type === 'existing') { + // Existing label - render as a pill + const isSelected = + selectedFrom === item.from && selectedTo === item.to; + const labelText = + item.from === item.to + ? `${item.from}` + : `${item.from} - ${item.to}`; + // Disable existing labels when user is selecting a new range + const isDisabled = + selectedFrom !== undefined && !isExistingSelected; + + return ( + handleExistingPress(item)} + disabled={isDisabled} + className={`h-10 items-center justify-center rounded-full px-3 ${ + isSelected + ? 'bg-primary' + : isDisabled + ? 'bg-muted/30' + : 'border border-primary/30 bg-primary/5' + } ${!isDisabled ? 'active:scale-95' : ''}`} + > + + {labelText} + + + ); + } else { + // Available verse - render as circle + const verse = item.verse; + const isSelectedFrom = verse === selectedFrom; + const isSelectedTo = verse === selectedTo; + const isSelected = isSelectedFrom || isSelectedTo; + const selectable = isVerseSelectable(verse); + + return ( + handleAvailablePress(verse)} + disabled={!selectable} + className={`h-10 w-10 items-center justify-center rounded-full ${ + isSelected + ? 'bg-primary' + : selectable + ? 'border border-primary/30 bg-primary/5' + : 'bg-muted/30' + } ${selectable ? 'active:scale-95' : ''}`} + > + + {verse} + + + ); + } + })} + + + {/* Selected inputs */} + + {/* From input */} + + {selectedFrom !== undefined ? ( + <> + + {selectedFrom} + + + + ) : ( + From + )} + + + โ€” + + {/* To input */} + + {selectedTo !== undefined ? ( + <> + + {selectedTo} + + + + ) : ( + To + )} + + + + {/* Action buttons */} + + + {selectedFrom !== undefined ? ( + // Show Apply when verse is selected + + ) : ( + // Show Remove when no selection but assets have labels + onRemove && + hasSelectedAssetsWithLabels && ( + + ) + )} + + + ); +} diff --git a/components/VersePill.tsx b/components/VersePill.tsx new file mode 100644 index 000000000..def7081ee --- /dev/null +++ b/components/VersePill.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Text } from './ui/text'; + +interface VersePillProps { + text: string; + className?: string; + largeText?: boolean; +} + +const VersePillComponent = ({ + text, + className = '', + largeText = false +}: VersePillProps) => { + return ( + + + + {text} + + + + ); +}; + +/** + * Memoized VersePill component + * Only re-renders when text, className, or largeText changes + * + * Performance: Prevents unnecessary re-renders when assets list changes + * but verse pills remain the same (common scenario in BibleRecordingView) + */ +export const VersePill = React.memo( + VersePillComponent, + (prevProps, nextProps) => { + // Return TRUE if props are EQUAL (skip re-render) + // Return FALSE if props are DIFFERENT (re-render needed) + return ( + prevProps.text === nextProps.text && + prevProps.className === nextProps.className && + prevProps.largeText === nextProps.largeText + ); + } +); + +VersePill.displayName = 'VersePill'; diff --git a/components/VerseRangeSelector.tsx b/components/VerseRangeSelector.tsx new file mode 100644 index 000000000..cbb6a6922 --- /dev/null +++ b/components/VerseRangeSelector.tsx @@ -0,0 +1,241 @@ +import { XIcon } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, ScrollView, View } from 'react-native'; +import { Button } from './ui/button'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface VerseRangeSelectorProps { + // Either provide availableVerses array OR from/to range (for backward compatibility) + availableVerses?: number[]; + from?: number; + to?: number; + selectedFrom?: number; + selectedTo?: number; + onApply: (from: number, to: number) => void; + onCancel: () => void; + className?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ScrollViewComponent?: React.ComponentType; + // Optional function to limit the maximum "to" value based on selected "from" + getMaxToForFrom?: (selectedFrom: number) => number; +} + +export function VerseRangeSelector({ + availableVerses, + from, + to, + selectedFrom: initialFrom, + selectedTo: initialTo, + onApply, + onCancel, + className = '', + ScrollViewComponent = ScrollView, + getMaxToForFrom +}: VerseRangeSelectorProps) { + const [selectedFrom, setSelectedFrom] = React.useState( + initialFrom + ); + const [selectedTo, setSelectedTo] = React.useState( + initialTo + ); + + // Generate array of numbers - use availableVerses if provided, otherwise generate from/to range + const allNumbers = React.useMemo(() => { + if (availableVerses && availableVerses.length > 0) { + return availableVerses; + } + // Fallback to from/to range for backward compatibility + if (from !== undefined && to !== undefined) { + const numbers: number[] = []; + for (let i = from; i <= to; i++) { + numbers.push(i); + } + return numbers; + } + return []; + }, [availableVerses, from, to]); + + // Calculate max "to" value when "from" is selected + const maxTo: number = React.useMemo(() => { + if (selectedFrom === undefined) { + return allNumbers.length > 0 + ? allNumbers[allNumbers.length - 1]! + : to || 1; + } + if (getMaxToForFrom) { + return getMaxToForFrom(selectedFrom); + } + // If no getMaxToForFrom function, allow up to the last available verse + return allNumbers.length > 0 ? allNumbers[allNumbers.length - 1]! : to || 1; + }, [selectedFrom, getMaxToForFrom, allNumbers, to]); + + // Check if a number is selectable based on current selection state + const isNumberSelectable = React.useCallback( + (num: number) => { + // Number must be in the available numbers list + if (!allNumbers.includes(num)) { + return false; + } + + // If nothing selected, all available numbers are selectable + if (selectedFrom === undefined) { + return true; + } + // If only "from" selected, only numbers >= selectedFrom and <= maxTo are selectable + if (selectedTo === undefined) { + return num >= selectedFrom && num <= maxTo && allNumbers.includes(num); + } + // If both selected, nothing is selectable (user must clear first) + return false; + }, + [selectedFrom, selectedTo, maxTo, allNumbers] + ); + + const handleNumberPress = (num: number) => { + if (!isNumberSelectable(num)) return; + + if (selectedFrom === undefined) { + // First selection - set "from" + setSelectedFrom(num); + // Clear "to" if it was set, since maxTo might have changed + setSelectedTo(undefined); + } else if (selectedTo === undefined) { + // Second selection - set "to" (but limit to maxTo) + setSelectedTo(Math.min(num, maxTo)); + } + }; + + const handleClearFrom = () => { + setSelectedFrom(undefined); + setSelectedTo(undefined); // Clear both since "to" depends on "from" + }; + + const handleClearTo = () => { + setSelectedTo(undefined); + }; + + const handleApply = () => { + if (selectedFrom !== undefined && selectedTo !== undefined) { + onApply(selectedFrom, selectedTo); + } else if (selectedFrom !== undefined) { + // If only "from" is selected, use same value for both + onApply(selectedFrom, selectedFrom); + } + }; + + const canApply = selectedFrom !== undefined; + + return ( + + {/* Number scroll */} + + {allNumbers.map((num) => { + const isSelectedFrom = num === selectedFrom; + const isSelectedTo = num === selectedTo; + const isSelected = isSelectedFrom || isSelectedTo; + const selectable = isNumberSelectable(num); + + return ( + handleNumberPress(num)} + disabled={!selectable} + className={`h-10 w-10 items-center justify-center rounded-full ${ + isSelected + ? 'bg-primary' + : selectable + ? 'border border-primary/30 bg-primary/5' + : 'bg-muted/30' + } ${selectable ? 'active:scale-95' : ''}`} + > + + {num} + + + ); + })} + + + {/* Selected inputs */} + + {/* From input */} + + {selectedFrom !== undefined ? ( + <> + + {selectedFrom} + + + + ) : ( + From + )} + + + โ€” + + {/* To input */} + + {selectedTo !== undefined ? ( + <> + + {selectedTo} + + + + ) : ( + To + )} + + + + {/* Action buttons */} + + + + + + ); +} diff --git a/components/VerseSeparator.tsx b/components/VerseSeparator.tsx new file mode 100644 index 000000000..0c9903c6c --- /dev/null +++ b/components/VerseSeparator.tsx @@ -0,0 +1,179 @@ +import { + AlertCircleIcon, + MoveVerticalIcon, + PencilIcon +} from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { Icon } from './ui/icon'; +import { Text } from './ui/text'; + +interface VerseSeparatorProps { + from?: number; + to?: number; + label: string; + className?: string; + editable?: boolean; + largeText?: boolean; + onPress?: () => void; + // Selection for recording: clicking the separator text selects it for recording + isSelectedForRecording?: boolean; + onSelectForRecording?: () => void; + dragHandleComponent?: React.ComponentType<{ + mode?: 'fixed-order' | 'draggable'; + children?: React.ReactNode; + }>; + dragHandleProps?: { + mode?: 'fixed-order' | 'draggable'; + }; +} + +export function VerseSeparator({ + from, + to, + label, + className = '', + editable = false, + largeText = false, + onPress, + isSelectedForRecording = false, + onSelectForRecording, + dragHandleComponent: DragHandleComponent, + dragHandleProps +}: VerseSeparatorProps) { + const hasNumbers = from !== undefined || to !== undefined; + + const getText = () => { + // No numbers provided + if (!hasNumbers) { + return `No label assigned`; + } + + // Only one number or both are the same + if (from === to || from === undefined || to === undefined) { + const value = from ?? to; + return `${label}:${value}`; + } + + // Range of numbers + return `${label}:${from}-${to}`; + }; + + if (!hasNumbers) { + // No assigned - warning style with amber/orange tones + // Background changes when selected for recording + const unassignedBgClass = isSelectedForRecording + ? 'border-primary bg-primary/20' + : 'border-amber-500/30 bg-amber-500/10'; + + return ( + + + + + {/* Text is clickable for recording selection when onSelectForRecording is provided */} + {onSelectForRecording ? ( + + + {getText()} + + + ) : ( + + {getText()} + + )} + + + + ); + } + + // Has numbers - pill style + // Background changes when selected for recording + const pillBgClass = isSelectedForRecording + ? 'bg-primary/30 border border-primary' + : 'bg-primary/10'; + + const pillContent = ( + + {DragHandleComponent && editable && ( + + + + )} + {/* Text is clickable for recording selection when onSelectForRecording is provided */} + {onSelectForRecording ? ( + + + {getText()} + + + ) : ( + + {getText()} + + )} + {/* Edit icon - only shown when editable and onPress is provided */} + {editable && onPress && ( + + + + )} + + ); + + return ( + + + {DragHandleComponent && editable ? ( + + {pillContent} + + ) : ( + pillContent + )} + + + ); +} diff --git a/components/ui/select.tsx b/components/ui/select.tsx index e61418e0f..17bdb13f8 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -172,27 +172,33 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< SelectPrimitive.ItemRef, - SelectPrimitive.ItemProps & { textClassName?: string } ->(({ className, textClassName, ...props }, ref) => ( + SelectPrimitive.ItemProps & { + textClassName?: string; + showCheckIcon?: boolean; + } +>(({ className, textClassName, showCheckIcon = true, ...props }, ref) => ( - - - - - + {showCheckIcon && ( + + + + + + )} { + try { + const assetLocalTable = resolveTable('asset', { localOverride: true }); + + // Verify this asset exists in the LOCAL table + const localAsset = await system.db + .select() + .from(assetLocalTable) + .where(eq(assetLocalTable.id, assetId)) + .limit(1); + + if (!localAsset || localAsset.length === 0) { + throw new Error( + 'Asset not found in local table - cannot update synced assets' + ); + } + + // Verify it doesn't exist in synced table (double-check it's not published) + const syncedTable = resolveTable('asset', { localOverride: false }); + const syncedAsset = await system.db + .select() + .from(syncedTable) + .where(eq(syncedTable.id, assetId)) + .limit(1); + + if (syncedAsset && syncedAsset.length > 0) { + throw new Error( + 'Cannot update synced assets - they are immutable once published' + ); + } + + // Safe to update - it's local only + const metadataStr = metadata ? JSON.stringify(metadata) : null; + await system.db + .update(assetLocalTable) + .set({ metadata: metadataStr }) + .where(eq(assetLocalTable.id, assetId)); + + console.log(`โœ… Asset ${assetId.slice(0, 8)} metadata updated`); + } catch (error) { + console.error('Failed to update asset metadata:', error); + throw error; + } +} + +/** + * Asset update payload for batch operations + */ +export interface AssetUpdatePayload { + assetId: string; + metadata?: AssetMetadata | null; + order_index?: number; +} + +/** + * Batch update asset metadata and/or order_index for multiple assets + * @param updates - Array of { assetId, metadata?, order_index? } objects + */ +export async function batchUpdateAssetMetadata( + updates: AssetUpdatePayload[] +): Promise { + if (updates.length === 0) return; + + try { + const assetLocalTable = resolveTable('asset', { localOverride: true }); + const syncedTable = resolveTable('asset', { localOverride: false }); + + const assetIds = updates.map((u) => u.assetId); + + // Check which assets exist in synced table (immutable) + const syncedAssets = await system.db + .select({ id: syncedTable.id }) + .from(syncedTable) + .where(inArray(syncedTable.id, assetIds)); + + const syncedIds = new Set(syncedAssets.map((a) => a.id)); + + // Filter out synced assets + const localUpdates = updates.filter((u) => !syncedIds.has(u.assetId)); + + if (localUpdates.length === 0) { + console.log('No local assets to update'); + return; + } + + // Update each local asset + for (const update of localUpdates) { + const setPayload: { metadata?: string | null; order_index?: number } = {}; + + // Only include metadata if explicitly provided + if (update.metadata !== undefined) { + setPayload.metadata = update.metadata + ? JSON.stringify(update.metadata) + : null; + } + + // Only include order_index if explicitly provided + if (update.order_index !== undefined) { + setPayload.order_index = update.order_index; + } + + // Skip if nothing to update + if (Object.keys(setPayload).length === 0) continue; + + await system.db + .update(assetLocalTable) + .set(setPayload) + .where(eq(assetLocalTable.id, update.assetId)); + } + + console.log(`โœ… Updated ${localUpdates.length} assets`); + } catch (error) { + console.error('Failed to batch update assets:', error); + throw error; + } +} diff --git a/database_services/tagCache.ts b/database_services/tagCache.ts new file mode 100644 index 000000000..acc1d018b --- /dev/null +++ b/database_services/tagCache.ts @@ -0,0 +1,8 @@ +// export type Tag = typeof tag.$inferSelect; +export interface Tag { + id: string; + key: string; + value?: string; +} + +export const tagCache = new Map(); diff --git a/database_services/tagService.ts b/database_services/tagService.ts index 8d4f54bc9..3c5136420 100644 --- a/database_services/tagService.ts +++ b/database_services/tagService.ts @@ -1,6 +1,8 @@ -import { eq } from 'drizzle-orm'; +import { resolveTable } from '@/utils/dbUtils'; +import { and, asc, eq, like } from 'drizzle-orm'; import { asset_tag_link, quest_tag_link, tag } from '../db/drizzleSchema'; import { system } from '../db/powersync/system'; +import { tagCache } from './tagCache'; export type Tag = typeof tag.$inferSelect; @@ -48,6 +50,109 @@ export class TagService { return Promise.all(tagPromises); } + + // async getTagsByTagKey(tagKey: string, limit = 200) { + // // First get tag IDs from junction table + // const tags = await db + // .select() + // .from(tag) + // .where(eq(tag.key, tagKey)) + // .orderBy(asc(tag.value)) + // .limit(limit); + + // return tags; + // } + + async searchTags(searchTerm?: string, limit = 200) { + const whereCondition = searchTerm + ? and(eq(tag.active, true), like(tag.key, `${searchTerm}%`)) + : eq(tag.active, true); + const tags = await db + .select() + .from(tag) + .where(whereCondition) + .orderBy(asc(tag.key), asc(tag.value)) + .limit(limit); + + return tags; + } + + async getAllActiveTags(limit = 200) { + const tags = await db + .select() + .from(tag) + .where(eq(tag.active, true)) + .orderBy(asc(tag.key)) + .limit(limit); + + return tags; + } + + async preloadTagsIntoCache() { + const tags = await db + .select({ id: tag.id, key: tag.key, value: tag.value }) + .from(tag) + .where(eq(tag.active, true)) + // .orderBy(asc(tag.key), asc(tag.value)) + .limit(20000); + + for (const tagRecord of tags) { + tagCache.set(tagRecord.id, tagRecord); + } + } + + /** + * Assigns a list of tags to an asset. + * Deletes all existing tag assignments for the asset and creates new ones. + * @param asset_id The ID of the asset + * @param tag_ids Array of tag IDs to assign to the asset + */ + async assignTagsToAssetLocal(asset_id: string, tag_ids: string[]) { + try { + // Start a transaction to ensure atomicity + const result = await db.transaction(async (tx) => { + const contentLocal = resolveTable('asset_tag_link', { + localOverride: true + }); + // 1. Delete all existing tag assignments for this asset + await tx + .delete(contentLocal) + .where(eq(contentLocal.asset_id, asset_id)); + + // 2. Insert new tag assignments if any tag IDs are provided + if (tag_ids.length > 0) { + const newAssignments = tag_ids.map((tag_id) => ({ + asset_id, + tag_id + })); + + const newAssignmentsResult = await tx + .insert(contentLocal) + .values(newAssignments) + .returning(); + console.log( + `[TagService] New assignments result:`, + newAssignmentsResult + ); + } + + return { success: true, assigned_count: tag_ids.length }; + }); + + console.log( + `[TagService] Successfully assigned ${tag_ids.length} tags to asset ${asset_id}` + ); + return result; + } catch (error) { + console.error( + `[TagService] Failed to assign tags to asset ${asset_id}:`, + error + ); + throw new Error( + `Failed to assign tags to asset: ${error instanceof Error ? error.message : String(error)}` + ); + } + } } export const tagService = new TagService(); diff --git a/db/drizzleSchemaColumns.ts b/db/drizzleSchemaColumns.ts index 3123d623e..809a05edf 100644 --- a/db/drizzleSchemaColumns.ts +++ b/db/drizzleSchemaColumns.ts @@ -359,6 +359,7 @@ export function createAssetTable< content_type: text({ enum: contentTypeOptions }).default('source'), creator_id: text().references(() => profile.id), order_index: int().notNull().default(0), + metadata: text(), // JSON metadata for asset-specific data (e.g., verse range) ...extraColumns }, (table) => { diff --git a/hooks/db/useAssets.ts b/hooks/db/useAssets.ts index 663301fc1..212e0b8e0 100644 --- a/hooks/db/useAssets.ts +++ b/hooks/db/useAssets.ts @@ -9,7 +9,7 @@ import { tag } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; -import type { WithSource } from '@/utils/dbUtils'; +import { useNetworkStatus } from '@/hooks/useNetworkStatus'; import { blockedContentQuery, blockedUsersQuery } from '@/utils/dbUtils'; import { getOptionShowHiddenContent } from '@/utils/settingsUtils'; import { @@ -17,6 +17,7 @@ import { useSimpleHybridInfiniteData } from '@/views/new/useHybridData'; import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { useQuery } from '@tanstack/react-query'; import type { InferSelectModel, SQL } from 'drizzle-orm'; import { and, @@ -29,9 +30,10 @@ import { isNull, like, notInArray, - or + or, + sql } from 'drizzle-orm'; -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { createHybridQueryConfig, useHybridInfiniteQuery, @@ -1032,6 +1034,7 @@ export function useAssetsQuestLinkById( type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[]; }; export function useAssetsByQuest( @@ -1081,7 +1084,12 @@ export function useAssetsByQuest( .select({ ...getTableColumns(asset), quest_visible: quest_asset_link.visible, - quest_active: quest_asset_link.active + quest_active: quest_asset_link.active, + tag_ids: sql`( + SELECT json_group_array(${asset_tag_link.tag_id}) + FROM ${asset_tag_link} + WHERE ${asset_tag_link.asset_id} = ${asset.id} + )` }) .from(asset) .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) @@ -1094,7 +1102,34 @@ export function useAssetsByQuest( .limit(pageSize) .offset(offset); - return assets; + // Convert tag_ids from JSON string to array for consistency with cloud query + const processedAssets = assets.map((asset) => { + let tagIds: string[] = []; + try { + if (asset.tag_ids) { + const parsed = JSON.parse(String(asset.tag_ids)); + tagIds = Array.isArray(parsed) ? (parsed as string[]) : []; + } + if (asset.metadata) { + const parsed = JSON.parse(String(asset.metadata)); + asset.metadata = parsed as string | null; + } + } catch (error) { + console.warn( + '[useAssetsByQuest] Failed to parse tag_ids:', + asset.tag_ids, + error + ); + tagIds = []; + } + + return { + ...asset, + tag_ids: tagIds + } as AssetQuestLink; + }); + + return processedAssets; } catch (error) { console.error('[ASSETS] Offline query error:', error); return []; @@ -1118,7 +1153,8 @@ export function useAssetsByQuest( visible, active, asset:asset_id ( - * + *, + asset_tag_link(tag_id) ) ` ) @@ -1146,7 +1182,8 @@ export function useAssetsByQuest( query = query.filter('asset.name', 'ilike', `%${searchQuery.trim()}%`); } - // Order by order_index, then created_at, then name + // Order by created_at for cloud query + // Note: order_index ordering is handled client-side for cloud data query = query.order('created_at', { ascending: true }); // Add pagination @@ -1161,20 +1198,347 @@ export function useAssetsByQuest( if (error) throw error; // Map to AssetQuestLink format with quest_visible and quest_active - const assets: AssetQuestLink[] = data - .map((item) => { - if (!item.asset) return null; + const assets: AssetQuestLink[] = data.map((item) => { + // Extract tag IDs from asset_tag_link array + const assetWithTags = item.asset as Asset & { + asset_tag_link?: { tag_id: string }[]; + }; + const tag_ids: string[] = + assetWithTags.asset_tag_link?.map((link) => link.tag_id) || []; + + return { + ...item.asset, + quest_visible: item.visible, + quest_active: item.active, + tag_ids + } as AssetQuestLink; + }); + + return assets; + }, + 20 // pageSize + ); + + return { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + }; +} + +export function useLocalAssetsByQuest( + quest_id: string, + searchQuery: string, + showHiddenContent: boolean +) { + const { currentUser } = useAuth(); + const isOnline = useNetworkStatus(); // ๐Ÿ”ง Get real network status + + // For local-only quests, use simple query (no pagination needed for ~200 records) + // This is wrapped to maintain API compatibility with infinite scroll structure + const simpleQuery = useQuery({ + queryKey: ['assets', 'by-quest-local-simple', quest_id || '', searchQuery], + queryFn: async () => { + if (!quest_id || !currentUser) return []; + + try { + const conditions = [ + isNull(asset.source_asset_id), + eq(quest_asset_link.quest_id, quest_id), + or( + !showHiddenContent ? eq(asset.visible, true) : undefined, + eq(asset.creator_id, currentUser.id) + ), + or( + !showHiddenContent ? eq(quest_asset_link.visible, true) : undefined, + eq(asset.creator_id, currentUser.id) + ), + notInArray(asset.id, blockedContentQuery(currentUser.id, 'asset')), + notInArray(asset.creator_id, blockedUsersQuery(currentUser.id)), + searchQuery.trim() && and(like(asset.name, `%${searchQuery.trim()}%`)) + ]; + + // Query all assets at once (no pagination for local data) + const assets = await system.db + .select({ + ...getTableColumns(asset), + quest_visible: quest_asset_link.visible, + quest_active: quest_asset_link.active, + tag_ids: sql`( + SELECT json_group_array(${asset_tag_link.tag_id}) + FROM ${asset_tag_link} + WHERE ${asset_tag_link.asset_id} = ${asset.id} + )` + }) + .from(asset) + .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) + .where(and(...conditions.filter(Boolean))) + .orderBy( + asc(asset.order_index), + asc(asset.created_at), + asc(asset.name) + ); + // No .limit() or .offset() - fetch everything + + // Process tag_ids and metadata + const processedAssets = assets.map((asset) => { + let tagIds: string[] = []; + try { + if (asset.tag_ids) { + const parsed = JSON.parse(String(asset.tag_ids)); + tagIds = Array.isArray(parsed) ? (parsed as string[]) : []; + } + if (asset.metadata) { + const parsed = JSON.parse(String(asset.metadata)); + asset.metadata = parsed as string | null; + } + } catch (error) { + console.warn( + '[useLocalAssetsByQuest] Failed to parse tag_ids:', + asset.tag_ids, + error + ); + tagIds = []; + } + return { - ...item.asset, - quest_visible: item.visible, - quest_active: item.active + ...asset, + tag_ids: tagIds } as AssetQuestLink; - }) - .filter((item): item is AssetQuestLink => item !== null); + }); + + return processedAssets; + } catch (error) { + console.error('[useLocalAssetsByQuest] Query error:', error); + return []; + } + }, + enabled: !!quest_id && !!currentUser + }); + + // Wrap simple query result to match infinite query structure + // This maintains API compatibility with code expecting infinite scroll data + const wrappedData = React.useMemo(() => { + if (!simpleQuery.data) { + return { pages: [], pageParams: [] }; + } + // Wrap all data in a single page to match InfiniteData<{ data: T[] }> structure + return { + pages: [{ data: simpleQuery.data }], + pageParams: [0] + }; + }, [simpleQuery.data]); + + // Return structure compatible with useInfiniteQuery + return { + data: wrappedData, + fetchNextPage: () => + Promise.resolve({ + data: wrappedData, + pageParam: undefined + }), // No-op for local data (all loaded) + hasNextPage: false, // All data loaded in one query + isFetchingNextPage: false, + isLoading: simpleQuery.isLoading, + isOnline, // ๐Ÿ”ง Use real network status (needed for publish/bookmark buttons) + isFetching: simpleQuery.isFetching, + refetch: simpleQuery.refetch + }; +} + +/** + * Legacy infinite scroll version (not used, kept for reference) + * This was the old implementation using pagination for local data + * Now replaced with simple query above since local data is small (~200 records) + */ +/* +function _useLocalAssetsByQuestInfinite( + quest_id: string, + searchQuery: string, + showHiddenContent: boolean +) { + const { currentUser } = useAuth(); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + } = useSimpleHybridInfiniteData( + 'assets', + ['by-quest', quest_id || '', searchQuery], + // Offline query function - Assets must be downloaded to use + async ({ pageParam, pageSize }) => { + if (!quest_id) return []; + + const limit = pageSize > 1000 ? pageSize : 1000; + + try { + const offset = pageParam * limit; + + const conditions = [ + isNull(asset.source_asset_id), + eq(quest_asset_link.quest_id, quest_id), + or( + !showHiddenContent ? eq(asset.visible, true) : undefined, + eq(asset.creator_id, currentUser!.id) + ), + or( + !showHiddenContent ? eq(quest_asset_link.visible, true) : undefined, + eq(asset.creator_id, currentUser!.id) + ), + notInArray(asset.id, blockedContentQuery(currentUser!.id, 'asset')), + notInArray(asset.creator_id, blockedUsersQuery(currentUser!.id)), + searchQuery.trim() && and(like(asset.name, `%${searchQuery.trim()}%`)) + ]; + + // Normal pagination without search + const assets = await system.db + .select({ + ...getTableColumns(asset), + quest_visible: quest_asset_link.visible, + quest_active: quest_asset_link.active, + tag_ids: sql`( + SELECT json_group_array(${asset_tag_link.tag_id}) + FROM ${asset_tag_link} + WHERE ${asset_tag_link.asset_id} = ${asset.id} + )` + }) + .from(asset) + .innerJoin(quest_asset_link, eq(asset.id, quest_asset_link.asset_id)) + .where(and(...conditions.filter(Boolean))) + .orderBy( + asc(asset.order_index), + asc(asset.created_at), + asc(asset.name) + ) + .limit(limit) + .offset(offset); + + // Convert tag_ids from JSON string to array for consistency with cloud query + const processedAssets = assets.map((asset) => { + let tagIds: string[] = []; + try { + if (asset.tag_ids) { + const parsed = JSON.parse(String(asset.tag_ids)); + tagIds = Array.isArray(parsed) ? (parsed as string[]) : []; + } + if (asset.metadata) { + const parsed = JSON.parse(String(asset.metadata)); + asset.metadata = parsed as string | null; + } + } catch (error) { + console.warn( + '[useAssetsByQuest] Failed to parse tag_ids:', + asset.tag_ids, + error + ); + tagIds = []; + } + + return { + ...asset, + tag_ids: tagIds + } as AssetQuestLink; + }); + + return processedAssets; + } catch (error) { + console.error('[ASSETS] Offline query error:', error); + return []; + } + }, + // Cloud query function - For anonymous users, fetch assets directly from cloud + // For authenticated users, assets must be downloaded to use offline, but cloud query + // can still be used for browsing (anonymous-style access) + async ({ pageParam, pageSize }) => { + if (!quest_id) return []; + + const offset = pageParam * pageSize; + const from = offset; + const to = offset + pageSize - 1; + + // Build query from quest_asset_link to get both asset data and link metadata + let query = system.supabaseConnector.client + .from('quest_asset_link') + .select( + ` + visible, + active, + asset:asset_id ( + *, + asset_tag_link(tag_id) + ) + ` + ) + .eq('quest_id', quest_id) + .is('asset.source_asset_id', null); // Only get original assets, not variants + + // Filter by visibility - anonymous users can only see visible assets + // Authenticated users can see their own hidden assets if showHiddenContent is true + if (!showHiddenContent) { + // Show only visible assets + query = query.eq('visible', true).filter('asset.visible', 'eq', true); + } else if (currentUser?.id) { + // Show all assets, but still filter by link visibility for non-creators + // For creators, show all their assets even if hidden + query = query.or( + `visible.eq.true,asset.creator_id.eq.${currentUser.id}` + ); + } else { + // Anonymous users with showHiddenContent=true still only see visible (for safety) + query = query.eq('visible', true).filter('asset.visible', 'eq', true); + } + + // Add search filtering + if (searchQuery.trim()) { + query = query.filter('asset.name', 'ilike', `%${searchQuery.trim()}%`); + } + + // Order by created_at for cloud query + // Note: order_index ordering is handled client-side for cloud data + query = query.order('created_at', { ascending: true }); + + // Add pagination + const { data, error } = await query.range(from, to).overrideTypes< + { + visible: boolean; + active: boolean; + asset: Asset; + }[] + >(); + + if (error) throw error; + + // Map to AssetQuestLink format with quest_visible and quest_active + const assets: AssetQuestLink[] = data.map((item) => { + // Extract tag IDs from asset_tag_link array + const assetWithTags = item.asset as Asset & { + asset_tag_link?: { tag_id: string }[]; + }; + const tag_ids: string[] = + assetWithTags.asset_tag_link?.map((link) => link.tag_id) || []; + + return { + ...item.asset, + quest_visible: item.visible, + quest_active: item.active, + tag_ids + } as AssetQuestLink; + }); return assets; }, - 20 // pageSize + 1000 // pageSize ); return { @@ -1188,3 +1552,5 @@ export function useAssetsByQuest( refetch }; } +*/ +// End of legacy infinite scroll implementation (commented out) diff --git a/hooks/db/useSearchTags.ts b/hooks/db/useSearchTags.ts new file mode 100644 index 000000000..2806ffdb3 --- /dev/null +++ b/hooks/db/useSearchTags.ts @@ -0,0 +1,32 @@ +import type { Tag } from '@/database_services/tagService'; +import { tagService } from '@/database_services/tagService'; +import { useQuery } from '@tanstack/react-query'; + +// Re-export Tag type for convenience +export type { Tag }; + +/** + * Returns { tags, isLoading, error } + * Searches tags by key with optional limit + */ +export function useSearchTags({ + searchTerm, + maxResults = 20, + enabled = true +}: { + searchTerm?: string; + maxResults?: number; + enabled?: boolean; +}) { + const { + data: tags, + isLoading: isTagsLoading, + ...rest + } = useQuery({ + queryKey: ['tags', 'search', searchTerm, maxResults], + queryFn: () => tagService.searchTags(searchTerm, maxResults), + enabled + }); + + return { tags, isTagsLoading, ...rest }; +} diff --git a/hooks/useAppNavigation.ts b/hooks/useAppNavigation.ts index d6b980c79..eea33519f 100644 --- a/hooks/useAppNavigation.ts +++ b/hooks/useAppNavigation.ts @@ -31,7 +31,8 @@ export function useAppNavigation() { navigationStack, setNavigationStack, addRecentQuest, - addRecentAsset + addRecentAsset, + enableVerseMarkers } = useLocalStore(); // Ensure navigationStack is always an array - safe access pattern @@ -169,6 +170,10 @@ export function useAppNavigation() { questData?: Record; projectData?: Record; }) => { + const assetView = + questData.projectData?.template === 'bible' && enableVerseMarkers + ? 'bible-assets' + : 'assets'; // Track recently visited addRecentQuest({ id: questData.id, @@ -180,7 +185,7 @@ export function useAppNavigation() { // Check if we're already at this quest if ( currentState.questId === questData.id && - currentState.view === 'assets' + currentState.view === assetView ) { // Already here, do nothing return; @@ -191,11 +196,11 @@ export function useAppNavigation() { currentState.questId === questData.id && currentState.view === 'asset-detail' ) { - goBackToView('assets'); + goBackToView(assetView); } else { // Navigate fresh, pass data forward and preserve bookId (for Bible navigation) navigate({ - view: 'assets', + view: assetView, questId: questData.id, questName: questData.name, projectId: questData.project_id, @@ -205,7 +210,7 @@ export function useAppNavigation() { }); } }, - [currentState, navigate, addRecentQuest, goBackToView] + [currentState, navigate, addRecentQuest, goBackToView, enableVerseMarkers] ); const goToAsset = useCallback( @@ -329,7 +334,8 @@ export function useAppNavigation() { goToQuest({ id: state.questId!, project_id: state.projectId!, - name: state.questName + name: state.questName, + projectData: state.projectData }) }); crumbs.push({ label: state.assetName, onPress: undefined }); diff --git a/hooks/useTagStore.ts b/hooks/useTagStore.ts new file mode 100644 index 000000000..0eb6393d2 --- /dev/null +++ b/hooks/useTagStore.ts @@ -0,0 +1,86 @@ +import { tag } from '@/db/drizzleSchema'; +import { system } from '@/db/powersync/system'; +import { eq, inArray } from 'drizzle-orm'; +import { create } from 'zustand'; +import type { Tag } from '../database_services/tagCache'; +import { tagCache } from '../database_services/tagCache'; + +export type { Tag }; + +interface TagStore { + getTag: (id: string) => Tag | undefined; + getManyTags: (ids: string[]) => Tag[]; + fetchTag: (id: string) => Promise; + fetchManyTags: (ids: string[]) => Promise; +} + +export const useTagStore = create(() => ({ + // Sync version - uses cache only + getTag: (id) => tagCache.get(id), + + // Sync version - uses cache only + getManyTags: (ids) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!ids || ids.length === 0) return []; + + return ids.map((id) => tagCache.get(id)).filter(Boolean); + }, + + // Async version - fetches from database + fetchTag: async (id) => { + // Check cache first + const cached = tagCache.get(id); + if (cached) return cached; + + // Fetch from database + const results = await system.db + .select({ id: tag.id, key: tag.key, value: tag.value }) + .from(tag) + .where(eq(tag.id, id)) + .limit(1); + + const result = results[0]; + if (result) { + // Store in cache for future use + tagCache.set(result.id, result); + } + return result; + }, + + // Async version - fetches from database + fetchManyTags: async (ids) => { + if (ids.length === 0) return []; + + // Filter out IDs already in cache + const cachedTags: Tag[] = []; + const missingIds: string[] = []; + + for (const id of ids) { + const cached = tagCache.get(id); + if (cached) { + cachedTags.push(cached); + } else { + missingIds.push(id); + } + } + + // If all tags are cached, return them + if (missingIds.length === 0) { + return cachedTags; + } + + // Fetch missing tags from database + const fetchedTags = await system.db + .select({ id: tag.id, key: tag.key, value: tag.value }) + .from(tag) + .where(inArray(tag.id, missingIds)); + + // Store fetched tags in cache + for (const fetchedTag of fetchedTags) { + tagCache.set(fetchedTag.id, fetchedTag); + } + + // Return all tags (cached + fetched) + return [...cachedTags, ...fetchedTags]; + } +})); diff --git a/package-lock.json b/package-lock.json index a70d0bf69..f97d91321 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,6 +97,7 @@ "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-sortables": "^1.9.4", "react-native-svg": "^15.11.2", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.3", @@ -23202,6 +23203,19 @@ "react-native": "*" } }, + "node_modules/react-native-haptic-feedback": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-2.3.3.tgz", + "integrity": "sha512-svS4D5PxfNv8o68m9ahWfwje5NqukM3qLS48+WTdhbDkNUkOhP9rDfDSRHzlhk4zq+ISjyw95EhLeh8NkKX5vQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "example" + ], + "peerDependencies": { + "react-native": ">=0.60.0" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -23247,9 +23261,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.0.tgz", - "integrity": "sha512-L8FqZn8VjZyBaCUMYFyx1Y+T+ZTbblaudpxReOXJ66RnOf52g6UM4Pa/IjwLD1XAw1FUxLRQrtpdjbkEc74FiQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.6.tgz", + "integrity": "sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==", "license": "MIT", "dependencies": { "react-native-is-edge-to-edge": "^1.2.1", @@ -23299,6 +23313,21 @@ "react-native": "*" } }, + "node_modules/react-native-sortables": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/react-native-sortables/-/react-native-sortables-1.9.4.tgz", + "integrity": "sha512-a6hxT+gl14HA5Sm8UiLXJqF8KMEQVa+mUJd75OnzoVsmrxUDtjAatlMdV0kI9qTQDT/ZSFLPRmdUhOR762IA4g==", + "license": "MIT", + "optionalDependencies": { + "react-native-haptic-feedback": ">=2.0.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-gesture-handler": ">=2.0.0", + "react-native-reanimated": ">=3.0.0" + } + }, "node_modules/react-native-svg": { "version": "15.12.1", "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", diff --git a/package.json b/package.json index ad1f72778..c4b2f78d1 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "react-native-reanimated": "~4.1.0", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", + "react-native-sortables": "^1.9.4", "react-native-svg": "^15.11.2", "react-native-url-polyfill": "^2.0.0", "react-native-uuid": "^2.0.3", diff --git a/services/localizations.ts b/services/localizations.ts index 7e807a428..aee3fe518 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -3444,6 +3444,48 @@ export const localizations = { tok_pisin: 'Recording...', indonesian: 'Merekam...' }, + recordTo: { + english: 'Record to', + spanish: 'Grabar en', + brazilian_portuguese: 'Gravar em', + tok_pisin: 'Rekodem long', + indonesian: 'Rekam ke' + }, + noLabelSelected: { + english: 'No label selected', + spanish: 'Sin etiqueta seleccionada', + brazilian_portuguese: 'Nenhum rรณtulo selecionado', + tok_pisin: 'No label i stap', + indonesian: 'Tidak ada label dipilih' + }, + startRecordingSession: { + english: 'Start Recording Session', + spanish: 'Iniciar Sesiรณn de Grabaciรณn', + brazilian_portuguese: 'Iniciar Sessรฃo de Gravaรงรฃo', + tok_pisin: 'Stat Rekodem Taim', + indonesian: 'Mulai Sesi Rekaman' + }, + typeToConfirm: { + english: 'Type {text} to confirm', + spanish: 'Escriba {text} para confirmar', + brazilian_portuguese: 'Digite {text} para confirmar', + tok_pisin: 'Raitim {text} bilong siaim', + indonesian: 'Ketik {text} untuk mengkonfirmasi' + }, + confirmDeletion: { + english: 'Confirm Deletion', + spanish: 'Confirmar Eliminaciรณn', + brazilian_portuguese: 'Confirmar Exclusรฃo', + tok_pisin: 'Siaim Rausim', + indonesian: 'Konfirmasi Penghapusan' + }, + deleting: { + english: 'Deleting...', + spanish: 'Eliminando...', + brazilian_portuguese: 'Excluindo...', + tok_pisin: 'Rausim nau...', + indonesian: 'Menghapus...' + }, audioSegments: { english: 'Audio Segments', spanish: 'Pistas de Audio', @@ -6110,7 +6152,23 @@ export const localizations = { tok_pisin: 'Link kopim igo long clipboard!', indonesian: 'Tautan disalin ke clipboard!' }, - + verseMarkers: { + english: 'Verse Labels', + spanish: 'Etiquetas de Versรญculos', + brazilian_portuguese: 'Etiquetas de Versรญculos', + tok_pisin: 'Verse Labels', + indonesian: 'Label Versi' + }, + verseMarkersDescription: { + english: 'Enable verse labels to help organize Bible resources', + spanish: + 'Habilitar etiquetas de versรญculos para ayudar a organizar recursos de la Biblia', + brazilian_portuguese: + 'Habilitar etiquetas de versรญculos para ajudar a organizar recursos da Bรญblia', + tok_pisin: 'Enable verse labels to help organize Bible resources', + indonesian: + 'Aktifkan label versi untuk membantu mengorganisir sumber daya Alkitab' + }, // Languoid Link Suggestion strings languoidLinkSuggestionTitle: { english: 'Link your language?', diff --git a/store/localStore.ts b/store/localStore.ts index b396f09d4..8fc92b4e8 100644 --- a/store/localStore.ts +++ b/store/localStore.ts @@ -11,6 +11,7 @@ export type AppView = | 'quests' | 'assets' | 'asset-detail' + | 'bible-assets' | 'profile' | 'notifications' | 'settings' @@ -120,6 +121,10 @@ export interface LocalState { setEnablePlayAll: (enabled: boolean) => void; enableQuestExport: boolean; setEnableQuestExport: (enabled: boolean) => void; + enableVerseMarkers: boolean; + setEnableVerseMarkers: (enabled: boolean) => void; + verseMarkersFeaturePrompted: boolean; + setVerseMarkersFeaturePrompted: (prompted: boolean) => void; enableTranscription: boolean; setEnableTranscription: (enabled: boolean) => void; enableLanguoidLinkSuggestions: boolean; @@ -261,6 +266,8 @@ export const useLocalStore = create()( enableAiSuggestions: false, enablePlayAll: false, enableQuestExport: false, + enableVerseMarkers: false, + verseMarkersFeaturePrompted: false, enableTranscription: false, enableLanguoidLinkSuggestions: false, @@ -369,6 +376,9 @@ export const useLocalStore = create()( set({ enableAiSuggestions: enabled }), setEnablePlayAll: (enabled) => set({ enablePlayAll: enabled }), setEnableQuestExport: (enabled) => set({ enableQuestExport: enabled }), + setEnableVerseMarkers: (enabled) => set({ enableVerseMarkers: enabled }), + setVerseMarkersFeaturePrompted: (prompted) => + set({ verseMarkersFeaturePrompted: prompted }), setEnableTranscription: (enabled) => set({ enableTranscription: enabled }), setEnableLanguoidLinkSuggestions: (enabled) => diff --git a/supabase/migrations/20251214120000_add_asset_metadata_field.sql b/supabase/migrations/20251214120000_add_asset_metadata_field.sql new file mode 100644 index 000000000..b8686a0a9 --- /dev/null +++ b/supabase/migrations/20251214120000_add_asset_metadata_field.sql @@ -0,0 +1,11 @@ +-- Migration: Add metadata field to asset table +-- Version: 2.0 โ†’ 2.1 +-- Purpose: Store JSON metadata for asset-specific data (e.g., verse ranges for Bible projects) + +-- Add metadata column to asset table +alter table asset + add column if not exists metadata text; + +-- Add comment describing the field +comment on column asset.metadata is 'JSON metadata for asset-specific data (e.g., {"verse": {"from": 1, "to": 3}})'; + diff --git a/views/AppView.tsx b/views/AppView.tsx index fae03eb51..89e41708c 100644 --- a/views/AppView.tsx +++ b/views/AppView.tsx @@ -34,6 +34,7 @@ const NextGenAssetDetailView = React.lazy( const NextGenAssetsView = React.lazy( () => import('@/views/new/NextGenAssetsView') ); +const BibleAssetsView = React.lazy(() => import('@/views/new/BibleAssetsView')); const NextGenProjectsView = React.lazy( () => import('@/views/new/NextGenProjectsView') ); @@ -76,6 +77,7 @@ function AppViewContent() { const setOnboardingIsOpen = useLocalStore( (state) => state.setOnboardingIsOpen ); + const enableVerseMarkers = useLocalStore((state) => state.enableVerseMarkers); const [drawerIsVisible, setDrawerIsVisible] = useState(false); const [deferredView, setDeferredView] = useState(currentView); const { isCloudLoading } = useCloudLoading(); @@ -162,6 +164,20 @@ function AppViewContent() { } }, [currentView, isAuthenticated, goToProjects]); + // Block bible-assets view if enableVerseMarkers is disabled + // Redirect to previous view if user tries to access bible-assets without the feature enabled + useEffect(() => { + if (currentView === 'bible-assets' && !enableVerseMarkers) { + // Redirect to previous view (usually quests or assets) + if (canGoBack) { + goBack(); + } else { + // Fallback to projects if no navigation history + goToProjects(); + } + } + }, [currentView, enableVerseMarkers, canGoBack, goBack, goToProjects]); + // Track if navigation is in progress const isNavigating = currentView !== deferredView; @@ -210,6 +226,8 @@ function AppViewContent() { return ; case 'assets': return ; + case 'bible-assets': + return ; case 'asset-detail': return ; case 'profile': diff --git a/views/SettingsView.tsx b/views/SettingsView.tsx index 36eb0ec4f..28254718c 100644 --- a/views/SettingsView.tsx +++ b/views/SettingsView.tsx @@ -53,6 +53,7 @@ export default function SettingsView() { ); const enablePlayAll = useLocalStore((state) => state.enablePlayAll); const enableQuestExport = useLocalStore((state) => state.enableQuestExport); + const enableVerseMarkers = useLocalStore((state) => state.enableVerseMarkers); const enableTranscription = useLocalStore( (state) => state.enableTranscription ); @@ -78,6 +79,9 @@ export default function SettingsView() { const setEnableQuestExport = useLocalStore( (state) => state.setEnableQuestExport ); + const setEnableVerseMarkers = useLocalStore( + (state) => state.setEnableVerseMarkers + ); const setEnableTranscription = useLocalStore( (state) => state.setEnableTranscription ); @@ -127,6 +131,10 @@ export default function SettingsView() { console.log('Quest export:', value); }; + const handleVerseMarkersToggle = (value: boolean) => { + setEnableVerseMarkers(value); + }; + const handleTranscriptionToggle = (value: boolean) => { setEnableTranscription(value); }; @@ -283,6 +291,16 @@ export default function SettingsView() { onPress: () => handleQuestExportToggle(!enableQuestExport), disabled: !isOnline }, + { + id: 'verseMarkers', + title: t('verseMarkers') || 'Verse Labels', + description: + t('verseMarkersDescription') || + 'Enable verse labels to help organize Bible resources', + type: 'toggle', + value: enableVerseMarkers, + onPress: () => handleVerseMarkersToggle(!enableVerseMarkers) + }, { id: 'transcription', title: t('transcription') || 'Transcription', diff --git a/views/new/AssetListItem.tsx b/views/new/AssetListItem.tsx index a4f4975b9..94d0e1964 100644 --- a/views/new/AssetListItem.tsx +++ b/views/new/AssetListItem.tsx @@ -8,14 +8,24 @@ import { import { Icon } from '@/components/ui/icon'; import { useAuth } from '@/contexts/AuthContext'; import { LayerType, useStatusContext } from '@/contexts/StatusContext'; +// import type { Tag } from '@/database_services/tagCache'; +// import { tagService } from '@/database_services/tagService'; import type { asset as asset_type } from '@/db/drizzleSchema'; import { useAppNavigation } from '@/hooks/useAppNavigation'; import { useLocalization } from '@/hooks/useLocalization'; +// import { useTagStore } from '@/hooks/useTagStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import type { AttachmentRecord } from '@powersync/attachments'; -import { EyeOffIcon, HardDriveIcon, PauseIcon } from 'lucide-react-native'; +import { + EyeOffIcon, + HardDriveIcon, + PauseIcon + // Plus, + // TagIcon +} from 'lucide-react-native'; import React from 'react'; import { Pressable, View } from 'react-native'; +// import { TagModal } from '../../components/TagModal'; import { useItemDownload, useItemDownloadStatus } from './useHybridData'; // Define props locally to avoid require cycle @@ -25,10 +35,13 @@ type Asset = typeof asset_type.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + // tag_ids?: string[] | undefined; }; export interface AssetListItemProps { asset: AssetQuestLink; + isPublished: boolean; questId: string; + onUpdate?: () => void; attachmentState?: AttachmentRecord; isCurrentlyPlaying?: boolean; } @@ -36,8 +49,10 @@ export interface AssetListItemProps { export const AssetListItem: React.FC = ({ asset, questId, - attachmentState, - isCurrentlyPlaying = false + isCurrentlyPlaying = false, + isPublished: _isPublished, + onUpdate: _onUpdate, + attachmentState: _attachmentState }) => { const { goToAsset, currentProjectData, currentQuestData } = useAppNavigation(); @@ -46,12 +61,57 @@ export const AssetListItem: React.FC = ({ // Check if asset is downloaded const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); + // Tags temporarily disabled + // const fetchManyTags = useTagStore((s) => s.fetchManyTags); + // const [tags, setTags] = React.useState< + // { id: string; key: string; value?: string }[] + // >([]); + + // React.useEffect(() => { + // const loadTags = async () => { + // if (asset.tag_ids && asset.tag_ids.length > 0) { + // const fetchedTags = await fetchManyTags(asset.tag_ids); + // setTags(fetchedTags); + // } + // }; + // void loadTags(); + // }, [asset.tag_ids, fetchManyTags]); + // Download mutation const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload( 'asset', asset.id ); + // Tag modal state - temporarily disabled + // const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + + // const handleOpenTagModal = () => { + // console.log('Opening tag modal for asset:', asset.id); + // setIsTagModalVisible(true); + // }; + + // const handleAssignTags = async (tags: Tag[]) => { + // try { + // // Extract tag IDs from the tags array + // const tagIds = tags.map((tag) => tag.id); + + // // Use the tagService to assign tags to the asset + // await tagService.assignTagsToAssetLocal(asset.id, tagIds); + + // onUpdate?.(); + + // console.log( + // `Successfully assigned ${tagIds.length} tags to asset ${asset.id}` + // ); + // } catch (error) { + // console.error('Failed to assign tags to asset:', error); + // // TODO: Show error toast/alert to user + // } finally { + // setIsTagModalVisible(false); + // } + // }; + const layerStatus = useStatusContext(); const { allowEditing, invisible } = layerStatus.getStatusParams( LayerType.ASSET, @@ -97,6 +157,8 @@ export const AssetListItem: React.FC = ({ downloadAsset({ userId: currentUser.id, download: !isDownloaded }); }; + // const tag = tags.length > 0 ? tags[0] : null; + return ( = ({ > - - + + {(!allowEditing || invisible) && ( {invisible && ( @@ -129,6 +191,33 @@ export const AssetListItem: React.FC = ({ + {/* Tags temporarily disabled */} + {/* + + {tags.length === 0 ? ( + !isPublished && ( + + + + + ) + ) : ( + + + + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} + + + + )} + + */} = ({ */} + + {/* Tags temporarily disabled */} + {/* setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> */} ); }; diff --git a/views/new/BibleAssetListItem.tsx b/views/new/BibleAssetListItem.tsx new file mode 100644 index 000000000..f4ebcc190 --- /dev/null +++ b/views/new/BibleAssetListItem.tsx @@ -0,0 +1,460 @@ +import { DownloadIndicator } from '@/components/DownloadIndicator'; +// import { Badge } from '@/components/ui/badge'; +import { + Card, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card'; +import { Icon } from '@/components/ui/icon'; +import { useAuth } from '@/contexts/AuthContext'; +import { LayerType, useStatusContext } from '@/contexts/StatusContext'; +// import type { Tag } from '@/database_services/tagCache'; +// import { tagService } from '@/database_services/tagService'; +import type { asset as asset_type } from '@/db/drizzleSchema'; +import { useAppNavigation } from '@/hooks/useAppNavigation'; +import { useLocalization } from '@/hooks/useLocalization'; +// import { useTagStore } from '@/hooks/useTagStore'; +import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; +import type { AttachmentRecord } from '@powersync/attachments'; +import { + CheckSquareIcon, + EyeOffIcon, + GripVerticalIcon, + HardDriveIcon, + PauseIcon, + PencilLineIcon, + PlayIcon, + // Plus, + SquareIcon + // TagIcon +} from 'lucide-react-native'; +import React from 'react'; +import { Pressable, View } from 'react-native'; +import Sortable from 'react-native-sortables'; +// import { TagModal } from '../../components/TagModal'; +import { useItemDownload, useItemDownloadStatus } from './useHybridData'; + +// Define props locally to avoid require cycle + +type Asset = typeof asset_type.$inferSelect; + +type AssetQuestLink = Asset & { + quest_active: boolean; + quest_visible: boolean; + tag_ids?: string[] | undefined; +}; +export interface BibleAssetListItemProps { + asset: AssetQuestLink; + isPublished: boolean; + questId: string; + onUpdate?: () => void; + onPlay?: (assetId: string) => void | Promise; + attachmentState?: AttachmentRecord; + isCurrentlyPlaying?: boolean; + // Drag & Drop props (replaces dragHandle ReactNode) + showDragHandle?: boolean; // Whether to show drag handle (not in selection mode, not published) + isDragFixed?: boolean; // Whether this item has fixed drag order + // Selection mode props (batch operations like merge/delete) + isSelectionMode?: boolean; + isSelected?: boolean; + onToggleSelect?: (assetId: string) => void; + onEnterSelection?: (assetId: string) => void; + // Recording insertion point selection + isSelectedForRecording?: boolean; + onSelectForRecording?: (assetId: string) => void; + // Rename asset + onRename?: (assetId: string, currentName: string | null) => void; +} + +const BibleAssetListItemComponent: React.FC = ({ + asset, + questId, + isCurrentlyPlaying = false, + isPublished, + onUpdate: _onUpdate, + onPlay, + attachmentState: _attachmentState, + showDragHandle = false, + isDragFixed = false, + isSelectionMode = false, + isSelected = false, + onToggleSelect, + onEnterSelection, + isSelectedForRecording = false, + onSelectForRecording, + onRename +}) => { + const { goToAsset, currentProjectData, currentQuestData } = + useAppNavigation(); + const { currentUser } = useAuth(); + const { t } = useLocalization(); + // Check if asset is downloaded + const isDownloaded = useItemDownloadStatus(asset, currentUser?.id); + + // Tags functionality commented out + // const fetchManyTags = useTagStore((s) => s.fetchManyTags); + // const [tags, setTags] = React.useState< + // { id: string; key: string; value?: string }[] + // >([]); + + // React.useEffect(() => { + // const loadTags = async () => { + // if (asset.tag_ids && asset.tag_ids.length > 0) { + // const fetchedTags = await fetchManyTags(asset.tag_ids); + // setTags(fetchedTags); + // } + // }; + // void loadTags(); + // }, [asset.tag_ids, fetchManyTags]); + + // Download mutation + const { mutate: downloadAsset, isPending: isDownloading } = useItemDownload( + 'asset', + asset.id + ); + + // Tag modal state - commented out + // const [isTagModalVisible, setIsTagModalVisible] = React.useState(false); + + // const handleOpenTagModal = () => { + // console.log('Opening tag modal for asset:', asset.id); + // setIsTagModalVisible(true); + // }; + + // const handleAssignTags = async (tags: Tag[]) => { + // try { + // // Extract tag IDs from the tags array + // const tagIds = tags.map((tag) => tag.id); + + // // Use the tagService to assign tags to the asset + // await tagService.assignTagsToAssetLocal(asset.id, tagIds); + + // onUpdate?.(); + + // console.log( + // `Successfully assigned ${tagIds.length} tags to asset ${asset.id}` + // ); + // } catch (error) { + // console.error('Failed to assign tags to asset:', error); + // // TODO: Show error toast/alert to user + // } finally { + // setIsTagModalVisible(false); + // } + // }; + + const layerStatus = useStatusContext(); + const { allowEditing, invisible } = layerStatus.getStatusParams( + LayerType.ASSET, + asset.id || '', + { + visible: asset.visible && asset.quest_visible, + active: asset.active && asset.quest_active, + source: asset.source + }, + questId + ); + + const handlePress = () => { + // If in selection mode, toggle selection instead of navigating + if (isSelectionMode) { + onToggleSelect?.(asset.id); + return; + } + + // If not published, select for recording (toggle) + if (!isPublished) { + onSelectForRecording?.(asset.id); + return; + } + + layerStatus.setLayerStatus( + LayerType.ASSET, + { + visible: asset.visible, + active: asset.active, + quest_active: asset.quest_active, + quest_visible: asset.quest_visible, + source: asset.source + }, + asset.id, + questId + ); + + goToAsset({ + id: asset.id, + name: asset.name || t('unnamedAsset'), + questId: questId, + projectId: asset.project_id!, + projectData: currentProjectData, // Pass project data forward! + questData: currentQuestData // Pass quest data forward! + // NOTE: Don't pass assetData - the detail view needs full asset with content/audio + // relationships which aren't loaded in the list view + }); + }; + + const handleLongPress = () => { + // Enter selection mode on long press + if (!isSelectionMode && onEnterSelection) { + onEnterSelection(asset.id); + } + }; + + const handleDownloadToggle = () => { + if (!currentUser?.id) return; + + // Toggle download status + downloadAsset({ userId: currentUser.id, download: !isDownloaded }); + }; + + // Tags display - commented out + // const tag = tags.length > 0 ? tags[0] : null; + + // Create drag handle inside component (memoized for performance) + const dragHandle = React.useMemo(() => { + if (!showDragHandle) return null; + + return ( + + + + ); + }, [showDragHandle, isDragFixed]); + + // Render selection checkbox or drag handle + const selectionOrDragElement = isSelectionMode ? ( + onToggleSelect?.(asset.id)} + className="mr-1 flex h-7 w-7 items-center justify-center" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : ( + dragHandle + ); + + return ( + + + + + + + {(!allowEditing || invisible) && ( + + {invisible && ( + + )} + {!allowEditing && ( + + )} + + )} + {selectionOrDragElement} + + {asset.source === 'local' && ( + + )} + {/* Play button - only show if onPlay is provided */} + {onPlay && ( + { + e.stopPropagation(); + void onPlay(asset.id); + }} + className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + )} + + {asset.name || t('unnamedAsset')} + + + + {/* Tags UI - commented out */} + {/* + + {tags.length === 0 ? ( + !isPublished && ( + + + + + ) + ) : ( + + + + + {tag && `${tag.key}${tag.value && `: ${tag.value}`}`} + + + + )} + + */} + {/* Show pencil button for local assets when not published, otherwise show download indicator */} + {!isPublished && onRename && asset.source === 'local' ? ( + { + e.stopPropagation(); + onRename(asset.id, asset.name); + }} + className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/20 active:bg-primary/40" + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + ) : ( + + )} + + {SHOW_DEV_ELEMENTS && ( + + {`ID: ${asset.id.substring(0, 8)}...`} + + )} + + + {/* + + + */} + + + {/* TagModal - commented out */} + {/* setIsTagModalVisible(false)} + onAssignTags={handleAssignTags} + /> */} + + ); +}; + +/** + * Custom comparison function for React.memo + * Returns TRUE if props are EQUAL (skip re-render) + * Returns FALSE if props are DIFFERENT (re-render needed) + */ +const arePropsEqual = ( + prevProps: BibleAssetListItemProps, + nextProps: BibleAssetListItemProps +): boolean => { + // 1. Compare primitive props that affect visual rendering + if ( + prevProps.questId !== nextProps.questId || + prevProps.isPublished !== nextProps.isPublished || + prevProps.isCurrentlyPlaying !== nextProps.isCurrentlyPlaying || + prevProps.showDragHandle !== nextProps.showDragHandle || + prevProps.isDragFixed !== nextProps.isDragFixed || + prevProps.isSelectionMode !== nextProps.isSelectionMode || + prevProps.isSelected !== nextProps.isSelected || + prevProps.isSelectedForRecording !== nextProps.isSelectedForRecording + ) { + return false; // Props changed, need to re-render + } + + // 2. Compare asset object (only fields that affect UI) + const prevAsset = prevProps.asset; + const nextAsset = nextProps.asset; + + if ( + prevAsset.id !== nextAsset.id || + prevAsset.name !== nextAsset.name || + prevAsset.order_index !== nextAsset.order_index || + prevAsset.visible !== nextAsset.visible || + prevAsset.active !== nextAsset.active || + prevAsset.quest_visible !== nextAsset.quest_visible || + prevAsset.quest_active !== nextAsset.quest_active || + prevAsset.source !== nextAsset.source + ) { + return false; // Asset changed, need to re-render + } + + // 3. Compare metadata (small object, JSON.stringify is fast) + const prevMetadata = JSON.stringify(prevAsset.metadata); + const nextMetadata = JSON.stringify(nextAsset.metadata); + if (prevMetadata !== nextMetadata) { + return false; // Metadata changed, need to re-render + } + + // 4. Compare attachmentState (only the state field matters for UI) + const prevState = prevProps.attachmentState?.state; + const nextState = nextProps.attachmentState?.state; + if (prevState !== nextState) { + return false; // Attachment state changed, need to re-render + } + + // 5. Ignore function props (they're stable from Part 2 optimization) + // onUpdate, onPlay, onToggleSelect, onEnterSelection, onSelectForRecording, onRename + // These are ignored because they're memoized in the parent component + + return true; // Props are equal, skip re-render โœ… +}; + +/** + * Memoized BibleAssetListItem component + * Only re-renders when props that affect visual output change + */ +export const BibleAssetListItem = React.memo( + BibleAssetListItemComponent, + arePropsEqual +); + +BibleAssetListItem.displayName = 'BibleAssetListItem'; diff --git a/views/new/BibleAssetsView.tsx b/views/new/BibleAssetsView.tsx new file mode 100644 index 000000000..2bdc5046d --- /dev/null +++ b/views/new/BibleAssetsView.tsx @@ -0,0 +1,3730 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { AssetsDeletionDrawer } from '@/components/AssetsDeletionDrawer'; +import { QuestSettingsModal } from '@/components/QuestSettingsModal'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Input } from '@/components/ui/input'; +import { + SpeedDial, + SpeedDialItem, + SpeedDialItems, + SpeedDialTrigger +} from '@/components/ui/speed-dial'; +import { Text } from '@/components/ui/text'; +import { useAudio } from '@/contexts/AudioContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { LayerType, useStatusContext } from '@/contexts/StatusContext'; +import type { asset } from '@/db/drizzleSchema'; +import { + asset_content_link, + project, + quest as questTable +} from '@/db/drizzleSchema'; +import { system } from '@/db/powersync/system'; +import { useDebouncedState } from '@/hooks/use-debounced-state'; +import { + useAppNavigation, + useCurrentNavigation +} from '@/hooks/useAppNavigation'; +import { useAttachmentStates } from '@/hooks/useAttachmentStates'; +import { useLocalization } from '@/hooks/useLocalization'; +import { useQuestDownloadStatusLive } from '@/hooks/useQuestDownloadStatusLive'; +import { useUserPermissions } from '@/hooks/useUserPermissions'; +import { useLocalStore } from '@/store/localStore'; +import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; +import RNAlert from '@blazejkustra/react-native-alert'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Audio } from 'expo-av'; +import { + BookmarkPlusIcon, + BrushCleaning, + CheckCheck, + ChevronRight, + CloudUpload, + FlagIcon, + InfoIcon, + LockIcon, + MicIcon, + PauseIcon, + PlayIcon, + RefreshCwIcon, + SearchIcon, + SettingsIcon, + UserPlusIcon +} from 'lucide-react-native'; +import React from 'react'; +import { ActivityIndicator, Pressable, View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + runOnJS, + useAnimatedRef, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useHybridData } from './useHybridData'; + +import { AssetListSkeleton } from '@/components/AssetListSkeleton'; +import { ExportButton } from '@/components/ExportButton'; +import { ModalDetails } from '@/components/ModalDetails'; +import { ReportModal } from '@/components/NewReportModal'; +import { PrivateAccessGate } from '@/components/PrivateAccessGate'; +import { QuestOffloadVerificationDrawer } from '@/components/QuestOffloadVerificationDrawer'; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerScrollView, + DrawerTitle +} from '@/components/ui/drawer'; +import { VerseAssigner } from '@/components/VerseAssigner'; +import { VerseRangeSelector } from '@/components/VerseRangeSelector'; +import { VerseSeparator } from '@/components/VerseSeparator'; +import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import type { AssetUpdatePayload } from '@/database_services/assetService'; +import { + batchUpdateAssetMetadata, + renameAsset +} from '@/database_services/assetService'; +import { audioSegmentService } from '@/database_services/audioSegmentService'; +import { AppConfig } from '@/db/supabase/AppConfig'; +import { useAssetsByQuest, useLocalAssetsByQuest } from '@/hooks/db/useAssets'; +import { useBlockedAssetsCount } from '@/hooks/useBlockedCount'; +import { useQuestOffloadVerification } from '@/hooks/useQuestOffloadVerification'; +import { useHasUserReported } from '@/hooks/useReports'; +import { resolveTable } from '@/utils/dbUtils'; +import { fileExists, getLocalAttachmentUriWithOPFS } from '@/utils/fileUtils'; +import { publishQuest as publishQuestUtils } from '@/utils/publishUtils'; +import { offloadQuest } from '@/utils/questOffloadUtils'; +import { getThemeColor } from '@/utils/styleUtils'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { and, asc, eq, gte, lte } from 'drizzle-orm'; +import { ScrollView as GHScrollView } from 'react-native-gesture-handler'; +import Sortable from 'react-native-sortables'; +import { BibleAssetListItem } from './BibleAssetListItem'; +import BibleRecordingView from './recording/components/BibleRecordingView'; +import { BibleSelectionControls } from './recording/components/BibleSelectionControls'; +import { RenameAssetDrawer } from './recording/components/RenameAssetDrawer'; +import { useSelectionMode } from './recording/hooks/useSelectionMode'; +// import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; + +type Asset = typeof asset.$inferSelect; + +interface AssetMetadata { + verse?: { + from: number; + to: number; + }; +} + +type AssetQuestLink = Asset & { + quest_active: boolean; + quest_visible: boolean; + tag_ids?: string[] | undefined; + metadata?: AssetMetadata | null; +}; + +// List item types for rendering +interface ListItemAsset { + type: 'asset'; + content: AssetQuestLink; + key: string; +} + +interface ListItemSeparator { + type: 'separator'; + from?: number; + to?: number; + key: string; +} + +type ListItem = ListItemAsset | ListItemSeparator; + +// Manual separator type used for verse grouping +interface ManualSeparator { + from: number; + to: number; + key: string; + assetId?: string; +} + +const RecordingPlaceIndicator = () => ( + + {/* */} + + REC + +); + +// ============================================================================ +// HELPER FUNCTIONS (moved outside component for better performance) +// ============================================================================ + +/** + * Builds the final list of items (assets + separators) for rendering. + * This is extracted as a pure function to avoid recreation on each render. + */ +function buildFinalList( + assetsWithMeta: AssetQuestLink[], + assetsWithoutMeta: AssetQuestLink[], + separatorsWithAssetId: ManualSeparator[], + sortedSeparatorsWithoutAssetId: ManualSeparator[], + allManualSeparators: ManualSeparator[] +): ListItem[] { + // Build list with auto-generated separators + assets with metadata + const result: ListItem[] = []; + let currentFrom: number | undefined; + let currentTo: number | undefined; + + for (const asset of assetsWithMeta) { + const from = asset.metadata?.verse?.from; + const to = asset.metadata?.verse?.to; + + // Add separator when verse range changes + if (from !== currentFrom || to !== currentTo) { + result.push({ + type: 'separator', + from, + to, + key: `sep-${from}-${to}` + }); + currentFrom = from; + currentTo = to; + } + + result.push({ + type: 'asset', + content: asset, + key: asset.id + }); + } + + // Build unassigned block (assets without verse metadata) + const unassignedBlock: ListItem[] = []; + if (assetsWithoutMeta.length > 0) { + unassignedBlock.push({ + type: 'separator', + key: 'sep-unassigned' + }); + + for (const asset of assetsWithoutMeta) { + unassignedBlock.push({ + type: 'asset', + content: asset, + key: asset.id + }); + } + } + + // Insert separators that target a specific asset + for (const sep of separatorsWithAssetId) { + if (!sep.assetId) continue; + + const assetIndex = result.findIndex( + (item) => item.type === 'asset' && item.content.id === sep.assetId + ); + + const sepItem: ListItemSeparator = { + type: 'separator', + from: sep.from, + to: sep.to, + key: sep.key + }; + + if (assetIndex !== -1) { + result.splice(assetIndex, 0, sepItem); + } else { + // Asset is in unassignedBlock, insert at end of result + result.push(sepItem); + } + } + + // Insert separators without assetId by verse order + for (const sep of sortedSeparatorsWithoutAssetId) { + const sepItem: ListItemSeparator = { + type: 'separator', + from: sep.from, + to: sep.to, + key: sep.key + }; + + let insertIdx = result.findIndex( + (item) => + item.type === 'separator' && + item.from !== undefined && + sep.from < item.from + ); + if (insertIdx === -1) { + insertIdx = result.length; + } + result.splice(insertIdx, 0, sepItem); + } + + // Combine: result + unassigned block + const combined: ListItem[] = [...result, ...unassignedBlock]; + + // Build set of manual separator ranges for deduplication + const manualSeparatorRanges = new Set(); + const manualSeparatorKeys = new Set(); + for (const sep of allManualSeparators) { + manualSeparatorRanges.add(`${sep.from ?? 'none'}-${sep.to ?? 'none'}`); + manualSeparatorKeys.add(sep.key); + } + + // Deduplicate separators (prefer manual over auto-generated) + const seenSeparatorRanges = new Set(); + const deduped: ListItem[] = []; + + for (const item of combined) { + if (item.type === 'separator') { + const sepRange = `${item.from ?? 'none'}-${item.to ?? 'none'}`; + const isManualSeparator = manualSeparatorKeys.has(item.key); + const hasManualSeparatorForRange = manualSeparatorRanges.has(sepRange); + + // Skip if we've already seen this range + if (seenSeparatorRanges.has(sepRange)) { + continue; + } + + // Skip auto-generated if manual exists for this range + if (!isManualSeparator && hasManualSeparatorForRange) { + continue; + } + + seenSeparatorRanges.add(sepRange); + } + deduped.push(item); + } + + return deduped; +} + +export default function BibleAssetsView() { + const { + currentQuestId, + currentProjectId, + currentProjectData, + currentQuestData, + currentBookId + } = useCurrentNavigation(); + const { goBack } = useAppNavigation(); + const { currentUser } = useAuth(); + const audioContext = useAudio(); + const queryClient = useQueryClient(); + const insets = useSafeAreaInsets(); + + // Selection mode for batch operations + const { + isSelectionMode, + selectedAssetIds, + enterSelection, + toggleSelect, + cancelSelection + } = useSelectionMode(); + const [debouncedSearchQuery, searchQuery, setSearchQuery] = useDebouncedState( + '', + 300 + ); + const { t } = useLocalization(); + const [showDetailsModal, setShowDetailsModal] = React.useState(false); + const [showSettingsModal, setShowSettingsModal] = React.useState(false); + const [showReportModal, setShowReportModal] = React.useState(false); + const [showOffloadDrawer, setShowOffloadDrawer] = React.useState(false); + const [showDeleteAllDrawer, setShowDeleteAllDrawer] = React.useState(false); + const [verseSelectorState, setVerseSelectorState] = React.useState<{ + isOpen: boolean; + key: string | null; + from?: number; + to?: number; + }>({ isOpen: false, key: null }); + + // State for adding new label (not editing existing) + const [newLabelSelectorState, setNewLabelSelectorState] = React.useState<{ + isOpen: boolean; + from?: number; + to?: number; + }>({ isOpen: false }); + + // State for adding verse label above a specific asset + const [assetVerseSelectorState, setAssetVerseSelectorState] = React.useState<{ + isOpen: boolean; + assetId: string | null; + from?: number; + to?: number; + }>({ isOpen: false, assetId: null }); + + // State for editing an existing separator + const [editSeparatorState, setEditSeparatorState] = React.useState<{ + isOpen: boolean; + separatorKey: string | null; + from?: number; + to?: number; + }>({ isOpen: false, separatorKey: null }); + + // State for renaming assets + const [showRenameDrawer, setShowRenameDrawer] = React.useState(false); + const [renameAssetId, setRenameAssetId] = React.useState(null); + const [renameAssetName, setRenameAssetName] = React.useState(''); + + // State for batch verse assignment + const [showVerseAssignerDrawer, setShowVerseAssignerDrawer] = + React.useState(false); + + // Manual verse separators created by the user + const [manualSeparators, setManualSeparators] = React.useState< + { from: number; to: number; key: string; assetId?: string }[] + >([]); + + // Track which separators have been processed for auto-assignment + const processedSeparatorsRef = React.useRef>(new Set()); + + // Function to add a new verse separator + // If assetId is provided, insert the separator right above that asset + const addVerseSeparator = React.useCallback( + (from: number, to: number, assetId?: string) => { + const newSeparator = { + from, + to, + key: `manual-sep-${from}-${to}-${Date.now()}`, + assetId // Store assetId to know where to insert it + }; + setManualSeparators((prev) => [...prev, newSeparator]); + }, + [] + ); + + const [showPrivateAccessModal, setShowPrivateAccessModal] = + React.useState(false); + const [isOffloading, setIsOffloading] = React.useState(false); + const [isRefreshing, setIsRefreshing] = React.useState(false); + // Track which asset is currently playing during play-all + const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState< + string | null + >(null); + const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId + const assetOrderRef = React.useRef([]); // Ordered list of asset IDs + const uriOrderRef = React.useRef([]); // Ordered list of URIs matching assetOrderRef + const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms + const fixedItemsIndexesRef = React.useRef([0]); + // Ref to allow handlePlayAsset to be used in renderItem before it's defined + const handlePlayAssetRef = React.useRef< + (assetId: string) => void | Promise + >((_assetId: string) => { + // No-op: will be replaced by handlePlayAsset when defined + }); + + const scrollableRef = useAnimatedRef(); + + // Animation for refresh button + const spinValue = useSharedValue(0); + + React.useEffect(() => { + if (isRefreshing) { + spinValue.value = withRepeat( + withTiming(1, { duration: 1000, easing: Easing.linear }), + -1 + ); + } else { + cancelAnimation(spinValue); + spinValue.value = 0; + } + }, [isRefreshing, spinValue]); + + const spinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${spinValue.value * 360}deg` }] + })); + + type Quest = typeof questTable.$inferSelect; + + // Use passed quest data if available (instant!), otherwise query + const { data: queriedQuestData, refetch: refetchQuest } = useHybridData({ + dataType: 'current-quest', + queryKeyParams: [currentQuestId], + offlineQuery: toCompilableQuery( + system.db.query.quest.findFirst({ + where: eq(questTable.id, currentQuestId!) + }) + ), + cloudQueryFn: async () => { + const { data, error } = await system.supabaseConnector.client + .from('quest') + .select('*') + .eq('id', currentQuestId) + .overrideTypes(); + if (error) throw error; + return data; + }, + enableCloudQuery: !!currentQuestId, + enableOfflineQuery: !!currentQuestId, + getItemId: (item) => item.id + }); + + // Prefer queried data (fresh) over navigation data (may be stale) + // This ensures UI updates immediately after publishing without needing to navigate away + const selectedQuest = React.useMemo(() => { + // If we have queried data, prefer it (it's fresh from refetch) + // Otherwise fall back to currentQuestData for instant initial rendering + const questData = + queriedQuestData && queriedQuestData.length > 0 + ? queriedQuestData + : currentQuestData + ? [currentQuestData as Quest] + : undefined; + return questData?.[0]; + }, [currentQuestData, queriedQuestData]); + + // Check if quest is published (source is 'synced') + const isPublished = selectedQuest?.source === 'synced'; + + // Store book name and chapter number for VerseSeparator label + const bookChapterLabelRef = React.useRef('Verse'); + + // Calculate book chapter label (short name for separators) + const bookChapterLabel = React.useMemo(() => { + if (!selectedQuest || !currentBookId) { + return 'Verse'; + } + + // Extract chapter number from metadata.bible.chapter + let chapterNum: number | undefined; + if (selectedQuest.metadata) { + try { + const metadata: unknown = + typeof selectedQuest.metadata === 'string' + ? JSON.parse(selectedQuest.metadata) + : selectedQuest.metadata; + if ( + metadata && + typeof metadata === 'object' && + 'bible' in metadata && + metadata.bible && + typeof metadata.bible === 'object' && + 'chapter' in metadata.bible + ) { + chapterNum = + typeof metadata.bible.chapter === 'number' + ? metadata.bible.chapter + : undefined; + } + } catch { + // Ignore parse errors + } + } + + if (typeof chapterNum !== 'number') return 'Verse'; + const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); + + if (book?.name && chapterNum) { + return `${book.shortName} ${chapterNum}`; + } + + return 'Verse'; + }, [selectedQuest, currentBookId]); + + // Update ref when label changes + React.useEffect(() => { + bookChapterLabelRef.current = bookChapterLabel; + }, [bookChapterLabel]); + + // Get verse count for current chapter + // Use selectedQuest instead of currentQuestData to ensure we have the metadata from the database + const verseCount = React.useMemo(() => { + if (!selectedQuest || !currentBookId) return 0; + + // Extract chapter number from metadata.bible.chapter + let chapterNum: number | undefined; + if (selectedQuest.metadata) { + try { + const metadata: unknown = + typeof selectedQuest.metadata === 'string' + ? JSON.parse(selectedQuest.metadata) + : selectedQuest.metadata; + if ( + metadata && + typeof metadata === 'object' && + 'bible' in metadata && + metadata.bible && + typeof metadata.bible === 'object' && + 'chapter' in metadata.bible + ) { + chapterNum = + typeof metadata.bible.chapter === 'number' + ? metadata.bible.chapter + : undefined; + } + } catch { + // Ignore parse errors + } + } + + if (typeof chapterNum !== 'number') return 0; + const book = BIBLE_BOOKS.find((b) => b.id === currentBookId); + return book?.verses[chapterNum - 1] ?? 0; + }, [selectedQuest, currentBookId]); + + // Query project data to get privacy status if not passed + const { data: queriedProjectData } = useHybridData({ + dataType: 'project-privacy-assets', + queryKeyParams: [currentProjectId], + offlineQuery: toCompilableQuery( + system.db.query.project.findFirst({ + where: eq(project.id, currentProjectId!), + columns: { id: true, private: true, creator_id: true } + }) + ), + cloudQueryFn: async () => { + if (!currentProjectId) return []; + const { data, error } = await system.supabaseConnector.client + .from('project') + .select('id, private, creator_id') + .eq('id', currentProjectId); + if (error) throw error; + return data as Pick< + typeof project.$inferSelect, + 'id' | 'private' | 'creator_id' + >[]; + }, + enableCloudQuery: !!currentProjectId && !currentProjectData, + enableOfflineQuery: !!currentProjectId && !currentProjectData, + getItemId: (item) => item.id + }); + + // Prefer passed project data for instant rendering + const projectPrivacyData = currentProjectData + ? { + private: currentProjectData.private, + creator_id: currentProjectData.creator_id + } + : queriedProjectData?.[0]; + const isPrivateProject = projectPrivacyData?.private ?? false; + + const [showRecording, setShowRecording] = React.useState(false); + + // Track selected item for recording insertion + // Can be an asset (insert after) or a separator (insert at beginning of verse) + const [selectedForRecording, setSelectedForRecording] = React.useState<{ + type: 'asset' | 'separator'; + assetId?: string; // Only for type === 'asset' + separatorKey?: string; // Only for type === 'separator' + orderIndex: number; + metadata: AssetMetadata | null; + verseName: string; // e.g., "1:5" or "1:5-7" + } | null>(null); + + const { membership } = useUserPermissions( + currentProjectId || '', + 'open_project', + !!isPrivateProject + ); + + const isOwner = membership === 'owner'; + const isMember = membership === 'member' || membership === 'owner'; + // Check if user is creator + const isCreator = currentUser?.id === projectPrivacyData?.creator_id; + // User can see published badge if they are creator, member, or owner + const canSeePublishedBadge = isCreator || isMember; + + // Initialize offload verification hook + const verificationState = useQuestOffloadVerification(currentQuestId || ''); + + // Query SQLite directly - single source of truth, no cache, no race conditions + const isQuestDownloaded = useQuestDownloadStatusLive(currentQuestId || null); + + // Clean deeper layers + const currentStatus = useStatusContext(); + currentStatus.layerStatus(LayerType.QUEST, currentQuestId || ''); + const showInvisibleContent = useLocalStore((s) => s.showHiddenContent); + + // Call both hooks unconditionally to comply with React Hooks rules + const publishedAssets = useAssetsByQuest( + currentQuestId || '', + debouncedSearchQuery, + showInvisibleContent + ); + const localAssets = useLocalAssetsByQuest( + currentQuestId || '', + debouncedSearchQuery, + showInvisibleContent + ); + + // Use the appropriate hook result based on isPublished condition + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isOnline, + isFetching, + refetch + //} = publishedAssets; + } = isPublished ? publishedAssets : localAssets; + + // Flatten all pages into a single array and deduplicate + // Prefer synced over local when the same asset ID appears in both + const assets = React.useMemo(() => { + const allAssets = data.pages.flatMap((page) => page.data); + const assetMap = new Map(); + + // First pass: collect all assets, preferring synced over local + for (const asset of allAssets) { + const existing = assetMap.get(asset.id); + if (!existing) { + assetMap.set(asset.id, asset); + } else { + // Prefer synced over local + if (asset.source === 'synced' && existing.source !== 'synced') { + assetMap.set(asset.id, asset); + } + } + } + + return Array.from(assetMap.values()); + }, [data.pages]); + + // Infinite scroll - load more when reaching end of list + const loadMoreAssets = React.useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const scrollHandler = useAnimatedScrollHandler({ + onScroll: (event) => { + 'worklet'; + const { layoutMeasurement, contentOffset, contentSize } = event; + const paddingToBottom = 200; // pixels before end to trigger loading + + const isCloseToBottom = + layoutMeasurement.height + contentOffset.y >= + contentSize.height - paddingToBottom; + + if (isCloseToBottom) { + runOnJS(loadMoreAssets)(); + } + } + }); + + // ============================================================================ + // OPTIMIZED LIST BUILDING - Split into smaller memoized steps + // ============================================================================ + + // Step 1: Separate and sort assets with metadata (only recomputes when assets change) + const assetsWithMeta = React.useMemo(() => { + const filtered = assets.filter((a) => a.metadata?.verse?.from != null); + // Sort by verse.from first, then by order_index within each verse group + // This preserves the user's ordering within each verse + return [...filtered].sort((a, b) => { + const aFrom = a.metadata?.verse?.from ?? 0; + const bFrom = b.metadata?.verse?.from ?? 0; + if (aFrom !== bFrom) { + return aFrom - bFrom; + } + // Same verse - sort by order_index to maintain user's ordering + return (a.order_index ?? 0) - (b.order_index ?? 0); + }); + }, [assets]); + + // Step 2: Get assets without metadata (only recomputes when assets change) + // Sorted by order_index to maintain user's ordering + const assetsWithoutMeta = React.useMemo(() => { + return assets + .filter((a) => a.metadata?.verse?.from == null) + .sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)); + }, [assets]); + + // Calculate the last order_index for unassigned assets (verse 999) + // This is used when opening BibleRecordingView without a selected verse + // to continue from where we left off instead of starting from DEFAULT_ORDER_INDEX + const lastUnassignedOrderIndex = React.useMemo(() => { + if (assetsWithoutMeta.length === 0) { + return undefined; // No unassigned assets, use default + } + // Get the highest order_index from unassigned assets + const lastAsset = assetsWithoutMeta[assetsWithoutMeta.length - 1]; + return lastAsset?.order_index; + }, [assetsWithoutMeta]); + + // Step 3: Split manual separators by type (only recomputes when separators change) + const separatorsWithAssetId = React.useMemo(() => { + return manualSeparators.filter((sep) => sep.assetId); + }, [manualSeparators]); + + const sortedSeparatorsWithoutAssetId = React.useMemo(() => { + return manualSeparators + .filter((sep) => !sep.assetId) + .sort((a, b) => a.from - b.from); + }, [manualSeparators]); + + // Step 4: Build final list using pure function (recomputes only when dependencies change) + const listItems = React.useMemo((): ListItem[] => { + return buildFinalList( + assetsWithMeta, + assetsWithoutMeta, + separatorsWithAssetId, + sortedSeparatorsWithoutAssetId, + manualSeparators + ); + }, [ + assetsWithMeta, + assetsWithoutMeta, + separatorsWithAssetId, + sortedSeparatorsWithoutAssetId, + manualSeparators + ]); + + // Keep a ref to assets for stable callback (avoids recreating on every asset change) + const assetsRef = React.useRef(assets); + React.useEffect(() => { + assetsRef.current = assets; + }, [assets]); + + // Handler for selecting/deselecting an asset for recording insertion + // Optimized with ref to avoid recreation on every asset change + const handleSelectForRecording = React.useCallback( + (assetId: string) => { + // Toggle: if same asset clicked, deselect + if ( + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === assetId + ) { + setSelectedForRecording(null); + return; + } + + // Find the asset using ref (stable across renders) + const asset = assetsRef.current.find((a) => a.id === assetId); + + if (!asset) { + console.warn('Asset not found:', assetId); + return; + } + + const metadata = asset.metadata as AssetMetadata | null; + const orderIndex = asset.order_index ?? 0; + + // Build verse name from metadata + let verseName = ''; + if (metadata?.verse) { + const { from, to } = metadata.verse; + if (from === to || to === undefined) { + verseName = `${from}`; + } else { + verseName = `${from}-${to}`; + } + } + + setSelectedForRecording({ + type: 'asset', + assetId, + orderIndex, + metadata, + verseName + }); + }, + [selectedForRecording?.type, selectedForRecording?.assetId] + ); + + // Handler for selecting/deselecting a separator for recording insertion + // When a separator is selected, recordings start at the BEGINNING of that verse + // order_index = verse * 1000 * 1000 (e.g., verse 7 โ†’ 7000000) + const handleSelectSeparatorForRecording = React.useCallback( + (separatorKey: string, from?: number, to?: number) => { + // Toggle: if same separator clicked, deselect + if ( + selectedForRecording?.type === 'separator' && + selectedForRecording?.separatorKey === separatorKey + ) { + setSelectedForRecording(null); + return; + } + + // Calculate order_index: verse * 1000 * 1000 to position BEFORE first asset + // For unassigned (sep-unassigned), use 999 + const verse = from ?? 999; + const orderIndex = verse * 1000 * 1000; + + // Build verse name + let verseName = ''; + if (from !== undefined) { + if (from === to || to === undefined) { + verseName = `${from}`; + } else { + verseName = `${from}-${to}`; + } + } + + // Build metadata + const metadata: AssetMetadata | null = + from !== undefined ? { verse: { from, to: to ?? from } } : null; + + setSelectedForRecording({ + type: 'separator', + separatorKey, + orderIndex, + metadata, + verseName + }); + }, + [selectedForRecording?.type, selectedForRecording?.separatorKey] + ); + + // Handle batch delete of selected assets + // Handle delete all assets + const handleDeleteAllAssets = React.useCallback(async () => { + if (!currentQuestId) return; + + // Filter assets that are local (not cloud-only) + const localAssets = assets.filter((a) => a.source !== 'cloud'); + + if (localAssets.length < 1) { + RNAlert.alert(t('info'), 'No local assets to delete.'); + return; + } + + try { + console.log(`๐Ÿ—‘๏ธ Starting deletion of ${localAssets.length} assets...`); + + for (const asset of localAssets) { + await audioSegmentService.deleteAudioSegment(asset.id); + } + + // Reset the name counter for this quest + const counterKey = `bible_recording_counter_${currentQuestId}`; + await AsyncStorage.removeItem(counterKey); + + setSelectedForRecording(null); + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + console.log( + `โœ… Delete all completed: ${localAssets.length} assets deleted` + ); + RNAlert.alert( + t('success'), + `${localAssets.length} assets deleted successfully.` + ); + } catch (e) { + console.error('Failed to delete all assets', e); + RNAlert.alert(t('error'), 'Failed to delete assets. Please try again.'); + } + }, [assets, currentQuestId, queryClient, t, refetch]); + + const handleBatchDeleteSelected = React.useCallback(() => { + // Filter selected assets that are local (not cloud-only) + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length < 1) return; + + RNAlert.alert( + 'Delete Assets', + `Are you sure you want to delete ${selectedAssets.length} asset${selectedAssets.length > 1 ? 's' : ''}? This action cannot be undone.`, + [ + { + text: t('cancel'), + style: 'cancel' + }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + try { + for (const asset of selectedAssets) { + await audioSegmentService.deleteAudioSegment(asset.id); + } + + cancelSelection(); + setSelectedForRecording(null); + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + console.log( + `โœ… Batch delete completed: ${selectedAssets.length} assets` + ); + } catch (e) { + console.error('Failed to batch delete assets', e); + RNAlert.alert( + t('error'), + 'Failed to delete assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [assets, selectedAssetIds, cancelSelection, queryClient, t, refetch]); + + // Handle batch merge of selected assets + const handleBatchMergeSelected = React.useCallback(() => { + // Filter selected assets that are local (not cloud-only) + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length < 2) return; + + RNAlert.alert( + 'Merge Assets', + `Are you sure you want to merge ${selectedAssets.length} assets? The audio segments will be combined into the first selected asset, and the others will be deleted.`, + [ + { + text: t('cancel'), + style: 'cancel' + }, + { + text: 'Merge', + style: 'destructive', + onPress: () => { + void (async () => { + try { + if (!currentUser) return; + + const target = selectedAssets[0]!; + const rest = selectedAssets.slice(1); + const contentLocal = resolveTable('asset_content_link', { + localOverride: true + }); + + for (const src of rest) { + // Find all content links for the source asset + const srcContent = await system.db + .select() + .from(asset_content_link) + .where(eq(asset_content_link.asset_id, src.id)); + + // Insert them for the target asset + for (const c of srcContent) { + if (!c.audio) continue; + await system.db.insert(contentLocal).values({ + asset_id: target.id, + source_language_id: c.source_language_id, + languoid_id: + c.languoid_id ?? c.source_language_id ?? null, + text: c.text || '', + audio: c.audio, + download_profiles: [currentUser.id] + }); + } + + // Delete the source asset + await audioSegmentService.deleteAudioSegment(src.id); + } + + cancelSelection(); + setSelectedForRecording(null); + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + + console.log( + `โœ… Batch merge completed: ${selectedAssets.length} assets merged into ${target.id.slice(0, 8)}` + ); + } catch (e) { + console.error('Failed to batch merge assets', e); + RNAlert.alert( + t('error'), + 'Failed to merge assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [ + assets, + selectedAssetIds, + currentUser, + cancelSelection, + queryClient, + t, + refetch + ]); + + // ============================================================================ + // RENAME ASSET + // ============================================================================ + + const handleRenameAsset = React.useCallback( + (assetId: string, currentName: string | null) => { + setRenameAssetId(assetId); + setRenameAssetName(currentName ?? ''); + setShowRenameDrawer(true); + }, + [] + ); + + const handleSaveRename = React.useCallback( + async (newName: string) => { + if (!renameAssetId) return; + + try { + // renameAsset will validate that this is a local-only asset + // and throw if it's synced (immutable) + await renameAsset(renameAssetId, newName); + + // Invalidate queries to refresh the list + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (error) { + console.error('โŒ Failed to rename asset:', error); + if (error instanceof Error) { + console.warn('โš ๏ธ Rename blocked:', error.message); + RNAlert.alert(t('error'), error.message); + } + } + }, + [renameAssetId, queryClient, refetch, t] + ); + + // Auto-assign labels to assets when a separator is created with assetId + React.useEffect(() => { + const processNewSeparators = async () => { + // Find separators with assetId that haven't been processed yet + const unprocessedSeparators = manualSeparators.filter( + (sep) => sep.assetId && !processedSeparatorsRef.current.has(sep.key) + ); + + if (unprocessedSeparators.length === 0) return; + + // Process each unprocessed separator + for (const separator of unprocessedSeparators) { + if (!separator.assetId) continue; + + // Mark as processed immediately to avoid duplicate processing + processedSeparatorsRef.current.add(separator.key); + + // Find the target asset to determine its position + const targetAsset = assets.find((a) => a.id === separator.assetId); + if (!targetAsset) { + console.warn( + `โš ๏ธ Asset ${separator.assetId} not found in assets list, skipping auto-assignment` + ); + processedSeparatorsRef.current.delete(separator.key); + continue; + } + + // Check if asset is in unassigned (no metadata) + const isUnassigned = !targetAsset.metadata?.verse?.from; + + // Find all assets to update (with order_index calculation) + const assetsToUpdate: AssetUpdatePayload[] = []; + let sequentialInGroup = 1; // Start at 1 (e.g., verse 7 โ†’ 7001, 7002...) + + if (isUnassigned) { + // Asset is in unassigned block - find it and all assets below it + // until we hit another separator or the end + const targetAssetIndex = listItems.findIndex( + (item) => + item.type === 'asset' && item.content.id === separator.assetId + ); + + if (targetAssetIndex === -1) { + console.warn( + `โš ๏ธ Asset ${separator.assetId} not found in listItems, skipping` + ); + processedSeparatorsRef.current.delete(separator.key); + continue; + } + + // Start from the target asset and go down + for (let i = targetAssetIndex; i < listItems.length; i++) { + const item = listItems[i]; + if (!item) continue; + + // Stop if we encounter a separator (not the "No Verse Assigned" separator) + if ( + item.type === 'separator' && + item.key !== 'sep-unassigned' && + item.key !== separator.key + ) { + break; + } + + // If it's an asset, add it to the update list with order_index + if (item.type === 'asset') { + const newOrderIndex = + (separator.from * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: separator.from, + to: separator.to ?? separator.from + } + }, + order_index: newOrderIndex + }); + } + } + } else { + // Asset already has metadata - find separator and assets below it + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separator.key + ); + + if (separatorIndex === -1) { + console.warn( + `โš ๏ธ Separator ${separator.key} not found in listItems, skipping` + ); + processedSeparatorsRef.current.delete(separator.key); + continue; + } + + // Start from the position right after the separator + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (!item) continue; + + // Stop if we encounter another separator + if (item.type === 'separator') { + break; + } + + // If it's an asset, add it to the update list with order_index + if (item.type === 'asset') { + const newOrderIndex = + (separator.from * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: separator.from, + to: separator.to ?? separator.from + } + }, + order_index: newOrderIndex + }); + } + } + } + + // Batch update all affected assets + if (assetsToUpdate.length > 0) { + try { + await batchUpdateAssetMetadata(assetsToUpdate); + + // Invalidate queries to refresh the UI + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (err: unknown) { + console.error('Failed to update asset metadata:', err); + // Remove from processed set so it can be retried + processedSeparatorsRef.current.delete(separator.key); + } + } else { + console.warn( + `โš ๏ธ No assets found below separator ${separator.key} to update` + ); + } + } + }; + + void processNewSeparators(); + }, [manualSeparators, listItems, assets, queryClient, refetch]); + + // Clean up manual separators that have been persisted to asset metadata + // This ensures the UI correctly reflects which verses are available after metadata updates + React.useEffect(() => { + // Find manual separators that have been processed and can be removed + // A separator can be removed if: + // 1. It has been processed (metadata was updated for assets below it), OR + // 2. Its range is already covered by auto-generated separators from asset metadata + const separatorsToRemove: string[] = []; + + for (const sep of manualSeparators) { + // If this separator was already processed, it can be removed + // The auto-generated separators from asset metadata will take over + if (processedSeparatorsRef.current.has(sep.key)) { + separatorsToRemove.push(sep.key); + continue; + } + + // Also check if any asset already has metadata with this exact verse range + // This handles cases where metadata was updated outside of the normal flow + // (e.g., via _handleSorting) + const hasMatchingAsset = assets.some((asset) => { + const metadata = asset.metadata; + if (!metadata?.verse) return false; + return metadata.verse.from === sep.from && metadata.verse.to === sep.to; + }); + + if (hasMatchingAsset) { + separatorsToRemove.push(sep.key); + } + } + + if (separatorsToRemove.length > 0) { + setManualSeparators((prev) => + prev.filter((sep) => !separatorsToRemove.includes(sep.key)) + ); + // Also clean up the processed refs + for (const key of separatorsToRemove) { + processedSeparatorsRef.current.delete(key); + } + } + }, [assets, manualSeparators]); + + // Function to update an existing separator and all assets below it (until next separator) + const updateVerseSeparator = React.useCallback( + async ( + separatorKey: string, + oldFrom: number | undefined, + oldTo: number | undefined, + newFrom: number, + newTo: number + ) => { + // Update the separator in state + setManualSeparators((prev) => + prev.map((sep) => + sep.key === separatorKey ? { ...sep, from: newFrom, to: newTo } : sep + ) + ); + + // Find the separator in the listItems to get its position + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separatorKey + ); + + if (separatorIndex === -1) { + console.warn( + `โš ๏ธ Separator ${separatorKey} not found in listItems, skipping asset update` + ); + return; + } + + // Find all assets below this separator until we hit another separator + const assetsToUpdate: AssetUpdatePayload[] = []; + let sequentialInGroup = 1; // Start at 1 (e.g., verse 7 โ†’ 7001, 7002...) + + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (!item) continue; + + // Stop if we encounter another separator + if (item.type === 'separator') { + break; + } + + // If it's an asset, add it to the update list with order_index + if (item.type === 'asset') { + const newOrderIndex = (newFrom * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + assetsToUpdate.push({ + assetId: item.content.id, + metadata: { + verse: { + from: newFrom, + to: newTo + } + }, + order_index: newOrderIndex + }); + } + } + + // Batch update all affected assets + if (assetsToUpdate.length > 0) { + try { + await batchUpdateAssetMetadata(assetsToUpdate); + // Invalidate queries to refresh the UI + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (err: unknown) { + console.error('Failed to update asset metadata:', err); + } + } else { + console.warn( + `โš ๏ธ No assets found below separator ${separatorKey} to update` + ); + } + }, + [listItems, queryClient, refetch] + ); + + // Compute the allowed range for a new separator based on existing separators + // The AddVerseLabelButton is above the current separator, so: + // - rangeFrom = previous separator's "to" + 1 (or 1 if no previous) + // - rangeTo = CURRENT separator's "from" - 1 (or verseCount if current has no "from") + // Note: Currently unused but kept for potential future use + const _computeAllowedRange = React.useCallback( + (separatorKey: string) => { + const currentIdx = listItems.findIndex((i) => i.key === separatorKey); + if (currentIdx === -1) { + return { from: 1, to: verseCount || 1 }; + } + + const currentSep = listItems[currentIdx]; + + // Get the CURRENT separator's "from" value (this is the ceiling for new range) + let currentFrom: number | undefined; + if (currentSep && currentSep.type === 'separator') { + currentFrom = currentSep.from; + } + + // Look backward for the PREVIOUS separator to get its "to" value + let prevTo: number | undefined; + for (let i = currentIdx - 1; i >= 0; i--) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.to !== undefined) { + prevTo = item.to; + break; + } + } + + // Calculate range: + // - From: previous separator's "to" + 1, or 1 if no previous + // - To: CURRENT separator's "from" - 1, or verseCount if current has no "from" + const rangeFrom = prevTo !== undefined ? prevTo + 1 : 1; + const rangeTo = + currentFrom !== undefined ? currentFrom - 1 : verseCount || 1; + + // Ensure valid range (from <= to) + const finalFrom = Math.max(1, rangeFrom); + const finalTo = Math.max(finalFrom, Math.min(rangeTo, verseCount || 1)); + + return { from: finalFrom, to: finalTo }; + }, + [listItems, verseCount] + ); + + // Compute available ranges for a new label (not editing existing) + // Returns all gaps between existing separators + // Note: Currently unused but kept for potential future use + const _computeAvailableRanges = React.useCallback(() => { + const ranges: { from: number; to: number }[] = []; + + // Get all separators with valid from/to values, sorted by 'from' + const separators = listItems + .filter( + (item): item is ListItemSeparator => + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ) + .sort((a, b) => (a.from ?? 0) - (b.from ?? 0)); + + // First gap: from 1 to first separator's from - 1 + if (separators.length > 0) { + const first = separators[0]; + if (first?.from !== undefined) { + const firstFrom = first.from; + if (firstFrom > 1) { + ranges.push({ from: 1, to: firstFrom - 1 }); + } + } + } else { + // No separators, entire range is available + ranges.push({ from: 1, to: verseCount || 1 }); + } + + // Gaps between separators + for (let i = 0; i < separators.length - 1; i++) { + const current = separators[i]; + const next = separators[i + 1]; + if ( + current && + next && + current.to !== undefined && + next.from !== undefined && + current.to < next.from - 1 + ) { + ranges.push({ from: current.to + 1, to: next.from - 1 }); + } + } + + // Last gap: from last separator's to + 1 to verseCount + if (separators.length > 0) { + const last = separators[separators.length - 1]; + if (last?.to !== undefined && last.to < (verseCount || 1)) { + ranges.push({ from: last.to + 1, to: verseCount || 1 }); + } + } + + return ranges; + }, [listItems, verseCount]); + + // Get all available verses (not occupied by separators) + const getAvailableVerses = React.useCallback(() => { + const occupiedVerses = new Set(); + + // Get all separators with valid from/to values + const separators = listItems.filter( + (item): item is ListItemSeparator => + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ); + + // Mark all occupied verses + for (const sep of separators) { + if (sep.from !== undefined && sep.to !== undefined) { + for (let verse = sep.from; verse <= sep.to; verse++) { + occupiedVerses.add(verse); + } + } + } + + // Return array of available verses (1 to verseCount, excluding occupied) + const available: number[] = []; + for (let verse = 1; verse <= (verseCount || 1); verse++) { + if (!occupiedVerses.has(verse)) { + available.push(verse); + } + } + + return available; + }, [listItems, verseCount]); + + // Given a selected 'from' value, find the maximum 'to' value allowed + // This prevents overlapping ranges by limiting to the next occupied verse + const getMaxToForFrom = React.useCallback( + (selectedFrom: number) => { + const availableVerses = getAvailableVerses(); + + // Find the index of selectedFrom in available verses + const fromIndex = availableVerses.indexOf(selectedFrom); + if (fromIndex === -1) { + // If selectedFrom is not available, return selectedFrom + return selectedFrom; + } + + // Find the next occupied verse after the available range + // We need to find where the next separator starts + const separators = listItems + .filter( + (item): item is ListItemSeparator => + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ) + .sort((a, b) => (a.from ?? 0) - (b.from ?? 0)); + + // Find the first separator that starts after selectedFrom + const nextSeparator = separators.find( + (sep) => sep.from !== undefined && sep.from > selectedFrom + ); + + if (nextSeparator?.from !== undefined) { + // Return the verse just before the next separator + return nextSeparator.from - 1; + } + + // No separator after selectedFrom, can go to the end + return verseCount || 1; + }, + [getAvailableVerses, listItems, verseCount] + ); + + // Get existing labels from separators for quick selection in VerseAssigner + const existingLabels = React.useMemo(() => { + const labels: { from: number; to: number }[] = []; + const seen = new Set(); + + for (const item of listItems) { + if ( + item.type === 'separator' && + item.from !== undefined && + item.to !== undefined + ) { + const key = `${item.from}-${item.to}`; + if (!seen.has(key)) { + seen.add(key); + labels.push({ from: item.from, to: item.to }); + } + } + } + + // Sort by from value + return labels.sort((a, b) => a.from - b.from); + }, [listItems]); + + // Calculate nextVerse and limitVerse for automatic progression + const { nextVerse, limitVerse } = React.useMemo(() => { + // If no verse count, can't calculate + if (!verseCount || verseCount === 0) { + return { nextVerse: null, limitVerse: null }; + } + + // Get the current verse range from selectedForRecording + const currentVerse = selectedForRecording?.metadata?.verse; + + // If no labels exist yet, start from verse 1 + if (existingLabels.length === 0) { + const result = { nextVerse: 1, limitVerse: verseCount }; + return result; + } + + // If no selection or no verse in selection, find the last gap + if (!currentVerse) { + // Find the last occupied verse + const lastLabel = existingLabels[existingLabels.length - 1]; + if (!lastLabel) { + const result = { nextVerse: 1, limitVerse: verseCount }; + return result; + } + + // If there's space after the last label + if (lastLabel.to < verseCount) { + const result = { nextVerse: lastLabel.to + 1, limitVerse: verseCount }; + return result; + } + + // No space available + const result = { nextVerse: null, limitVerse: null }; + return result; + } + + // Find the next available verse after the current selection + const currentTo = currentVerse.to; + + // Find the next label that starts after currentTo + const nextLabel = existingLabels.find((label) => label.from > currentTo); + + if (nextLabel) { + // There's a next label - check if there's space between current and next + if (currentTo + 1 < nextLabel.from) { + // There's a gap + const result = { + nextVerse: currentTo + 1, + limitVerse: nextLabel.from - 1 + }; + return result; + } else { + // No gap - next verse is already occupied + const result = { nextVerse: null, limitVerse: null }; + return result; + } + } else { + // No next label - check if there's space until the end + if (currentTo < verseCount) { + const result = { nextVerse: currentTo + 1, limitVerse: verseCount }; + return result; + } else { + // Already at the end + const result = { nextVerse: null, limitVerse: null }; + return result; + } + } + }, [selectedForRecording, existingLabels, verseCount]); + + // Check if any selected assets already have labels + const selectedAssetsHaveLabels = React.useMemo(() => { + for (const assetId of selectedAssetIds) { + const asset = assets.find((a) => a.id === assetId); + if (asset?.metadata) { + try { + const meta = + typeof asset.metadata === 'string' + ? (JSON.parse(asset.metadata) as AssetMetadata | null) + : (asset.metadata as AssetMetadata | null); + if (meta?.verse?.from !== undefined) { + return true; + } + } catch { + // Ignore parse errors + } + } + } + return false; + }, [selectedAssetIds, assets]); + + // Handle applying verse label to selected assets + const handleAssignVerseToSelected = React.useCallback( + async (from: number, to: number) => { + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length === 0) return; + + try { + const verseBase = from; + const minOrderIndex = verseBase * 1000 * 1000; + const maxOrderIndex = (verseBase + 1) * 1000 * 1000 - 1; + + // Find the highest order_index already assigned to this verse + // (excluding selected assets since they might be moving from another verse) + let lastSequential = 0; + for (const asset of assets) { + if (selectedAssetIds.has(asset.id)) continue; // Skip assets being reassigned + if ( + asset.order_index >= minOrderIndex && + asset.order_index <= maxOrderIndex + ) { + // Extract sequential part: order_index = (verseBase * 1000 + seq) * 1000 + // seq = (order_index / 1000) - (verseBase * 1000) + const seq = Math.floor(asset.order_index / 1000) - verseBase * 1000; + if (seq > lastSequential) { + lastSequential = seq; + } + } + } + + // Calculate order_index continuing from the last existing asset + const updates: AssetUpdatePayload[] = selectedAssets.map( + (asset, index) => ({ + assetId: asset.id, + metadata: { + verse: { from, to } + }, + order_index: + (verseBase * 1000 + (lastSequential + index + 1)) * 1000 + }) + ); + + await batchUpdateAssetMetadata(updates); + + // Close drawer and clear selection + setShowVerseAssignerDrawer(false); + cancelSelection(); + setSelectedForRecording(null); + + // Refresh the list + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (error) { + console.error('Failed to assign verse to assets:', error); + RNAlert.alert(t('error'), 'Failed to assign verse. Please try again.'); + } + }, + [assets, selectedAssetIds, cancelSelection, queryClient, refetch, t] + ); + + // Handle removing labels from selected assets + const handleRemoveLabelFromSelected = React.useCallback(async () => { + const selectedAssets = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + + if (selectedAssets.length === 0) return; + + try { + const verseBase = UNASSIGNED_VERSE_BASE; + const minOrderIndex = verseBase * 1000 * 1000; + const maxOrderIndex = (verseBase + 1) * 1000 * 1000 - 1; + + // Find the highest order_index among unassigned assets + let lastSequential = 0; + for (const asset of assets) { + if (selectedAssetIds.has(asset.id)) continue; // Skip assets being moved + if ( + asset.order_index >= minOrderIndex && + asset.order_index <= maxOrderIndex + ) { + const seq = Math.floor(asset.order_index / 1000) - verseBase * 1000; + if (seq > lastSequential) { + lastSequential = seq; + } + } + } + + // Set metadata to null and assign order_index at end of unassigned list + const updates: AssetUpdatePayload[] = selectedAssets.map( + (asset, index) => ({ + assetId: asset.id, + metadata: null, + order_index: (verseBase * 1000 + (lastSequential + index + 1)) * 1000 + }) + ); + + await batchUpdateAssetMetadata(updates); + + // Close drawer and clear selection + setShowVerseAssignerDrawer(false); + cancelSelection(); + setSelectedForRecording(null); + + // Refresh the list + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); + } catch (error) { + console.error('Failed to remove labels from assets:', error); + RNAlert.alert(t('error'), 'Failed to remove labels. Please try again.'); + } + }, [assets, selectedAssetIds, cancelSelection, queryClient, refetch, t]); + + const assetIds = React.useMemo(() => { + return assets.map((asset) => asset.id).filter((id): id is string => !!id); + }, [assets]); + + const { attachmentStates, isLoading: isAttachmentStatesLoading } = + useAttachmentStates(assetIds); + + const safeAttachmentStates = attachmentStates; + + const _blockedCount = useBlockedAssetsCount(currentQuestId || ''); + + const attachmentStateSummary = React.useMemo(() => { + if (safeAttachmentStates.size === 0) { + return {}; + } + + const states = Array.from(safeAttachmentStates.values()); + const summary = states.reduce( + (acc, attachment) => { + acc[attachment.state] = (acc[attachment.state] || 0) + 1; + return acc; + }, + {} as Record + ); + return summary; + // Use memo key instead of Map reference for stable dependencies (always 1 string) + }, [safeAttachmentStates]); + + const handleAssetUpdate = React.useCallback(async () => { + // await queryClient.invalidateQueries({ + // // queryKey: ['assets', 'by-quest', currentQuestId], + // queryKey: ['by-quest', currentQuestId], + // exact: false + // }); + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + }, [queryClient]); + + // ============================================================================ + // ORDER_INDEX NORMALIZATION + // When returning from BibleRecordingView, normalize order_index for recorded verses + // Recording uses unit scale (7001001, 7001002) but Assets view uses thousand scale (7001000, 7002000) + // This function reads assets from DB and reassigns order_index with thousand scale + // ============================================================================ + const normalizeOrderIndexForVerses = React.useCallback( + async (verses: number[]) => { + if (!currentQuestId || verses.length === 0) return; + + const assetTable = resolveTable('asset', { localOverride: true }); + const questAssetLinkTable = resolveTable('quest_asset_link', { + localOverride: true + }); + + for (const verse of verses) { + // Calculate order_index range for this verse + // Formula: verse * 1000 * 1000 to (verse + 1) * 1000 * 1000 - 1 + // Example: verse 7 โ†’ 7000000 to 7999999 + const minOrderIndex = verse * 1000 * 1000; + const maxOrderIndex = (verse + 1) * 1000 * 1000 - 1; + + try { + // Query assets by order_index range using join with quest_asset_link + // This ensures we only get assets that belong to this quest + const assetsInVerse = await system.db + .select({ + id: assetTable.id, + name: assetTable.name, + order_index: assetTable.order_index + }) + .from(assetTable) + .innerJoin( + questAssetLinkTable, + eq(assetTable.id, questAssetLinkTable.asset_id) + ) + .where( + and( + eq(questAssetLinkTable.quest_id, currentQuestId), + gte(assetTable.order_index, minOrderIndex), + lte(assetTable.order_index, maxOrderIndex) + ) + ) + .orderBy(asc(assetTable.order_index)); + + if (assetsInVerse.length === 0) { + continue; + } + + // Recalculate order_index with thousand scale + // Formula: (verse * 1000 + sequential) * 1000 + // sequential starts at 1: 7001000, 7002000, 7003000... + const updates: AssetUpdatePayload[] = []; + let hasChanges = false; + + for (let i = 0; i < assetsInVerse.length; i++) { + const asset = assetsInVerse[i]; + if (!asset) continue; + + const sequential = i + 1; // 1-based + const newOrderIndex = (verse * 1000 + sequential) * 1000; + + // Only update if order_index changed + if (asset.order_index !== newOrderIndex) { + hasChanges = true; + updates.push({ + assetId: asset.id, + order_index: newOrderIndex + }); + } + } + + if (hasChanges && updates.length > 0) { + await batchUpdateAssetMetadata(updates); + console.log( + ` โœ… Verse ${verse}: normalized ${updates.length} of ${assetsInVerse.length} asset(s)` + ); + } + } catch (error) { + console.error(` โŒ Failed to normalize verse ${verse}:`, error); + } + } + }, + [currentQuestId] + ); + + // Calculate available range for adding verse label above a specific asset + // Returns only verses between the previous separator's "to" and next separator's "from" + const getRangeForAsset = React.useCallback( + (assetId: string) => { + const assetIndex = listItems.findIndex( + (item) => item.type === 'asset' && item.content.id === assetId + ); + + if (assetIndex === -1) { + return { from: 1, to: verseCount || 1, availableVerses: [] }; + } + + // Find previous separator (looking backward) + let prevTo: number | undefined; + for (let i = assetIndex - 1; i >= 0; i--) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.to !== undefined) { + prevTo = item.to; + break; + } + } + + // Find next separator (looking forward) + let nextFrom: number | undefined; + for (let i = assetIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.from !== undefined) { + nextFrom = item.from; + break; + } + } + + // Calculate range - only between prevTo and nextFrom + const rangeFrom = prevTo !== undefined ? prevTo + 1 : 1; + const rangeTo = nextFrom !== undefined ? nextFrom - 1 : verseCount || 1; + + // Ensure valid range and check if there's actually space available + const finalFrom = Math.max(1, rangeFrom); + const finalTo = Math.max(finalFrom, Math.min(rangeTo, verseCount || 1)); + + // Check if there's actually space between separators + // If prevTo + 1 > nextFrom - 1, there's no space + if ( + prevTo !== undefined && + nextFrom !== undefined && + prevTo + 1 > nextFrom - 1 + ) { + return { + from: finalFrom, + to: finalTo, + availableVerses: [] + }; + } + + // Generate array of available verses only in this range + const availableVerses: number[] = []; + for ( + let verse = finalFrom; + verse <= finalTo && verse <= (verseCount || 1); + verse++ + ) { + availableVerses.push(verse); + } + + return { + from: finalFrom, + to: finalTo, + availableVerses + }; + }, + [listItems, verseCount] + ); + + // Get available verses for editing a separator (between previous and next separators) + const getRangeForSeparator = React.useCallback( + (separatorKey: string) => { + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separatorKey + ); + + if (separatorIndex === -1) { + return { from: 1, to: verseCount || 1, availableVerses: [] }; + } + + // Find previous separator (looking backward) + let prevTo: number | undefined; + for (let i = separatorIndex - 1; i >= 0; i--) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.to !== undefined) { + prevTo = item.to; + break; + } + } + + // Find next separator (looking forward) + let nextFrom: number | undefined; + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.from !== undefined) { + nextFrom = item.from; + break; + } + } + + // Calculate range - only between prevTo and nextFrom + const rangeFrom = prevTo !== undefined ? prevTo + 1 : 1; + const rangeTo = nextFrom !== undefined ? nextFrom - 1 : verseCount || 1; + + // Ensure valid range + const finalFrom = Math.max(1, rangeFrom); + const finalTo = Math.max(finalFrom, Math.min(rangeTo, verseCount || 1)); + + // Generate array of available verses only in this range + const availableVerses: number[] = []; + for ( + let verse = finalFrom; + verse <= finalTo && verse <= (verseCount || 1); + verse++ + ) { + availableVerses.push(verse); + } + + return { + from: finalFrom, + to: finalTo, + availableVerses + }; + }, + [listItems, verseCount] + ); + + // Get max 'to' value for editing a separator (limited to available range) + const getMaxToForFromSeparator = React.useCallback( + (separatorKey: string, selectedFrom: number): number => { + const range = getRangeForSeparator(separatorKey); + const availableVerses = range.availableVerses; + + // Find the index of selectedFrom in available verses + const fromIndex = availableVerses.indexOf(selectedFrom); + if (fromIndex === -1) { + // If selectedFrom is not available, return selectedFrom + return selectedFrom; + } + + // Find the next occupied verse after selectedFrom + // Look for the next separator's 'from' value + const separatorIndex = listItems.findIndex( + (item) => item.type === 'separator' && item.key === separatorKey + ); + + let nextFrom: number | undefined; + for (let i = separatorIndex + 1; i < listItems.length; i++) { + const item = listItems[i]; + if (item && item.type === 'separator' && item.from !== undefined) { + nextFrom = item.from; + break; + } + } + + // The maximum 'to' is the verse before the next separator's 'from', or the last available verse + const maxTo = nextFrom !== undefined ? nextFrom - 1 : range.to; + + // Find the index of maxTo in available verses, or use the last available verse + const maxToIndex = availableVerses.indexOf(maxTo); + if (maxToIndex !== -1 && maxToIndex >= fromIndex) { + const result = availableVerses[maxToIndex]; + if (result !== undefined) { + return result; + } + } + + // If maxTo is not in available verses, return the last available verse from selectedFrom onwards + const remainingVerses = availableVerses.slice(fromIndex); + if (remainingVerses.length > 0) { + const lastVerse = remainingVerses[remainingVerses.length - 1]; + if (lastVerse !== undefined) { + return lastVerse; + } + } + + return selectedFrom; + }, + [listItems, getRangeForSeparator] + ); + + // Stable wrapper for onPlay callback (avoids creating new function in renderItem) + const stableOnPlay = React.useCallback( + (assetId: string) => handlePlayAssetRef.current(assetId), + [] + ); + + const renderItem = React.useCallback( + ({ + item, + isPublished, + index + }: { + item: ListItem; + isPublished: boolean; + index: number; + }) => { + if (item.type === 'separator') { + // Check if this separator is selected for recording + const isSeparatorSelected = + selectedForRecording?.type === 'separator' && + selectedForRecording?.separatorKey === item.key; + + return ( + + { + setEditSeparatorState({ + isOpen: true, + separatorKey: item.key, + from: item.from, + to: item.to + }); + } + : undefined + } + // Recording selection - clicking the separator text selects it for recording + isSelectedForRecording={!isPublished && isSeparatorSelected} + onSelectForRecording={ + !isPublished + ? () => + handleSelectSeparatorForRecording( + item.key, + item.from, + item.to + ) + : undefined + } + dragHandleComponent={!isPublished ? Sortable.Handle : undefined} + dragHandleProps={ + !isPublished + ? { + mode: fixedItemsIndexesRef.current.includes(index) + ? 'fixed-order' + : 'draggable' + } + : undefined + } + /> + {!isPublished && !isSelectionMode && isSeparatorSelected && ( + + )} + + ); + } + + // Handle asset items + const asset = item.content; + const isPlaying = + audioContext.isPlaying && + (audioContext.currentAudioId === asset.id || // Individual play + (audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && + currentlyPlayingAssetId === asset.id)); // Play all + + const isSelected = selectedAssetIds.has(asset.id); + + const isAssetSelectedForRecording = + !isPublished && + selectedForRecording?.type === 'asset' && + selectedForRecording?.assetId === asset.id; + + // Only calculate range if this asset is selected for recording (performance optimization) + const assetRange = isAssetSelectedForRecording + ? getRangeForAsset(asset.id) + : null; + const hasAvailableVerses = assetRange + ? assetRange.availableVerses.length > 0 + : false; + + return ( + + {/* Add verse button - centered, only shown when asset is selected for recording */} + {!isPublished && + !isSelectionMode && + isAssetSelectedForRecording && + hasAvailableVerses && ( + + { + const range = getRangeForAsset(asset.id); + setAssetVerseSelectorState({ + isOpen: true, + assetId: asset.id, + from: range.from, + to: range.to + }); + }} + className="rounded-full bg-primary/80 p-1 shadow-sm active:bg-primary" + > + + + + )} + + {!isPublished && !isSelectionMode && isAssetSelectedForRecording && ( + + )} + + ); + }, + [ + currentQuestId, + safeAttachmentStates, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + handleAssetUpdate, + stableOnPlay, + getRangeForAsset, + isSelectionMode, + selectedAssetIds, + toggleSelect, + enterSelection, + selectedForRecording?.type, + selectedForRecording?.assetId, + selectedForRecording?.separatorKey, + handleSelectForRecording, + handleSelectSeparatorForRecording, + handleRenameAsset + ] + ); + + const _onEndReached = React.useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + // footer handled inline in ListFooterComponent + + const statusText = React.useMemo(() => { + const cloudCount = assets.filter((a) => a.source === 'cloud').length; + const offlineCount = assets.length - cloudCount; + return `${isOnline ? '๐ŸŸข' : '๐Ÿ”ด'} Offline: ${offlineCount} | Cloud: ${isOnline ? cloudCount : 'N/A'} | Total: ${assets.length}`; + }, [isOnline, assets]); + + const attachmentSummaryText = React.useMemo(() => { + return Object.entries(attachmentStateSummary) + .map(([state, count]) => { + const stateNames = { + '0': `โณ ${t('queued')}`, + '1': `๐Ÿ”„ ${t('syncing')}`, + '2': `โœ… ${t('synced')}`, + '3': `โŒ ${t('failed')}`, + '4': `๐Ÿ“ฅ ${t('downloading')}` + }; + return `${stateNames[state as keyof typeof stateNames] || `${t('state')} ${state}`}: ${count}`; + }) + .join(' | '); + }, [attachmentStateSummary, t]); + + const { + hasReported, + // isLoading: isReportLoading, + refetch: refetchReport + } = useHasUserReported(currentQuestId || '', 'quests'); + + const statusContext = useStatusContext(); + const { allowSettings } = statusContext.getStatusParams( + LayerType.QUEST, + currentQuestId + ); + + // Special audio ID for "play all" mode + const PLAY_ALL_AUDIO_ID = 'play-all-assets'; + + // Fetch audio URIs for an asset (similar to RecordingViewSimplified) + // Includes fallback logic for local-only files when server records are removed + const getAssetAudioUris = React.useCallback( + async (assetId: string): Promise => { + try { + // Get content links from both synced and local tables + const assetContentLinkSynced = resolveTable('asset_content_link', { + localOverride: false + }); + const contentLinksSynced = await system.db + .select() + .from(assetContentLinkSynced) + .where(eq(assetContentLinkSynced.asset_id, assetId)); + + const assetContentLinkLocal = resolveTable('asset_content_link', { + localOverride: true + }); + const contentLinksLocal = await system.db + .select() + .from(assetContentLinkLocal) + .where(eq(assetContentLinkLocal.asset_id, assetId)); + + // Prefer synced links, but merge with local for fallback + const allContentLinks = [...contentLinksSynced, ...contentLinksLocal]; + + // Deduplicate by ID (prefer synced over local) + const seenIds = new Set(); + const uniqueLinks = allContentLinks.filter((link) => { + if (seenIds.has(link.id)) { + return false; + } + seenIds.add(link.id); + return true; + }); + + if (uniqueLinks.length === 0) { + return []; + } + + // Get audio values from content links (can be URIs or attachment IDs) + const audioValues = uniqueLinks + .flatMap((link) => { + const audioArray = link.audio ?? []; + return audioArray; + }) + .filter((value): value is string => !!value); + + if (audioValues.length === 0) { + return []; + } + + // Process each audio value - can be either a local URI or an attachment ID + const uris: string[] = []; + for (const audioValue of audioValues) { + // Check if this is already a local URI (starts with 'local/' or 'file://') + if (audioValue.startsWith('local/')) { + // It's a direct local URI from saveAudioLocally() + const constructedUri = + await getLocalAttachmentUriWithOPFS(audioValue); + // Check if file exists at constructed path + if (await fileExists(constructedUri)) { + uris.push(constructedUri); + } else { + // File doesn't exist at expected path - try to find it in attachment queue + console.log( + `โš ๏ธ Local URI ${audioValue} not found at ${constructedUri}, searching attachment queue...` + ); + + if (system.permAttachmentQueue) { + // Extract filename from local path (e.g., "local/uuid.wav" -> "uuid.wav") + const filename = audioValue.replace(/^local\//, ''); + // Extract UUID part (without extension) for more flexible matching + const uuidPart = filename.split('.')[0]; + + // Search attachment queue by filename or UUID + let attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR filename LIKE ? OR id = ? OR id LIKE ? LIMIT 1`, + [filename, `%${uuidPart}%`, filename, `%${uuidPart}%`] + ); + + // If not found, try searching all attachments for this asset's content links + if (!attachment && uniqueLinks.length > 0) { + const allAttachmentIds = uniqueLinks + .flatMap((link) => link.audio ?? []) + .filter( + (av): av is string => + typeof av === 'string' && + !av.startsWith('local/') && + !av.startsWith('file://') + ); + if (allAttachmentIds.length > 0) { + const placeholders = allAttachmentIds + .map(() => '?') + .join(','); + attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id IN (${placeholders}) LIMIT 1`, + allAttachmentIds + ); + } + } + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + // Verify the found file actually exists + if (await fileExists(foundUri)) { + uris.push(foundUri); + console.log( + `โœ… Found attachment in queue for local URI ${audioValue.slice(0, 20)}` + ); + } else { + console.warn( + `โš ๏ธ Attachment found in queue but file doesn't exist: ${foundUri}` + ); + } + } else { + // Try fallback to local table for alternative audio values + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + console.log(`โœ… Found fallback file URI`); + break; + } + } + } + } + } + } + } + } else if (audioValue.startsWith('file://')) { + // Already a full file URI - verify it exists + if (await fileExists(audioValue)) { + uris.push(audioValue); + } else { + console.warn(`File URI does not exist: ${audioValue}`); + // Try to find in attachment queue by extracting filename from path + if (system.permAttachmentQueue) { + const filename = audioValue.split('/').pop(); + if (filename) { + const attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR id = ? LIMIT 1`, + [filename, filename] + ); + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(foundUri)) { + uris.push(foundUri); + console.log(`โœ… Found attachment in queue for file URI`); + } + } + } + } + } + } else { + // It's an attachment ID - look it up in the attachment queue + if (!system.permAttachmentQueue) { + // No attachment queue - try fallback to local table + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + break; + } + } + } + } + continue; + } + + const attachment = await system.powersync.getOptional<{ + id: string; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id = ?`, + [audioValue] + ); + + if (attachment?.local_uri) { + const localUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(localUri)) { + uris.push(localUri); + } + } else { + // Attachment ID not found in queue - try fallback to local table + console.log( + `โš ๏ธ Attachment ID ${audioValue.slice(0, 8)} not found in queue, checking local table fallback...` + ); + + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + console.log( + `โœ… Found fallback local URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + console.log( + `โœ… Found fallback file URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } + } + } else { + // Try to get cloud URL if local not available + try { + if (!AppConfig.supabaseBucket) { + continue; + } + const { data } = system.supabaseConnector.client.storage + .from(AppConfig.supabaseBucket) + .getPublicUrl(audioValue); + if (data.publicUrl) { + uris.push(data.publicUrl); + } + } catch (error) { + console.error('Failed to get cloud audio URL:', error); + } + } + } + } + } + + return uris; + } catch (error) { + console.error('Failed to fetch audio URIs:', error); + return []; + } + }, + [] + ); + + // Asset ranges for play-all: maps each asset to its time range + const assetTimeRangesRef = React.useRef< + { assetId: string; startMs: number; endMs: number }[] + >([]); + + // Calculate which asset should be highlighted based on position (useMemo for performance) + const derivedCurrentlyPlayingAssetId = React.useMemo(() => { + // Not playing at all + if (!audioContext.isPlaying) { + return null; + } + + // Playing a single asset (not play-all mode) + if (audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID) { + return audioContext.currentAudioId; + } + + // Play-all mode: Find asset by time range + const position = audioContext.position; + const ranges = assetTimeRangesRef.current; + + if (ranges.length === 0) { + // Fallback: use first asset in order + return assetOrderRef.current[0] || null; + } + + // Find which range the current position falls into + for (const range of ranges) { + if (position >= range.startMs && position < range.endMs) { + return range.assetId; + } + } + + // If position is beyond all ranges, return the last asset + return ranges[ranges.length - 1]?.assetId || null; + }, [ + audioContext.isPlaying, + audioContext.currentAudioId, + audioContext.position + ]); + + // Update state only when the derived value actually changes + React.useEffect(() => { + setCurrentlyPlayingAssetId(derivedCurrentlyPlayingAssetId); + }, [derivedCurrentlyPlayingAssetId]); + + // Handle play all assets + const handlePlayAllAssets = React.useCallback(async () => { + try { + const isPlayingAll = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + + if (isPlayingAll) { + await audioContext.stopCurrentSound(); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + assetOrderRef.current = []; + uriOrderRef.current = []; + segmentDurationsRef.current = []; + assetTimeRangesRef.current = []; + } else { + if (assets.length === 0) { + console.warn('โš ๏ธ No assets to play'); + return; + } + + // Collect all URIs from all assets in order, tracking which asset each URI belongs to + const allUris: string[] = []; + assetUriMapRef.current.clear(); + assetOrderRef.current = []; + uriOrderRef.current = []; + segmentDurationsRef.current = []; + assetTimeRangesRef.current = []; + + // Build time ranges for each asset + let cumulativeTime = 0; + for (const asset of assets) { + const uris = await getAssetAudioUris(asset.id); + if (uris.length > 0) { + const assetStartTime = cumulativeTime; + assetOrderRef.current.push(asset.id); + + // Add all URIs for this asset + for (const uri of uris) { + allUris.push(uri); + uriOrderRef.current.push(uri); + assetUriMapRef.current.set(uri, asset.id); + + // Load duration for this URI + try { + const { sound } = await Audio.Sound.createAsync({ uri }); + const status = await sound.getStatusAsync(); + await sound.unloadAsync(); + if (status.isLoaded) { + const duration = status.durationMillis ?? 0; + segmentDurationsRef.current.push(duration); + cumulativeTime += duration; + } else { + segmentDurationsRef.current.push(0); + } + } catch { + segmentDurationsRef.current.push(0); + } + } + + // Store the time range for this asset + assetTimeRangesRef.current.push({ + assetId: asset.id, + startMs: assetStartTime, + endMs: cumulativeTime + }); + + console.log( + `๐Ÿ“Š Asset ${asset.id.slice(0, 8)}: ${Math.round(assetStartTime)}ms - ${Math.round(cumulativeTime)}ms (${uris.length} segments)` + ); + } + } + + if (allUris.length === 0) { + console.error('โŒ No audio URIs found for any assets'); + return; + } + + console.log( + `โ–ถ๏ธ Playing ${allUris.length} audio segments from ${assets.length} assets (total: ${Math.round(cumulativeTime)}ms)` + ); + + // Start playing (AudioContext will handle sequence playback) + await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); + } + } catch (error) { + console.error('โŒ Failed to play all assets:', error); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + assetOrderRef.current = []; + uriOrderRef.current = []; + segmentDurationsRef.current = []; + } + }, [audioContext, getAssetAudioUris, assets]); + + // Handle play individual asset + const handlePlayAsset = React.useCallback( + async (assetId: string) => { + try { + const isThisAssetPlaying = + audioContext.isPlaying && audioContext.currentAudioId === assetId; + + if (isThisAssetPlaying) { + console.log('โธ๏ธ Stopping asset:', assetId.slice(0, 8)); + await audioContext.stopCurrentSound(); + setCurrentlyPlayingAssetId(null); + } else { + console.log('โ–ถ๏ธ Playing asset:', assetId.slice(0, 8)); + const uris = await getAssetAudioUris(assetId); + + if (uris.length === 0) { + console.warn('โš ๏ธ No audio URIs found for asset:', assetId); + return; + } + + // Set the asset as currently playing immediately for visual feedback + setCurrentlyPlayingAssetId(assetId); + + if (uris.length === 1 && uris[0]) { + console.log('โ–ถ๏ธ Playing single segment'); + await audioContext.playSound(uris[0], assetId); + } else if (uris.length > 1) { + console.log(`โ–ถ๏ธ Playing ${uris.length} segments in sequence`); + await audioContext.playSoundSequence(uris, assetId); + } + } + } catch (error) { + console.error('โŒ Failed to play audio:', error); + setCurrentlyPlayingAssetId(null); + } + }, + [audioContext, getAssetAudioUris] + ); + + // Update ref so renderItem can use it + handlePlayAssetRef.current = handlePlayAsset; + + // Handle publish button press with useMutation + const { mutate: publishQuest, isPending: isPublishing } = useMutation({ + mutationFn: async () => { + if (!currentQuestId || !currentProjectId) { + throw new Error('Missing quest or project ID'); + } + console.log(`๐Ÿ“ค Publishing quest ${currentQuestId}...`); + const result = await publishQuestUtils(currentQuestId, currentProjectId); + return result; + }, + onSuccess: async (result) => { + if (result.success) { + // Wait for PowerSync to sync the published quest before invalidating + await new Promise((resolve) => setTimeout(resolve, 1500)); + + console.log('๐Ÿ“ฅ [Publish Quest] Invalidating queries...'); + + // Invalidate the quest query used by this component + await queryClient.invalidateQueries({ + queryKey: ['current-quest', 'offline', currentQuestId] + }); + await queryClient.invalidateQueries({ + queryKey: ['current-quest', 'cloud', currentQuestId] + }); + + // Invalidate general quest queries + await queryClient.invalidateQueries({ + queryKey: ['quests', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'infinite', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'offline', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'cloud', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests'] + }); + + // Invalidate assets queries to refresh the assets list + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + + // Refetch quest data to update the selectedQuest immediately + void refetchQuest(); + + // Refetch assets to update download indicators + void refetch(); + + console.log('โœ… [Publish Quest] All queries invalidated'); + + RNAlert.alert(t('success'), result.message, [{ text: t('ok') }]); + } else { + RNAlert.alert(t('error'), result.message || t('error'), [ + { text: t('ok') } + ]); + } + }, + onError: (error) => { + console.error('Publish error:', error); + RNAlert.alert( + t('error'), + error instanceof Error ? error.message : t('failedCreateTranslation'), + [{ text: t('ok') }] + ); + } + }); + + // Handle offload button click - start verification + const handleOffloadClick = () => { + console.log('๐Ÿ—‘๏ธ [Offload] Opening verification drawer'); + setShowOffloadDrawer(true); + verificationState.startVerification(); + }; + + // Handle offload confirmation - execute offload + const handleOffloadConfirm = async () => { + console.log('๐Ÿ—‘๏ธ [Offload] User confirmed, executing offload'); + setIsOffloading(true); + try { + await offloadQuest({ + questId: currentQuestId || '', + verifiedIds: verificationState.verifiedIds, + onProgress: (progress, message) => { + console.log(`๐Ÿ—‘๏ธ [Offload Progress] ${progress}%: ${message}`); + } + }); + + console.log('๐Ÿ—‘๏ธ [Offload] Complete - waiting for PowerSync to sync...'); + // Wait for PowerSync to sync the removal before invalidating + await new Promise((resolve) => setTimeout(resolve, 1500)); + + console.log('๐Ÿ—‘๏ธ [Offload] Invalidating all queries...'); + + // Invalidate download status queries + await queryClient.invalidateQueries({ + queryKey: ['download-status', 'quest', currentQuestId] + }); + await queryClient.invalidateQueries({ + queryKey: ['download-status', 'project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quest-download-status', currentQuestId] + }); + await queryClient.invalidateQueries({ + queryKey: ['project-download-status', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['download-status'] + }); + + // Invalidate ALL quest queries (comprehensive like create quest) + await queryClient.invalidateQueries({ + queryKey: ['quests', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'infinite', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'offline', 'for-project', currentProjectId] + }); + await queryClient.invalidateQueries({ + queryKey: ['quests', 'cloud', 'for-project', currentProjectId] + }); + // Also invalidate generic quest queries + await queryClient.invalidateQueries({ + queryKey: ['quests'] + }); + + // Invalidate project queries + await queryClient.invalidateQueries({ + queryKey: ['projects'] + }); + + // Invalidate assets queries to refresh the assets list + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + + // Invalidate quest closure data + await queryClient.invalidateQueries({ + queryKey: ['quest-closure', currentQuestId] + }); + + console.log('โœ… [Offload] All queries invalidated'); + + RNAlert.alert(t('success'), t('offloadComplete')); + setShowOffloadDrawer(false); + + // Navigate back to project directory view (quests view) + goBack(); + } catch (error) { + console.error('Failed to offload quest:', error); + RNAlert.alert(t('error'), t('offloadError')); + } finally { + setIsOffloading(false); + } + }; + + // ============================================================================ + // SORTING HANDLER (memoized for performance) + // ============================================================================ + const UNASSIGNED_VERSE_BASE = 999; // High value so unassigned assets appear at the end + + const handleSorting = React.useCallback( + async (params: { indexToKey: string[]; data: ListItem[] }) => { + // Build a map of key -> item for quick lookup + const keyToItem = new Map(params.data.map((item) => [item.key, item])); + + // Iterate through the new order and update asset metadata + order_index + // based on the preceding separator + let currentSeparator: ListItemSeparator | null = null; + let sequentialInGroup = 1; // Tracks position within current verse group (starts at 1) + const updates: AssetUpdatePayload[] = []; + + for (const key of params.indexToKey) { + const item = keyToItem.get(key); + if (!item) continue; + + if (item.type === 'separator') { + currentSeparator = item; + sequentialInGroup = 1; // Reset counter for new group (starts at 1) + } else if (item.type === 'asset') { + // Calculate order_index: (from * 1000 + sequential) * 1000 + const verseBase = currentSeparator?.from ?? UNASSIGNED_VERSE_BASE; + const newOrderIndex = (verseBase * 1000 + sequentialInGroup) * 1000; + sequentialInGroup++; + + // Determine the metadata based on the current separator + const newMetadata: AssetMetadata | null = currentSeparator?.from + ? { + verse: { + from: currentSeparator.from, + to: currentSeparator.to ?? currentSeparator.from + } + } + : null; + + // Check if metadata or order_index has changed + const currentMetadata = item.content.metadata; + const currentOrderIndex = item.content.order_index; + + const metadataChanged = + JSON.stringify(newMetadata) !== JSON.stringify(currentMetadata); + const orderIndexChanged = newOrderIndex !== currentOrderIndex; + + if (metadataChanged || orderIndexChanged) { + const update: AssetUpdatePayload = { + assetId: item.content.id + }; + + // Only include changed fields + if (metadataChanged) { + update.metadata = newMetadata; + } + if (orderIndexChanged) { + update.order_index = newOrderIndex; + } + + updates.push(update); + } + } + } + + // Batch update all changed assets + if (updates.length > 0) { + try { + await batchUpdateAssetMetadata(updates); + + // Invalidate queries to refresh the UI + void queryClient.invalidateQueries({ queryKey: ['assets'] }); + void refetch(); // Refresh current assets to remove stale separators + } catch (err: unknown) { + console.error('Failed to update assets:', err); + } + } + }, + [queryClient, refetch] + ); + + if (!currentQuestId) { + return ( + + {t('noQuestSelected')} + + ); + } + + // Recording mode UI + if (showRecording) { + // Calculate initialOrderIndex: + // - If an asset is selected, use its order_index + // - Otherwise, use the last unassigned order_index (to continue from where we left off) + // - If no unassigned assets exist, undefined will trigger default in BibleRecordingView + const recordingOrderIndex = + selectedForRecording?.orderIndex ?? lastUnassignedOrderIndex; + + // Pass existing assets as initial data for instant rendering + return ( + { + setShowRecording(false); + setSelectedForRecording(null); // Clear selection when exiting + + // Normalize order_index for recorded verses before refetching + // This converts unit-scale (7001001) to thousand-scale (7001000) + if (recordedVerses && recordedVerses.length > 0) { + await normalizeOrderIndexForVerses(recordedVerses); + } + + // Refetch to show newly recorded assets with normalized order_index + void refetch(); + }} + initialAssets={assets} + label={selectedForRecording?.verseName} + initialOrderIndex={recordingOrderIndex} + verse={selectedForRecording?.metadata?.verse} + bookChapterLabel={bookChapterLabel} + bookChapterLabelFull={selectedQuest?.name} + nextVerse={nextVerse} + limitVerse={limitVerse} + /> + ); + } + + // Check if quest is published (source is 'synced') + // const isPublished = selectedQuest?.source === 'synced'; + + // Get project name for PrivateAccessGate + // Note: queriedProjectData doesn't include name, so we only use currentProjectData + const projectName = currentProjectData?.name || ''; + + return ( + + + {/* Left side: Quest name on top, Assets below */} + + {selectedQuest?.name && ( + + {selectedQuest.name.length > 25 + ? `${selectedQuest.name.slice(0, 25)}...` + : selectedQuest.name} + + )} + + {t('assets')} + + + + {/* Right side: Icons */} + + + {assets.length > 0 && ( + + )} + {isPublished ? ( + // Only show cloud-check icon if user is creator, member, or owner + canSeePublishedBadge ? ( + <> + + {currentQuestId && currentProjectId && ( + + )} + + ) : ( + // Show membership request button for non-members viewing published quest + isPrivateProject && ( + + ) + ) + ) : ( + // Only show publish/record buttons for authenticated users + currentUser && ( + + + {!isPublished && ( + + )} + {currentQuestId && currentProjectId && ( + + )} + + ) + )} + + + + + ) : undefined + } + suffixStyling={false} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + /> + + {SHOW_DEV_ELEMENTS && ( + {statusText} + )} + + {SHOW_DEV_ELEMENTS && + !isAttachmentStatesLoading && + safeAttachmentStates.size > 0 && ( + + + ๐Ÿ“Ž {t('liveAttachmentStates')}: + + + {attachmentSummaryText} + + + )} + + {isLoading || (isFetching && assets.length === 0) ? ( + searchQuery.trim().length > 0 ? ( + + + {t('searching')} + + ) : ( + + ) + ) : ( + + + renderItem({ item, isPublished, index }) + } + rowGap={3} + scrollableRef={scrollableRef} // required for auto scroll + overDrag="vertical" + onDragEnd={(params) => void handleSorting(params)} + customHandle + sortEnabled={!isSelectionMode} // Disable sorting in selection mode + // autoScrollActivationOffset={75} + // autoScrollSpeed={1} + // autoScrollEnabled={true} + /> + {/* Loading indicator for infinite scroll */} + {isFetchingNextPage && ( + + + + {t('loading')}... + + + )} + {/* End of list indicator */} + {!hasNextPage && assets.length > 0 && ( + + โ€ขโ€ขโ€ข + + )} + + )} + + {/* Hide SpeedDial in selection mode */} + {!isSelectionMode && ( + + + + {/* For anonymous users, only show info button */} + {currentUser ? ( + <> + {allowSettings && isOwner ? ( + setShowSettingsModal(true)} + /> + ) : !hasReported ? ( + setShowReportModal(true)} + /> + ) : null} + + ) : null} + {!isPublished && ( + setShowDeleteAllDrawer(true)} + /> + )} + {/* Info button always visible */} + { + console.log('๐Ÿ“‹ [Info] Opening details modal', { + selectedQuest: selectedQuest?.id, + isDownloaded: isQuestDownloaded, + storageBytes: verificationState.estimatedStorageBytes + }); + setShowDetailsModal(true); + // Start verification to get storage estimate if quest is downloaded + if (isQuestDownloaded && !verificationState.isVerifying) { + verificationState.startVerification(); + } + }} + /> + + + + + )} + + {/* Sticky Record Button Footer - only show for authenticated users */} + {!isPublished && currentUser && ( + + {isSelectionMode ? ( + setShowVerseAssignerDrawer(true)} + /> + ) : ( + setShowRecording(true)} + > + + + + {t('startRecordingSession')} + + + {selectedForRecording?.verseName + ? `${bookChapterLabelRef.current}:${selectedForRecording.verseName}` + : t('noLabelSelected')} + {/* {selectedForRecording?.verseName + ? `${t('doRecord')} ${bookChapterLabelRef.current}:${selectedForRecording.verseName}` + : t('doRecord')} */} + + + + + )} + + )} + + {allowSettings && isOwner && showSettingsModal && ( + setShowSettingsModal(false)} + questId={currentQuestId} + projectId={currentProjectId || ''} + /> + )} + + {/* Delete All Assets Drawer */} + {showDeleteAllDrawer && ( + setShowDeleteAllDrawer(false)} + onConfirm={() => void handleDeleteAllAssets()} + title="Delete All Assets?" + description="All assets in this quest will be permanently deleted. This action is irreversible and cannot be undone." + confirmationString={selectedQuest?.name || 'DELETE'} + /> + )} + {showDetailsModal && selectedQuest && ( + setShowDetailsModal(false)} + isDownloaded={isQuestDownloaded} + estimatedStorageBytes={verificationState.estimatedStorageBytes} + onOffloadClick={handleOffloadClick} + /> + )} + {showReportModal && ( + setShowReportModal(false)} + recordId={currentQuestId} + recordTable="quest" + hasAlreadyReported={hasReported} + creatorId={selectedQuest?.creator_id ?? undefined} + onReportSubmitted={() => refetchReport()} + /> + )} + + {/* Offload Verification Drawer */} + {showOffloadDrawer && ( + { + if (!open && !isOffloading) { + setShowOffloadDrawer(false); + verificationState.cancel(); + } + }} + onContinue={handleOffloadConfirm} + verificationState={verificationState} + isOffloading={isOffloading} + /> + )} + + {/* Rename Asset Drawer */} + {showRenameDrawer && ( + { + setShowRenameDrawer(open); + if (!open) { + setRenameAssetId(null); + } + }} + onSave={handleSaveRename} + /> + )} + + {/* Batch Verse Assignment Drawer */} + {showVerseAssignerDrawer && ( + { + setShowVerseAssignerDrawer(open); + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Assign Verse + + Select verse range for {selectedAssetIds.size} selected asset + {selectedAssetIds.size !== 1 ? 's' : ''} + + + { + void handleAssignVerseToSelected(from, to); + }} + onCancel={() => setShowVerseAssignerDrawer(false)} + onRemove={handleRemoveLabelFromSelected} + hasSelectedAssetsWithLabels={selectedAssetsHaveLabels} + className="mx-4" + ScrollViewComponent={DrawerScrollView} + /> + + + )} + + {/* Private Access Gate Modal for Membership Requests */} + {isPrivateProject && showPrivateAccessModal && ( + setShowPrivateAccessModal(false)} + /> + )} + + {/* Verse Range Selector Drawer for editing existing separator */} + {verseSelectorState.isOpen && ( + { + if (!open) { + setVerseSelectorState({ isOpen: false, key: null }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Select Verse Range + + + { + addVerseSeparator(from, to); + // Clear recording selection when any label is added + setSelectedForRecording(null); + setVerseSelectorState({ isOpen: false, key: null }); + }} + onCancel={() => + setVerseSelectorState({ isOpen: false, key: null }) + } + /> + + + + )} + + {/* Verse Range Selector Drawer for adding new label */} + {newLabelSelectorState.isOpen && ( + { + if (!open) { + setNewLabelSelectorState({ isOpen: false }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Add Verse Label + + + { + addVerseSeparator(from, to); + // Clear recording selection when any label is added + setSelectedForRecording(null); + setNewLabelSelectorState({ isOpen: false }); + }} + onCancel={() => setNewLabelSelectorState({ isOpen: false })} + /> + + + + )} + + {/* Verse Range Selector Drawer for adding label above asset */} + {assetVerseSelectorState.isOpen && ( + { + if (!open) { + setAssetVerseSelectorState({ isOpen: false, assetId: null }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Add Verse Label + + + { + if (assetVerseSelectorState.assetId) { + addVerseSeparator( + from, + to, + assetVerseSelectorState.assetId + ); + } else { + addVerseSeparator(from, to); + } + // Clear recording selection when any label is added + setSelectedForRecording(null); + setAssetVerseSelectorState({ isOpen: false, assetId: null }); + }} + onCancel={() => + setAssetVerseSelectorState({ isOpen: false, assetId: null }) + } + /> + + + + )} + + {/* Verse Range Selector Drawer for editing separator */} + {editSeparatorState.isOpen && ( + { + if (!open) { + setEditSeparatorState({ isOpen: false, separatorKey: null }); + } + }} + snapPoints={['40%']} + enableDynamicSizing={false} + > + + + Edit Verse Label + + + {editSeparatorState.separatorKey && ( + + getMaxToForFromSeparator( + editSeparatorState.separatorKey!, + selectedFrom + ) + } + onApply={async (from, to) => { + if (editSeparatorState.separatorKey) { + await updateVerseSeparator( + editSeparatorState.separatorKey, + editSeparatorState.from, + editSeparatorState.to, + from, + to + ); + } + // Clear recording selection when any label is edited + // This ensures we don't have stale order_index references + setSelectedForRecording(null); + setEditSeparatorState({ + isOpen: false, + separatorKey: null + }); + }} + onCancel={() => + setEditSeparatorState({ isOpen: false, separatorKey: null }) + } + /> + )} + + + + )} + + ); +} diff --git a/views/new/BibleBookList.tsx b/views/new/BibleBookList.tsx index d9d973898..1c0ee238e 100644 --- a/views/new/BibleBookList.tsx +++ b/views/new/BibleBookList.tsx @@ -1,8 +1,10 @@ +import { QuestionModal } from '@/components/QuestionModal'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import { useLocalStore } from '@/store/localStore'; import { BOOK_ICON_MAP } from '@/utils/BOOK_GRAPHICS'; import { cn, useThemeColor } from '@/utils/styleUtils'; import { LegendList } from '@legendapp/list'; @@ -38,6 +40,28 @@ export function BibleBookList({ const buttonWidth = 110; const gap = 12; const padding = 16; + const verseMarkersFeaturePrompted = useLocalStore( + (state) => state.verseMarkersFeaturePrompted + ); + const setVerseMarkersFeaturePrompted = useLocalStore( + (state) => state.setVerseMarkersFeaturePrompted + ); + const setEnableVerseMarkers = useLocalStore( + (state) => state.setEnableVerseMarkers + ); + // Show modal if verseMarkersFeaturePrompted is false + const showPromptModal = verseMarkersFeaturePrompted === false; + + const handleYes = () => { + setVerseMarkersFeaturePrompted(true); + setEnableVerseMarkers(true); + }; + + const handleNo = () => { + setVerseMarkersFeaturePrompted(true); + setEnableVerseMarkers(false); + }; + const availableWidth = screenWidth - padding * 2; const buttonsPerRow = Math.max( 2, @@ -112,6 +136,13 @@ export function BibleBookList({ return ( + (typeof item === 'string' ? item : item.id)} diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index c81671c1b..4a3eaa26e 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -86,6 +86,7 @@ type Asset = typeof asset.$inferSelect; type AssetQuestLink = Asset & { quest_active: boolean; quest_visible: boolean; + tag_ids?: string[] | undefined; }; export default function NextGenAssetsView() { @@ -311,8 +312,25 @@ export default function NextGenAssetsView() { // Use memo key instead of Map reference for stable dependencies (always 1 string) }, [safeAttachmentStates]); + const handleAssetUpdate = React.useCallback(async () => { + // await queryClient.invalidateQueries({ + // // queryKey: ['assets', 'by-quest', currentQuestId], + // queryKey: ['by-quest', currentQuestId], + // exact: false + // }); + await queryClient.invalidateQueries({ + queryKey: ['assets'] + }); + }, [queryClient]); + const renderItem = React.useCallback( - ({ item }: { item: AssetQuestLink & { source?: HybridDataSource } }) => { + ({ + item, + isPublished + }: { + item: AssetQuestLink & { source?: HybridDataSource }; + isPublished: boolean; + }) => { const isPlaying = audioContext.isPlaying && audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && @@ -324,13 +342,17 @@ export default function NextGenAssetsView() { } return ( - + <> + + ); }, // Use stable memo key instead of Map reference to prevent hook dependency issues @@ -340,7 +362,8 @@ export default function NextGenAssetsView() { safeAttachmentStates, audioContext.isPlaying, audioContext.currentAudioId, - currentlyPlayingAssetId + currentlyPlayingAssetId, + handleAssetUpdate ] ); @@ -1276,7 +1299,7 @@ export default function NextGenAssetsView() { data={assets} keyExtractor={(item) => item.id} extraData={currentlyPlayingAssetId} - renderItem={({ item }) => renderItem({ item })} + renderItem={({ item }) => renderItem({ item, isPublished })} onEndReached={onEndReached} onEndReachedThreshold={0.5} estimatedItemSize={120} diff --git a/views/new/recording/components/AssetCard.tsx b/views/new/recording/components/AssetCard.tsx index b6fb25c66..6a50866ac 100644 --- a/views/new/recording/components/AssetCard.tsx +++ b/views/new/recording/components/AssetCard.tsx @@ -27,12 +27,12 @@ import { cn } from '@/utils/styleUtils'; import { CheckCircleIcon, CircleIcon } from 'lucide-react-native'; import React from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; import Animated, { Extrapolation, interpolate, useAnimatedStyle, - useDerivedValue, - type SharedValue + useDerivedValue } from 'react-native-reanimated'; import type { HybridDataSource } from '../../useHybridData'; diff --git a/views/new/recording/components/BibleRecordingView.tsx b/views/new/recording/components/BibleRecordingView.tsx new file mode 100644 index 000000000..9cf19a2d6 --- /dev/null +++ b/views/new/recording/components/BibleRecordingView.tsx @@ -0,0 +1,3059 @@ +import type { ArrayInsertionWheelHandle } from '@/components/ArrayInsertionWheel'; +import ArrayInsertionWheel from '@/components/ArrayInsertionWheel'; +import { VersePill } from '@/components/VersePill'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useAudio } from '@/contexts/AudioContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { renameAsset } from '@/database_services/assetService'; +import { audioSegmentService } from '@/database_services/audioSegmentService'; +import { asset_content_link, project_language_link } from '@/db/drizzleSchema'; +import { system } from '@/db/powersync/system'; +import { useProjectById } from '@/hooks/db/useProjects'; +import { useCurrentNavigation } from '@/hooks/useAppNavigation'; +import { useLocalization } from '@/hooks/useLocalization'; +import { useLocalStore } from '@/store/localStore'; +import { resolveTable } from '@/utils/dbUtils'; +import { + fileExists, + getLocalAttachmentUriWithOPFS, + saveAudioLocally +} from '@/utils/fileUtils'; +import RNAlert from '@blazejkustra/react-native-alert'; +import { toCompilableQuery } from '@powersync/drizzle-driver'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useQueryClient } from '@tanstack/react-query'; +import { and, asc, eq } from 'drizzle-orm'; +import { Audio } from 'expo-av'; +import { + ArrowDownNarrowWide, + ArrowLeft, + ChevronLeft, + Mic, + PauseIcon, + PlayIcon, + Plus +} from 'lucide-react-native'; +import React, { useMemo } from 'react'; +import { InteractionManager, View } from 'react-native'; +import { useSharedValue } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useHybridData } from '../../useHybridData'; +import { useSelectionMode } from '../hooks/useSelectionMode'; +import { useVADRecording } from '../hooks/useVADRecording'; +import { saveRecording } from '../services/recordingService'; +import { AssetCard } from './AssetCard'; +import { FullScreenVADOverlay } from './FullScreenVADOverlay'; +import { RecordingControls } from './RecordingControls'; +import { RenameAssetDrawer } from './RenameAssetDrawer'; +import { SelectionControls } from './SelectionControls'; +import { VADSettingsDrawer } from './VADSettingsDrawer'; + +// Feature flag: true = use ArrayInsertionWheel, false = use LegendList +// const USE_INSERTION_WHEEL = true; +const DEBUG_MODE = false; +function debugLog(...args: unknown[]) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (DEBUG_MODE) { + console.log(...args); + } +} + +interface UIAsset { + type: 'asset'; + id: string; + name: string; + created_at: string; + order_index: number; + source: 'local' | 'synced' | 'cloud'; + segmentCount: number; + duration?: number; // Total duration in milliseconds + verse?: { from: number; to: number } | null; // Verse metadata (can be a range like 1-3) +} + +interface VersePillItem { + type: 'pill'; + id: string; // Unique ID for the pill (e.g., 'pill-verse-5') + order_index: number; + verse: { from: number; to: number } | null; // Verse metadata +} + +// Union type for items in the list (assets or verse pills) +type ListItem = UIAsset | VersePillItem; + +// Type guard to check if item is an asset +const isAsset = (item: ListItem): item is UIAsset => item.type === 'asset'; + +// Type guard to check if item is a pill +const isPill = (item: ListItem): item is VersePillItem => item.type === 'pill'; + +// Default order_index for unassigned verses: (999 * 1000 + 1) * 1000 = 999001000 +// Sequence starts at 1, not 0 (e.g., verse 7 โ†’ 7001000, 7002000...) +// The extra *1000 leaves space for future insertions between assets +const DEFAULT_ORDER_INDEX = 999001000; + +// Verse metadata type +interface VerseRange { + from: number; + to: number; +} + +// Asset metadata type (prefixed with _ to allow unused) +interface _AssetMetadata { + verse?: VerseRange; +} + +interface BibleRecordingViewProps { + // Callback when user navigates back - receives array of verse numbers that were recorded + // Used by parent to normalize order_index for those verses + onBack: (recordedVerses?: number[]) => void; + // Pass existing assets as initial data to avoid redundant query + initialAssets?: unknown[]; + // Label for the recording session (e.g., verse reference like "5" or "5-7") + label?: string; + // Initial order_index for new recordings (default: 999001 for unassigned) + initialOrderIndex?: number; + // Verse metadata from the selected asset + verse?: VerseRange; + // Book chapter label for separators (short name, e.g., "Gen 1" or "Mat 3") + bookChapterLabel?: string; + // Book chapter label for header (full name from quest.name, e.g., "Genesis 1" or "Matthew 3") + bookChapterLabelFull?: string; + // Next verse number to record (for automatic progression) + nextVerse?: number | null; + // Limit verse number (for stopping automatic progression) + limitVerse?: number | null; +} + +const BibleRecordingView = ({ + onBack, + initialAssets: _initialAssets, // Not used - session mode starts with empty list + label: _label = '', // TODO: Display label in header + initialOrderIndex: _initialOrderIndex = DEFAULT_ORDER_INDEX, // TODO: Use for order_index calculation + verse: _verse, // TODO: Use for verse tracking and metadata + bookChapterLabel = 'Verse', // Book chapter label for separators (short name, e.g., "Gen 1") + bookChapterLabelFull, // Book chapter label for header (full name from quest, e.g., "Genesis 1") + nextVerse = null, // Next verse number to record (for automatic progression) + limitVerse = null // Limit verse number (for stopping automatic progression) +}: BibleRecordingViewProps) => { + // Log props on mount + React.useEffect(() => { + console.log( + `๐Ÿ“ฅ BibleRecordingView props | initialOrderIndex: ${_initialOrderIndex} | label: "${_label}" | verse: ${_verse ? `${_verse.from}-${_verse.to}` : 'null'} | nextVerse: ${nextVerse} | limitVerse: ${limitVerse}` + ); + }, [_initialOrderIndex, _label, _verse, nextVerse, limitVerse]); + + const queryClient = useQueryClient(); + const { t } = useLocalization(); + const navigation = useCurrentNavigation(); + const { currentQuestId, currentProjectId } = navigation; + const { currentUser } = useAuth(); + const { project: currentProject } = useProjectById(currentProjectId); + const audioContext = useAudio(); + const insets = useSafeAreaInsets(); + + // Get target languoid_id from project_language_link + const { data: targetLanguoidLink = [] } = useHybridData<{ + languoid_id: string | null; + }>({ + dataType: 'project-target-languoid-id', + queryKeyParams: [currentProjectId || ''], + offlineQuery: toCompilableQuery( + system.db + .select({ languoid_id: project_language_link.languoid_id }) + .from(project_language_link) + .where( + and( + eq(project_language_link.project_id, currentProjectId!), + eq(project_language_link.language_type, 'target') + ) + ) + .limit(1) + ), + cloudQueryFn: async () => { + if (!currentProjectId) return []; + const { data, error } = await system.supabaseConnector.client + .from('project_language_link') + .select('languoid_id') + .eq('project_id', currentProjectId) + .eq('language_type', 'target') + .not('languoid_id', 'is', null) + .limit(1) + .overrideTypes<{ languoid_id: string | null }[]>(); + if (error) throw error; + return data; + }, + enableCloudQuery: !!currentProjectId, + enableOfflineQuery: !!currentProjectId + }); + + const targetLanguoidId = targetLanguoidLink[0]?.languoid_id; + + // Recording state + const [isRecording, setIsRecording] = React.useState(false); + const [isVADLocked, setIsVADLocked] = React.useState(false); + + // VAD settings - persisted in local store for consistent UX + // These settings are automatically saved to AsyncStorage and restored on app restart + // Default: threshold=0.03 (normal sensitivity), silenceDuration=1000ms (1 second pause) + const vadThreshold = useLocalStore((state) => state.vadThreshold); + const setVadThreshold = useLocalStore((state) => state.setVadThreshold); + const vadSilenceDuration = useLocalStore((state) => state.vadSilenceDuration); + const setVadSilenceDuration = useLocalStore( + (state) => state.setVadSilenceDuration + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const vadMinSegmentLength = useLocalStore( + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + (state) => state.vadMinSegmentLength + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const setVadMinSegmentLength = useLocalStore( + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + (state) => state.setVadMinSegmentLength + ); + const vadDisplayMode = useLocalStore((state) => state.vadDisplayMode); + const setVadDisplayMode = useLocalStore((state) => state.setVadDisplayMode); + const enablePlayAll = useLocalStore((state) => state.enablePlayAll); + const [showVADSettings, setShowVADSettings] = React.useState(false); + const [autoCalibrateOnOpen, setAutoCalibrateOnOpen] = React.useState(false); + + // Track current recording order index and verse + const currentRecordingOrderRef = React.useRef(0); + const currentRecordingVerseRef = React.useRef<{ + from: number; + to: number; + } | null>(null); + const vadCounterRef = React.useRef(null); + const dbWriteQueueRef = React.useRef>(Promise.resolve()); + + // Track pending asset names to prevent duplicates when recording multiple assets quickly + const pendingAssetNamesRef = React.useRef>(new Set()); + + // Sequential name counter - persisted per quest in AsyncStorage + // Key format: `bible_recording_counter_${questId}` + // This counter is independent of order_index and VAD mode + const nameCounterRef = React.useRef(1); + const nameCounterLoadedRef = React.useRef(false); + + // Track which verses were recorded during this session + // Used to normalize order_index when returning to BibleAssetsView + const recordedVersesRef = React.useRef>(new Set()); + + // Track if the user is allowed to add a new verse + const allowAddVerseRef = React.useRef(true); + + // Load name counter from AsyncStorage on mount + React.useEffect(() => { + if (!currentQuestId || nameCounterLoadedRef.current) return; + + const loadCounter = async () => { + try { + const key = `bible_recording_counter_${currentQuestId}`; + const saved = await AsyncStorage.getItem(key); + if (saved) { + const value = parseInt(saved, 10); + if (!isNaN(value) && value > 0) { + nameCounterRef.current = value; + console.log( + `๐Ÿ“Š Loaded name counter: ${value} for quest ${currentQuestId.slice(0, 8)}` + ); + } + } + nameCounterLoadedRef.current = true; + } catch (error) { + console.error('Failed to load name counter:', error); + nameCounterLoadedRef.current = true; + } + }; + + void loadCounter(); + }, [currentQuestId]); + + // Helper to save counter to AsyncStorage + const saveNameCounter = React.useCallback( + async (value: number) => { + if (!currentQuestId) return; + try { + const key = `bible_recording_counter_${currentQuestId}`; + await AsyncStorage.setItem(key, String(value)); + console.log( + `๐Ÿ’พ Saved name counter: ${value} for quest ${currentQuestId.slice(0, 8)}` + ); + } catch (error) { + console.error('Failed to save name counter:', error); + } + }, + [currentQuestId] + ); + + // Track which asset is currently playing during play-all + const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState< + string | null + >(null); + const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId + const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms + // Track segment ranges for each asset (start position, end position, duration) + const assetSegmentRangesRef = React.useRef< + Map + >(new Map()); + // Track last scrolled asset to avoid scrolling to the same asset multiple times + const lastScrolledAssetIdRef = React.useRef(null); + + // Track setTimeout IDs for cleanup + const timeoutIdsRef = React.useRef>>( + new Set() + ); + + // Track AbortController for batch loading cleanup + const batchLoadingControllerRef = React.useRef(null); + + // Create SharedValues for each asset's progress (0-100 percentage) + // We need to create them at the top level, so we'll create a pool and map them + // Store the mapping in a ref that gets updated when assets change + const assetProgressSharedMapRef = React.useRef< + Map>> + >(new Map()); + + // Create SharedValues for assets (max 100 assets supported) + // We create a pool and reuse them - must create at top level (hooks rule) + const progressPool0 = useSharedValue(0); + const progressPool1 = useSharedValue(0); + const progressPool2 = useSharedValue(0); + const progressPool3 = useSharedValue(0); + const progressPool4 = useSharedValue(0); + const progressPool5 = useSharedValue(0); + const progressPool6 = useSharedValue(0); + const progressPool7 = useSharedValue(0); + const progressPool8 = useSharedValue(0); + const progressPool9 = useSharedValue(0); + // Create more if needed (extend this pattern or use a different approach) + const progressPool = React.useRef([ + progressPool0, + progressPool1, + progressPool2, + progressPool3, + progressPool4, + progressPool5, + progressPool6, + progressPool7, + progressPool8, + progressPool9 + ]).current; + + // Insertion wheel state + const [insertionIndex, setInsertionIndex] = React.useState(0); + const wheelRef = React.useRef(null); + + // Track footer height for proper scrolling + const [footerHeight, setFooterHeight] = React.useState(0); + const ROW_HEIGHT = 80; + + // Dynamic verse tracking for automatic progression + // Starts as null - only set when user clicks "Add verse" button + // This allows the initial verse (_verse) to be displayed first + const [currentDynamicVerse, setCurrentDynamicVerse] = React.useState< + number | null + >(null); + + // Persist initial props in refs - these should NOT change during the recording session + // even when invalidateQueries causes re-renders. We capture them once on mount. + const persistedNextVerseRef = React.useRef(nextVerse); + const persistedLimitVerseRef = React.useRef(limitVerse); + const persistedVerseRef = React.useRef(_verse); + + // Log initial values on mount + React.useEffect(() => { + console.log( + `๐Ÿ“Œ Persisted initial props | nextVerse: ${nextVerse} | limitVerse: ${limitVerse} | _verse: ${_verse?.from}-${_verse?.to}` + ); + persistedNextVerseRef.current = nextVerse; + persistedLimitVerseRef.current = limitVerse; + persistedVerseRef.current = _verse; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run on mount - these values are persisted for the session + + // Debounced insertion index to prevent button flickering when scrolling fast + const [debouncedIsAtEnd, setDebouncedIsAtEnd] = React.useState(false); + + // Selection mode for batch operations (merge, delete) + const { + isSelectionMode, + selectedAssetIds, + enterSelection, + toggleSelect, + cancelSelection, + selectMultiple + } = useSelectionMode(); + + // Rename drawer state + const [showRenameDrawer, setShowRenameDrawer] = React.useState(false); + const [renameAssetId, setRenameAssetId] = React.useState(null); + const [renameAssetName, setRenameAssetName] = React.useState(''); + + // Track segment counts for each asset (loaded lazily) + const [assetSegmentCounts, setAssetSegmentCounts] = React.useState< + Map + >(new Map()); + + // Track durations for each asset (loaded lazily) + const [assetDurations, setAssetDurations] = React.useState< + Map + >(new Map()); + + // SESSION-ONLY ITEMS: Assets and verse pills created during this recording session + // When user exits and returns, the list starts with just the initial verse pill + // Assets are still saved to database, but we don't load existing ones + const [sessionItems, setSessionItems] = React.useState(() => { + // Initialize with the initial verse pill + if (!_verse) return []; + const initialVerse = _verse; + const initialPill: VersePillItem = { + type: 'pill', + id: `pill-initial-${_initialOrderIndex}`, + order_index: _initialOrderIndex, + verse: initialVerse + }; + console.log( + `๐Ÿท๏ธ Initial pill created | order_index: ${_initialOrderIndex} | verse: ${initialVerse.from}-${initialVerse.to}` + ); + return [initialPill]; + }); + + // Track the "append" order_index (used when recording at the end of the list) + // Initialized from props or DEFAULT_ORDER_INDEX, increments by 1 for each recording at end + const appendOrderIndexRef = React.useRef(_initialOrderIndex + 1); + + // Helper to add a new asset to the session list + // Replicates the shift logic from recordingService.ts to keep UI in sync with DB + const addSessionAsset = React.useCallback( + (newAsset: { + id: string; + name: string; + order_index: number; + verse?: { from: number; to: number } | null; + }) => { + const targetOrderIndex = newAsset.order_index; + + setSessionItems((prev) => { + // 1. Shift existing items (both assets and pills) with order_index >= targetOrderIndex + // This mirrors the logic in recordingService.ts + const shifted = prev.map((item) => { + if (item.order_index >= targetOrderIndex) { + const itemName = isAsset(item) + ? item.name + : `pill-${item.verse?.from ?? 'null'}`; + console.log( + `๐Ÿ“Š UI Shift: "${itemName}" ${item.order_index} โ†’ ${item.order_index + 1}` + ); + return { ...item, order_index: item.order_index + 1 }; + } + return item; + }); + + // 2. Create new asset with the target order_index and verse + const uiAsset: UIAsset = { + type: 'asset', + id: newAsset.id, + name: newAsset.name, + created_at: new Date().toISOString(), + order_index: targetOrderIndex, + source: 'local', + segmentCount: 1, + duration: undefined, + verse: newAsset.verse ?? null + }; + + if (!newAsset.verse) { + allowAddVerseRef.current = false; + } + + console.log( + `โž• Adding "${newAsset.name}" with order_index: ${targetOrderIndex} | verse: ${newAsset.verse ? `${newAsset.verse.from}-${newAsset.verse.to}` : 'null'}` + ); + + // 3. Add new asset and sort by order_index + const newList = [...shifted, uiAsset]; + return newList.sort((a, b) => { + if (a.order_index === b.order_index) { + // Pills come before assets at the same order_index + if (isPill(a) && isAsset(b)) return -1; + if (isAsset(a) && isPill(b)) return 1; + // Both are same type - sort by created_at for assets, keep order for pills + if (isAsset(a) && isAsset(b)) { + return a.created_at.localeCompare(b.created_at); + } + return 0; + } + return a.order_index > b.order_index ? 1 : -1; + }); + }); + }, + [] + ); + + // Helper to add a new verse pill to the session list + const addVersePill = React.useCallback( + (verse: number, orderIndex: number) => { + const newPill: VersePillItem = { + type: 'pill', + id: `pill-verse-${verse}-${Date.now()}`, + order_index: orderIndex, + verse: { from: verse, to: verse } + }; + + console.log( + `๐Ÿท๏ธ Adding pill for verse ${verse} with order_index: ${orderIndex}` + ); + + setSessionItems((prev) => { + const newList = [...prev, newPill]; + return newList.sort((a, b) => { + if (a.order_index === b.order_index) { + // Pills come before assets at the same order_index + if (isPill(a) && isAsset(b)) return -1; + if (isAsset(a) && isPill(b)) return 1; + if (isAsset(a) && isAsset(b)) { + return a.created_at.localeCompare(b.created_at); + } + return 0; + } + return a.order_index > b.order_index ? 1 : -1; + }); + }); + }, + [] + ); + + // Use session items - filter to get only assets for backward compatibility + const rawAssets = React.useMemo( + () => sessionItems.filter(isAsset), + [sessionItems] + ); + + // All items (assets + pills) for the wheel + const allItems = sessionItems; + + // Normalize assets + // ARCHITECTURE: + // - Asset: A single recording or merged group of recordings + // - Segment: One content_link row (merged assets have multiple segments) + // - Audio file: Individual audio file (each segment has audio[] array) + // + // METADATA (loaded lazily in background): + // - segmentCount: Number of content_link rows for this asset + // - duration: Sum of all audio files' durations across all segments + const assets = React.useMemo((): UIAsset[] => { + const result = rawAssets + .filter((a) => { + const obj = a as { + id?: string; + name?: string; + created_at?: string; + source?: string; + } | null; + return obj?.id && obj.name && obj.created_at && obj.source; + }) + .map((a, index) => { + const obj = a as { + id: string; + name: string; + created_at: string; + order_index?: number | null; + source: 'local' | 'synced' | 'cloud'; + verse?: { from: number; to: number } | null; + }; + // Get segment count and duration from lazy-loaded maps + // Default to 1 segment if not loaded yet, undefined for duration (shows loading state) + const segmentCount = assetSegmentCounts.get(obj.id) ?? 1; + const duration = assetDurations.get(obj.id); // undefined if not loaded yet + + // DEBUG: Log assets with multiple segments + if (segmentCount > 1) { + debugLog( + `๐Ÿ“Š Asset "${obj.name}" (${obj.id.slice(0, 8)}) has ${segmentCount} segments` + ); + } + + return { + type: 'asset' as const, + id: obj.id, + name: obj.name, + created_at: obj.created_at, + order_index: + typeof obj.order_index === 'number' ? obj.order_index : index, + source: obj.source, + segmentCount, + duration, + verse: obj.verse ?? null + }; + }); + + // DEBUG: Summary of segment counts + const multiSegmentAssets = result.filter((a) => a.segmentCount > 1); + if (multiSegmentAssets.length > 0) { + debugLog( + `๐Ÿ“Š Total assets with multiple segments: ${multiSegmentAssets.length}` + ); + } + + return result; + }, [rawAssets, assetSegmentCounts, assetDurations]); + + // Check if we're at the end of the list (for add verse button behavior) + const isAtEndOfList = React.useMemo( + () => allItems.length === 0 || insertionIndex >= allItems.length, + [allItems.length, insertionIndex] + ); + + // Get the item at the current insertion position (center of wheel) + // This can be either an asset or a verse pill + const highlightedItem = React.useMemo(() => { + if (allItems.length === 0) return null; + // insertionIndex can be 0 to allItems.length (inclusive) + // When at the end (insertionIndex >= allItems.length), we still want to show the last item + const idx = Math.min(insertionIndex, allItems.length - 1); + return allItems[idx] ?? null; + }, [allItems, insertionIndex]); + + // Get the highlighted item as an asset (null if it's a pill) + // Prefixed with _ as it may not be used directly but kept for potential future use + const _highlightedAsset = React.useMemo(() => { + if (!highlightedItem || isPill(highlightedItem)) return null; + return highlightedItem; + }, [highlightedItem]); + + // Get verse metadata from highlighted item (works for both assets and pills) + // This can be a range like { from: 1, to: 3 } + const highlightedItemVerse = React.useMemo(() => { + if (!highlightedItem) return null; + return highlightedItem.verse ?? null; + }, [highlightedItem]); + + // Legacy alias for backward compatibility + const highlightedAssetVerse = highlightedItemVerse; + + // Helper to format verse range as text + const formatVerseRange = React.useCallback( + (verse: { from: number; to: number } | null | undefined) => { + if (!verse?.from) return null; + const verseText = + verse.from === verse.to ? `${verse.from}` : `${verse.from}-${verse.to}`; + return `${bookChapterLabel}:${verseText}`; + }, + [bookChapterLabel] + ); + + // Build verse pill text based on context: + // - Always show the verse of the asset in the center of the wheel (highlightedAsset) + // - EXCEPT when user clicked "Add verse" button - then show the new verse + // - If no assets exist, show the initial verse from props or "No Label Assigned" + const versePillText = React.useMemo(() => { + // If user clicked "Add verse" button, show the new dynamic verse + if (currentDynamicVerse !== null) { + return ( + formatVerseRange({ + from: currentDynamicVerse, + to: currentDynamicVerse + }) ?? 'No Label Assigned' + ); + } + + // If there are assets, show the verse of the asset in the center + if (highlightedAssetVerse) { + return formatVerseRange(highlightedAssetVerse) ?? 'No Label Assigned'; + } + + // No assets yet - show the initial verse from props + if (persistedVerseRef.current) { + return formatVerseRange(persistedVerseRef.current) ?? 'No Label Assigned'; + } + + return 'No Label Assigned'; + }, [highlightedAssetVerse, formatVerseRange, currentDynamicVerse]); + + // Debounce logic for showing add verse button + // Uses isAtEndOfList calculated above + React.useEffect(() => { + const timeout = setTimeout(() => { + setDebouncedIsAtEnd(isAtEndOfList); + }, 300); // 300ms debounce + + return () => clearTimeout(timeout); + }, [isAtEndOfList]); + + // Calculate the next verse to add (what the button will show) + // Uses persisted refs to avoid issues with query invalidation re-renders + // If user already clicked Add, show currentDynamicVerse + 1 (if within limit) + // If user hasn't clicked yet, show persisted nextVerse + const verseToAdd = React.useMemo(() => { + const limit = persistedLimitVerseRef.current; + + if (currentDynamicVerse !== null) { + // User already clicked Add - next verse is current + 1 + const next = currentDynamicVerse + 1; + // Check if next is within limit + if (limit !== null && next > limit) { + return null; // No more verses to add + } + return next; + } + // User hasn't clicked Add yet - use persisted nextVerse + return persistedNextVerseRef.current; + }, [currentDynamicVerse]); + + // Show add verse button if: + // 1. At the end of the list (debounced) + // 2. There's a verse available to add (verseToAdd not null) + const showAddVerseButton = React.useMemo( + () => debouncedIsAtEnd && verseToAdd !== null, + [debouncedIsAtEnd, verseToAdd] + ); + + // Log for debugging button visibility and verse pill + React.useEffect(() => { + const highlightedVerseStr = highlightedAssetVerse + ? `${highlightedAssetVerse.from}-${highlightedAssetVerse.to}` + : 'null'; + console.log( + `๐Ÿ”˜ State | insertionIdx: ${insertionIndex} | assetsLen: ${assets.length} | isAtEnd: ${isAtEndOfList} | debouncedIsAtEnd: ${debouncedIsAtEnd} | highlightedVerse: ${highlightedVerseStr} | verseToAdd: ${verseToAdd} | currentDynamic: ${currentDynamicVerse} | pillText: ${versePillText}` + ); + }, [ + insertionIndex, + assets.length, + isAtEndOfList, + debouncedIsAtEnd, + highlightedAssetVerse, + verseToAdd, + currentDynamicVerse, + showAddVerseButton, + versePillText + ]); + + // Handle adding next verse metadata + // When clicked, adds a pill at the end of the list + // The button will then show the next verse (verseToAdd + 1) + const handleAddNextVerse = React.useCallback(() => { + if (verseToAdd === null) return; + + console.log(`โž• Adding verse ${verseToAdd} as pill to list`); + + // Calculate order_index for the first asset of this verse + // Formula: (verse * 1000 + 1) * 1000 + // This positions it at the beginning of the verse range + const newOrderIndex = (verseToAdd * 1000 + 1) * 1000; + + // Update appendOrderIndexRef to point to after this new pill + appendOrderIndexRef.current = newOrderIndex + 1; + + console.log( + `๐Ÿ“Š Adding pill for verse ${verseToAdd} | order_index: ${newOrderIndex} | next append: ${appendOrderIndexRef.current}` + ); + + // Mark that a pill was added (so auto-scroll moves to end) + wasPillAddedRef.current = true; + + // Add the verse pill to the list + addVersePill(verseToAdd, newOrderIndex); + + // Set currentDynamicVerse to this verse (for button calculation) + setCurrentDynamicVerse(verseToAdd); + + // Move insertion index to the end (where the pill was added) + // This is done in the next useEffect that monitors allItems.length change + + // If VAD is active, update recording context to use the new pill + if (isVADLocked) { + const newVerse = { from: verseToAdd, to: verseToAdd }; + currentRecordingVerseRef.current = newVerse; + vadCounterRef.current = newOrderIndex + 1; + console.log( + `๐ŸŽฏ VAD: Updated to verse ${verseToAdd} | order_index: ${newOrderIndex + 1}` + ); + } + }, [verseToAdd, isVADLocked, addVersePill]); + + // Map assets to SharedValues from the pool (after assets is declared) + const assetIdsKey = React.useMemo( + () => assets.map((a) => a.id).join(','), + [assets] + ); + React.useEffect(() => { + if (assets.length === 0) { + assetProgressSharedMapRef.current.clear(); + return; + } + + const map = assetProgressSharedMapRef.current; + map.clear(); + + // Assign SharedValues from pool to assets + for (let i = 0; i < Math.min(assets.length, progressPool.length); i++) { + const asset = assets[i]; + if (asset) { + // Reset the SharedValue + progressPool[i]!.value = 0; + map.set(asset.id, progressPool[i]!); + } + } + }, [assetIdsKey, assets, progressPool]); + + // Stable item list that only updates when content actually changes + // We intentionally use assetContentKey instead of allItems to prevent re-renders + // when items array reference changes but content is identical + const itemsForWheel = React.useMemo(() => allItems, [allItems]); + + // Clamp insertion index when item count changes + React.useEffect(() => { + const maxIndex = allItems.length; // Can insert at 0..N (after last item) + if (insertionIndex > maxIndex) { + setInsertionIndex(maxIndex); + } + }, [allItems.length, insertionIndex]); + + // Track item count to detect new insertions + const previousItemCountRef = React.useRef(allItems.length); + + // Track if the last recording was in the middle (not at end) + // Used to determine if we should move insertionIndex to the new item + const wasRecordingInMiddleRef = React.useRef(false); + + // Track if a pill was just added (to distinguish from asset recording) + const wasPillAddedRef = React.useRef(false); + + // Auto-scroll behavior differs between list and wheel + React.useEffect(() => { + const currentCount = allItems.length; + const previousCount = previousItemCountRef.current; + + // Only scroll if a new item was added (count increased) + if (currentCount > previousCount && currentCount > 0) { + console.log( + `๐Ÿ“œ Item added | prevCount: ${previousCount} โ†’ ${currentCount} | insertionIndex: ${insertionIndex} | wasInMiddle: ${wasRecordingInMiddleRef.current} | wasPillAdded: ${wasPillAddedRef.current}` + ); + + console.log( + '[recording in the middle]>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>', + wasRecordingInMiddleRef.current + ); + + const wasInMiddle = wasRecordingInMiddleRef.current; + const wasPillAdded = wasPillAddedRef.current; + + // Reset the flags + wasRecordingInMiddleRef.current = false; + wasPillAddedRef.current = false; + + if (wasPillAdded) { + // A pill was added - move to the end + console.log( + `๐Ÿ“ Pill added - moving to end: ${insertionIndex} โ†’ ${currentCount}` + ); + setInsertionIndex(currentCount); + + // Scroll to the end + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(currentCount, true); + } catch (error) { + console.error('Failed to scroll after pill added:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } else if (wasInMiddle) { + // Recorded in the middle - move to the new asset + const newIndex = insertionIndex + 1; + console.log( + `๐Ÿ“ Moving to new asset (recorded in middle): ${insertionIndex} โ†’ ${newIndex}` + ); + setInsertionIndex(newIndex); + + // Scroll to the new item + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(newIndex, true); + } catch (error) { + console.error('Failed to scroll:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } else { + // Asset appended at the end - move to stay at end + console.log( + `๐Ÿ“ Moving to end (appended): ${insertionIndex} โ†’ ${currentCount}` + ); + setInsertionIndex(currentCount); + + // Scroll to the end + const timeoutId = setTimeout(() => { + try { + wheelRef.current?.scrollToInsertionIndex(currentCount, true); + } catch (error) { + console.error('Failed to scroll:', error); + } + timeoutIdsRef.current.delete(timeoutId); + }, 100); + timeoutIdsRef.current.add(timeoutId); + } + } + + previousItemCountRef.current = currentCount; + }, [allItems.length, insertionIndex]); + + // ============================================================================ + // AUDIO PLAYBACK + // ============================================================================ + + // Fetch audio URIs for an asset + // Includes fallback logic for local-only files when server records are removed + const getAssetAudioUris = React.useCallback( + async (assetId: string): Promise => { + try { + // Get content links from both synced and local tables + const assetContentLinkSynced = resolveTable('asset_content_link', { + localOverride: false + }); + const contentLinksSynced = await system.db + .select() + .from(assetContentLinkSynced) + .where(eq(assetContentLinkSynced.asset_id, assetId)); + + const assetContentLinkLocal = resolveTable('asset_content_link', { + localOverride: true + }); + const contentLinksLocal = await system.db + .select() + .from(assetContentLinkLocal) + .where(eq(assetContentLinkLocal.asset_id, assetId)); + + // Prefer synced links, but merge with local for fallback + const allContentLinks = [...contentLinksSynced, ...contentLinksLocal]; + + // Deduplicate by ID (prefer synced over local) + const seenIds = new Set(); + const uniqueLinks = allContentLinks.filter((link) => { + if (seenIds.has(link.id)) { + return false; + } + seenIds.add(link.id); + return true; + }); + + debugLog( + `๐Ÿ“€ Found ${uniqueLinks.length} content link(s) for asset ${assetId.slice(0, 8)} (${contentLinksSynced.length} synced, ${contentLinksLocal.length} local)` + ); + + if (uniqueLinks.length === 0) { + debugLog('No content links found for asset:', assetId); + return []; + } + + // Get audio values from content links (can be URIs or attachment IDs) + const audioValues = uniqueLinks + .flatMap((link) => { + const audioArray = link.audio ?? []; + debugLog( + ` ๐Ÿ“Ž Content link has ${audioArray.length} audio file(s):`, + audioArray + ); + return audioArray; + }) + .filter((value): value is string => !!value); + + debugLog(`๐Ÿ“Š Total audio files for asset: ${audioValues.length}`); + + if (audioValues.length === 0) { + debugLog('No audio values found in content links'); + return []; + } + + // Process each audio value - can be either a local URI or an attachment ID + const uris: string[] = []; + for (const audioValue of audioValues) { + // Check if this is already a local URI (starts with 'local/' or 'file://') + if (audioValue.startsWith('local/')) { + // It's a direct local URI from saveAudioLocally() + const constructedUri = + await getLocalAttachmentUriWithOPFS(audioValue); + // Check if file exists at constructed path + if (await fileExists(constructedUri)) { + uris.push(constructedUri); + debugLog( + 'โœ… Using direct local URI:', + constructedUri.slice(0, 80) + ); + } else { + // File doesn't exist at expected path - try to find it in attachment queue + debugLog( + `โš ๏ธ Local URI ${audioValue} not found at ${constructedUri}, searching attachment queue...` + ); + + if (system.permAttachmentQueue) { + // Extract filename from local path (e.g., "local/uuid.wav" -> "uuid.wav") + const filename = audioValue.replace(/^local\//, ''); + // Extract UUID part (without extension) for more flexible matching + const uuidPart = filename.split('.')[0]; + + // Search attachment queue by filename or UUID + let attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR filename LIKE ? OR id = ? OR id LIKE ? LIMIT 1`, + [filename, `%${uuidPart}%`, filename, `%${uuidPart}%`] + ); + + // If not found, try searching all attachments for this asset's content links + if (!attachment && uniqueLinks.length > 0) { + const allAttachmentIds = uniqueLinks + .flatMap((link) => link.audio ?? []) + .filter( + (av): av is string => + typeof av === 'string' && + !av.startsWith('local/') && + !av.startsWith('file://') + ); + if (allAttachmentIds.length > 0) { + const placeholders = allAttachmentIds + .map(() => '?') + .join(','); + attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id IN (${placeholders}) LIMIT 1`, + allAttachmentIds + ); + } + } + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + // Verify the found file actually exists + if (await fileExists(foundUri)) { + uris.push(foundUri); + debugLog( + `โœ… Found attachment in queue for local URI ${audioValue.slice(0, 20)}` + ); + } else { + debugLog( + `โš ๏ธ Attachment found in queue but file doesn't exist: ${foundUri}` + ); + } + } else { + // Try fallback to local table for alternative audio values + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + debugLog(`โœ… Found fallback file URI`); + break; + } + } + } + } + } + } + } + } else if (audioValue.startsWith('file://')) { + // Already a full file URI - verify it exists + if (await fileExists(audioValue)) { + uris.push(audioValue); + debugLog('โœ… Using full file URI:', audioValue.slice(0, 80)); + } else { + debugLog(`โš ๏ธ File URI does not exist: ${audioValue}`); + // Try to find in attachment queue by extracting filename from path + if (system.permAttachmentQueue) { + const filename = audioValue.split('/').pop(); + if (filename) { + const attachment = await system.powersync.getOptional<{ + id: string; + filename: string | null; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE filename = ? OR id = ? LIMIT 1`, + [filename, filename] + ); + + if (attachment?.local_uri) { + const foundUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(foundUri)) { + uris.push(foundUri); + debugLog(`โœ… Found attachment in queue for file URI`); + } + } + } + } + } + } else { + // It's an attachment ID - look it up in the attachment queue + if (!system.permAttachmentQueue) { + // No attachment queue - try fallback to local table + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + break; + } + } + } + } + continue; + } + + const attachment = await system.powersync.getOptional<{ + id: string; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id = ?`, + [audioValue] + ); + + if (attachment?.local_uri) { + const localUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + if (await fileExists(localUri)) { + uris.push(localUri); + debugLog('โœ… Found attachment URI:', localUri.slice(0, 60)); + } + } else { + // Attachment ID not found in queue - try fallback to local table + debugLog( + `โš ๏ธ Attachment ID ${audioValue.slice(0, 8)} not found in queue, checking local table fallback...` + ); + + const fallbackLink = contentLinksLocal.find( + (link) => link.asset_id === assetId + ); + if (fallbackLink?.audio) { + for (const fallbackAudioValue of fallbackLink.audio) { + if (fallbackAudioValue.startsWith('local/')) { + const fallbackUri = + await getLocalAttachmentUriWithOPFS(fallbackAudioValue); + if (await fileExists(fallbackUri)) { + uris.push(fallbackUri); + debugLog( + `โœ… Found fallback local URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } else if (fallbackAudioValue.startsWith('file://')) { + if (await fileExists(fallbackAudioValue)) { + uris.push(fallbackAudioValue); + debugLog( + `โœ… Found fallback file URI for attachment ${audioValue.slice(0, 8)}` + ); + break; + } + } + } + } else { + debugLog(`โš ๏ธ Audio ${audioValue} not downloaded yet`); + } + } + } + } + + return uris; + } catch (error) { + console.error('Failed to fetch audio URIs:', error); + return []; + } + }, + [] + ); + + // Special audio ID for "play all" mode + const PLAY_ALL_AUDIO_ID = 'play-all-assets'; + + // Handle asset playback + const handlePlayAsset = React.useCallback( + async (assetId: string) => { + try { + const isThisAssetPlaying = + audioContext.isPlaying && audioContext.currentAudioId === assetId; + + if (isThisAssetPlaying) { + debugLog('โธ๏ธ Stopping asset:', assetId.slice(0, 8)); + await audioContext.stopCurrentSound(); + } else { + debugLog('โ–ถ๏ธ Playing asset:', assetId.slice(0, 8)); + const uris = await getAssetAudioUris(assetId); + + if (uris.length === 0) { + console.error('โŒ No audio URIs found for asset:', assetId); + return; + } + + if (uris.length === 1 && uris[0]) { + debugLog('โ–ถ๏ธ Playing single segment'); + await audioContext.playSound(uris[0], assetId); + } else if (uris.length > 1) { + debugLog(`โ–ถ๏ธ Playing ${uris.length} segments in sequence`); + await audioContext.playSoundSequence(uris, assetId); + } + } + } catch (error) { + console.error('โŒ Failed to play audio:', error); + } + }, + [audioContext, getAssetAudioUris] + ); + + // Track currently playing asset based on audio position during play-all + React.useEffect(() => { + if ( + !audioContext.isPlaying || + audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID + ) { + setCurrentlyPlayingAssetId(null); + return; + } + + // Calculate which asset is playing based on cumulative position + // Also update progress for each asset based on its segment range + const checkCurrentAsset = () => { + const uris = Array.from(assetUriMapRef.current.keys()); + const durations = segmentDurationsRef.current; + const ranges = assetSegmentRangesRef.current; + + if (uris.length === 0) return; + + const position = audioContext.position; // Position in milliseconds + + // Update progress for each asset based on its segment range + const progressMap = assetProgressSharedMapRef.current; + for (const [assetId, range] of ranges.entries()) { + const progressShared = progressMap.get(assetId); + if (!progressShared) { + debugLog( + `โš ๏ธ No progress SharedValue found for asset ${assetId.slice(0, 8)}` + ); + continue; + } + + if (position < range.startMs) { + // Before this asset's segments - no progress + progressShared.value = 0; + } else if (position >= range.endMs) { + // After this asset's segments - fully complete + progressShared.value = 100; + } else { + // Within this asset's segments - calculate progress + const assetPosition = position - range.startMs; + const progressPercent = (assetPosition / range.durationMs) * 100; + const clampedProgress = Math.min(100, Math.max(0, progressPercent)); + progressShared.value = clampedProgress; + debugLog( + `๐Ÿ“Š Asset ${assetId.slice(0, 8)} progress: ${Math.round(clampedProgress)}% (position: ${Math.round(position)}ms, range: [${Math.round(range.startMs)}-${Math.round(range.endMs)}]ms)` + ); + } + } + + // Find which asset is currently playing + let newPlayingAssetId: string | null = null; + + // If we don't have durations yet, use simple percentage-based approach + if (durations.length === 0 || durations.every((d) => d === 0)) { + const duration = audioContext.duration; + if (duration === 0) return; + + // Fallback: use percentage-based calculation + const positionPercent = position / duration; + const uriIndex = Math.min( + Math.floor(positionPercent * uris.length), + uris.length - 1 + ); + + const currentUri = uris[uriIndex]; + if (currentUri) { + const assetId = assetUriMapRef.current.get(currentUri); + if (assetId) { + newPlayingAssetId = assetId; + } + } + } else { + // Calculate which segment we're in based on cumulative durations + let cumulativeDuration = 0; + for (let i = 0; i < uris.length; i++) { + const segmentDuration = durations[i] || 0; + const segmentStart = cumulativeDuration; + cumulativeDuration += segmentDuration; + + // If position is within this segment's range + if ( + (position >= segmentStart && position <= cumulativeDuration) || + (i === uris.length - 1 && position >= segmentStart) + ) { + const currentUri = uris[i]; + if (currentUri) { + const assetId = assetUriMapRef.current.get(currentUri); + if (assetId) { + newPlayingAssetId = assetId; + } + } + break; + } + } + } + + // Update currently playing asset ID and scroll to it + if (newPlayingAssetId) { + setCurrentlyPlayingAssetId((prev) => { + if (newPlayingAssetId !== prev) { + debugLog( + `๐ŸŽต Highlighting asset ${newPlayingAssetId.slice(0, 8)} (was: ${prev?.slice(0, 8) ?? 'none'})` + ); + + // Scroll to the currently playing asset (only if it changed) + if ( + wheelRef.current && + newPlayingAssetId !== lastScrolledAssetIdRef.current + ) { + // Find the index of the asset in the assets array + const assetIndex = assets.findIndex( + (a) => a.id === newPlayingAssetId + ); + if (assetIndex >= 0) { + debugLog( + `๐Ÿ“œ Scrolling to asset at index ${assetIndex} (asset ${newPlayingAssetId.slice(0, 8)})` + ); + // Scroll the item to the top of the wheel + // scrollItemToTop adds 1 internally, so subtract 1 to get correct position + wheelRef.current.scrollItemToTop(assetIndex - 1, true); + lastScrolledAssetIdRef.current = newPlayingAssetId; + } else { + debugLog( + `โš ๏ธ Could not find asset ${newPlayingAssetId.slice(0, 8)} in assets array` + ); + } + } + + return newPlayingAssetId; + } + return prev; + }); + } + }; + + // Check immediately and then periodically while playing + checkCurrentAsset(); + const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms + return () => clearInterval(interval); + // Note: We intentionally read audioContext.position and audioContext.duration inside the callback + // rather than including them as dependencies, because they change frequently (every ~200ms) + // and we don't want to re-run the effect that often. The interval handles the updates. + // assetProgressSharedMap is a ref, so we access it directly in the callback. + // assets is included to find the asset index for scrolling. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [audioContext.isPlaying, audioContext.currentAudioId, assets]); + + // Handle play all assets + const handlePlayAllAssets = React.useCallback(async () => { + try { + const isPlayingAll = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + + if (isPlayingAll) { + debugLog('โธ๏ธ Stopping play all'); + await audioContext.stopCurrentSound(); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + segmentDurationsRef.current = []; + assetSegmentRangesRef.current.clear(); + lastScrolledAssetIdRef.current = null; + // Reset all asset progress + for (const progressShared of assetProgressSharedMapRef.current.values()) { + progressShared.value = 0; + } + } else { + debugLog('โ–ถ๏ธ Playing all assets'); + if (assets.length === 0) { + console.warn('โš ๏ธ No assets to play'); + return; + } + + // Collect all URIs from all assets in order, tracking which asset each URI belongs to + const allUris: string[] = []; + assetUriMapRef.current.clear(); + segmentDurationsRef.current = []; + + for (const asset of assets) { + const uris = await getAssetAudioUris(asset.id); + for (const uri of uris) { + allUris.push(uri); + // Map each URI to its asset ID + assetUriMapRef.current.set(uri, asset.id); + } + } + + if (allUris.length === 0) { + console.error('โŒ No audio URIs found for any assets'); + return; + } + + debugLog( + `โ–ถ๏ธ Playing ${allUris.length} audio segments from ${assets.length} assets` + ); + + // Preload durations for accurate highlighting and calculate asset segment ranges + try { + const durations: number[] = []; + for (const uri of allUris) { + try { + const { sound } = await Audio.Sound.createAsync({ uri }); + const status = await sound.getStatusAsync(); + await sound.unloadAsync(); + durations.push( + status.isLoaded ? (status.durationMillis ?? 0) : 0 + ); + } catch (error) { + debugLog( + `Failed to get duration for ${uri.slice(0, 30)}:`, + error + ); + durations.push(0); + } + } + segmentDurationsRef.current = durations; + debugLog( + `๐Ÿ“Š Loaded durations for ${durations.length} segments:`, + durations.map((d) => Math.round(d / 1000)).join('s, ') + 's' + ); + + // Calculate segment ranges for each asset + assetSegmentRangesRef.current.clear(); + let cumulativeStart = 0; + for (const asset of assets) { + const assetUris = allUris.filter( + (uri) => assetUriMapRef.current.get(uri) === asset.id + ); + if (assetUris.length === 0) continue; + + // Find the indices of this asset's URIs in the allUris array + const assetUriIndices: number[] = []; + for (let i = 0; i < allUris.length; i++) { + const uri = allUris[i]; + if (uri && assetUriMapRef.current.get(uri) === asset.id) { + assetUriIndices.push(i); + } + } + + // Calculate total duration for this asset's segments + const assetDuration = assetUriIndices.reduce( + (sum, idx) => sum + (durations[idx] || 0), + 0 + ); + + const startMs = cumulativeStart; + const endMs = cumulativeStart + assetDuration; + + assetSegmentRangesRef.current.set(asset.id, { + startMs, + endMs, + durationMs: assetDuration + }); + + // Reset progress for this asset + const progressShared = assetProgressSharedMapRef.current.get( + asset.id + ); + if (progressShared) { + progressShared.value = 0; + debugLog(`๐Ÿ”„ Reset progress for asset ${asset.id.slice(0, 8)}`); + } else { + debugLog( + `โš ๏ธ No progress SharedValue found for asset ${asset.id.slice(0, 8)} when setting up ranges` + ); + } + + debugLog( + `๐Ÿ“Š Asset ${asset.id.slice(0, 8)} segments: ${assetUriIndices.length} segments, ${Math.round(assetDuration / 1000)}s total, range [${Math.round(startMs)}-${Math.round(endMs)}]ms` + ); + + cumulativeStart = endMs; + } + } catch (error) { + debugLog('Failed to preload durations:', error); + // Continue anyway - will use percentage-based fallback + } + + // Set the first asset as currently playing and scroll to it + if (assets.length > 0 && assets[0]) { + const firstAssetId = assets[0].id; + setCurrentlyPlayingAssetId(firstAssetId); + lastScrolledAssetIdRef.current = null; // Reset to allow immediate scroll + + // Scroll to first asset immediately + if (wheelRef.current) { + debugLog( + `๐Ÿ“œ Scrolling to first asset at index 0 (asset ${firstAssetId.slice(0, 8)})` + ); + // scrollItemToTop adds 1 internally, so subtract 1 to get correct position (0 -> -1 -> 0) + wheelRef.current.scrollItemToTop(-1, true); + lastScrolledAssetIdRef.current = firstAssetId; + } + } + + await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); + } + } catch (error) { + console.error('โŒ Failed to play all assets:', error); + setCurrentlyPlayingAssetId(null); + assetUriMapRef.current.clear(); + segmentDurationsRef.current = []; + assetSegmentRangesRef.current.clear(); + lastScrolledAssetIdRef.current = null; + // Reset all asset progress + for (const progressShared of assetProgressSharedMapRef.current.values()) { + progressShared.value = 0; + } + } + }, [audioContext, getAssetAudioUris, assets]); + + // ============================================================================ + // RECORDING HANDLERS + // ============================================================================ + + /** + * CENTRALIZED INSERTION CONTEXT + * This function calculates where and how to insert new recordings + * based on the current wheel position and list state. + * + * Returns: + * - orderIndex: The order_index to use for the new recording + * - verse: The verse metadata to use for the new recording + * - isAtEnd: Whether we're inserting at the end of the list + */ + const getInsertionContext = React.useCallback( + (currentIndex: number = insertionIndex) => { + const isAtEnd = allItems.length === 0 || currentIndex >= allItems.length; + console.log( + '๐Ÿ” getInsertionContext | currentIndex:', + currentIndex, + '| allItems.length:', + allItems.length, + '| isAtEnd:', + isAtEnd + ); + + if (isAtEnd) { + // At end: calculate order_index based on last item, not appendOrderIndexRef + // appendOrderIndexRef is only used as a cache and gets updated after recording + const lastItem = allItems[allItems.length - 1]; + const orderIndex = lastItem + ? lastItem.order_index + 1 + : appendOrderIndexRef.current; // Fallback for empty list + const verse = lastItem?.verse ?? persistedVerseRef.current ?? null; + + console.log( + '๐Ÿ” At END | lastItem order_index:', + lastItem?.order_index, + '| calculated orderIndex:', + orderIndex, + '| appendOrderIndexRef:', + appendOrderIndexRef.current + ); + + return { orderIndex, verse, isAtEnd: true }; + } else { + // In middle: use selected item's context + const selectedItem = allItems[currentIndex]; + const orderIndex = selectedItem + ? selectedItem.order_index + 1 + : currentIndex + 1; + const verse = selectedItem?.verse ?? null; + + return { orderIndex, verse, isAtEnd: false }; + } + }, + [allItems, insertionIndex] + ); + + // Store insertion index in ref to prevent stale closure issues + const insertionIndexRef = React.useRef(insertionIndex); + React.useEffect(() => { + insertionIndexRef.current = insertionIndex; + }, [insertionIndex]); + + // Track if we're currently in the middle of the list (for VAD continuous recording) + // When VAD starts, we capture the position and use same order_index for all segments + // until VAD stops or user moves the wheel + const vadInsertionIndexRef = React.useRef(null); + // Track if VAD was started at the end of the list (append mode) + // This is captured once when VAD activates and doesn't change during the session + const vadIsAtEndRef = React.useRef(false); + + // Initialize VAD counter and verse when VAD mode activates + React.useEffect(() => { + if (isVADLocked && vadCounterRef.current === null) { + // Capture current position when VAD starts + vadInsertionIndexRef.current = insertionIndexRef.current; + + // Get insertion context based on current position + const { + orderIndex, + verse, + isAtEnd: contextIsAtEnd + } = getInsertionContext(insertionIndexRef.current); + + vadCounterRef.current = orderIndex; + currentRecordingVerseRef.current = verse; + + const selectedItem = contextIsAtEnd + ? allItems[allItems.length - 1] + : allItems[insertionIndexRef.current]; + const itemName = selectedItem + ? isPill(selectedItem) + ? `pill-${selectedItem.verse?.from ?? 'null'}` + : selectedItem.name + : 'none'; + + debugLog( + `๐ŸŽฏ VAD initialized ${contextIsAtEnd ? 'at END' : 'in MIDDLE'} | index: ${insertionIndexRef.current} | item: "${itemName}" | order_index: ${orderIndex} | verse: ${verse ? `${verse.from}-${verse.to}` : 'null'}` + ); + } else if (!isVADLocked) { + vadCounterRef.current = null; + vadInsertionIndexRef.current = null; + console.log( + '[INSERTION INDEX REF 000X VAD]>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>', + vadInsertionIndexRef.current, + insertionIndexRef.current + ); + vadIsAtEndRef.current = false; + } + }, [ + isVADLocked, + allItems, + currentDynamicVerse, + assets.length, + getInsertionContext + ]); + + // Manual recording handlers + const handleRecordingStart = React.useCallback(() => { + if (isRecording) return; + + const currentInsertionIndex = insertionIndexRef.current; + + console.log( + `๐ŸŽฌ Recording START | insertionIndex: ${currentInsertionIndex} | allItems.length: ${allItems.length}` + ); + + // Get insertion context (order_index and verse) based on current position + const { orderIndex, verse, isAtEnd } = getInsertionContext( + currentInsertionIndex + ); + + // Store values for use during recording + currentRecordingOrderRef.current = orderIndex; + currentRecordingVerseRef.current = verse; + + // Track if we're recording in the middle (for auto-scroll behavior) + wasRecordingInMiddleRef.current = !isAtEnd; + + // Update appendOrderIndexRef to point to next position after this recording + // This serves as a fallback for empty lists or initial state + if (isAtEnd) { + appendOrderIndexRef.current = orderIndex + 1; + console.log( + '๐Ÿ“Š Updated appendOrderIndexRef:', + appendOrderIndexRef.current, + '(for next recording at end)' + ); + } + + // If starting recording without verse, disable adding new verses + if (!verse) { + allowAddVerseRef.current = false; + } + + setIsRecording(true); + + const selectedItem = isAtEnd + ? allItems[allItems.length - 1] + : allItems[currentInsertionIndex]; + const itemName = selectedItem + ? isPill(selectedItem) + ? `pill-${selectedItem.verse?.from ?? 'null'}` + : selectedItem.name + : 'none'; + + console.log( + `๐ŸŽฏ Recording ${isAtEnd ? 'at END' : 'in MIDDLE'} | index: ${currentInsertionIndex} | item: "${itemName}" | order_index: ${orderIndex} | verse: ${verse ? `${verse.from}-${verse.to}` : 'null'}` + ); + }, [isRecording, allItems, getInsertionContext]); + + const handleRecordingStop = React.useCallback(() => { + debugLog('๐Ÿ›‘ Manual recording stop'); + setIsRecording(false); + }, []); + + const handleRecordingDiscarded = React.useCallback(() => { + debugLog('๐Ÿ—‘๏ธ Recording discarded'); + setIsRecording(false); + }, []); + + const handleRecordingComplete = React.useCallback( + async (uri: string, _duration: number, _waveformData: number[]) => { + // Recalculate order_index based on current list state + // This ensures each consecutive recording gets a unique incremented order_index + const currentContext = getInsertionContext(insertionIndexRef.current); + const targetOrder = currentContext.orderIndex; + const verseToUse = currentContext.verse; + + // Update refs for next recording in same session + currentRecordingOrderRef.current = targetOrder; + currentRecordingVerseRef.current = verseToUse; + + try { + debugLog('๐Ÿ’พ Saving recording | order_index:', targetOrder); + + // Validate required data + if ( + !currentProjectId || + !currentQuestId || + !currentProject || + !currentUser + ) { + console.error('โŒ Missing required data'); + return; + } + + // Generate name using persistent counter (independent of order_index and VAD mode) + // Counter is persisted per quest in AsyncStorage and increments continuously + const nextNumber = nameCounterRef.current; + nameCounterRef.current++; // Increment immediately to reserve this number + const assetName = String(nextNumber).padStart(3, '0'); + pendingAssetNamesRef.current.add(assetName); + console.log( + `๐Ÿท๏ธ Reserved name: ${assetName} | counter: ${nextNumber} โ†’ ${nameCounterRef.current} | order_index: ${targetOrder}` + ); + + // Native module flushes the file before sending onSegmentComplete event. + // File should be ready, but iOS Simulator may need a moment (handled by retry logic in saveAudioLocally). + + // Save audio file locally (with retry logic for timing issues) + const saveResult = await (async () => { + try { + const savedUri = await saveAudioLocally(uri); + return { success: true as const, uri: savedUri }; + } catch (error) { + // Release the reserved name on error + pendingAssetNamesRef.current.delete(assetName); + console.error('โŒ Failed to save audio file locally:', error); + return { success: false as const, error }; + } + })(); + + if (!saveResult.success) { + // Re-throw to be caught by outer catch block + throw saveResult.error; + } + + const localUri = saveResult.uri; + + // Queue DB write (serialized to prevent race conditions) + dbWriteQueueRef.current = dbWriteQueueRef.current + .then(async () => { + if (!targetLanguoidId) { + throw new Error('Target languoid not found for project'); + } + // Use the verse that was captured when recording started + // This ensures we use the correct verse for middle-of-list recordings + const verseToUse = currentRecordingVerseRef.current; + + const newAssetId = await saveRecording({ + questId: currentQuestId, + projectId: currentProjectId, + targetLanguoidId: targetLanguoidId, + userId: currentUser.id, + orderIndex: targetOrder, + audioUri: localUri, + assetName: assetName, // Pass the reserved name + metadata: verseToUse ? { verse: verseToUse } : null // Pass verse metadata if provided + }); + + // Log the saved asset details + console.log( + `๐Ÿ“ผ Asset saved | name: "${assetName}" | order_index: ${targetOrder} | verse: ${verseToUse ? `${verseToUse.from}-${verseToUse.to}` : 'null'}` + ); + + // Add to session assets list (UI only - not loaded from DB) + addSessionAsset({ + id: newAssetId, + name: assetName, + order_index: targetOrder, + verse: verseToUse + }); + + // Track which verse was recorded (for order_index normalization on return) + // If no verse is assigned, use 999 (UNASSIGNED_VERSE_BASE) + const verseToTrack = verseToUse?.from ?? 999; + recordedVersesRef.current.add(verseToTrack); + debugLog( + `๐Ÿ“‹ Tracked verse ${verseToTrack} for normalization (total: ${recordedVersesRef.current.size})` + ); + + // Save the updated name counter to AsyncStorage + await saveNameCounter(nameCounterRef.current); + + // Release the reserved name after successful save + pendingAssetNamesRef.current.delete(assetName); + debugLog( + `โœ… Released name: ${assetName} (pending: ${pendingAssetNamesRef.current.size})` + ); + }) + .catch((err) => { + console.error('โŒ DB write failed:', err); + // Release the reserved name on error + pendingAssetNamesRef.current.delete(assetName); + throw err; + }); + + await dbWriteQueueRef.current; + + // Invalidate queries to sync order_index after insertions in the middle + // This is needed because recordingService shifts order_index values + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog('๐Ÿ Recording saved'); + setIsRecording(false); + } catch (error) { + console.error('โŒ Failed to save recording:', error); + setIsRecording(false); + } + }, + [ + currentProjectId, + currentQuestId, + currentProject, + currentUser, + queryClient, + targetLanguoidId, + addSessionAsset, + saveNameCounter, + getInsertionContext + ] + ); + + // VAD segment handlers + const handleVADSegmentStart = React.useCallback(() => { + if (vadCounterRef.current === null) { + console.error('โŒ VAD counter not initialized!'); + return; + } + + const targetOrder = vadCounterRef.current; + // Use the captured isAtEnd state from when VAD was activated + // This prevents issues where assets.length changes during recording + const isAtEnd = vadIsAtEndRef.current; + + // Track if we're recording in the middle (for auto-scroll behavior) + wasRecordingInMiddleRef.current = !isAtEnd; + + debugLog( + `๐ŸŽฌ VAD: Segment starting | order_index: ${targetOrder} | isAtEnd: ${isAtEnd}` + ); + + currentRecordingOrderRef.current = targetOrder; + + // Increment VAD counter for next segment ONLY if appending at end + // If inserting in middle, all recordings get the same order_index + if (isAtEnd) { + vadCounterRef.current = targetOrder + 1; + appendOrderIndexRef.current = vadCounterRef.current; // Keep append ref in sync + } + }, []); + + const handleVADSegmentComplete = React.useCallback( + (uri: string) => { + if (!uri || uri === '') { + debugLog('๐Ÿ—‘๏ธ VAD: Segment discarded'); + return; + } + + debugLog('๐Ÿ“ผ VAD: Segment complete'); + void handleRecordingComplete(uri, 0, []); + }, + [handleRecordingComplete] + ); + + // Hook up native VAD recording + const { + currentEnergy, + isRecording: isVADRecording, + energyShared, + isRecordingShared + } = useVADRecording({ + threshold: vadThreshold, + silenceDuration: vadSilenceDuration, + isVADActive: isVADLocked, + onSegmentStart: handleVADSegmentStart, + onSegmentComplete: handleVADSegmentComplete, + isManualRecording: isRecording + }); + + // Invalidate queries when VAD mode ends + React.useEffect(() => { + if (!isVADLocked) { + void queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + } + }, [isVADLocked, currentQuestId, queryClient]); + + // ============================================================================ + // LAZY LOAD SEGMENT COUNTS + // ============================================================================ + + // Stable reference to raw assets for segment count loading + // Only extract what we need to avoid circular dependencies + const assetMetadata = React.useMemo( + () => + rawAssets + .map((a) => { + const obj = a as { id?: string } | null; + return obj?.id; + }) + .filter((id): id is string => !!id), + [rawAssets] + ); + + const assetIds = React.useMemo( + () => assetMetadata.join(','), + [assetMetadata] + ); + + // Track which asset IDs we've loaded counts for to prevent re-loading + const loadedAssetIdsRef = React.useRef(new Set()); + + // Clear loaded IDs when asset list changes significantly (e.g., after merge/delete) + // This ensures segment counts are re-loaded for modified assets + const previousAssetIdsRef = React.useRef(assetIds); + React.useEffect(() => { + if (previousAssetIdsRef.current !== assetIds) { + // Asset list changed - clear cache for assets that no longer exist + const currentAssetIdSet = new Set(assetMetadata); + const toRemove = Array.from(loadedAssetIdsRef.current).filter( + (id) => !currentAssetIdSet.has(id) + ); + + if (toRemove.length > 0) { + debugLog( + `๐Ÿงน Clearing ${toRemove.length} stale asset segment cache entries` + ); + toRemove.forEach((id) => loadedAssetIdsRef.current.delete(id)); + + // Also clear from state maps + setAssetSegmentCounts((prev) => { + const next = new Map(prev); + toRemove.forEach((id) => next.delete(id)); + return next; + }); + setAssetDurations((prev) => { + const next = new Map(prev); + toRemove.forEach((id) => next.delete(id)); + return next; + }); + } + + previousAssetIdsRef.current = assetIds; + } + }, [assetIds, assetMetadata]); + + // OPTIMIZED: Load segment counts and durations in batches after UI is idle + // This prevents blocking the UI thread during initial render and animations + React.useEffect(() => { + // Check both ref AND state to determine if we need to load + // This ensures we reload when re-entering the view (state is cleared on unmount) + const assetsToLoad = assetMetadata.filter((id) => { + // Load if not in ref (never attempted) OR missing from state (needs reload) + const notInRef = !loadedAssetIdsRef.current.has(id); + const missingFromState = + !assetSegmentCounts.has(id) || !assetDurations.has(id); + return notInRef || missingFromState; + }); + + if (assetsToLoad.length === 0) { + // Nothing new to load - don't even start the async work + return; + } + + // Defer until animations complete + const interactionHandle = InteractionManager.runAfterInteractions(() => { + const controller = new AbortController(); + batchLoadingControllerRef.current = controller; + + // Process assets in batches to prevent blocking + const processBatch = async (startIdx: number) => { + if (controller.signal.aborted) return; + + const BATCH_SIZE = 5; // Process 5 assets at a time + const batch = assetsToLoad.slice(startIdx, startIdx + BATCH_SIZE); + + if (batch.length === 0) { + // All done! + debugLog('โœ… Finished loading all asset metadata'); + return; + } + + debugLog( + `๐Ÿ“Š Loading batch ${Math.floor(startIdx / BATCH_SIZE) + 1}: ${batch.length} assets (${startIdx + 1}-${startIdx + batch.length} of ${assetsToLoad.length})` + ); + + try { + const newCounts = new Map(); + const newDurations = new Map(); + + for (const assetId of batch) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) break; + + try { + // Query asset_content_link to get audio segments + // ARCHITECTURE EXPLANATION: + // - Each asset can have multiple segments (merged assets) + // - Each segment is one row in asset_content_link + // - Each segment can have one or more audio files in its audio[] array + // + // COUNTS: + // - Segment count = number of content_link rows + // - Audio file count = total audio files across all segments + // - Duration = sum of all audio files' durations + const contentLinks = + await system.db.query.asset_content_link.findMany({ + columns: { + id: true, + audio: true + }, + where: eq(asset_content_link.asset_id, assetId), + orderBy: asc(asset_content_link.created_at) + }); + + // DEBUG: Log raw query result + debugLog( + `๐Ÿ”Ž Query result for asset ${assetId.slice(0, 8)}:`, + contentLinks.length, + 'rows found' + ); + if (contentLinks.length > 0) { + debugLog( + ` First row ID: ${contentLinks[0]?.id.slice(0, 8)}, audio count: ${contentLinks[0]?.audio?.length ?? 0}` + ); + if (contentLinks.length > 1) { + debugLog( + ` Second row ID: ${contentLinks[1]?.id.slice(0, 8)}, audio count: ${contentLinks[1]?.audio?.length ?? 0}` + ); + } + } else { + console.warn( + `โš ๏ธ NO content_link rows found for asset ${assetId.slice(0, 8)}!` + ); + } + + // SEGMENT COUNT: Number of content_link rows (each row = one segment) + const segmentCount = contentLinks.length || 1; + newCounts.set(assetId, segmentCount); + + // DEBUG: Log segment count for this asset + debugLog( + `๐Ÿ” Asset ${assetId.slice(0, 8)} segment count: ${segmentCount} ${segmentCount > 1 ? 'โœ… MULTI-SEGMENT' : '(single)'}` + ); + + // AUDIO FILES: Extract all audio file references from all segments + // This flattens the audio arrays from all content_link rows + const audioValues = contentLinks + .flatMap((link) => link.audio ?? []) + .filter((value): value is string => !!value); + + // DEBUG: Log audio values found + debugLog( + `๐ŸŽต Asset ${assetId.slice(0, 8)} has ${audioValues.length} audio file(s) across ${segmentCount} segment(s) - loading durations...` + ); + + // DURATION: Load and sum all audio file durations + let totalDuration = 0; + + for (const audioValue of audioValues) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) break; + + try { + // Get the full URI for this audio + let audioUri: string | null = null; + if (audioValue.startsWith('local/')) { + audioUri = await getLocalAttachmentUriWithOPFS(audioValue); + } else if (audioValue.startsWith('file://')) { + audioUri = audioValue; + } else if (system.permAttachmentQueue) { + // It's an attachment ID + const attachment = await system.powersync.getOptional<{ + id: string; + local_uri: string | null; + }>( + `SELECT * FROM ${system.permAttachmentQueue.table} WHERE id = ?`, + [audioValue] + ); + if (attachment?.local_uri) { + audioUri = system.permAttachmentQueue.getLocalUri( + attachment.local_uri + ); + } + } + + if (audioUri) { + // Load audio file to get duration + const { sound } = await Audio.Sound.createAsync({ + uri: audioUri + }); + const status = await sound.getStatusAsync(); + await sound.unloadAsync(); + + if (status.isLoaded && status.durationMillis) { + totalDuration += status.durationMillis; + } + } + } catch (err) { + // Skip this segment if we can't load it + console.warn(`Failed to load duration for segment:`, err); + } + } + + if (totalDuration > 0) { + newDurations.set(assetId, totalDuration); + debugLog( + `โฑ๏ธ Asset ${assetId.slice(0, 8)} total duration: ${Math.round(totalDuration / 1000)}s` + ); + } else { + // Set duration to 0 to mark as loaded (prevents infinite retries) + // AssetCard will only show duration if it's > 0, so 0 won't be displayed + newDurations.set(assetId, 0); + debugLog( + `โš ๏ธ Asset ${assetId.slice(0, 8)} has no duration (${audioValues.length} audio files found) - marked as loaded` + ); + } + + loadedAssetIdsRef.current.add(assetId); + } catch (err) { + // If query fails for any asset, default to 1 segment and 0 duration + // This marks it as loaded (prevents infinite retries) + console.warn(`Failed to load data for asset ${assetId}:`, err); + newCounts.set(assetId, 1); + newDurations.set(assetId, 0); + loadedAssetIdsRef.current.add(assetId); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) { + return; + } else { + if (newCounts.size > 0) { + // Merge with existing counts + setAssetSegmentCounts((prev) => { + const merged = new Map(prev); + for (const [id, count] of newCounts) { + merged.set(id, count); + } + return merged; + }); + debugLog( + `โœ… Batch loaded segment counts for ${newCounts.size} asset${newCounts.size > 1 ? 's' : ''}` + ); + } + + if (newDurations.size > 0) { + // Merge with existing durations + setAssetDurations((prev) => { + const merged = new Map(prev); + for (const [id, duration] of newDurations) { + merged.set(id, duration); + } + return merged; + }); + debugLog( + `โœ… Batch loaded durations for ${newDurations.size} asset${newDurations.size > 1 ? 's' : ''}` + ); + } + + // Schedule next batch with a frame delay to keep UI responsive + const timeoutId = setTimeout(() => { + timeoutIdsRef.current.delete(timeoutId); + void processBatch(startIdx + BATCH_SIZE); + }, 16); // One frame delay (60fps) + timeoutIdsRef.current.add(timeoutId); + } + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (controller.signal.aborted) { + return; + } else { + console.error('Failed to load asset metadata batch:', error); + // Continue with next batch even if this one failed + setTimeout(() => { + void processBatch(startIdx + BATCH_SIZE); + }, 16); + } + } + }; + + // Start processing from first batch + void processBatch(0); + + return () => { + controller.abort(); + }; + }); + + return () => { + interactionHandle.cancel(); + // Abort controller if it exists + if (batchLoadingControllerRef.current) { + batchLoadingControllerRef.current.abort(); + batchLoadingControllerRef.current = null; + } + // Clear any pending timeouts + const timeoutIds = timeoutIdsRef.current; + timeoutIds.forEach((id) => clearTimeout(id)); + timeoutIds.clear(); + }; + // Only depend on assetIds and assetMetadata - NOT on the state Maps + // The Maps are checked inside the effect with .has(), so we don't need them as dependencies + // Including them causes the effect to re-run every time durations are updated, which + // triggers unnecessary re-checks even though loadedAssetIdsRef prevents actual re-loading + }, [assetIds, assetMetadata]); + + // ============================================================================ + // ASSET OPERATIONS (Delete, Merge) + // ============================================================================ + + const handleDeleteLocalAsset = React.useCallback( + async (assetId: string) => { + try { + await audioSegmentService.deleteAudioSegment(assetId); + + // Remove from session assets list + setSessionItems((prev) => prev.filter((a) => a.id !== assetId)); + + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + } catch (e) { + console.error('Failed to delete local asset', e); + } + }, + [queryClient, currentQuestId] + ); + + const handleMergeDownLocal = React.useCallback( + async (index: number) => { + try { + const first = assets[index]; + const second = assets[index + 1]; + if (!first || !second || !currentUser) return; + if (first.source === 'cloud' || second.source === 'cloud') return; + + const contentLocal = resolveTable('asset_content_link', { + localOverride: true + }); + const secondContent = await system.db + .select() + .from(contentLocal) + .where(eq(contentLocal.asset_id, second.id)); + + for (const c of secondContent) { + if (!c.audio) continue; + await system.db.insert(contentLocal).values({ + asset_id: first.id, + source_language_id: c.source_language_id, // Deprecated field, kept for backward compatibility + languoid_id: c.languoid_id ?? c.source_language_id ?? null, // Use languoid_id if available, fallback to source_language_id + text: c.text || '', + audio: c.audio, + download_profiles: [currentUser.id] + }); + } + + await audioSegmentService.deleteAudioSegment(second.id); + + // Remove merged asset from session list (second one gets deleted) + setSessionItems((prev) => prev.filter((a) => a.id !== second.id)); + + // Force re-load of segment count for the merged asset + debugLog( + `๐Ÿ”„ Forcing segment count reload for merged asset: ${first.id}` + ); + loadedAssetIdsRef.current.delete(first.id); + setAssetSegmentCounts((prev) => { + const next = new Map(prev); + next.delete(first.id); + return next; + }); + setAssetDurations((prev) => { + const next = new Map(prev); + next.delete(first.id); + return next; + }); + + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + } catch (e) { + console.error('Failed to merge local assets', e); + } + }, + [assets, currentUser, queryClient, currentQuestId] + ); + + const handleBatchMergeSelected = React.useCallback(() => { + const selectedOrdered = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + if (selectedOrdered.length < 2) return; + + RNAlert.alert( + 'Merge Assets', + `Are you sure you want to merge ${selectedOrdered.length} assets? The audio segments will be combined into the first selected asset, and the others will be deleted.`, + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Merge', + style: 'destructive', + onPress: () => { + void (async () => { + try { + if (!currentUser) return; + + const target = selectedOrdered[0]!; + const rest = selectedOrdered.slice(1); + const contentLocal = resolveTable('asset_content_link', { + localOverride: true + }); + + for (const src of rest) { + const srcContent = await system.db + .select() + .from(contentLocal) + .where(eq(contentLocal.asset_id, src.id)); + + for (const c of srcContent) { + if (!c.audio) continue; + await system.db.insert(contentLocal).values({ + asset_id: target.id, + source_language_id: c.source_language_id, // Deprecated field, kept for backward compatibility + languoid_id: + c.languoid_id ?? c.source_language_id ?? null, // Use languoid_id if available, fallback to source_language_id + text: c.text || '', + audio: c.audio, + download_profiles: [currentUser.id] + }); + } + + await audioSegmentService.deleteAudioSegment(src.id); + } + + // Remove merged assets from session list (all except target get deleted) + const deletedIds = new Set(rest.map((a) => a.id)); + setSessionItems((prev) => + prev.filter((a) => !deletedIds.has(a.id)) + ); + + // Force re-load of segment count for the merged target asset + debugLog( + `๐Ÿ”„ Forcing segment count reload for merged asset: ${target.id}` + ); + loadedAssetIdsRef.current.delete(target.id); + setAssetSegmentCounts((prev) => { + const next = new Map(prev); + next.delete(target.id); + return next; + }); + setAssetDurations((prev) => { + const next = new Map(prev); + next.delete(target.id); + return next; + }); + + cancelSelection(); + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog('โœ… Batch merge completed'); + } catch (e) { + console.error('Failed to batch merge local assets', e); + RNAlert.alert( + 'Error', + 'Failed to merge assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [ + assets, + selectedAssetIds, + currentUser, + cancelSelection, + queryClient, + currentQuestId + ]); + + const handleBatchDeleteSelected = React.useCallback(() => { + const selectedOrdered = assets.filter( + (a) => selectedAssetIds.has(a.id) && a.source !== 'cloud' + ); + if (selectedOrdered.length < 1) return; + + RNAlert.alert( + 'Delete Assets', + `Are you sure you want to delete ${selectedOrdered.length} asset${selectedOrdered.length > 1 ? 's' : ''}? This action cannot be undone.`, + [ + { + text: 'Cancel', + style: 'cancel' + }, + { + text: 'Delete', + style: 'destructive', + onPress: () => { + void (async () => { + try { + for (const asset of selectedOrdered) { + await audioSegmentService.deleteAudioSegment(asset.id); + } + + // Remove deleted assets from session list + const deletedIds = new Set(selectedOrdered.map((a) => a.id)); + setSessionItems((prev) => + prev.filter((a) => !deletedIds.has(a.id)) + ); + + cancelSelection(); + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog( + `โœ… Batch delete completed: ${selectedOrdered.length} assets` + ); + } catch (e) { + console.error('Failed to batch delete local assets', e); + RNAlert.alert( + 'Error', + 'Failed to delete assets. Please try again.' + ); + } + })(); + } + } + ] + ); + }, [assets, selectedAssetIds, cancelSelection, queryClient, currentQuestId]); + + // ============================================================================ + // SELECT ALL / DESELECT ALL + // ============================================================================ + + // Calculate if all local assets are selected + const allSelected = React.useMemo(() => { + if (!isSelectionMode || assets.length === 0) return false; + const selectableAssets = assets.filter((a) => a.source !== 'cloud'); + if (selectableAssets.length === 0) return false; + return selectableAssets.every((a) => selectedAssetIds.has(a.id)); + }, [isSelectionMode, assets, selectedAssetIds]); + + // Handle select all / deselect all + const handleSelectAll = React.useCallback(() => { + if (allSelected) { + // Deselect all + selectMultiple([]); + } else { + // Select all local assets (exclude cloud assets) + const selectableIds = assets + .filter((a) => a.source !== 'cloud') + .map((a) => a.id); + selectMultiple(selectableIds); + } + }, [allSelected, assets, selectMultiple]); + + // ============================================================================ + // RENAME ASSET + // ============================================================================ + + const handleRenameAsset = React.useCallback( + (assetId: string, currentName: string | null) => { + setRenameAssetId(assetId); + setRenameAssetName(currentName ?? ''); + setShowRenameDrawer(true); + }, + [] + ); + + const handleSaveRename = React.useCallback( + async (newName: string) => { + if (!renameAssetId) return; + + try { + // renameAsset will validate that this is a local-only asset + // and throw if it's synced (immutable) + await renameAsset(renameAssetId, newName); + + // Update the name directly in sessionAssets to reflect in UI immediately + // This is safe because the database was already updated successfully + setSessionItems((prev) => + prev.map((asset) => + asset.id === renameAssetId ? { ...asset, name: newName } : asset + ) + ); + + // Invalidate queries to refresh the list in parent view + await queryClient.invalidateQueries({ + queryKey: ['assets', 'by-quest', currentQuestId], + exact: false + }); + + debugLog('โœ… Asset renamed successfully'); + } catch (error) { + console.error('โŒ Failed to rename asset:', error); + if (error instanceof Error) { + console.warn('โš ๏ธ Rename blocked:', error.message); + RNAlert.alert('Error', error.message); + } + } + }, + [renameAssetId, queryClient, currentQuestId] + ); + + // ============================================================================ + // CLEANUP ON UNMOUNT + // ============================================================================ + + // Cleanup effect: Clear all refs and stop audio when component unmounts + // This prevents memory leaks when navigating away from the recording view + React.useEffect(() => { + // Capture refs in variables to avoid stale closure warnings + const assetUriMap = assetUriMapRef.current; + const segmentDurations = segmentDurationsRef.current; + const assetSegmentRanges = assetSegmentRangesRef.current; + const assetProgressSharedMap = assetProgressSharedMapRef.current; + const pendingAssetNames = pendingAssetNamesRef.current; + const loadedAssetIds = loadedAssetIdsRef.current; + const timeoutIds = timeoutIdsRef.current; + // Store reference to audioContext - access current value in cleanup + const audioContextRef = audioContext; + + return () => { + // Stop audio playback if playing (check current state, not captured state) + if (audioContextRef.isPlaying) { + void audioContextRef.stopCurrentSound(); + } + + // Clear all refs to free memory + assetUriMap.clear(); + segmentDurations.length = 0; + assetSegmentRanges.clear(); + assetProgressSharedMap.clear(); + lastScrolledAssetIdRef.current = null; + pendingAssetNames.clear(); + loadedAssetIds.clear(); + + // Abort any ongoing batch loading + if (batchLoadingControllerRef.current) { + batchLoadingControllerRef.current.abort(); + batchLoadingControllerRef.current = null; + } + + // Clear all pending timeouts + timeoutIds.forEach((id) => clearTimeout(id)); + timeoutIds.clear(); + + // Reset state maps (they'll be recreated on remount) + setAssetSegmentCounts(new Map()); + setAssetDurations(new Map()); + setCurrentlyPlayingAssetId(null); + + debugLog('๐Ÿงน Cleaned up RecordingViewSimplified on unmount'); + }; + // Empty dependency array - this effect should only run on mount/unmount + // We access audioContext directly in cleanup to get the latest state + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ============================================================================ + // RENDER HELPERS + // ============================================================================ + + // Stable callbacks for AssetCard (don't change unless handlers change) + const stableHandlePlayAsset = React.useCallback(handlePlayAsset, [ + handlePlayAsset + ]); + const stableToggleSelect = React.useCallback(toggleSelect, [toggleSelect]); + const stableEnterSelection = React.useCallback(enterSelection, [ + enterSelection + ]); + const stableHandleDeleteLocalAsset = React.useCallback( + handleDeleteLocalAsset, + [handleDeleteLocalAsset] + ); + const stableHandleMergeDownLocal = React.useCallback(handleMergeDownLocal, [ + handleMergeDownLocal + ]); + const stableHandleRenameAsset = React.useCallback(handleRenameAsset, [ + handleRenameAsset + ]); + + // ============================================================================ + // OPTIMIZED CALLBACKS MAP - Prevents creating new functions in wheelChildren + // ============================================================================ + + // Create a memoized factory for asset callbacks + // This prevents creating new inline functions in wheelChildren useMemo + const createAssetCallbacks = React.useCallback( + (assetId: string) => ({ + onPress: () => { + if (isSelectionMode) { + stableToggleSelect(assetId); + } else { + void stableHandlePlayAsset(assetId); + } + }, + onLongPress: () => { + stableEnterSelection(assetId); + }, + onPlay: () => { + void stableHandlePlayAsset(assetId); + } + }), + [ + isSelectionMode, + stableToggleSelect, + stableHandlePlayAsset, + stableEnterSelection + ] + ); + + // Create a Map of callbacks per asset (only recreates when dependencies change) + // This is much more efficient than creating new functions in the render loop + const assetCallbacksMap = React.useMemo(() => { + const map = new Map< + string, + { + onPress: () => void; + onLongPress: () => void; + onPlay: () => void; + } + >(); + + itemsForWheel.forEach((item) => { + if (isAsset(item)) { + map.set(item.id, createAssetCallbacks(item.id)); + } + }); + + return map; + }, [itemsForWheel, createAssetCallbacks]); + + // Lazy renderItem for ArrayInsertionWheel + // OPTIMIZED: Only renders items when they become visible (virtualizaรงรฃo) + // No audioContext.position/duration dependencies - progress now uses SharedValues! + // This is much more efficient than pre-creating all children + const renderWheelItem = React.useCallback( + (item: ListItem, index: number) => { + // Render verse pill items differently from asset items + if (isPill(item)) { + const pillText = item.verse + ? (formatVerseRange(item.verse) ?? 'No Label') + : 'No Label'; + return ( + + + + ); + } + + // Asset item rendering + // Check if this asset is playing individually OR if it's the currently playing asset during play-all + const isThisAssetPlayingIndividually = + audioContext.isPlaying && audioContext.currentAudioId === item.id; + const isThisAssetPlayingInPlayAll = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && + currentlyPlayingAssetId === item.id; + const isThisAssetPlaying = + isThisAssetPlayingIndividually || isThisAssetPlayingInPlayAll; + const isSelected = selectedAssetIds.has(item.id); + + // Check if next item is an asset (not a pill) and not from cloud + const nextItem = itemsForWheel[index + 1]; + const canMergeDown = + index < itemsForWheel.length - 1 && + nextItem && + isAsset(nextItem) && + nextItem.source !== 'cloud'; + + // Duration from lazy-loaded metadata + const duration = item.duration; + + // Get custom progress for play-all mode + const customProgress = + audioContext.isPlaying && + audioContext.currentAudioId === PLAY_ALL_AUDIO_ID + ? assetProgressSharedMapRef.current.get(item.id) + : undefined; + + // Get stable callbacks from Map (avoids creating new functions) + const callbacks = assetCallbacksMap.get(item.id); + + // Fallback if callbacks not found (shouldn't happen, but defensive) + if (!callbacks) { + console.warn(`Missing callbacks for asset ${item.id}`); + return ; + } + + return ( + + ); + }, + [ + formatVerseRange, + audioContext.isPlaying, + audioContext.currentAudioId, + currentlyPlayingAssetId, + selectedAssetIds, + isSelectionMode, + itemsForWheel, + assetCallbacksMap, + stableHandleDeleteLocalAsset, + stableHandleMergeDownLocal, + stableHandleRenameAsset + ] + ); + + // SESSION-ONLY MODE: No loading/error states needed + // The list starts empty and only shows assets recorded in this session + + // Show full-screen overlay when VAD is locked and display mode is fullscreen + const showFullScreenOverlay = isVADLocked && vadDisplayMode === 'fullscreen'; + + const addButtonComponent = useMemo(() => { + // Apply same conditions as floating button (line 3050) + const shouldShow = + !isSelectionMode && + showAddVerseButton && + verseToAdd !== null && + !isVADRecording && + allowAddVerseRef.current; + + if (!shouldShow) return null; + + return ( + + + + + + + ); + }, [ + isSelectionMode, + showAddVerseButton, + verseToAdd, + isVADRecording, + handleAddNextVerse + ]); + + const boundaryComponent = useMemo( + () => ( + + + + {/* Language-agnostic visual: mic + circle-plus = "add recording here" */} + + + + + + {addButtonComponent} + + ), + [addButtonComponent] + ); + + return ( + + {/* Full-screen VAD overlay - takes over entire screen */} + {showFullScreenOverlay && ( + { + // Cancel VAD mode + setIsVADLocked(false); + }} + /> + )} + + {/* Header */} + + + + + {bookChapterLabelFull || bookChapterLabel} + + {/* + {t('doRecord')} + */} + + + + {assets.length} {t('assets').toLowerCase()} + + {assets.length > 0 && enablePlayAll && ( + + )} + + + + {/* {(isRecording || isVADRecording)? ( */} + {isVADLocked ? ( + + {highlightedItemVerse + ? `${t('recording')}: ${formatVerseRange(highlightedItemVerse)}` + : t('recording')} + + ) : ( + + {highlightedItemVerse + ? `${t('recordTo')}: ${formatVerseRange(highlightedItemVerse)}` + : `${t('noLabelSelected')}`} + + )} + + {/* Scrollable list area - full height with padding for controls */} + + + + ref={wheelRef} + value={insertionIndex} + onChange={(newIndex) => { + const item = itemsForWheel[newIndex]; + const itemDesc = item + ? isPill(item) + ? `pill-${item.verse?.from ?? 'null'}` + : item.name + : 'end'; + console.log( + `๐ŸŽก Wheel onChange: ${insertionIndex} โ†’ ${newIndex} | ${itemDesc} ${item?.order_index}` + ); + setInsertionIndex(newIndex); + }} + rowHeight={ROW_HEIGHT} + className="h-full flex-1" + bottomInset={footerHeight} + boundaryComponent={boundaryComponent} + data={itemsForWheel} + renderItem={renderWheelItem} + /> + + + + {/* Bottom controls - absolutely positioned */} + + {isSelectionMode ? ( + + + + ) : ( + setShowVADSettings(true)} + onAutoCalibratePress={() => { + setAutoCalibrateOnOpen(true); + setShowVADSettings(true); + }} + currentEnergy={currentEnergy} + vadThreshold={vadThreshold} + energyShared={energyShared} + isRecordingShared={isRecordingShared} + displayMode={vadDisplayMode} + /> + )} + + + {/* Rename drawer */} + { + setShowRenameDrawer(open); + if (!open) { + setRenameAssetId(null); + } + }} + onSave={handleSaveRename} + /> + + {/* VAD Settings Drawer */} + { + setShowVADSettings(open); + // Reset auto-calibrate flag when drawer closes + if (!open) { + setAutoCalibrateOnOpen(false); + } + }} + minSegmentLength={vadMinSegmentLength} // eslint-disable-line @typescript-eslint/no-unsafe-assignment + onMinSegmentLengthChange={setVadMinSegmentLength} // eslint-disable-line @typescript-eslint/no-unsafe-assignment + threshold={vadThreshold} + onThresholdChange={setVadThreshold} + silenceDuration={vadSilenceDuration} + onSilenceDurationChange={setVadSilenceDuration} + isVADLocked={isVADLocked} + displayMode={vadDisplayMode} + onDisplayModeChange={setVadDisplayMode} + autoCalibrateOnOpen={autoCalibrateOnOpen} + energyShared={energyShared} + /> + + ); +}; + +export default BibleRecordingView; diff --git a/views/new/recording/components/BibleSelectionControls.tsx b/views/new/recording/components/BibleSelectionControls.tsx new file mode 100644 index 000000000..f94b909bc --- /dev/null +++ b/views/new/recording/components/BibleSelectionControls.tsx @@ -0,0 +1,71 @@ +/** + * SelectionControls - Batch operation controls when in selection mode + * + * Shows: + * - Selected count + * - Cancel button + * - Merge button (requires 2+ selections) + * - Delete button (requires 1+ selections) + */ + +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useLocalization } from '@/hooks/useLocalization'; +import { Bookmark, Merge, Trash2, X } from 'lucide-react-native'; +import React from 'react'; +import { View } from 'react-native'; + +interface BibleSelectionControlsProps { + selectedCount: number; + onCancel: () => void; + onMerge: () => void; + onDelete: () => void; + onAssignVerse?: () => void; +} + +export const BibleSelectionControls = React.memo(function SelectionControls({ + selectedCount, + onCancel, + onMerge, + onDelete, + onAssignVerse +}: BibleSelectionControlsProps) { + const { t } = useLocalization(); + return ( + + ({selectedCount}) + + + + + + + + + + ); +}); diff --git a/views/new/recording/components/LabeledAssetCard.tsx b/views/new/recording/components/LabeledAssetCard.tsx new file mode 100644 index 000000000..0caf5efb2 --- /dev/null +++ b/views/new/recording/components/LabeledAssetCard.tsx @@ -0,0 +1,438 @@ +/** + * AssetCard - Individual asset display with actions + * + * Features: + * - Tap card to play/pause audio (except when tapping label to rename) + * - Visual progress bar during playback + * - Duration display (monospace, muted) next to label + * - Delete and merge actions + * - Selection mode (WhatsApp-style long-press) + * + * Interaction: + * - Tap card โ†’ play/pause audio (or toggle selection if in selection mode) + * - Tap label โ†’ rename asset (when renameable) + * - Long press โ†’ enter selection mode + * + * Performance: + * - Uses Reanimated for animations on native thread + * - Memoized to prevent unnecessary re-renders + */ + +import { Icon } from '@/components/ui/icon'; +import { Text } from '@/components/ui/text'; +import { useAudio } from '@/contexts/AudioContext'; +import type { Asset } from '@/hooks/db/useAssets'; +import { useLocalization } from '@/hooks/useLocalization'; +import { cn } from '@/utils/styleUtils'; +import { CheckCircleIcon, CircleIcon } from 'lucide-react-native'; +import React from 'react'; +import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; +import Animated, { + Easing, + Extrapolation, + interpolate, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withTiming +} from 'react-native-reanimated'; +import type { HybridDataSource } from '../../useHybridData'; + +interface AssetCardProps { + asset: Pick & { + source: HybridDataSource | 'optimistic'; + created_at?: string; + order_index?: number | null; + metadata?: string | { verse?: { from: number; to: number } } | null; + }; + index: number; + isSelected: boolean; + isSelectionMode: boolean; + isPlaying: boolean; + // progress removed - now calculated from SharedValues for 0 re-renders! + duration?: number; // Duration in milliseconds + segmentCount?: number; // Number of audio segments in this asset + // Custom progress for play-all mode (0-100 percentage) + // If provided, this overrides the default global progress calculation + customProgress?: SharedValue; + onPress: () => void; + onLongPress: () => void; + onPlay: (assetId: string) => void; + onRename?: (assetId: string, currentName: string | null) => void; + // Note: These callbacks are still passed but no longer used (batch operations only) + onDelete?: (assetId: string) => void; + onMerge?: (index: number) => void; + onEdit?: (assetId: string, assetName: string) => void; + canMergeDown?: boolean; + showVerseLabel?: boolean; // Whether to show the verse label on the card + bookChapterLabel?: string; // Book name and chapter (e.g., "Gen 1") for Bible verse format +} + +// Format duration in milliseconds to MM:SS +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +/** + * Calculate age of asset in milliseconds + * Used by Reanimated worklet to compute highlight intensity + */ +function calculateAssetAge(createdAt?: string | Date): number { + if (!createdAt) return Infinity; // Very old, no highlight + + const now = Date.now(); + const created = + typeof createdAt === 'string' + ? new Date(createdAt).getTime() + : createdAt.getTime(); + const age = now - created; + + return age < 0 ? Infinity : age; +} + +function AssetCardInternal({ + asset, + index, + isSelected, + isSelectionMode, + isPlaying, + duration, + segmentCount, + customProgress, + onPress, + onLongPress, + onPlay, + onRename, + showVerseLabel = true, + bookChapterLabel +}: AssetCardProps) { + const audioContext = useAudio(); + + // CRITICAL: Only local-only assets can be renamed/edited/deleted (synced assets are immutable) + const isLocal = asset.source === 'local'; + // Renameable = local and not currently saving + const isRenameable = isLocal; + + // DEBUG: Log segment count and duration for this asset + React.useEffect(() => { + console.log( + `๐Ÿƒ AssetCard render: ${asset.name} | segments: ${segmentCount ?? 'loading'} | duration: ${duration ? `${Math.round(duration / 1000)}s` : 'loading'}` + ); + }, [segmentCount, duration, asset.name]); + + // ============================================================================ + // REANIMATED ANIMATIONS (Run on native thread for better performance) + // ============================================================================ + + // NEW: Calculate progress from SharedValues (no re-renders!) + // This runs entirely on the UI thread at 60fps + // If customProgress is provided (for play-all mode), use that instead + const animatedProgress = useDerivedValue(() => { + 'worklet'; + if (!isPlaying) return 0; + + // Use custom progress if provided (for play-all mode with asset-specific progress) + if (customProgress) { + return customProgress.value; + } + + // Otherwise, use global progress calculation + const pos = audioContext.positionShared.value; + const dur = audioContext.durationShared.value; + + if (dur <= 0) return 0; + + // Calculate progress percentage (0-100) + const progressPercent = (pos / dur) * 100; + return Math.min(100, Math.max(0, progressPercent)); + }, [isPlaying, customProgress]); + + // Progress bar style (interpolate to slightly lead at the end) + const progressBarStyle = useAnimatedStyle(() => { + 'worklet'; + const progress = animatedProgress.value; + const width = interpolate( + progress, + [0, 95, 100], + [0, 97, 100], + Extrapolation.CLAMP + ); + return { + width: `${width}%` + }; + }); + + // Highlight animation for newly created assets + // Calculate initial age once to avoid recalculation + const initialAge = React.useMemo( + () => calculateAssetAge(asset.created_at), + [asset.created_at] + ); + + // Animate highlight intensity on native thread + const highlightProgress = useSharedValue(0); + const HIGHLIGHT_DURATION_MS = 12000; // Total highlight duration (12 seconds) + + React.useEffect(() => { + if (initialAge > HIGHLIGHT_DURATION_MS) { + // Too old, no animation needed + highlightProgress.value = 1; // 1 = fully decayed + return; + } + + // Animate from current age to fully decayed + const startProgress = initialAge / HIGHLIGHT_DURATION_MS; + highlightProgress.value = startProgress; + highlightProgress.value = withTiming(1, { + duration: HIGHLIGHT_DURATION_MS - initialAge, + easing: Easing.out(Easing.ease) + }); + }, [initialAge, highlightProgress]); + + // Derive highlight intensity using worklet (runs on native thread) + const highlightIntensity = useDerivedValue(() => { + 'worklet'; + // Power law decay: intensity = 1 / (1 + (progress * 4)^2) + // At progress=0: intensity = 1.0 (full highlight) + // At progress=0.25: intensity = 0.5 (half) + // At progress=0.5: intensity = 0.2 + // At progress=1: intensity = 0.06 (barely visible) + const normalized = highlightProgress.value * 4; + return 1 / (1 + Math.pow(normalized, 2)); + }); + + const highlightStyle = useAnimatedStyle(() => { + 'worklet'; + const intensity = highlightIntensity.value; + return { + opacity: intensity, + backgroundColor: `hsl(var(--chart-5) / ${intensity * 0.3})` + }; + }); + + // Handle card press: play/pause in normal mode, toggle selection in selection mode + const handleCardPress = React.useCallback(() => { + if (isSelectionMode) { + onPress(); // Toggle selection + } else { + onPlay(asset.id); // Play/pause audio + } + }, [isSelectionMode, onPress, onPlay, asset.id]); + + const { t } = useLocalization(); + + // Extract verse range from metadata if available + const verseRange = React.useMemo(() => { + console.log('๐Ÿ” LabeledAssetCard - Checking metadata:', { + assetId: asset.id, + assetName: asset.name, + metadata: asset.metadata, + metadataType: typeof asset.metadata + }); + + if (!asset.metadata) { + return null; + } + + try { + const metadata: unknown = + typeof asset.metadata === 'string' + ? JSON.parse(asset.metadata) + : asset.metadata; + + if (metadata && typeof metadata === 'object' && 'verse' in metadata) { + const verseObj = (metadata as { verse?: unknown }).verse; + console.log('๐Ÿ“– Verse object:', verseObj); + if ( + verseObj && + typeof verseObj === 'object' && + 'from' in verseObj && + 'to' in verseObj + ) { + const verse = verseObj as { from: unknown; to: unknown }; + if (typeof verse.from === 'number' && typeof verse.to === 'number') { + return { + from: verse.from, + to: verse.to + }; + } + } + } + } catch (e) { + console.error('โŒ Error parsing metadata:', e); + } + + return null; + }, [asset.metadata, asset.id, asset.name]); + + // Format verse label in Bible format (e.g., "Gen 1:5" or "Gen 1:5-10") + const formattedVerseLabel = React.useMemo(() => { + if (!verseRange || !bookChapterLabel) { + return null; + } + + const { from, to } = verseRange; + if (from === to) { + return `${bookChapterLabel}:${from}`; + } + return `${bookChapterLabel}:${from}-${to}`; + }, [verseRange, bookChapterLabel]); + + return ( + + {/* Verse label - positioned above the top edge, center-right, outside the card */} + {formattedVerseLabel && showVerseLabel && ( + + + {formattedVerseLabel} + + + )} + + + {/* New asset highlight - decaying gradient overlay (Reanimated on native thread) */} + {initialAge < 12000 && ( + + )} + + {/* Progress bar overlay - positioned absolutely behind content (Reanimated on native thread) */} + {isPlaying && ( + + + + )} + + {/* Content - z-index ensures it appears above progress bar */} + + + + {index + 1} + + + + + {/* Label with rename functionality - prevents card play when tapped */} + { + if (!isSelectionMode && isRenameable && onRename) { + onRename(asset.id, asset.name); + } + }} + disabled={isSelectionMode || !isRenameable || !onRename} + activeOpacity={0.7} + > + + {asset.name || t('unnamedAsset')} + + + {segmentCount && segmentCount > 1 && ( + + + {segmentCount} + + + )} + + + + {asset.created_at && + new Date(asset.created_at).toLocaleString()} + + + + {duration !== undefined && duration > 0 && ( + + {formatDuration(duration)} + + )} + + {/* Selection checkbox - only show for local assets in selection mode */} + {isSelectionMode && isLocal && ( + + + + )} + + + + ); +} + +/** + * Memoized AssetCard to prevent unnecessary re-renders + * Only re-renders when props actually change + * + * OPTIMIZATION: progress removed from comparison - now uses SharedValues + * This eliminates 10 re-renders/second during audio playback! + */ +export const LabeledAssetCard = React.memo(AssetCardInternal, (prev, next) => { + // Custom equality check - only re-render if these props change + return ( + prev.asset.id === next.asset.id && + prev.asset.name === next.asset.name && + prev.asset.source === next.asset.source && + prev.asset.metadata === next.asset.metadata && + prev.index === next.index && + prev.isSelected === next.isSelected && + prev.isSelectionMode === next.isSelectionMode && + prev.isPlaying === next.isPlaying && + // prev.progress removed - uses SharedValues now! + prev.duration === next.duration && + prev.segmentCount === next.segmentCount && + prev.canMergeDown === next.canMergeDown && + // Compare customProgress SharedValue reference (needed when it changes from undefined to SharedValue) + prev.customProgress === next.customProgress && + prev.showVerseLabel === next.showVerseLabel && + prev.bookChapterLabel === next.bookChapterLabel && + // Callbacks are stable (wrapped in useCallback in parent), so we can skip checking them + prev.onPress === next.onPress && + prev.onLongPress === next.onLongPress && + prev.onPlay === next.onPlay && + prev.onRename === next.onRename && + prev.onDelete === next.onDelete && + prev.onMerge === next.onMerge && + prev.onEdit === next.onEdit + ); +}); diff --git a/views/new/recording/components/SelectionControls.tsx b/views/new/recording/components/SelectionControls.tsx index adfbd97b8..3bcdd2a66 100644 --- a/views/new/recording/components/SelectionControls.tsx +++ b/views/new/recording/components/SelectionControls.tsx @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { useLocalization } from '@/hooks/useLocalization'; -import { Merge, Trash2, X } from 'lucide-react-native'; +import { ListChecks, ListX, Merge, Trash2, X } from 'lucide-react-native'; import React from 'react'; import { View } from 'react-native'; @@ -21,13 +21,19 @@ interface SelectionControlsProps { onCancel: () => void; onMerge: () => void; onDelete: () => void; + allowSelectAll?: boolean; + allSelected?: boolean; + onSelectAll?: () => void; } export const SelectionControls = React.memo(function SelectionControls({ selectedCount, onCancel, onMerge, - onDelete + onDelete, + allowSelectAll = false, + allSelected = false, + onSelectAll }: SelectionControlsProps) { const { t } = useLocalization(); return ( @@ -35,6 +41,11 @@ export const SelectionControls = React.memo(function SelectionControls({ ({selectedCount}) + {allowSelectAll && onSelectAll && ( + + )} diff --git a/constants/bibleBookNames.ts b/constants/bibleBookNames.ts new file mode 100644 index 000000000..ef0721cae --- /dev/null +++ b/constants/bibleBookNames.ts @@ -0,0 +1,577 @@ +import type { SupportedLanguage } from '@/services/localizations'; + +interface BookName { + name: string; + abbrev: string; +} + +type BibleBookNames = Record< + string, + Partial> +>; + +/** + * Localized Bible book names and abbreviations for all supported UI languages. + * Book IDs match the BIBLE_BOOKS constant in bibleStructure.ts. + */ +export const BIBLE_BOOK_NAMES: BibleBookNames = { + // ============================================================================ + // OLD TESTAMENT + // ============================================================================ + gen: { + english: { name: 'Genesis', abbrev: 'Gen' }, + spanish: { name: 'Gรฉnesis', abbrev: 'Gรฉn' }, + brazilian_portuguese: { name: 'Gรชnesis', abbrev: 'Gn' }, + tok_pisin: { name: 'Jenesis', abbrev: 'Jen' }, + indonesian: { name: 'Kejadian', abbrev: 'Kej' }, + nepali: { name: 'เค‰เคคเฅเคชเคคเฅเคคเคฟ', abbrev: 'เค‰เคคเฅเคช' } + }, + exo: { + english: { name: 'Exodus', abbrev: 'Exod' }, + spanish: { name: 'ร‰xodo', abbrev: 'ร‰xo' }, + brazilian_portuguese: { name: 'รŠxodo', abbrev: 'รŠx' }, + tok_pisin: { name: 'Kisim Bek', abbrev: 'Kis' }, + indonesian: { name: 'Keluaran', abbrev: 'Kel' }, + nepali: { name: 'เคชเฅเคฐเคธเฅเคฅเคพเคจ', abbrev: 'เคชเฅเคฐเคธเฅเคฅ' } + }, + lev: { + english: { name: 'Leviticus', abbrev: 'Lev' }, + spanish: { name: 'Levรญtico', abbrev: 'Lev' }, + brazilian_portuguese: { name: 'Levรญtico', abbrev: 'Lv' }, + tok_pisin: { name: 'Wok Pris', abbrev: 'Wok' }, + indonesian: { name: 'Imamat', abbrev: 'Im' }, + nepali: { name: 'เคฒเฅ‡เคตเฅ€เคนเคฐเฅ‚', abbrev: 'เคฒเฅ‡เคตเฅ€' } + }, + num: { + english: { name: 'Numbers', abbrev: 'Num' }, + spanish: { name: 'Nรบmeros', abbrev: 'Nรบm' }, + brazilian_portuguese: { name: 'Nรบmeros', abbrev: 'Nm' }, + tok_pisin: { name: 'Namba', abbrev: 'Nam' }, + indonesian: { name: 'Bilangan', abbrev: 'Bil' }, + nepali: { name: 'เค—เคจเฅเคคเฅ€', abbrev: 'เค—เคจเฅเคคเฅ€' } + }, + deu: { + english: { name: 'Deuteronomy', abbrev: 'Deut' }, + spanish: { name: 'Deuteronomio', abbrev: 'Deut' }, + brazilian_portuguese: { name: 'Deuteronรดmio', abbrev: 'Dt' }, + tok_pisin: { name: 'Lo Namba Tu', abbrev: 'Lo2' }, + indonesian: { name: 'Ulangan', abbrev: 'Ul' }, + nepali: { name: 'เคตเฅเคฏเคตเคธเฅเคฅเคพ', abbrev: 'เคตเฅเคฏเคต' } + }, + jos: { + english: { name: 'Joshua', abbrev: 'Josh' }, + spanish: { name: 'Josuรฉ', abbrev: 'Jos' }, + brazilian_portuguese: { name: 'Josuรฉ', abbrev: 'Js' }, + tok_pisin: { name: 'Josua', abbrev: 'Jos' }, + indonesian: { name: 'Yosua', abbrev: 'Yos' }, + nepali: { name: 'เคฏเคนเฅ‹เคถเฅ‚', abbrev: 'เคฏเคนเฅ‹' } + }, + jdg: { + english: { name: 'Judges', abbrev: 'Judg' }, + spanish: { name: 'Jueces', abbrev: 'Jue' }, + brazilian_portuguese: { name: 'Juรญzes', abbrev: 'Jz' }, + tok_pisin: { name: 'Jas', abbrev: 'Jas' }, + indonesian: { name: 'Hakim-hakim', abbrev: 'Hak' }, + nepali: { name: 'เคจเฅเคฏเคพเคฏเค•เคฐเฅเคคเคพ', abbrev: 'เคจเฅเคฏเคพ' } + }, + rut: { + english: { name: 'Ruth', abbrev: 'Ruth' }, + spanish: { name: 'Rut', abbrev: 'Rut' }, + brazilian_portuguese: { name: 'Rute', abbrev: 'Rt' }, + tok_pisin: { name: 'Rut', abbrev: 'Rut' }, + indonesian: { name: 'Rut', abbrev: 'Rut' }, + nepali: { name: 'เคฐเฅ‚เคฅ', abbrev: 'เคฐเฅ‚เคฅ' } + }, + '1sa': { + english: { name: '1 Samuel', abbrev: '1 Sam' }, + spanish: { name: '1 Samuel', abbrev: '1 Sam' }, + brazilian_portuguese: { name: '1 Samuel', abbrev: '1Sm' }, + tok_pisin: { name: '1 Samuel', abbrev: '1Sam' }, + indonesian: { name: '1 Samuel', abbrev: '1Sam' }, + nepali: { name: 'เฅง เคถเคฎเฅ‚เคเคฒ', abbrev: 'เฅงเคถเคฎเฅ‚' } + }, + '2sa': { + english: { name: '2 Samuel', abbrev: '2 Sam' }, + spanish: { name: '2 Samuel', abbrev: '2 Sam' }, + brazilian_portuguese: { name: '2 Samuel', abbrev: '2Sm' }, + tok_pisin: { name: '2 Samuel', abbrev: '2Sam' }, + indonesian: { name: '2 Samuel', abbrev: '2Sam' }, + nepali: { name: 'เฅจ เคถเคฎเฅ‚เคเคฒ', abbrev: 'เฅจเคถเคฎเฅ‚' } + }, + '1ki': { + english: { name: '1 Kings', abbrev: '1 Kgs' }, + spanish: { name: '1 Reyes', abbrev: '1 Rey' }, + brazilian_portuguese: { name: '1 Reis', abbrev: '1Rs' }, + tok_pisin: { name: '1 King', abbrev: '1Kin' }, + indonesian: { name: '1 Raja-raja', abbrev: '1Raj' }, + nepali: { name: 'เฅง เคฐเคพเคœเคพ', abbrev: 'เฅงเคฐเคพเคœเคพ' } + }, + '2ki': { + english: { name: '2 Kings', abbrev: '2 Kgs' }, + spanish: { name: '2 Reyes', abbrev: '2 Rey' }, + brazilian_portuguese: { name: '2 Reis', abbrev: '2Rs' }, + tok_pisin: { name: '2 King', abbrev: '2Kin' }, + indonesian: { name: '2 Raja-raja', abbrev: '2Raj' }, + nepali: { name: 'เฅจ เคฐเคพเคœเคพ', abbrev: 'เฅจเคฐเคพเคœเคพ' } + }, + '1ch': { + english: { name: '1 Chronicles', abbrev: '1 Chr' }, + spanish: { name: '1 Crรณnicas', abbrev: '1 Crรณ' }, + brazilian_portuguese: { name: '1 Crรดnicas', abbrev: '1Cr' }, + tok_pisin: { name: '1 Kronikel', abbrev: '1Kro' }, + indonesian: { name: '1 Tawarikh', abbrev: '1Taw' }, + nepali: { name: 'เฅง เค‡เคคเคฟเคนเคพเคธ', abbrev: 'เฅงเค‡เคคเคฟ' } + }, + '2ch': { + english: { name: '2 Chronicles', abbrev: '2 Chr' }, + spanish: { name: '2 Crรณnicas', abbrev: '2 Crรณ' }, + brazilian_portuguese: { name: '2 Crรดnicas', abbrev: '2Cr' }, + tok_pisin: { name: '2 Kronikel', abbrev: '2Kro' }, + indonesian: { name: '2 Tawarikh', abbrev: '2Taw' }, + nepali: { name: 'เฅจ เค‡เคคเคฟเคนเคพเคธ', abbrev: 'เฅจเค‡เคคเคฟ' } + }, + ezr: { + english: { name: 'Ezra', abbrev: 'Ezra' }, + spanish: { name: 'Esdras', abbrev: 'Esd' }, + brazilian_portuguese: { name: 'Esdras', abbrev: 'Ed' }, + tok_pisin: { name: 'Esra', abbrev: 'Esr' }, + indonesian: { name: 'Ezra', abbrev: 'Ezr' }, + nepali: { name: 'เคเคœเฅเคฐเคพ', abbrev: 'เคเคœเฅเคฐเคพ' } + }, + neh: { + english: { name: 'Nehemiah', abbrev: 'Neh' }, + spanish: { name: 'Nehemรญas', abbrev: 'Neh' }, + brazilian_portuguese: { name: 'Neemias', abbrev: 'Ne' }, + tok_pisin: { name: 'Nehemia', abbrev: 'Neh' }, + indonesian: { name: 'Nehemia', abbrev: 'Neh' }, + nepali: { name: 'เคจเคนเฅ‡เคฎเฅเคฏเคพเคน', abbrev: 'เคจเคนเฅ‡' } + }, + est: { + english: { name: 'Esther', abbrev: 'Esth' }, + spanish: { name: 'Ester', abbrev: 'Est' }, + brazilian_portuguese: { name: 'Ester', abbrev: 'Et' }, + tok_pisin: { name: 'Esta', abbrev: 'Est' }, + indonesian: { name: 'Ester', abbrev: 'Est' }, + nepali: { name: 'เคเคธเฅเคคเคฐ', abbrev: 'เคเคธเฅเคค' } + }, + job: { + english: { name: 'Job', abbrev: 'Job' }, + spanish: { name: 'Job', abbrev: 'Job' }, + brazilian_portuguese: { name: 'Jรณ', abbrev: 'Jรณ' }, + tok_pisin: { name: 'Jop', abbrev: 'Jop' }, + indonesian: { name: 'Ayub', abbrev: 'Ayb' }, + nepali: { name: 'เค…เคฏเฅเคฏเฅ‚เคฌ', abbrev: 'เค…เคฏเฅเคฏเฅ‚' } + }, + psa: { + english: { name: 'Psalms', abbrev: 'Ps' }, + spanish: { name: 'Salmos', abbrev: 'Sal' }, + brazilian_portuguese: { name: 'Salmos', abbrev: 'Sl' }, + tok_pisin: { name: 'Buk Song', abbrev: 'Sng' }, + indonesian: { name: 'Mazmur', abbrev: 'Mzm' }, + nepali: { name: 'เคญเคœเคจเคธเค‚เค—เฅเคฐเคน', abbrev: 'เคญเคœ' } + }, + pro: { + english: { name: 'Proverbs', abbrev: 'Prov' }, + spanish: { name: 'Proverbios', abbrev: 'Prov' }, + brazilian_portuguese: { name: 'Provรฉrbios', abbrev: 'Pv' }, + tok_pisin: { name: 'Gutpela Tok', abbrev: 'Gut' }, + indonesian: { name: 'Amsal', abbrev: 'Ams' }, + nepali: { name: 'เคนเคฟเคคเฅ‹เคชเคฆเฅ‡เคถ', abbrev: 'เคนเคฟเคคเฅ‹' } + }, + ecc: { + english: { name: 'Ecclesiastes', abbrev: 'Eccl' }, + spanish: { name: 'Eclesiastรฉs', abbrev: 'Ecl' }, + brazilian_portuguese: { name: 'Eclesiastes', abbrev: 'Ec' }, + tok_pisin: { name: 'Saveman', abbrev: 'Sav' }, + indonesian: { name: 'Pengkhotbah', abbrev: 'Pkh' }, + nepali: { name: 'เค‰เคชเคฆเฅ‡เคถเค•', abbrev: 'เค‰เคชเคฆเฅ‡' } + }, + sng: { + english: { name: 'Song of Solomon', abbrev: 'Song' }, + spanish: { name: 'Cantares', abbrev: 'Cant' }, + brazilian_portuguese: { name: 'Cรขnticos', abbrev: 'Ct' }, + tok_pisin: { name: 'Song Solomon', abbrev: 'Son' }, + indonesian: { name: 'Kidung Agung', abbrev: 'Kid' }, + nepali: { name: 'เคถเฅเคฐเฅ‡เคทเฅเค เค—เฅ€เคค', abbrev: 'เคถเฅเคฐเฅ‡เคทเฅเค ' } + }, + isa: { + english: { name: 'Isaiah', abbrev: 'Isa' }, + spanish: { name: 'Isaรญas', abbrev: 'Isa' }, + brazilian_portuguese: { name: 'Isaรญas', abbrev: 'Is' }, + tok_pisin: { name: 'Aisaia', abbrev: 'Ais' }, + indonesian: { name: 'Yesaya', abbrev: 'Yes' }, + nepali: { name: 'เคฏเคถเฅˆเคฏเคพ', abbrev: 'เคฏเคถเฅˆ' } + }, + jer: { + english: { name: 'Jeremiah', abbrev: 'Jer' }, + spanish: { name: 'Jeremรญas', abbrev: 'Jer' }, + brazilian_portuguese: { name: 'Jeremias', abbrev: 'Jr' }, + tok_pisin: { name: 'Jeremaia', abbrev: 'Jer' }, + indonesian: { name: 'Yeremia', abbrev: 'Yer' }, + nepali: { name: 'เคฏเคฐเฅเคฎเคฟเคฏเคพ', abbrev: 'เคฏเคฐเฅเคฎเคฟ' } + }, + lam: { + english: { name: 'Lamentations', abbrev: 'Lam' }, + spanish: { name: 'Lamentaciones', abbrev: 'Lam' }, + brazilian_portuguese: { name: 'Lamentaรงรตes', abbrev: 'Lm' }, + tok_pisin: { name: 'Krai', abbrev: 'Kra' }, + indonesian: { name: 'Ratapan', abbrev: 'Rat' }, + nepali: { name: 'เคตเคฟเคฒเคพเคช', abbrev: 'เคตเคฟเคฒเคพ' } + }, + ezk: { + english: { name: 'Ezekiel', abbrev: 'Ezek' }, + spanish: { name: 'Ezequiel', abbrev: 'Eze' }, + brazilian_portuguese: { name: 'Ezequiel', abbrev: 'Ez' }, + tok_pisin: { name: 'Esekiel', abbrev: 'Ese' }, + indonesian: { name: 'Yehezkiel', abbrev: 'Yeh' }, + nepali: { name: 'เค‡เคœเค•เคฟเคเคฒ', abbrev: 'เค‡เคœ' } + }, + dan: { + english: { name: 'Daniel', abbrev: 'Dan' }, + spanish: { name: 'Daniel', abbrev: 'Dan' }, + brazilian_portuguese: { name: 'Daniel', abbrev: 'Dn' }, + tok_pisin: { name: 'Daniel', abbrev: 'Dan' }, + indonesian: { name: 'Daniel', abbrev: 'Dan' }, + nepali: { name: 'เคฆเคพเคจเคฟเคเคฒ', abbrev: 'เคฆเคพเคจเคฟ' } + }, + hos: { + english: { name: 'Hosea', abbrev: 'Hos' }, + spanish: { name: 'Oseas', abbrev: 'Ose' }, + brazilian_portuguese: { name: 'Osรฉias', abbrev: 'Os' }, + tok_pisin: { name: 'Hosea', abbrev: 'Hos' }, + indonesian: { name: 'Hosea', abbrev: 'Hos' }, + nepali: { name: 'เคนเฅ‹เคถเฅ‡', abbrev: 'เคนเฅ‹เคถเฅ‡' } + }, + joe: { + english: { name: 'Joel', abbrev: 'Joel' }, + spanish: { name: 'Joel', abbrev: 'Joel' }, + brazilian_portuguese: { name: 'Joel', abbrev: 'Jl' }, + tok_pisin: { name: 'Joel', abbrev: 'Joe' }, + indonesian: { name: 'Yoรซl', abbrev: 'Yoรซ' }, + nepali: { name: 'เคฏเฅ‹เคเคฒ', abbrev: 'เคฏเฅ‹เคเคฒ' } + }, + amo: { + english: { name: 'Amos', abbrev: 'Amos' }, + spanish: { name: 'Amรณs', abbrev: 'Amรณs' }, + brazilian_portuguese: { name: 'Amรณs', abbrev: 'Am' }, + tok_pisin: { name: 'Amos', abbrev: 'Amo' }, + indonesian: { name: 'Amos', abbrev: 'Amo' }, + nepali: { name: 'เค†เคฎเฅ‹เคธ', abbrev: 'เค†เคฎเฅ‹' } + }, + oba: { + english: { name: 'Obadiah', abbrev: 'Obad' }, + spanish: { name: 'Abdรญas', abbrev: 'Abd' }, + brazilian_portuguese: { name: 'Obadias', abbrev: 'Ob' }, + tok_pisin: { name: 'Obadia', abbrev: 'Oba' }, + indonesian: { name: 'Obaja', abbrev: 'Ob' }, + nepali: { name: 'เค“เคฌเคฆเคฟเคฏเคพ', abbrev: 'เค“เคฌ' } + }, + jon: { + english: { name: 'Jonah', abbrev: 'Jonah' }, + spanish: { name: 'Jonรกs', abbrev: 'Jon' }, + brazilian_portuguese: { name: 'Jonas', abbrev: 'Jn' }, + tok_pisin: { name: 'Jona', abbrev: 'Jon' }, + indonesian: { name: 'Yunus', abbrev: 'Yun' }, + nepali: { name: 'เคฏเฅ‹เคจเคพ', abbrev: 'เคฏเฅ‹เคจเคพ' } + }, + mic: { + english: { name: 'Micah', abbrev: 'Mic' }, + spanish: { name: 'Miqueas', abbrev: 'Miq' }, + brazilian_portuguese: { name: 'Miquรฉias', abbrev: 'Mq' }, + tok_pisin: { name: 'Maika', abbrev: 'Mai' }, + indonesian: { name: 'Mikha', abbrev: 'Mik' }, + nepali: { name: 'เคฎเฅ€เค•เคพ', abbrev: 'เคฎเฅ€เค•เคพ' } + }, + nah: { + english: { name: 'Nahum', abbrev: 'Nah' }, + spanish: { name: 'Nahรบm', abbrev: 'Nah' }, + brazilian_portuguese: { name: 'Naum', abbrev: 'Na' }, + tok_pisin: { name: 'Nahum', abbrev: 'Nah' }, + indonesian: { name: 'Nahum', abbrev: 'Nah' }, + nepali: { name: 'เคจเคนเฅ‚เคฎ', abbrev: 'เคจเคนเฅ‚' } + }, + hab: { + english: { name: 'Habakkuk', abbrev: 'Hab' }, + spanish: { name: 'Habacuc', abbrev: 'Hab' }, + brazilian_portuguese: { name: 'Habacuque', abbrev: 'Hc' }, + tok_pisin: { name: 'Habakuk', abbrev: 'Hab' }, + indonesian: { name: 'Habakuk', abbrev: 'Hab' }, + nepali: { name: 'เคนเคฌเค•เฅเค•เฅ‚เค•', abbrev: 'เคนเคฌ' } + }, + zep: { + english: { name: 'Zephaniah', abbrev: 'Zeph' }, + spanish: { name: 'Sofonรญas', abbrev: 'Sof' }, + brazilian_portuguese: { name: 'Sofonias', abbrev: 'Sf' }, + tok_pisin: { name: 'Sefanaia', abbrev: 'Sef' }, + indonesian: { name: 'Zefanya', abbrev: 'Zef' }, + nepali: { name: 'เคธเคชเคจเฅเคฏเคพเคน', abbrev: 'เคธเคช' } + }, + hag: { + english: { name: 'Haggai', abbrev: 'Hag' }, + spanish: { name: 'Hageo', abbrev: 'Hag' }, + brazilian_portuguese: { name: 'Ageu', abbrev: 'Ag' }, + tok_pisin: { name: 'Hagai', abbrev: 'Hag' }, + indonesian: { name: 'Hagai', abbrev: 'Hag' }, + nepali: { name: 'เคนเคพเค—เฅเค—เฅˆ', abbrev: 'เคนเคพเค—เฅเค—เฅˆ' } + }, + zec: { + english: { name: 'Zechariah', abbrev: 'Zech' }, + spanish: { name: 'Zacarรญas', abbrev: 'Zac' }, + brazilian_portuguese: { name: 'Zacarias', abbrev: 'Zc' }, + tok_pisin: { name: 'Sekaraia', abbrev: 'Sek' }, + indonesian: { name: 'Zakharia', abbrev: 'Zak' }, + nepali: { name: 'เคœเค•เคฐเคฟเคฏเคพ', abbrev: 'เคœเค•' } + }, + mal: { + english: { name: 'Malachi', abbrev: 'Mal' }, + spanish: { name: 'Malaquรญas', abbrev: 'Mal' }, + brazilian_portuguese: { name: 'Malaquias', abbrev: 'Ml' }, + tok_pisin: { name: 'Malakai', abbrev: 'Mal' }, + indonesian: { name: 'Maleakhi', abbrev: 'Mal' }, + nepali: { name: 'เคฎเคฒเคพเค•เฅ€', abbrev: 'เคฎเคฒเคพ' } + }, + + // ============================================================================ + // NEW TESTAMENT + // ============================================================================ + mat: { + english: { name: 'Matthew', abbrev: 'Matt' }, + spanish: { name: 'Mateo', abbrev: 'Mat' }, + brazilian_portuguese: { name: 'Mateus', abbrev: 'Mt' }, + tok_pisin: { name: 'Matyu', abbrev: 'Mat' }, + indonesian: { name: 'Matius', abbrev: 'Mat' }, + nepali: { name: 'เคฎเคคเฅเคคเฅ€', abbrev: 'เคฎเคคเฅเคคเฅ€' } + }, + mar: { + english: { name: 'Mark', abbrev: 'Mark' }, + spanish: { name: 'Marcos', abbrev: 'Mar' }, + brazilian_portuguese: { name: 'Marcos', abbrev: 'Mc' }, + tok_pisin: { name: 'Mak', abbrev: 'Mak' }, + indonesian: { name: 'Markus', abbrev: 'Mrk' }, + nepali: { name: 'เคฎเคฐเฅเค•เฅ‚เคธ', abbrev: 'เคฎเคฐเฅเค•เฅ‚' } + }, + luk: { + english: { name: 'Luke', abbrev: 'Luke' }, + spanish: { name: 'Lucas', abbrev: 'Luc' }, + brazilian_portuguese: { name: 'Lucas', abbrev: 'Lc' }, + tok_pisin: { name: 'Luk', abbrev: 'Luk' }, + indonesian: { name: 'Lukas', abbrev: 'Luk' }, + nepali: { name: 'เคฒเฅ‚เค•เคพ', abbrev: 'เคฒเฅ‚เค•เคพ' } + }, + jhn: { + english: { name: 'John', abbrev: 'John' }, + spanish: { name: 'Juan', abbrev: 'Juan' }, + brazilian_portuguese: { name: 'Joรฃo', abbrev: 'Jo' }, + tok_pisin: { name: 'Jon', abbrev: 'Jon' }, + indonesian: { name: 'Yohanes', abbrev: 'Yoh' }, + nepali: { name: 'เคฏเฅ‚เคนเคจเฅเคจเคพ', abbrev: 'เคฏเฅ‚เคนเคจเฅ' } + }, + act: { + english: { name: 'Acts', abbrev: 'Acts' }, + spanish: { name: 'Hechos', abbrev: 'Hch' }, + brazilian_portuguese: { name: 'Atos', abbrev: 'At' }, + tok_pisin: { name: 'Wok', abbrev: 'Wok' }, + indonesian: { name: 'Kisah Para Rasul', abbrev: 'Kis' }, + nepali: { name: 'เคชเฅเคฐเฅ‡เคฐเคฟเคค', abbrev: 'เคชเฅเคฐเฅ‡เคฐเคฟ' } + }, + rom: { + english: { name: 'Romans', abbrev: 'Rom' }, + spanish: { name: 'Romanos', abbrev: 'Rom' }, + brazilian_portuguese: { name: 'Romanos', abbrev: 'Rm' }, + tok_pisin: { name: 'Rom', abbrev: 'Rom' }, + indonesian: { name: 'Roma', abbrev: 'Rom' }, + nepali: { name: 'เคฐเฅ‹เคฎเฅ€', abbrev: 'เคฐเฅ‹เคฎเฅ€' } + }, + '1co': { + english: { name: '1 Corinthians', abbrev: '1 Cor' }, + spanish: { name: '1 Corintios', abbrev: '1 Cor' }, + brazilian_portuguese: { name: '1 Corรญntios', abbrev: '1Co' }, + tok_pisin: { name: '1 Korin', abbrev: '1Kor' }, + indonesian: { name: '1 Korintus', abbrev: '1Kor' }, + nepali: { name: 'เฅง เค•เฅ‹เคฐเคฟเคจเฅเคฅเฅ€', abbrev: 'เฅงเค•เฅ‹เคฐเคฟ' } + }, + '2co': { + english: { name: '2 Corinthians', abbrev: '2 Cor' }, + spanish: { name: '2 Corintios', abbrev: '2 Cor' }, + brazilian_portuguese: { name: '2 Corรญntios', abbrev: '2Co' }, + tok_pisin: { name: '2 Korin', abbrev: '2Kor' }, + indonesian: { name: '2 Korintus', abbrev: '2Kor' }, + nepali: { name: 'เฅจ เค•เฅ‹เคฐเคฟเคจเฅเคฅเฅ€', abbrev: 'เฅจเค•เฅ‹เคฐเคฟ' } + }, + gal: { + english: { name: 'Galatians', abbrev: 'Gal' }, + spanish: { name: 'Gรกlatas', abbrev: 'Gรกl' }, + brazilian_portuguese: { name: 'Gรกlatas', abbrev: 'Gl' }, + tok_pisin: { name: 'Galesia', abbrev: 'Gal' }, + indonesian: { name: 'Galatia', abbrev: 'Gal' }, + nepali: { name: 'เค—เคฒเคพเคคเฅ€', abbrev: 'เค—เคฒเคพ' } + }, + eph: { + english: { name: 'Ephesians', abbrev: 'Eph' }, + spanish: { name: 'Efesios', abbrev: 'Efe' }, + brazilian_portuguese: { name: 'Efรฉsios', abbrev: 'Ef' }, + tok_pisin: { name: 'Efesus', abbrev: 'Efe' }, + indonesian: { name: 'Efesus', abbrev: 'Ef' }, + nepali: { name: 'เคเคซเคฟเคธเฅ€', abbrev: 'เคเคซเคฟ' } + }, + phi: { + english: { name: 'Philippians', abbrev: 'Phil' }, + spanish: { name: 'Filipenses', abbrev: 'Fil' }, + brazilian_portuguese: { name: 'Filipenses', abbrev: 'Fp' }, + tok_pisin: { name: 'Filipai', abbrev: 'Fil' }, + indonesian: { name: 'Filipi', abbrev: 'Flp' }, + nepali: { name: 'เคซเคฟเคฒเคฟเคชเฅเคชเฅ€', abbrev: 'เคซเคฟเคฒเคฟ' } + }, + col: { + english: { name: 'Colossians', abbrev: 'Col' }, + spanish: { name: 'Colosenses', abbrev: 'Col' }, + brazilian_portuguese: { name: 'Colossenses', abbrev: 'Cl' }, + tok_pisin: { name: 'Kolosi', abbrev: 'Kol' }, + indonesian: { name: 'Kolose', abbrev: 'Kol' }, + nepali: { name: 'เค•เคฒเคธเฅเคธเฅ€', abbrev: 'เค•เคฒ' } + }, + '1th': { + english: { name: '1 Thessalonians', abbrev: '1 Thess' }, + spanish: { name: '1 Tesalonicenses', abbrev: '1 Tes' }, + brazilian_portuguese: { name: '1 Tessalonicenses', abbrev: '1Ts' }, + tok_pisin: { name: '1 Tesalonaika', abbrev: '1Tes' }, + indonesian: { name: '1 Tesalonika', abbrev: '1Tes' }, + nepali: { name: 'เฅง เคฅเฅ‡เคธเฅเคธเคฒเฅ‹เคจเคฟเค•เฅ€', abbrev: 'เฅงเคฅเฅ‡เคธเฅเคธ' } + }, + '2th': { + english: { name: '2 Thessalonians', abbrev: '2 Thess' }, + spanish: { name: '2 Tesalonicenses', abbrev: '2 Tes' }, + brazilian_portuguese: { name: '2 Tessalonicenses', abbrev: '2Ts' }, + tok_pisin: { name: '2 Tesalonaika', abbrev: '2Tes' }, + indonesian: { name: '2 Tesalonika', abbrev: '2Tes' }, + nepali: { name: 'เฅจ เคฅเฅ‡เคธเฅเคธเคฒเฅ‹เคจเคฟเค•เฅ€', abbrev: 'เฅจเคฅเฅ‡เคธเฅเคธ' } + }, + '1ti': { + english: { name: '1 Timothy', abbrev: '1 Tim' }, + spanish: { name: '1 Timoteo', abbrev: '1 Tim' }, + brazilian_portuguese: { name: '1 Timรณteo', abbrev: '1Tm' }, + tok_pisin: { name: '1 Timoti', abbrev: '1Tim' }, + indonesian: { name: '1 Timotius', abbrev: '1Tim' }, + nepali: { name: 'เฅง เคคเคฟเคฎเฅ‹เคฅเฅ€', abbrev: 'เฅงเคคเคฟเคฎเฅ‹' } + }, + '2ti': { + english: { name: '2 Timothy', abbrev: '2 Tim' }, + spanish: { name: '2 Timoteo', abbrev: '2 Tim' }, + brazilian_portuguese: { name: '2 Timรณteo', abbrev: '2Tm' }, + tok_pisin: { name: '2 Timoti', abbrev: '2Tim' }, + indonesian: { name: '2 Timotius', abbrev: '2Tim' }, + nepali: { name: 'เฅจ เคคเคฟเคฎเฅ‹เคฅเฅ€', abbrev: 'เฅจเคคเคฟเคฎเฅ‹' } + }, + tit: { + english: { name: 'Titus', abbrev: 'Titus' }, + spanish: { name: 'Tito', abbrev: 'Tit' }, + brazilian_portuguese: { name: 'Tito', abbrev: 'Tt' }, + tok_pisin: { name: 'Taitus', abbrev: 'Tai' }, + indonesian: { name: 'Titus', abbrev: 'Tit' }, + nepali: { name: 'เคคเฅ€เคคเคธ', abbrev: 'เคคเฅ€เคค' } + }, + phm: { + english: { name: 'Philemon', abbrev: 'Phlm' }, + spanish: { name: 'Filemรณn', abbrev: 'Flm' }, + brazilian_portuguese: { name: 'Filemom', abbrev: 'Fm' }, + tok_pisin: { name: 'Filemon', abbrev: 'Fil' }, + indonesian: { name: 'Filemon', abbrev: 'Flm' }, + nepali: { name: 'เคซเคฟเคฒเฅ‡เคฎเฅ‹เคจ', abbrev: 'เคซเคฟเคฒเฅ‡' } + }, + heb: { + english: { name: 'Hebrews', abbrev: 'Heb' }, + spanish: { name: 'Hebreos', abbrev: 'Heb' }, + brazilian_portuguese: { name: 'Hebreus', abbrev: 'Hb' }, + tok_pisin: { name: 'Hibru', abbrev: 'Hib' }, + indonesian: { name: 'Ibrani', abbrev: 'Ibr' }, + nepali: { name: 'เคนเคฟเคฌเฅเคฐเฅ‚', abbrev: 'เคนเคฟเคฌเฅเคฐเฅ‚' } + }, + jas: { + english: { name: 'James', abbrev: 'Jas' }, + spanish: { name: 'Santiago', abbrev: 'Sant' }, + brazilian_portuguese: { name: 'Tiago', abbrev: 'Tg' }, + tok_pisin: { name: 'Jems', abbrev: 'Jem' }, + indonesian: { name: 'Yakobus', abbrev: 'Yak' }, + nepali: { name: 'เคฏเคพเค•เฅ‚เคฌ', abbrev: 'เคฏเคพเค•เฅ‚' } + }, + '1pe': { + english: { name: '1 Peter', abbrev: '1 Pet' }, + spanish: { name: '1 Pedro', abbrev: '1 Ped' }, + brazilian_portuguese: { name: '1 Pedro', abbrev: '1Pe' }, + tok_pisin: { name: '1 Pita', abbrev: '1Pit' }, + indonesian: { name: '1 Petrus', abbrev: '1Ptr' }, + nepali: { name: 'เฅง เคชเคคเฅเคฐเฅเคธ', abbrev: 'เฅงเคชเคคเฅเคฐเฅ' } + }, + '2pe': { + english: { name: '2 Peter', abbrev: '2 Pet' }, + spanish: { name: '2 Pedro', abbrev: '2 Ped' }, + brazilian_portuguese: { name: '2 Pedro', abbrev: '2Pe' }, + tok_pisin: { name: '2 Pita', abbrev: '2Pit' }, + indonesian: { name: '2 Petrus', abbrev: '2Ptr' }, + nepali: { name: 'เฅจ เคชเคคเฅเคฐเฅเคธ', abbrev: 'เฅจเคชเคคเฅเคฐเฅ' } + }, + '1jn': { + english: { name: '1 John', abbrev: '1 John' }, + spanish: { name: '1 Juan', abbrev: '1 Jn' }, + brazilian_portuguese: { name: '1 Joรฃo', abbrev: '1Jo' }, + tok_pisin: { name: '1 Jon', abbrev: '1Jon' }, + indonesian: { name: '1 Yohanes', abbrev: '1Yoh' }, + nepali: { name: 'เฅง เคฏเฅ‚เคนเคจเฅเคจเคพ', abbrev: 'เฅงเคฏเฅ‚เคนเคจเฅ' } + }, + '2jn': { + english: { name: '2 John', abbrev: '2 John' }, + spanish: { name: '2 Juan', abbrev: '2 Jn' }, + brazilian_portuguese: { name: '2 Joรฃo', abbrev: '2Jo' }, + tok_pisin: { name: '2 Jon', abbrev: '2Jon' }, + indonesian: { name: '2 Yohanes', abbrev: '2Yoh' }, + nepali: { name: 'เฅจ เคฏเฅ‚เคนเคจเฅเคจเคพ', abbrev: 'เฅจเคฏเฅ‚เคนเคจเฅ' } + }, + '3jn': { + english: { name: '3 John', abbrev: '3 John' }, + spanish: { name: '3 Juan', abbrev: '3 Jn' }, + brazilian_portuguese: { name: '3 Joรฃo', abbrev: '3Jo' }, + tok_pisin: { name: '3 Jon', abbrev: '3Jon' }, + indonesian: { name: '3 Yohanes', abbrev: '3Yoh' }, + nepali: { name: 'เฅฉ เคฏเฅ‚เคนเคจเฅเคจเคพ', abbrev: 'เฅฉเคฏเฅ‚เคนเคจเฅ' } + }, + jud: { + english: { name: 'Jude', abbrev: 'Jude' }, + spanish: { name: 'Judas', abbrev: 'Jud' }, + brazilian_portuguese: { name: 'Judas', abbrev: 'Jd' }, + tok_pisin: { name: 'Jut', abbrev: 'Jut' }, + indonesian: { name: 'Yudas', abbrev: 'Yud' }, + nepali: { name: 'เคฏเคนเฅ‚เคฆเคพ', abbrev: 'เคฏเคนเฅ‚' } + }, + rev: { + english: { name: 'Revelation', abbrev: 'Rev' }, + spanish: { name: 'Apocalipsis', abbrev: 'Apoc' }, + brazilian_portuguese: { name: 'Apocalipse', abbrev: 'Ap' }, + tok_pisin: { name: 'Kamapim Tok Hait', abbrev: 'Kam' }, + indonesian: { name: 'Wahyu', abbrev: 'Why' }, + nepali: { name: 'เคชเฅเคฐเค•เคพเคถ', abbrev: 'เคชเฅเคฐเค•เคพ' } + } +}; + +/** + * Get localized book name and abbreviation for a given book ID and language. + * Falls back to English if the language is not available. + */ +export function getLocalizedBookName( + bookId: string, + language: SupportedLanguage +): BookName { + const bookNames = BIBLE_BOOK_NAMES[bookId.toLowerCase()]; + if (!bookNames) { + // Return the book ID as fallback + return { name: bookId.toUpperCase(), abbrev: bookId.toUpperCase() }; + } + + // Try requested language, then fall back to English + return ( + bookNames[language] ?? + bookNames.english ?? { + name: bookId.toUpperCase(), + abbrev: bookId.toUpperCase() + } + ); +} diff --git a/db/seedData.json b/db/seedData.json index d172512fe..9abf9438b 100644 --- a/db/seedData.json +++ b/db/seedData.json @@ -116,6 +116,54 @@ "created_at": "2024-01-01T00:00:00Z", "last_updated": "2024-01-01T00:00:00Z", "creator_id": null + }, + { + "id": "lang-por-br", + "parent_id": null, + "name": "Brazilian Portuguese", + "level": "language", + "ui_ready": true, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "lang-tpi", + "parent_id": null, + "name": "Tok Pisin", + "level": "language", + "ui_ready": true, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "lang-ind", + "parent_id": null, + "name": "Indonesian", + "level": "language", + "ui_ready": true, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "lang-nep", + "parent_id": null, + "name": "Nepali", + "level": "language", + "ui_ready": true, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null } ], "languoid_aliases": [ @@ -183,6 +231,58 @@ "created_at": "2024-01-01T00:00:00Z", "last_updated": "2024-01-01T00:00:00Z", "creator_id": null + }, + { + "id": "alias-por-br-endonym", + "subject_languoid_id": "lang-por-br", + "label_languoid_id": "lang-por-br", + "name": "Portuguรชs Brasileiro", + "alias_type": "endonym", + "source_names": ["lexvo"], + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "alias-tpi-endonym", + "subject_languoid_id": "lang-tpi", + "label_languoid_id": "lang-tpi", + "name": "Tok Pisin", + "alias_type": "endonym", + "source_names": ["lexvo"], + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "alias-ind-endonym", + "subject_languoid_id": "lang-ind", + "label_languoid_id": "lang-ind", + "name": "Bahasa Indonesia", + "alias_type": "endonym", + "source_names": ["lexvo"], + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "alias-nep-endonym", + "subject_languoid_id": "lang-nep", + "label_languoid_id": "lang-nep", + "name": "เคจเฅ‡เคชเคพเคฒเฅ€", + "alias_type": "endonym", + "source_names": ["lexvo"], + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null } ], "languoid_sources": [ @@ -250,6 +350,58 @@ "created_at": "2024-01-01T00:00:00Z", "last_updated": "2024-01-01T00:00:00Z", "creator_id": null + }, + { + "id": "lsrc-por-br-iso", + "name": "iso639-3", + "version": null, + "languoid_id": "lang-por-br", + "unique_identifier": "por", + "url": null, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "lsrc-tpi-iso", + "name": "iso639-3", + "version": null, + "languoid_id": "lang-tpi", + "unique_identifier": "tpi", + "url": null, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "lsrc-ind-iso", + "name": "iso639-3", + "version": null, + "languoid_id": "lang-ind", + "unique_identifier": "ind", + "url": null, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null + }, + { + "id": "lsrc-nep-iso", + "name": "iso639-3", + "version": null, + "languoid_id": "lang-nep", + "unique_identifier": "nep", + "url": null, + "active": true, + "download_profiles": null, + "created_at": "2024-01-01T00:00:00Z", + "last_updated": "2024-01-01T00:00:00Z", + "creator_id": null } ], "languoid_properties": [ diff --git a/hooks/db/useLanguoids.ts b/hooks/db/useLanguoids.ts index 5a1a0beca..2ef76c331 100644 --- a/hooks/db/useLanguoids.ts +++ b/hooks/db/useLanguoids.ts @@ -6,6 +6,7 @@ import { languoid } from '@/db/drizzleSchema'; import { system } from '@/db/powersync/system'; +import { SUPPORTED_LANGUAGE_NAMES } from '@/services/localizations'; import { useHybridData } from '@/views/new/useHybridData'; import { toCompilableQuery } from '@powersync/drizzle-driver'; import type { InferSelectModel } from 'drizzle-orm'; @@ -17,12 +18,14 @@ export type Languoid = InferSelectModel; /** * Returns { languoids, isLoading, error } * Fetches all ui_ready languoids from Supabase (online) or local Drizzle DB (offline) + * Filters to only include languages with local app support to prevent + * showing languages from newer DB versions that the app can't render. */ export function useUIReadyLanguoids() { const { db, supabaseConnector } = system; const { - data: languoids, + data: rawLanguoids, isLoading: isLanguoidsLoading, ...rest } = useHybridData({ @@ -50,6 +53,16 @@ export function useUIReadyLanguoids() { } }); + // Filter to only include languages with local app support + // This prevents older app versions from showing languages they can't render + const languoids = useMemo( + () => + rawLanguoids.filter( + (l) => l.name && SUPPORTED_LANGUAGE_NAMES.has(l.name.toLowerCase()) + ), + [rawLanguoids] + ); + return { languoids, isLanguoidsLoading, ...rest }; } diff --git a/hooks/useAppNavigation.ts b/hooks/useAppNavigation.ts index eea33519f..2e2fe55f5 100644 --- a/hooks/useAppNavigation.ts +++ b/hooks/useAppNavigation.ts @@ -7,6 +7,7 @@ import type { AppView, NavigationStackItem } from '@/store/localStore'; import { useLocalStore } from '@/store/localStore'; import { profiler } from '@/utils/profiler'; import { useCallback, useMemo } from 'react'; +import { useLocalization } from './useLocalization'; export interface NavigationState { view: AppView; @@ -34,6 +35,7 @@ export function useAppNavigation() { addRecentAsset, enableVerseMarkers } = useLocalStore(); + const { t } = useLocalization(); // Ensure navigationStack is always an array - safe access pattern const safeNavigationStack = useMemo(() => { @@ -284,24 +286,26 @@ export function useAppNavigation() { const breadcrumbs = useMemo(() => { const crumbs: { label: string; onPress?: () => void }[] = []; + const projectsLabel = t('projects'); + // Guard against malformed currentState (should always have view based on useMemo logic) if (!('view' in currentState)) { - return [{ label: 'Projects', onPress: goToProjects }]; + return [{ label: projectsLabel, onPress: goToProjects }]; } const state = currentState; if (state.view === 'projects') { - crumbs.push({ label: 'Projects', onPress: goToProjects }); + crumbs.push({ label: projectsLabel, onPress: goToProjects }); } else if (state.view === 'quests' && state.projectName) { - crumbs.push({ label: 'Projects', onPress: goToProjects }); + crumbs.push({ label: projectsLabel, onPress: goToProjects }); crumbs.push({ label: state.projectName, onPress: undefined }); } else if ( state.view === 'assets' && state.projectName && state.questName ) { - crumbs.push({ label: 'Projects', onPress: goToProjects }); + crumbs.push({ label: projectsLabel, onPress: goToProjects }); crumbs.push({ label: state.projectName, onPress: () => @@ -318,7 +322,7 @@ export function useAppNavigation() { state.questName && state.assetName ) { - crumbs.push({ label: 'Projects', onPress: goToProjects }); + crumbs.push({ label: projectsLabel, onPress: goToProjects }); crumbs.push({ label: state.projectName, onPress: () => @@ -344,8 +348,8 @@ export function useAppNavigation() { // Always return at least one crumb to prevent empty array errors return crumbs.length > 0 ? crumbs - : [{ label: 'Projects', onPress: goToProjects }]; - }, [currentState, goToProjects, goToProject, goToQuest]); + : [{ label: projectsLabel, onPress: goToProjects }]; + }, [currentState, goToProjects, goToProject, goToQuest, t]); return { // Current state diff --git a/hooks/useBibleBookName.ts b/hooks/useBibleBookName.ts new file mode 100644 index 000000000..6e38fd55c --- /dev/null +++ b/hooks/useBibleBookName.ts @@ -0,0 +1,53 @@ +import { getLocalizedBookName } from '@/constants/bibleBookNames'; +import { useLocalization } from '@/hooks/useLocalization'; + +/** + * Hook to get localized Bible book name and abbreviation. + * Automatically uses the current UI language. + * + * For English, returns the uppercase book ID (e.g., "GEN", "EXO") to match + * the original display behavior. For other languages, returns localized abbreviations. + * + * @param bookId - The book ID (e.g., 'gen', 'exo', 'mat') + * @returns Object with localized name and abbreviation + * + * @example + * const { name, abbrev } = useBibleBookName('gen'); + * // In English: { name: 'Genesis', abbrev: 'GEN' } + * // In Nepali: { name: 'เค‰เคคเฅเคชเคคเฅเคคเคฟ', abbrev: 'เค‰เคคเฅเคช' } + */ +export function useBibleBookName(bookId: string) { + const { currentLanguage } = useLocalization(); + + // For English, preserve original behavior of uppercase book ID + if (currentLanguage === 'english') { + const localized = getLocalizedBookName(bookId, currentLanguage); + return { name: localized.name, abbrev: bookId.toUpperCase() }; + } + + return getLocalizedBookName(bookId, currentLanguage); +} + +/** + * Hook to get a function that returns localized book names. + * Useful when you need to localize multiple books without multiple hook calls. + * + * @returns Function that takes a bookId and returns localized name/abbreviation + * + * @example + * const getBookName = useBibleBookNameGetter(); + * const genesis = getBookName('gen'); + * const exodus = getBookName('exo'); + */ +export function useBibleBookNameGetter() { + const { currentLanguage } = useLocalization(); + + return (bookId: string) => { + // For English, preserve original behavior of uppercase book ID + if (currentLanguage === 'english') { + const localized = getLocalizedBookName(bookId, currentLanguage); + return { name: localized.name, abbrev: bookId.toUpperCase() }; + } + return getLocalizedBookName(bookId, currentLanguage); + }; +} diff --git a/hooks/useLocalization.ts b/hooks/useLocalization.ts index 3d73741c9..3700cddf6 100644 --- a/hooks/useLocalization.ts +++ b/hooks/useLocalization.ts @@ -42,7 +42,10 @@ function mapLanguoidNameToSupportedLanguage( // Indonesian names and endonyms 'standard indonesian': 'indonesian', indonesian: 'indonesian', - 'bahasa indonesia': 'indonesian' + 'bahasa indonesia': 'indonesian', + // Nepali names and endonyms + nepali: 'nepali', + เคจเฅ‡เคชเคพเคฒเฅ€: 'nepali' }; return mapping[normalized] ?? 'english'; @@ -159,5 +162,5 @@ export function useLocalization(languageOverride?: string | null) { return translatedString; }; - return { t }; + return { t, currentLanguage: userLanguage }; } diff --git a/services/localizations.ts b/services/localizations.ts index aee3fe518..4707d57db 100644 --- a/services/localizations.ts +++ b/services/localizations.ts @@ -4,7 +4,36 @@ export type SupportedLanguage = | 'spanish' | 'brazilian_portuguese' | 'tok_pisin' - | 'indonesian'; + | 'indonesian' + | 'nepali'; + +/** + * Languoid names that have local UI support. + * Used to filter ui_ready languoids from the DB to prevent showing + * languages that newer DB versions support but older app versions don't. + * Must match the mapping in useLocalization.ts mapLanguoidNameToSupportedLanguage() + */ +export const SUPPORTED_LANGUAGE_NAMES = new Set([ + // English + 'english', + // Spanish + 'spanish', + 'espaรฑol', + 'espanol', + // Brazilian Portuguese + 'brazilian portuguese', + 'portuguรชs brasileiro', + 'portugues brasileiro', + // Tok Pisin + 'tok pisin', + // Indonesian + 'standard indonesian', + 'indonesian', + 'bahasa indonesia', + // Nepali + 'nepali', + 'เคจเฅ‡เคชเคพเคฒเฅ€' +]); // Define the structure for translations export type LocalizationKey = keyof typeof localizations; @@ -19,7 +48,8 @@ export const localizations = { spanish: 'Aceptar', brazilian_portuguese: 'Aceitar', tok_pisin: 'Orait', - indonesian: 'Terima' + indonesian: 'Terima', + nepali: 'เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, accountNotVerified: { english: @@ -31,68 +61,79 @@ export const localizations = { tok_pisin: 'Plis checkum email adres bilong yu pastaim long sainum. Lukim email bilong yu long verification link.', indonesian: - 'Harap verifikasi alamat email Anda sebelum masuk. Periksa email Anda untuk tautan verifikasi.' + 'Harap verifikasi alamat email Anda sebelum masuk. Periksa email Anda untuk tautan verifikasi.', + nepali: + 'เคธเคพเค‡เคจ เค‡เคจ เค—เคฐเฅเคจเฅ เค…เค˜เคฟ เค•เฅƒเคชเคฏเคพ เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เค เฅ‡เค—เคพเคจเคพ เคชเฅเคฐเคฎเคพเคฃเคฟเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค เคชเฅเคฐเคฎเคพเคฃเฅ€เค•เคฐเคฃ เคฒเคฟเค‚เค•เค•เฅ‹ เคฒเคพเค—เคฟ เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เคœเคพเคเคš เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, done: { english: 'Done', spanish: 'Listo', brazilian_portuguese: 'Feito', tok_pisin: 'Done', - indonesian: 'Selesai' + indonesian: 'Selesai', + nepali: 'เคธเคฎเฅเคชเคจเฅเคจ' }, all: { english: 'All', spanish: 'Todo', brazilian_portuguese: 'Todos', tok_pisin: 'Olgeta', - indonesian: 'Semua' + indonesian: 'Semua', + nepali: 'เคธเคฌเฅˆ' }, options: { english: 'Options', spanish: 'Opciones', - brazilian_portuguese: 'Opรงรตes' + brazilian_portuguese: 'Opรงรตes', + nepali: 'เคตเคฟเค•เคฒเฅเคชเคนเคฐเฅ‚' }, membersOnlyCreate: { english: 'Only project members can create content', spanish: 'Solo los miembros del proyecto pueden crear contenido', brazilian_portuguese: 'Apenas membros do projeto podem criar conteรบdo', tok_pisin: 'Tasol ol memba bilong projek inap mekim nupela samting', - indonesian: 'Hanya anggota proyek yang dapat membuat konten' + indonesian: 'Hanya anggota proyek yang dapat membuat konten', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฒเฅ‡ เคฎเคพเคคเฅเคฐ เคธเคพเคฎเค—เฅเคฐเฅ€ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅ' }, membersOnlyPublish: { english: 'Only project members can save to the cloud', spanish: 'Solo los miembros del proyecto pueden guardar en la nube', brazilian_portuguese: 'Apenas membros do projeto podem salvar na nuvem', tok_pisin: 'Tasol ol memba bilong projek inap save long cloud', - indonesian: 'Hanya anggota proyek yang dapat menyimpan ke cloud' + indonesian: 'Hanya anggota proyek yang dapat menyimpan ke cloud', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฒเฅ‡ เคฎเคพเคคเฅเคฐ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเฅ‡เคญ เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅ' }, apply: { english: 'Apply', spanish: 'Aplicar', brazilian_portuguese: 'Aplicar', tok_pisin: 'Putim', - indonesian: 'Terapkan' + indonesian: 'Terapkan', + nepali: 'เคฒเคพเค—เฅ‚ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, avatar: { english: 'Avatar', spanish: 'Avatar', brazilian_portuguese: 'Avatar', tok_pisin: 'Avatar', - indonesian: 'Avatar' + indonesian: 'Avatar', + nepali: 'เค…เคตเคคเคพเคฐ' }, backToLogin: { english: 'Back to Login', spanish: 'Volver al inicio de sesiรณn', brazilian_portuguese: 'Voltar para o Login', tok_pisin: 'Go bek long Login', - indonesian: 'Kembali ke Login' + indonesian: 'Kembali ke Login', + nepali: 'เคฒเค—เค‡เคจเคฎเคพ เคซเคฐเฅเค•เคจเฅเคนเฅ‹เคธเฅ' }, checkEmail: { english: 'Please check your email', spanish: 'Por favor revise su correo electrรณnico', brazilian_portuguese: 'Por favor, verifique seu e-mail', tok_pisin: 'Plis checkum email bilong yu', - indonesian: 'Silakan periksa email Anda' + indonesian: 'Silakan periksa email Anda', + nepali: 'เค•เฅƒเคชเคฏเคพ เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เคœเคพเคเคš เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, checkEmailForResetLink: { english: 'Please check your email for the password reset link', @@ -101,102 +142,118 @@ export const localizations = { brazilian_portuguese: 'Por favor, verifique seu e-mail para o link de redefiniรงรฃo de senha', tok_pisin: 'Plis checkum email bilong yu long password reset link', - indonesian: 'Silakan periksa email Anda untuk tautan reset kata sandi' + indonesian: 'Silakan periksa email Anda untuk tautan reset kata sandi', + nepali: 'เค•เฅƒเคชเคฏเคพ เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เคฒเคฟเค‚เค•เค•เฅ‹ เคฒเคพเค—เคฟ เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เคœเคพเคเคš เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmNewPassword: { english: 'Confirm New Password', spanish: 'Confirmar nueva contraseรฑa', brazilian_portuguese: 'Confirmar Nova Senha', tok_pisin: 'Confirm nupela password', - indonesian: 'Konfirmasi Kata Sandi Baru' + indonesian: 'Konfirmasi Kata Sandi Baru', + nepali: 'เคจเคฏเคพเค เคชเคพเคธเคตเคฐเฅเคก เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmPassword: { english: 'Confirm Password', spanish: 'Confirmar contraseรฑa', brazilian_portuguese: 'Confirmar Senha', tok_pisin: 'Confirm password', - indonesian: 'Konfirmasi Kata Sandi' + indonesian: 'Konfirmasi Kata Sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createObject: { english: 'Create', spanish: 'Crear', brazilian_portuguese: 'Criar', tok_pisin: 'Create', - indonesian: 'Buat' + indonesian: 'Buat', + nepali: 'เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, projectName: { english: 'Project Name', spanish: 'Nombre del Proyecto', brazilian_portuguese: 'Nome do Projeto', tok_pisin: 'Project Name', - indonesian: 'Nama Proyek' + indonesian: 'Nama Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเค•เฅ‹ เคจเคพเคฎ' }, newProject: { english: 'New Project', spanish: 'Nuevo Proyecto', brazilian_portuguese: 'Novo Projeto', tok_pisin: 'Nupela Project', - indonesian: 'Proyek Baru' + indonesian: 'Proyek Baru', + nepali: 'เคจเคฏเคพเค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ' }, newQuest: { - english: 'New Quest' + english: 'New Quest', + nepali: 'เคจเคฏเคพเค เค•เฅเคตเฅ‡เคธเฅเคŸ' }, questName: { - english: 'Quest Name' + english: 'Quest Name', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸเค•เฅ‹ เคจเคพเคฎ' }, description: { english: 'Description', spanish: 'Descripciรณn', brazilian_portuguese: 'Descriรงรฃo', tok_pisin: 'Description', - indonesian: 'Deskripsi' + indonesian: 'Deskripsi', + nepali: 'เคตเคฟเคตเคฐเคฃ' }, visible: { english: 'Visible', spanish: 'Visible', brazilian_portuguese: 'Visible', tok_pisin: 'Visible', - indonesian: 'Visible' + indonesian: 'Visible', + nepali: 'เคฆเฅ‡เค–เคฟเคจเฅ‡' }, private: { english: 'Private', spanish: 'Privado', brazilian_portuguese: 'Privado', tok_pisin: 'Private', - indonesian: 'Private' + indonesian: 'Private', + nepali: 'เคจเคฟเคœเฅ€' }, date: { english: 'Date', spanish: 'Fecha', brazilian_portuguese: 'Data', tok_pisin: 'De', - indonesian: 'Tanggal' + indonesian: 'Tanggal', + nepali: 'เคฎเคฟเคคเคฟ' }, decline: { english: 'Decline', spanish: 'Rechazar', brazilian_portuguese: 'Rejeitar', tok_pisin: 'No', - indonesian: 'Tolak' + indonesian: 'Tolak', + nepali: 'เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, downloadAnyway: { english: 'Download Anyway', spanish: 'Descargar de todas formas', brazilian_portuguese: 'Descarregar de qualquer forma', tok_pisin: 'Download tasol', - indonesian: 'Unduh Saja' + indonesian: 'Unduh Saja', + nepali: 'เคœเฅ‡ เคญเค เคชเคจเคฟ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, downloadProject: { english: 'Download Project', spanish: 'Descargar Proyecto', brazilian_portuguese: 'Descarregar Projeto', tok_pisin: 'Download project', - indonesian: 'Unduh Proyek' + indonesian: 'Unduh Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, downloadQuest: { english: 'Download Quest', spanish: 'Descargar Quest', brazilian_portuguese: 'Descarregar Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', tok_pisin: 'Download quest', indonesian: 'Unduh Quest' }, @@ -210,49 +267,57 @@ export const localizations = { tok_pisin: 'Sapos yu no download project, yu no inap contributim long em taim yu no gat internet. Yu ken download em bihain long presim download button long project card.', indonesian: - 'Jika Anda tidak mengunduh proyek, Anda tidak akan dapat berkontribusi secara offline. Anda dapat mengunduhnya nanti dengan menekan tombol unduh di kartu proyek.' + 'Jika Anda tidak mengunduh proyek, Anda tidak akan dapat berkontribusi secara offline. Anda dapat mengunduhnya nanti dengan menekan tombol unduh di kartu proyek.', + nepali: + 'เคฏเคฆเคฟ เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคญเคเคจ เคญเคจเฅ‡, เคคเคชเคพเคˆเค‚ เค…เคซเคฒเคพเค‡เคจ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคธเค•เฅเคทเคฎ เคนเฅเคจเฅเคนเฅเคจเฅ‡ เค›เฅˆเคจเฅค เคคเคชเคพเคˆเค‚ เคชเค›เคฟ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค•เคพเคฐเฅเคกเค•เฅ‹ เคกเคพเค‰เคจเคฒเฅ‹เคก เคฌเคŸเคจ เคฅเคฟเคšเฅ‡เคฐ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, downloadProjectWhenRequestSent: { english: 'Download project when request is sent', spanish: 'Descargar proyecto cuando se envรญe la solicitud', brazilian_portuguese: 'Baixar projeto quando a solicitaรงรฃo for enviada', tok_pisin: 'Download project taim request i go', - indonesian: 'Unduh proyek saat permintaan dikirim' + indonesian: 'Unduh proyek saat permintaan dikirim', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคชเค เคพเค‡เคเคฆเคพ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, discoveringQuestData: { english: 'Discovering Quest Data', spanish: 'Descubriendo Datos de la Misiรณn', brazilian_portuguese: 'Descobrindo Dados da Missรฃo', tok_pisin: 'Painimaut long Quest Data', - indonesian: 'Menemukan Data Quest' + indonesian: 'Menemukan Data Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคกเคพเคŸเคพ เค–เฅ‹เคœเฅเคฆเฅˆ' }, offloadQuest: { english: 'Offload Quest', spanish: 'Descargar Quest', brazilian_portuguese: 'Descarregar Quest', tok_pisin: 'Rausim Quest', - indonesian: 'Lepas Quest' + indonesian: 'Lepas Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคนเคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, offloadQuestDescription: { english: 'Remove local data to free up storage', spanish: 'Eliminar datos locales para liberar almacenamiento', brazilian_portuguese: 'Remover dados locais para liberar armazenamento', tok_pisin: 'Rausim data long freeup storage', - indonesian: 'Hapus data lokal untuk membebaskan penyimpanan' + indonesian: 'Hapus data lokal untuk membebaskan penyimpanan', + nepali: 'เคญเคฃเฅเคกเคพเคฐเคฃ เค–เคพเคฒเฅ€ เค—เคฐเฅเคจ เคธเฅเคฅเคพเคจเฅ€เคฏ เคกเคพเคŸเคพ เคนเคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, verifyingCloudData: { english: 'Verifying data in cloud...', spanish: 'Verificando datos en la nube...', brazilian_portuguese: 'Verificando dados na nuvem...', tok_pisin: 'Checkim data long klaud...', - indonesian: 'Memverifikasi data di cloud...' + indonesian: 'Memverifikasi data di cloud...', + nepali: 'เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคกเคพเคŸเคพ เคชเฅเคฐเคฎเคพเคฃเคฟเคค เค—เคฐเฅเคฆเฅˆ...' }, pendingUploadsDetected: { english: 'Pending uploads detected', spanish: 'Se detectaron cargas pendientes', brazilian_portuguese: 'Uploads pendentes detectados', tok_pisin: 'Painimaut sampela hap i no go yet', - indonesian: 'Mendeteksi upload tertunda' + indonesian: 'Mendeteksi upload tertunda', + nepali: 'เคฌเคพเคเค•เฅ€ เค…เคชเคฒเฅ‹เคกเคนเคฐเฅ‚ เคชเคคเฅเคคเคพ เคฒเคพเค—เฅเคฏเฅ‹' }, pendingUploadsMessage: { english: @@ -264,14 +329,17 @@ export const localizations = { tok_pisin: 'Wetim olgeta senis i go long klaud pastaim long rausim. Joinim internet na wetim sync i pinis.', indonesian: - 'Harap tunggu semua perubahan terupload ke cloud sebelum melepas. Sambungkan ke internet dan tunggu sinkronisasi selesai.' + 'Harap tunggu semua perubahan terupload ke cloud sebelum melepas. Sambungkan ke internet dan tunggu sinkronisasi selesai.', + nepali: + 'เค•เฅƒเคชเคฏเคพ เคนเคŸเคพเค‰เคจเฅ เค…เค˜เคฟ เคธเคฌเฅˆ เคชเคฐเคฟเคตเคฐเฅเคคเคจเคนเคฐเฅ‚ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เค…เคชเคฒเฅ‹เคก เคนเฅเคจเค•เฅ‹ เคฒเคพเค—เคฟ เคชเคฐเฅเค–เคจเฅเคนเฅ‹เคธเฅเฅค เค‡เคจเฅเคŸเคฐเคจเฅ‡เคŸเคฎเคพ เคœเคกเคพเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคฐ เคธเคฟเค™เฅเค• เคชเฅ‚เคฐเคพ เคนเฅเคจเค•เฅ‹ เคฒเคพเค—เคฟ เคชเคฐเฅเค–เคจเฅเคนเฅ‹เคธเฅเฅค' }, readyToOffload: { english: 'Ready to offload', spanish: 'Listo para descargar', brazilian_portuguese: 'Pronto para descarregar', tok_pisin: 'Redi long rausim', - indonesian: 'Siap untuk melepas' + indonesian: 'Siap untuk melepas', + nepali: 'เคนเคŸเคพเค‰เคจ เคคเคฏเคพเคฐ' }, offloadWarning: { english: @@ -283,119 +351,137 @@ export const localizations = { tok_pisin: 'Dispela bai rausim kopi long dispela mashin tasol. Data bai stap save long klaud na yu ken daunim gen bihain.', indonesian: - 'Ini akan menghapus salinan lokal. Data akan tetap aman di cloud dan dapat diunduh kembali nanti.' + 'Ini akan menghapus salinan lokal. Data akan tetap aman di cloud dan dapat diunduh kembali nanti.', + nepali: + 'เคฏเคธเคฒเฅ‡ เคธเฅเคฅเคพเคจเฅ€เคฏ เคชเฅเคฐเคคเคฟเคฒเคฟเคชเคฟเคนเคฐเฅ‚ เคฎเฅ‡เคŸเฅเคจเฅ‡เค›เฅค เคกเคพเคŸเคพ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเฅเคฐเค•เฅเคทเคฟเคค เคฐเคนเคจเฅ‡เค› เคฐ เคชเค›เคฟ เคชเฅเคจ: เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เคฟเคจเฅเค›เฅค' }, storageToFree: { english: 'Storage to Free', spanish: 'Almacenamiento para Liberar', brazilian_portuguese: 'Armazenamento a Liberar', tok_pisin: 'Storage Long Freeup', - indonesian: 'Penyimpanan yang Dibebaskan' + indonesian: 'Penyimpanan yang Dibebaskan', + nepali: 'เค–เคพเคฒเฅ€ เค—เคฐเฅเคจเฅ‡ เคญเคฃเฅเคกเคพเคฐเคฃ' }, continue: { english: 'Continue', spanish: 'Continuar', brazilian_portuguese: 'Continuar', tok_pisin: 'Go Het', - indonesian: 'Lanjutkan' + indonesian: 'Lanjutkan', + nepali: 'เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจเฅเคนเฅ‹เคธเฅ' }, continueToOffload: { english: 'Offload from Device', spanish: 'Descargar del Dispositivo', brazilian_portuguese: 'Descarregar do Dispositivo', tok_pisin: 'Rausim long Mashin', - indonesian: 'Lepas dari Perangkat' + indonesian: 'Lepas dari Perangkat', + nepali: 'เค‰เคชเค•เคฐเคฃเคฌเคพเคŸ เคนเคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, offloadingQuest: { english: 'Offloading quest...', spanish: 'Descargando quest...', brazilian_portuguese: 'Descarregando quest...', tok_pisin: 'Rausim quest...', - indonesian: 'Melepas quest...' + indonesian: 'Melepas quest...', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคนเคŸเคพเค‰เคเคฆเฅˆ...' }, offloadComplete: { english: 'Quest offloaded successfully', spanish: 'Quest descargada con รฉxito', brazilian_portuguese: 'Quest descarregada com sucesso', tok_pisin: 'Quest i rausim orait', - indonesian: 'Quest berhasil dilepas' + indonesian: 'Quest berhasil dilepas', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคนเคŸเคพเค‡เคฏเฅ‹' }, offloadError: { english: 'Failed to offload quest', spanish: 'Error al descargar quest', brazilian_portuguese: 'Falha ao descarregar quest', tok_pisin: 'Pasin long rausim quest i no inap', - indonesian: 'Gagal melepas quest' + indonesian: 'Gagal melepas quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคนเคŸเคพเค‰เคจ เค…เคธเคซเคฒ' }, cannotOffloadErrors: { english: 'Cannot offload - errors detected', spanish: 'No se puede descargar - errores detectados', brazilian_portuguese: 'Nรฃo รฉ possรญvel descarregar - erros detectados', tok_pisin: 'No inap rausim - painimaut sampela rong', - indonesian: 'Tidak dapat melepas - kesalahan terdeteksi' + indonesian: 'Tidak dapat melepas - kesalahan terdeteksi', + nepali: 'เคนเคŸเคพเค‰เคจ เคธเค•เคฟเคเคฆเฅˆเคจ - เคคเฅเคฐเฅเคŸเคฟเคนเคฐเฅ‚ เคชเคคเฅเคคเคพ เคฒเคพเค—เฅเคฏเฅ‹' }, allDataVerifiedInCloud: { english: 'All data verified in cloud', spanish: 'Todos los datos verificados en la nube', brazilian_portuguese: 'Todos os dados verificados na nuvem', tok_pisin: 'Olgeta data i stret long klaud', - indonesian: 'Semua data terverifikasi di cloud' + indonesian: 'Semua data terverifikasi di cloud', + nepali: 'เคธเคฌเฅˆ เคกเคพเคŸเคพ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคชเฅเคฐเคฎเคพเคฃเคฟเคค' }, checkingPendingChanges: { english: 'Checking for pending changes...', spanish: 'Verificando cambios pendientes...', brazilian_portuguese: 'Verificando alteraรงรตes pendentes...', tok_pisin: 'Checkim sampela senis i no go yet...', - indonesian: 'Memeriksa perubahan tertunda...' + indonesian: 'Memeriksa perubahan tertunda...', + nepali: 'เคฌเคพเคเค•เฅ€ เคชเคฐเคฟเคตเคฐเฅเคคเคจเคนเคฐเฅ‚ เคœเคพเคเคš เค—เคฐเฅเคฆเฅˆ...' }, verifyingDatabaseRecords: { english: 'Verifying database records', spanish: 'Verificando registros de base de datos', brazilian_portuguese: 'Verificando registros do banco de dados', tok_pisin: 'Checkim ol rekod long database', - indonesian: 'Memverifikasi catatan database' + indonesian: 'Memverifikasi catatan database', + nepali: 'เคกเคพเคŸเคพเคฌเฅ‡เคธ เคฐเฅ‡เค•เคฐเฅเคกเคนเคฐเฅ‚ เคชเฅเคฐเคฎเคพเคฃเคฟเคค เค—เคฐเฅเคฆเฅˆ' }, verifyingAttachments: { english: 'Verifying attachments', spanish: 'Verificando archivos adjuntos', brazilian_portuguese: 'Verificando anexos', tok_pisin: 'Checkim ol fail i pas long', - indonesian: 'Memverifikasi lampiran' + indonesian: 'Memverifikasi lampiran', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคชเฅเคฐเคฎเคพเคฃเคฟเคค เค—เคฐเฅเคฆเฅˆ' }, waitingForUploads: { english: 'Waiting for Uploads', spanish: 'Esperando Cargas', brazilian_portuguese: 'Aguardando Uploads', tok_pisin: 'Wetim Upload', - indonesian: 'Menunggu Upload' + indonesian: 'Menunggu Upload', + nepali: 'เค…เคชเคฒเฅ‹เคกเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เคชเคฐเฅเค–เคเคฆเฅˆ' }, cannotOffload: { english: 'Cannot Offload', spanish: 'No se puede Descargar', brazilian_portuguese: 'Nรฃo รฉ possรญvel Descarregar', tok_pisin: 'No Inap Rausim', - indonesian: 'Tidak dapat Melepas' + indonesian: 'Tidak dapat Melepas', + nepali: 'เคนเคŸเคพเค‰เคจ เคธเค•เคฟเคเคฆเฅˆเคจ' }, analyzingRelatedRecords: { english: 'Analyzing related records...', spanish: 'Analizando registros relacionados...', brazilian_portuguese: 'Analisando registros relacionados...', tok_pisin: 'Lukautim ol related records...', - indonesian: 'Menganalisis catatan terkait...' + indonesian: 'Menganalisis catatan terkait...', + nepali: 'เคธเคฎเฅเคฌเคจเฅเคงเคฟเคค เคฐเฅ‡เค•เคฐเฅเคกเคนเคฐเฅ‚ เคตเคฟเคถเฅเคฒเฅ‡เคทเคฃ เค—เคฐเฅเคฆเฅˆ...' }, discoveryComplete: { english: 'Discovery complete', spanish: 'Descubrimiento completo', brazilian_portuguese: 'Descoberta completa', tok_pisin: 'Discovery i pinis', - indonesian: 'Penemuan selesai' + indonesian: 'Penemuan selesai', + nepali: 'เค–เฅ‹เคœ เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹' }, totalRecords: { english: 'Total Records', spanish: 'Registros Totales', brazilian_portuguese: 'Registros Totais', tok_pisin: 'Total Records', - indonesian: 'Total Catatan' + indonesian: 'Total Catatan', + nepali: 'เค•เฅเคฒ เคฐเฅ‡เค•เคฐเฅเคกเคนเคฐเฅ‚' }, discoveryErrorsOccurred: { english: @@ -407,7 +493,9 @@ export const localizations = { tok_pisin: 'Sampela problem i kamap taim long painimaut. Yu ken download yet ol records we mipela painimaut.', indonesian: - 'Beberapa kesalahan terjadi selama penemuan. Anda masih dapat mengunduh catatan yang ditemukan.' + 'Beberapa kesalahan terjadi selama penemuan. Anda masih dapat mengunduh catatan yang ditemukan.', + nepali: + 'เค–เฅ‹เคœเค•เฅ‹ เค•เฅเคฐเคฎเคฎเคพ เค•เฅ‡เคนเฅ€ เคคเฅเคฐเฅเคŸเคฟเคนเคฐเฅ‚ เคญเคเฅค เคคเคชเคพเคˆเค‚ เค…เคเฅˆ เคชเคจเคฟ เค–เฅ‹เคœเคฟเคเค•เคพ เคฐเฅ‡เค•เคฐเฅเคกเคนเคฐเฅ‚ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, questNotFoundInCloud: { english: @@ -419,28 +507,33 @@ export const localizations = { tok_pisin: 'Quest i no gat long cloud database. I may only exist long local o yu no have permission long access it. Plis refresh page o contact support long this persists.', indonesian: - 'Quest tidak ditemukan di basis data cloud. Mungkin hanya ada secara lokal atau Anda tidak memiliki izin untuk mengaksesnya. Silakan muat ulang halaman atau hubungi dukungan jika masalah ini tetap terjadi.' + 'Quest tidak ditemukan di basis data cloud. Mungkin hanya ada secara lokal atau Anda tidak memiliki izin untuk mengaksesnya. Silakan muat ulang halaman atau hubungi dukungan jika masalah ini tetap terjadi.', + nepali: + 'เค•เฅเคฒเคพเค‰เคก เคกเคพเคŸเคพเคฌเฅ‡เคธเคฎเคพ เค•เฅเคตเฅ‡เคธเฅเคŸ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจเฅค เคฏเฅ‹ เคธเฅเคฅเคพเคจเฅ€เคฏ เคฐเฅ‚เคชเคฎเคพ เคฎเคพเคคเฅเคฐ เค…เคตเคธเฅเคฅเคฟเคค เคนเฅเคจ เคธเค•เฅเค› เคตเคพ เคคเคชเคพเคˆเค‚เคธเคเค— เคฏเคธเคฎเคพ เคชเคนเฅเคเคš เค—เคฐเฅเคจเฅ‡ เค…เคจเฅเคฎเคคเคฟ เคจเคนเฅเคจ เคธเค•เฅเค›เฅค เคชเฅƒเคทเฅเค  เคฐเคฟเคซเฅเคฐเฅ‡เคถ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคตเคพ เคฏเฅ‹ เคธเคฎเคธเฅเคฏเคพ เคœเคพเคฐเฅ€ เคฐเคนเฅ‡เคฎเคพ เคธเคฎเคฐเฅเคฅเคจเคฒเคพเคˆ เคธเคฎเฅเคชเคฐเฅเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, discovering: { english: 'Discovering...', spanish: 'Descubriendo...', brazilian_portuguese: 'Descobrindo...', tok_pisin: 'Painimaut...', - indonesian: 'Menemukan...' + indonesian: 'Menemukan...', + nepali: 'เค–เฅ‹เคœเฅเคฆเฅˆ...' }, continueToDownload: { english: 'Continue to Download', spanish: 'Continuar con la Descarga', brazilian_portuguese: 'Continuar para Download', tok_pisin: 'Go het long Download', - indonesian: 'Lanjutkan ke Unduhan' + indonesian: 'Lanjutkan ke Unduhan', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคกเคฎเคพ เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจเฅเคนเฅ‹เคธเฅ' }, email: { english: 'Email', spanish: 'Email', brazilian_portuguese: 'E-mail', tok_pisin: 'Email', - indonesian: 'Email' + indonesian: 'Email', + nepali: 'เค‡เคฎเฅ‡เคฒ' }, emailAlreadyMemberMessage: { english: 'This email address is already a {role} of this project.', @@ -448,119 +541,136 @@ export const localizations = { 'Esta direcciรณn de correo electrรณnico ya es {role} de este proyecto.', brazilian_portuguese: 'Este endereรงo de e-mail jรก รฉ {role} deste projeto.', tok_pisin: 'Dispela email adres i {role} pinis long dispela project.', - indonesian: 'Alamat email ini sudah menjadi {role} dari proyek ini.' + indonesian: 'Alamat email ini sudah menjadi {role} dari proyek ini.', + nepali: 'เคฏเฅ‹ เค‡เคฎเฅ‡เคฒ เค เฅ‡เค—เคพเคจเคพ เคชเคนเคฟเคฒเฅ‡ เคจเฅˆ เคฏเคธ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเค•เฅ‹ {role} เคนเฅ‹เฅค' }, emailRequired: { english: 'Email is required', spanish: 'Se requiere email', brazilian_portuguese: 'E-mail รฉ obrigatรณrio', tok_pisin: 'Email i mas', - indonesian: 'Email diperlukan' + indonesian: 'Email diperlukan', + nepali: 'เค‡เคฎเฅ‡เคฒ เค†เคตเคถเฅเคฏเค• เค›' }, nameRequired: { english: 'Name is required', spanish: 'Nombre es requerido', brazilian_portuguese: 'Nome รฉ obrigatรณrio', tok_pisin: 'Name i mas', - indonesian: 'Nama diperlukan' + indonesian: 'Nama diperlukan', + nepali: 'เคจเคพเคฎ เค†เคตเคถเฅเคฏเค• เค›' }, descriptionTooLong: { english: 'Description must be less than {max} characters', spanish: 'La descripciรณn debe tener menos de {max} caracteres', brazilian_portuguese: 'A descriรงรฃo deve ter menos de {max} caracteres', tok_pisin: 'Description i no sem long {max} character', - indonesian: 'Deskripsi harus kurang dari {max} karakter' + indonesian: 'Deskripsi harus kurang dari {max} karakter', + nepali: 'เคตเคฟเคตเคฐเคฃ {max} เค…เค•เฅเคทเคฐเคญเคจเฅเคฆเคพ เค•เคฎ เคนเฅเคจเฅเคชเคฐเฅเค›' }, enterTranslation: { english: 'Enter your translation here', spanish: 'Ingrese su traducciรณn aquรญ', brazilian_portuguese: 'Digite sua traduรงรฃo aqui', tok_pisin: 'Putim translation bilong yu long hia', - indonesian: 'Masukkan terjemahan Anda di sini' + indonesian: 'Masukkan terjemahan Anda di sini', + nepali: 'เคฏเคนเคพเค เค†เคซเฅเคจเฅ‹ เค…เคจเฅเคตเคพเคฆ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enterTranscription: { english: 'Enter your transcription here', spanish: 'Ingrese su transcripciรณn aquรญ', brazilian_portuguese: 'Digite sua transcriรงรฃo aqui', tok_pisin: 'Putim transcription bilong yu long hia', - indonesian: 'Masukkan transkripsi Anda di sini' + indonesian: 'Masukkan transkripsi Anda di sini', + nepali: 'เคฏเคนเคพเค เค†เคซเฅเคจเฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enterYourTranscriptionIn: { english: 'Enter your transcription in {language}', spanish: 'Ingrese su transcripciรณn en {language}', brazilian_portuguese: 'Digite sua transcriรงรฃo em {language}', tok_pisin: 'Putim transcription bilong yu long {language}', - indonesian: 'Masukkan transkripsi Anda dalam {language}' + indonesian: 'Masukkan transkripsi Anda dalam {language}', + nepali: '{language} เคฎเคพ เค†เคซเฅเคจเฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enterValidEmail: { english: 'Please enter a valid email', spanish: 'Por favor ingrese un correo electrรณnico vรกlido', brazilian_portuguese: 'Por favor, digite um e-mail vรกlido', tok_pisin: 'Plis putim wanpela gutpela email', - indonesian: 'Silakan masukkan email yang valid' + indonesian: 'Silakan masukkan email yang valid', + nepali: 'เค•เฅƒเคชเคฏเคพ เคฎเคพเคจเฅเคฏ เค‡เคฎเฅ‡เคฒ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enterYourEmail: { english: 'Enter your email', spanish: 'Ingrese su correo electrรณnico', brazilian_portuguese: 'Digite seu e-mail', tok_pisin: 'Putim email bilong yu', - indonesian: 'Masukkan email Anda' + indonesian: 'Masukkan email Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enterYourPassword: { english: 'Enter your password', spanish: 'Ingrese su contraseรฑa', brazilian_portuguese: 'Digite sua senha', tok_pisin: 'Putim password bilong yu', - indonesian: 'Masukkan kata sandi Anda' + indonesian: 'Masukkan kata sandi Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคชเคพเคธเคตเคฐเฅเคก เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, error: { english: 'Error', spanish: 'Error', brazilian_portuguese: 'Erro', tok_pisin: 'Rong', - indonesian: 'Kesalahan' + indonesian: 'Kesalahan', + nepali: 'เคคเฅเคฐเฅเคŸเคฟ' }, failedCreateTranslation: { english: 'Failed to create translation', spanish: 'Error al crear la traducciรณn', brazilian_portuguese: 'Falha ao criar traduรงรฃo', tok_pisin: 'I no inap mekim translation', - indonesian: 'Gagal membuat terjemahan' + indonesian: 'Gagal membuat terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedCreateTranscription: { english: 'Failed to create transcription', spanish: 'Error al crear la transcripciรณn', brazilian_portuguese: 'Falha ao criar transcriรงรฃo', tok_pisin: 'I no inap mekim transcription', - indonesian: 'Gagal membuat transkripsi' + indonesian: 'Gagal membuat transkripsi', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedLoadProjects: { english: 'Failed to load projects', spanish: 'Error al cargar proyectos', brazilian_portuguese: 'Falha ao carregar projetos', tok_pisin: 'I no inap loadim ol project', - indonesian: 'Gagal memuat proyek' + indonesian: 'Gagal memuat proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedLoadQuests: { english: 'Failed to load quests', spanish: 'Error al cargar misiones', brazilian_portuguese: 'Falha ao carregar missรตes', tok_pisin: 'I no inap loadim ol quest', - indonesian: 'Gagal memuat misi' + indonesian: 'Gagal memuat misi', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedResetPassword: { english: 'Failed to reset password', spanish: 'Error al restablecer la contraseรฑa', brazilian_portuguese: 'Falha ao redefinir senha', tok_pisin: 'I no inap resetim password', - indonesian: 'Gagal mereset kata sandi' + indonesian: 'Gagal mereset kata sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedSendResetEmail: { english: 'Failed to send reset email', spanish: 'Error al enviar el correo de restablecimiento', brazilian_portuguese: 'Falha ao enviar e-mail de redefiniรงรฃo', tok_pisin: 'I no inap salim reset email', - indonesian: 'Gagal mengirim email reset' + indonesian: 'Gagal mengirim email reset', + nepali: 'เคฐเคฟเคธเฅ‡เคŸ เค‡เคฎเฅ‡เคฒ เคชเค เคพเค‰เคจ เค…เคธเคซเคฒ' }, failedToAcceptInvitation: { english: 'Failed to accept invitation. Please try again.', @@ -568,7 +678,8 @@ export const localizations = { brazilian_portuguese: 'Falha ao aceitar o convite. Por favor, tente novamente.', tok_pisin: 'I no inap akseptim invitation. Plis traim gen.', - indonesian: 'Gagal menerima undangan. Silakan coba lagi.' + indonesian: 'Gagal menerima undangan. Silakan coba lagi.', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจ เค…เคธเคซเคฒเฅค เค•เฅƒเคชเคฏเคพ เคซเฅ‡เคฐเคฟ เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, failedToDeclineInvitation: { english: 'Failed to decline invitation. Please try again.', @@ -576,147 +687,168 @@ export const localizations = { brazilian_portuguese: 'Falha ao recusar o convite. Por favor, tente novamente.', tok_pisin: 'I no inap refusim invitation. Plis traim gen.', - indonesian: 'Gagal menolak undangan. Silakan coba lagi.' + indonesian: 'Gagal menolak undangan. Silakan coba lagi.', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจ เค…เคธเคซเคฒเฅค เค•เฅƒเคชเคฏเคพ เคซเฅ‡เคฐเคฟ เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, failedToVote: { english: 'Failed to submit vote', spanish: 'Error al enviar el voto', brazilian_portuguese: 'Falha ao enviar voto', tok_pisin: 'I no inap salim vote', - indonesian: 'Gagal mengirim suara' + indonesian: 'Gagal mengirim suara', + nepali: 'เคฎเคค เคชเฅ‡เคถ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, fillFields: { english: 'Please fill in all required fields', spanish: 'Por favor complete todos los campos requeridos', brazilian_portuguese: 'Por favor, preencha todos os campos obrigatรณrios', tok_pisin: 'Plis fulupim olgeta field i mas', - indonesian: 'Silakan isi semua bidang yang diperlukan' + indonesian: 'Silakan isi semua bidang yang diperlukan', + nepali: 'เค•เฅƒเคชเคฏเคพ เคธเคฌเฅˆ เค†เคตเคถเฅเคฏเค• เคซเคฟเคฒเฅเคกเคนเคฐเฅ‚ เคญเคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, forgotPassword: { english: 'I forgot my password', spanish: 'Olvidรฉ mi contraseรฑa', brazilian_portuguese: 'Esqueci minha senha', tok_pisin: 'Mi lusim password bilong mi', - indonesian: 'Saya lupa kata sandi saya' + indonesian: 'Saya lupa kata sandi saya', + nepali: 'เคฎเฅˆเคฒเฅ‡ เคชเคพเคธเคตเคฐเฅเคก เคฌเคฟเคฐเฅเคธเฅ‡เค' }, invalidResetLink: { english: 'Invalid or expired reset link', spanish: 'Enlace de restablecimiento invรกlido o expirado', brazilian_portuguese: 'Link de redefiniรงรฃo invรกlido ou expirado', tok_pisin: 'Reset link i no gutpela o i pinis', - indonesian: 'Tautan reset tidak valid atau kedaluwarsa' + indonesian: 'Tautan reset tidak valid atau kedaluwarsa', + nepali: 'เค…เคฎเคพเคจเฅเคฏ เคตเคพ เคธเคฎเคพเคชเฅเคค เคฐเคฟเคธเฅ‡เคŸ เคฒเคฟเค‚เค•' }, logInToTranslate: { english: 'You must be logged in to submit translations', spanish: 'Debe iniciar sesiรณn para enviar traducciones', brazilian_portuguese: 'Vocรช precisa estar logado para enviar traduรงรตes', tok_pisin: 'Yu mas login pastaim long salim ol translation', - indonesian: 'Anda harus masuk untuk mengirim terjemahan' + indonesian: 'Anda harus masuk untuk mengirim terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚ เคชเฅ‡เคถ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคฒเค— เค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›' }, logInToVote: { english: 'You must be logged in to vote', spanish: 'Debe iniciar sesiรณn para votar', brazilian_portuguese: 'Vocรช precisa estar logado para votar', tok_pisin: 'Yu mas login pastaim long vote', - indonesian: 'Anda harus masuk untuk memberikan suara' + indonesian: 'Anda harus masuk untuk memberikan suara', + nepali: 'เคฎเคค เคฆเคฟเคจ เคคเคชเคพเคˆเค‚ เคฒเค— เค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›' }, menu: { english: 'Menu', spanish: 'Menรบ', brazilian_portuguese: 'Menu', tok_pisin: 'Menu', - indonesian: 'Menu' + indonesian: 'Menu', + nepali: 'เคฎเฅ‡เคจเฅ' }, newTranslation: { english: 'New Translation', spanish: 'Nueva Traducciรณn', brazilian_portuguese: 'Nova Traduรงรฃo', tok_pisin: 'Nupela Translation', - indonesian: 'Terjemahan Baru' + indonesian: 'Terjemahan Baru', + nepali: 'เคจเคฏเคพเค เค…เคจเฅเคตเคพเคฆ' }, newTranscription: { english: 'New Transcription', spanish: 'Nueva Transcripciรณn', brazilian_portuguese: 'Nova Transcriรงรฃo', tok_pisin: 'Nupela Transcription', - indonesian: 'Transkripsi Baru' + indonesian: 'Transkripsi Baru', + nepali: 'เคจเคฏเคพเค เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ' }, newUser: { english: 'New user?', spanish: 'ยฟUsuario nuevo?', brazilian_portuguese: 'Novo usuรกrio?', tok_pisin: 'Nupela user?', - indonesian: 'Pengguna baru?' + indonesian: 'Pengguna baru?', + nepali: 'เคจเคฏเคพเค เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ?' }, newUserRegistration: { english: 'New User Registration', spanish: 'Registro de nuevo usuario', brazilian_portuguese: 'Registro de Novo Usuรกrio', tok_pisin: 'Nupela User Registration', - indonesian: 'Pendaftaran Pengguna Baru' + indonesian: 'Pendaftaran Pengguna Baru', + nepali: 'เคจเคฏเคพเค เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคฆเคฐเฅเคคเคพ' }, noComment: { english: 'No Comment', spanish: 'Sin comentarios', brazilian_portuguese: 'Sem Comentรกrios', tok_pisin: 'No gat comment', - indonesian: 'Tidak Ada Komentar' + indonesian: 'Tidak Ada Komentar', + nepali: 'เค•เฅเคจเฅˆ เคŸเคฟเคชเฅเคชเคฃเฅ€ เค›เฅˆเคจ' }, noProject: { english: 'No active project found', spanish: 'No se encontrรณ ningรบn proyecto activo', brazilian_portuguese: 'Nenhum projeto ativo encontrado', tok_pisin: 'No gat active project', - indonesian: 'Tidak ada proyek aktif yang ditemukan' + indonesian: 'Tidak ada proyek aktif yang ditemukan', + nepali: 'เค•เฅเคจเฅˆ เคธเค•เฅเคฐเคฟเคฏ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, ok: { english: 'OK', spanish: 'OK', brazilian_portuguese: 'OK', tok_pisin: 'Orait', - indonesian: 'OK' + indonesian: 'OK', + nepali: 'เค เฅ€เค• เค›' }, offline: { english: 'Offline', spanish: 'Sin conexiรณn', brazilian_portuguese: 'Offline', tok_pisin: 'No gat internet', - indonesian: 'Offline' + indonesian: 'Offline', + nepali: 'เค…เคซเคฒเคพเค‡เคจ' }, password: { english: 'Password', spanish: 'Contraseรฑa', brazilian_portuguese: 'Senha', tok_pisin: 'Password', - indonesian: 'Kata Sandi' + indonesian: 'Kata Sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก' }, passwordRequired: { english: 'Password is required', spanish: 'Se requiere contraseรฑa', brazilian_portuguese: 'Senha รฉ obrigatรณria', tok_pisin: 'Password i mas', - indonesian: 'Kata sandi diperlukan' + indonesian: 'Kata sandi diperlukan', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เค†เคตเคถเฅเคฏเค• เค›' }, passwordMinLength: { english: 'Password must be at least 6 characters', spanish: 'La contraseรฑa debe tener al menos 6 caracteres', brazilian_portuguese: 'A senha deve ter pelo menos 6 caracteres', tok_pisin: 'Password i mas gat 6 character', - indonesian: 'Kata sandi harus minimal 6 karakter' + indonesian: 'Kata sandi harus minimal 6 karakter', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เค•เคฎเฅเคคเคฟเคฎเคพ เฅฌ เค…เค•เฅเคทเคฐ เคนเฅเคจเฅเคชเคฐเฅเค›' }, passwordsNoMatch: { english: 'Passwords do not match', spanish: 'Las contraseรฑas no coinciden', brazilian_portuguese: 'As senhas nรฃo coincidem', tok_pisin: 'Ol password i no sem', - indonesian: 'Kata sandi tidak cocok' + indonesian: 'Kata sandi tidak cocok', + nepali: 'เคชเคพเคธเคตเคฐเฅเคกเคนเคฐเฅ‚ เคฎเฅ‡เคฒ เค–เคพเคเคฆเฅˆเคจเคจเฅ' }, passwordResetSuccess: { english: 'Password has been reset successfully', spanish: 'La contraseรฑa se ha restablecido correctamente', brazilian_portuguese: 'A senha foi redefinida com sucesso', tok_pisin: 'Password i reset gut pinis', - indonesian: 'Kata sandi berhasil direset' + indonesian: 'Kata sandi berhasil direset', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเคฟเคฏเฅ‹' }, projectDownloadFailed: { english: @@ -728,61 +860,71 @@ export const localizations = { tok_pisin: 'Invitation i orait, tasol project download i no inap. Yu ken download em bihain long projects page.', indonesian: - 'Undangan diterima, tetapi unduhan proyek gagal. Anda dapat mengunduhnya nanti dari halaman proyek.' + 'Undangan diterima, tetapi unduhan proyek gagal. Anda dapat mengunduhnya nanti dari halaman proyek.', + nepali: + 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹, เคคเคฐ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค…เคธเคซเคฒ เคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚ เคชเค›เคฟ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคชเฅƒเคทเฅเค เคฌเคพเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, projects: { english: 'Projects', spanish: 'Proyectos', brazilian_portuguese: 'Projetos', tok_pisin: 'Ol Project', - indonesian: 'Proyek' + indonesian: 'Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚' }, quests: { english: 'Quests', spanish: 'Misiones', brazilian_portuguese: 'Missรตes', tok_pisin: 'Ol Quest', - indonesian: 'Misi' + indonesian: 'Misi', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚' }, project: { english: 'Project', spanish: 'Proyecto', - brazilian_portuguese: 'Projeto' + brazilian_portuguese: 'Projeto', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ' }, noProjectsFound: { english: 'No projects found', spanish: 'No se encontraron proyectos', brazilian_portuguese: 'Nenhum projeto encontrado', tok_pisin: 'Nogat projek i painim', - indonesian: 'Tidak ada proyek yang ditemukan' + indonesian: 'Tidak ada proyek yang ditemukan', + nepali: 'เค•เฅเคจเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, noProjectsYet: { english: 'No projects yet', spanish: 'Aรบn no hay proyectos', brazilian_portuguese: 'Ainda nรฃo hรก projetos', tok_pisin: 'I no gat projek yet', - indonesian: 'Belum ada proyek' + indonesian: 'Belum ada proyek', + nepali: 'เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เฅˆเคจ' }, noProjectsAvailable: { english: 'No projects available', spanish: 'No hay proyectos disponibles', brazilian_portuguese: 'Nenhum projeto disponรญvel', tok_pisin: 'Nogat projek i stap', - indonesian: 'Tidak ada proyek yang tersedia' + indonesian: 'Tidak ada proyek yang tersedia', + nepali: 'เค•เฅเคจเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค‰เคชเคฒเคฌเฅเคง เค›เฅˆเคจ' }, createProject: { english: 'Create Project', spanish: 'Crear proyecto', brazilian_portuguese: 'Criar projeto', tok_pisin: 'Wokim Nupela Projek', - indonesian: 'Buat Proyek' + indonesian: 'Buat Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, published: { english: 'Published', spanish: 'Publicado', brazilian_portuguese: 'Publicado', tok_pisin: 'Publisim pinis', - indonesian: 'Diterbitkan' + indonesian: 'Diterbitkan', + nepali: 'เคชเฅเคฐเค•เคพเคถเคฟเคค' }, cannotPublishWhileOffline: { english: 'Cannot save to cloud while offline', @@ -790,32 +932,37 @@ export const localizations = { brazilian_portuguese: 'Nรฃo รฉ possรญvel salvar na nuvem enquanto estรก desconectado', tok_pisin: 'I no inap save long cloud long no gat internet', - indonesian: 'Tidak dapat menyimpan ke cloud saat offline' + indonesian: 'Tidak dapat menyimpan ke cloud saat offline', + nepali: 'เค…เคซเคฒเคพเค‡เคจ เคนเฅเคเคฆเคพ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเฅ‡เคญ เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจ' }, chapters: { english: 'Chapters', spanish: 'Capรญtulos', brazilian_portuguese: 'Capรญtulos', tok_pisin: 'Chapter', - indonesian: 'Bab' + indonesian: 'Bab', + nepali: 'เค…เคงเฅเคฏเคพเคฏเคนเคฐเฅ‚' }, chapter: { english: 'Chapter', spanish: 'Capรญtulo', brazilian_portuguese: 'Capรญtulo', tok_pisin: 'Chapter', - indonesian: 'Bab' + indonesian: 'Bab', + nepali: 'เค…เคงเฅเคฏเคพเคฏ' }, publishChapter: { english: 'Save to Cloud', spanish: 'Guardar en la Nube', brazilian_portuguese: 'Salvar na Nuvem', tok_pisin: 'Save long Cloud', - indonesian: 'Simpan ke Cloud' + indonesian: 'Simpan ke Cloud', + nepali: 'เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเฅ‡เคญ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, publish: { english: 'Save', spanish: 'Guardar', + nepali: 'เคธเฅ‡เคญ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', brazilian_portuguese: 'Salvar', tok_pisin: 'Save', indonesian: 'Simpan' @@ -830,254 +977,292 @@ export const localizations = { tok_pisin: 'Dispela bai wokim wanpela permanent kopi bilong {questName} long cloud.\n\nOlgeta recording bai save olsem wanpela snapshot we yu no inap senisim. Taim yu save pinis, dispela version i no inap senis, tasol yu ken wokim nupela version bihain sapos yu laik.\n\nSapos papa buk o project i no save long cloud yet, bai ol i save otomatik.', indonesian: - 'Ini akan membuat salinan permanen dari {questName} di cloud.\n\nSemua rekaman akan disimpan sebagai snapshot yang tidak dapat diubah. Setelah disimpan, versi ini tidak dapat diubah, tetapi Anda dapat membuat versi baru nanti jika diperlukan.\n\nJika buku atau proyek induk belum disimpan ke cloud, mereka akan disimpan secara otomatis.' + 'Ini akan membuat salinan permanen dari {questName} di cloud.\n\nSemua rekaman akan disimpan sebagai snapshot yang tidak dapat diubah. Setelah disimpan, versi ini tidak dapat diubah, tetapi Anda dapat membuat versi baru nanti jika diperlukan.\n\nJika buku atau proyek induk belum disimpan ke cloud, mereka akan disimpan secara otomatis.', + nepali: + 'เคฏเคธเคฒเฅ‡ {questName} เค•เฅ‹ เคธเฅเคฅเคพเคฏเฅ€ เคชเฅเคฐเคคเคฟเคฒเคฟเคชเคฟ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅ‡เค›เฅค\n\nเคธเคฌเฅˆ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™เคนเคฐเฅ‚ เค…เคชเคฐเคฟเคตเคฐเฅเคคเคจเฅ€เคฏ เคธเฅเคจเฅเคฏเคพเคชเคถเคŸเค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคธเฅ‡เคญ เคนเฅเคจเฅ‡เค›เคจเฅเฅค เคเค• เคชเคŸเค• เคธเฅ‡เคญ เค—เคฐเฅ‡เคชเค›เคฟ, เคฏเฅ‹ เคธเค‚เคธเฅเค•เคฐเคฃ เคชเคฐเคฟเคตเคฐเฅเคคเคจ เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจ, เคคเคฐ เค†เคตเคถเฅเคฏเค• เคญเคเคฎเคพ เคคเคชเคพเคˆเค‚ เคชเค›เคฟ เคจเคฏเคพเค เคธเค‚เคธเฅเค•เคฐเคฃเคนเคฐเฅ‚ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค\n\nเคฏเคฆเคฟ เค…เคญเคฟเคญเคพเคตเค• เคชเฅเคธเฅเคคเค• เคตเคพ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค…เคเฅˆ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเฅ‡เคญ เค—เคฐเคฟเคเค•เฅ‹ เค›เฅˆเคจ เคญเคจเฅ‡, เคคเคฟเคจเฅ€เคนเคฐเฅ‚ เคธเฅเคตเคšเคพเคฒเคฟเคค เคฐเฅ‚เคชเคฎเคพ เคธเฅ‡เคญ เคนเฅเคจเฅ‡เค›เคจเฅเฅค' }, quest: { english: 'Quest', spanish: 'Misiรณn', - brazilian_portuguese: 'Missรฃo' + brazilian_portuguese: 'Missรฃo', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ' }, questOptions: { english: 'Quest Options', spanish: 'Opciones de misiรณn', brazilian_portuguese: 'Opรงรตes de Missรฃo', tok_pisin: 'Quest Options', - indonesian: 'Opsi Misi' + indonesian: 'Opsi Misi', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคตเคฟเค•เคฒเฅเคชเคนเคฐเฅ‚' }, recording: { english: 'Recording', spanish: 'Grabando', brazilian_portuguese: 'Gravando', tok_pisin: 'Recording', - indonesian: 'Merekam' + indonesian: 'Merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™' }, register: { english: 'Register', spanish: 'Registrarse', brazilian_portuguese: 'Registrar', tok_pisin: 'Register', - indonesian: 'Daftar' + indonesian: 'Daftar', + nepali: 'เคฆเคฐเฅเคคเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createAccount: { english: 'Create Account', spanish: 'Crear Cuenta', brazilian_portuguese: 'Criar Conta', tok_pisin: 'Mekim Account', - indonesian: 'Buat Akun' + indonesian: 'Buat Akun', + nepali: 'เค–เคพเคคเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, registrationFail: { english: 'Registration failed', spanish: 'Error en el registro', brazilian_portuguese: 'Falha no registro', tok_pisin: 'Registration i no inap', - indonesian: 'Pendaftaran gagal' + indonesian: 'Pendaftaran gagal', + nepali: 'เคฆเคฐเฅเคคเคพ เค…เคธเคซเคฒ' }, registrationSuccess: { english: 'Registration successful', spanish: 'Registro exitoso', brazilian_portuguese: 'Registro bem-sucedido', tok_pisin: 'Registration i orait', - indonesian: 'Pendaftaran berhasil' + indonesian: 'Pendaftaran berhasil', + nepali: 'เคฆเคฐเฅเคคเคพ เคธเคซเคฒ' }, resetPassword: { english: 'Reset Password', spanish: 'Restablecer contraseรฑa', brazilian_portuguese: 'Redefinir Senha', tok_pisin: 'Reset Password', - indonesian: 'Reset Kata Sandi' + indonesian: 'Reset Kata Sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, returningHero: { english: 'Returning hero? Sign In', spanish: 'ยฟHรฉroe que regresa? Inicia sesiรณn', brazilian_portuguese: 'Herรณi retornando? Faรงa Login', tok_pisin: 'Hero i kam bek? Sign In', - indonesian: 'Pahlawan kembali? Masuk' + indonesian: 'Pahlawan kembali? Masuk', + nepali: 'เคซเคฐเฅเค•เคฟเคจเฅ‡ เคจเคพเคฏเค•? เคธเคพเค‡เคจ เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, search: { english: 'Search...', spanish: 'Buscar...', brazilian_portuguese: 'Buscar...', tok_pisin: 'Painim...', - indonesian: 'Cari...' + indonesian: 'Cari...', + nepali: 'เค–เฅ‹เคœเฅเคจเฅเคนเฅ‹เคธเฅ...' }, searchAssets: { english: 'Search assets...', spanish: 'Buscar recursos...', brazilian_portuguese: 'Buscar recursos...', tok_pisin: 'Painim ol asset...', - indonesian: 'Cari aset...' + indonesian: 'Cari aset...', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เค–เฅ‹เคœเฅเคจเฅเคนเฅ‹เคธเฅ...' }, noAssetsFound: { english: 'No assets found', spanish: 'No se encontraron recursos', brazilian_portuguese: 'Nenhum recurso encontrado', tok_pisin: 'No gat asset', - indonesian: 'Tidak ada aset ditemukan' + indonesian: 'Tidak ada aset ditemukan', + nepali: 'เค•เฅเคจเฅˆ เคเคธเฅ‡เคŸ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, nothingHereYet: { english: 'Nothing here yet!', spanish: 'ยกNada aquรญ todavรญa!', brazilian_portuguese: 'ยกNada aqui ainda!', tok_pisin: 'I no gat here yet!', - indonesian: 'Belum ada di sini!' + indonesian: 'Belum ada di sini!', + nepali: 'เคฏเคนเคพเค เค…เคเฅˆ เค•เฅ‡เคนเฅ€ เค›เฅˆเคจ!' }, searchQuests: { english: 'Search quests...', spanish: 'Buscar misiones...', brazilian_portuguese: 'Buscar missรตes...', tok_pisin: 'Painim ol quest...', - indonesian: 'Cari misi...' + indonesian: 'Cari misi...', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚ เค–เฅ‹เคœเฅเคจเฅเคนเฅ‹เคธเฅ...' }, selectItem: { english: 'Select item', spanish: 'Seleccionar elemento', brazilian_portuguese: 'Selecionar item', tok_pisin: 'Makim item', - indonesian: 'Pilih item' + indonesian: 'Pilih item', + nepali: 'เคตเคธเฅเคคเฅ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectLanguage: { english: 'Please select a language', spanish: 'Por favor seleccione un idioma', brazilian_portuguese: 'Por favor, selecione um idioma', tok_pisin: 'Plis makim wanpela tokples', - indonesian: 'Silakan pilih bahasa' + indonesian: 'Silakan pilih bahasa', + nepali: 'เค•เฅƒเคชเคฏเคพ เคเค‰เคŸเคพ เคญเคพเคทเคพ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectRegion: { english: 'Select Region', spanish: 'Seleccionar Regiรณn', brazilian_portuguese: 'Selecionar Regiรฃo', tok_pisin: 'Makim Region', - indonesian: 'Pilih Wilayah' + indonesian: 'Pilih Wilayah', + nepali: 'เค•เฅเคทเฅ‡เคคเฅเคฐ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectRegionToFilterLanguages: { english: 'Select a region to see languages from that area', spanish: 'Seleccione una regiรณn para ver idiomas de esa รกrea', brazilian_portuguese: 'Selecionar uma regiรฃo para ver idiomas dessa รกrea', tok_pisin: 'Makim wanpela region long lukim ol tokples bilong ples ya', - indonesian: 'Pilih wilayah untuk melihat bahasa dari area tersebut' + indonesian: 'Pilih wilayah untuk melihat bahasa dari area tersebut', + nepali: 'เคคเฅเคฏเคธ เค•เฅเคทเฅ‡เคคเฅเคฐเค•เคพ เคญเคพเคทเคพเคนเคฐเฅ‚ เคนเฅ‡เคฐเฅเคจ เคเค‰เคŸเคพ เค•เฅเคทเฅ‡เคคเฅเคฐ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectYourLanguage: { english: 'Select Your Language', spanish: 'Seleccione Su Idioma', brazilian_portuguese: 'Selecionar Seu Idioma', tok_pisin: 'Makim Tokples Bilong Yu', - indonesian: 'Pilih Bahasa Anda' + indonesian: 'Pilih Bahasa Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคญเคพเคทเคพ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createLanguage: { english: 'Create Language', spanish: 'Crear Idioma', brazilian_portuguese: 'Criar Idioma', tok_pisin: 'Mekim Tokples', - indonesian: 'Buat Bahasa' + indonesian: 'Buat Bahasa', + nepali: 'เคญเคพเคทเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createNewLanguage: { english: 'Create New Language', spanish: 'Crear Nuevo Idioma', brazilian_portuguese: 'Criar Novo Idioma', tok_pisin: 'Mekim Nupela Tokples', - indonesian: 'Buat Bahasa Baru' + indonesian: 'Buat Bahasa Baru', + nepali: 'เคจเคฏเคพเค เคญเคพเคทเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, languageNotInList: { english: 'My language is not in the list', spanish: 'Mi idioma no estรก en la lista', brazilian_portuguese: 'Meu idioma nรฃo estรก na lista', tok_pisin: 'Tokples bilong mi i no stap long list', - indonesian: 'Bahasa saya tidak ada dalam daftar' + indonesian: 'Bahasa saya tidak ada dalam daftar', + nepali: 'เคฎเฅ‡เคฐเฅ‹ เคญเคพเคทเคพ เคธเฅ‚เคšเฅ€เคฎเคพ เค›เฅˆเคจ' }, willCreateLanguage: { english: 'Will create language', spanish: 'Crearรก idioma', brazilian_portuguese: 'Criarรก idioma', tok_pisin: 'Bai mekim tokples', - indonesian: 'Akan membuat bahasa' + indonesian: 'Akan membuat bahasa', + nepali: 'เคญเคพเคทเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเคฟเคจเฅ‡เค›' }, nativeName: { english: 'Native Name', spanish: 'Nombre Nativo', brazilian_portuguese: 'Nome Nativo', tok_pisin: 'Nem Bilong Tokples', - indonesian: 'Nama Asli' + indonesian: 'Nama Asli', + nepali: 'เคธเฅเคฅเคพเคจเฅ€เคฏ เคจเคพเคฎ' }, englishName: { english: 'English Name', spanish: 'Nombre en Inglรฉs', brazilian_portuguese: 'Nome em Inglรชs', tok_pisin: 'Nem Long English', - indonesian: 'Nama dalam Bahasa Inggris' + indonesian: 'Nama dalam Bahasa Inggris', + nepali: 'เค…เค‚เค—เฅเคฐเฅ‡เคœเฅ€ เคจเคพเคฎ' }, iso6393Code: { english: 'ISO 639-3 Code', spanish: 'Cรณdigo ISO 639-3', brazilian_portuguese: 'Cรณdigo ISO 639-3', tok_pisin: 'ISO 639-3 Code', - indonesian: 'Kode ISO 639-3' + indonesian: 'Kode ISO 639-3', + nepali: 'ISO 639-3 เค•เฅ‹เคก' }, locale: { english: 'Locale', spanish: 'Idioma', brazilian_portuguese: 'Idioma', tok_pisin: 'Locale', - indonesian: 'Lokalisasi' + indonesian: 'Lokalisasi', + nepali: 'เคฒเฅ‹เค•เฅ‡เคฒ' }, createAndContinue: { english: 'Create and Continue', spanish: 'Crear y Continuar', brazilian_portuguese: 'Criar e Continuar', tok_pisin: 'Mekim na Go Long', - indonesian: 'Buat dan Lanjutkan' + indonesian: 'Buat dan Lanjutkan', + nepali: 'เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคฐ เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจเฅเคนเฅ‹เคธเฅ' }, whatWouldYouLikeToCreate: { english: 'What would you like to create?', spanish: 'ยฟQuรฉ te gustarรญa crear?', brazilian_portuguese: 'O que vocรช gostaria de criar?', tok_pisin: 'Wanem samting yu laik mekim?', - indonesian: 'Apa yang ingin Anda buat?' + indonesian: 'Apa yang ingin Anda buat?', + nepali: 'เคคเคชเคพเคˆเค‚ เค•เฅ‡ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›?' }, createBibleProject: { english: 'Bible', spanish: 'Biblia', brazilian_portuguese: 'Bรญblia', tok_pisin: 'Baibel', - indonesian: 'Alkitab' + indonesian: 'Alkitab', + nepali: 'เคฌเคพเค‡เคฌเคฒ' }, translateBibleIntoYourLanguage: { english: 'Translate the Bible into your language', spanish: 'Traduce la Biblia a tu idioma', brazilian_portuguese: 'Traduza a Bรญblia para o seu idioma', tok_pisin: 'Translate Baibel long tokples bilong yu', - indonesian: 'Terjemahkan Alkitab ke bahasa Anda' + indonesian: 'Terjemahkan Alkitab ke bahasa Anda', + nepali: 'เคฌเคพเค‡เคฌเคฒเคฒเคพเคˆ เค†เคซเฅเคจเฅ‹ เคญเคพเคทเคพเคฎเคพ เค…เคจเฅเคตเคพเคฆ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createOtherProject: { english: 'Other Translation', spanish: 'Otra Traducciรณn', brazilian_portuguese: 'Outra Traduรงรฃo', tok_pisin: 'Narapela Translation', - indonesian: 'Terjemahan Lain' + indonesian: 'Terjemahan Lain', + nepali: 'เค…เคจเฅเคฏ เค…เคจเฅเคตเคพเคฆ' }, createGeneralTranslationProject: { english: 'Create a general translation project', spanish: 'Crear un proyecto de traducciรณn general', brazilian_portuguese: 'Criar um projeto de traduรงรฃo geral', tok_pisin: 'Mekim wanpela project long translate ol samting', - indonesian: 'Buat proyek terjemahan umum' + indonesian: 'Buat proyek terjemahan umum', + nepali: 'เคธเคพเคฎเคพเคจเฅเคฏ เค…เคจเฅเคตเคพเคฆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectProject: { english: 'Select Project', spanish: 'Seleccionar Proyecto', brazilian_portuguese: 'Selecionar Projeto', tok_pisin: 'Makim Project', - indonesian: 'Pilih Proyek' + indonesian: 'Pilih Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createFirstProject: { english: 'Create First Project', spanish: 'Crear Primer Proyecto', brazilian_portuguese: 'Criar Primeiro Projeto', tok_pisin: 'Mekim Nambawan Project', - indonesian: 'Buat Proyek Pertama' + indonesian: 'Buat Proyek Pertama', + nepali: 'เคชเคนเคฟเคฒเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createNewProject: { english: 'Create New Project', spanish: 'Crear Nuevo Proyecto', + nepali: 'เคจเคฏเคพเค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', brazilian_portuguese: 'Criar Novo Projeto', tok_pisin: 'Mekim Nupela Project', indonesian: 'Buat Proyek Baru' @@ -1087,28 +1272,32 @@ export const localizations = { spanish: 'Proyectos existentes en {language}', brazilian_portuguese: 'Projetos existentes em {language}', tok_pisin: 'Ol project i stap pinis long {language}', - indonesian: 'Proyek yang ada dalam {language}' + indonesian: 'Proyek yang ada dalam {language}', + nepali: '{language} เคฎเคพ เค…เคตเคธเฅเคฅเคฟเคค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚' }, noProjectsInLanguage: { english: 'No projects yet in {language}', spanish: 'Aรบn no hay proyectos en {language}', brazilian_portuguese: 'Ainda nรฃo hรก projetos em {language}', tok_pisin: 'I no gat project yet long {language}', - indonesian: 'Belum ada proyek dalam {language}' + indonesian: 'Belum ada proyek dalam {language}', + nepali: '{language} เคฎเคพ เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เฅˆเคจ' }, searchLanguages: { english: 'Search languages...', spanish: 'Buscar idiomas...', brazilian_portuguese: 'Pesquisar idiomas...', tok_pisin: 'Painim ol tokples...', - indonesian: 'Cari bahasa...' + indonesian: 'Cari bahasa...', + nepali: 'เคญเคพเคทเคพเคนเคฐเฅ‚ เค–เฅ‹เคœเฅเคจเฅเคนเฅ‹เคธเฅ...' }, noLanguagesFound: { english: 'No languages found', spanish: 'No se encontraron idiomas', brazilian_portuguese: 'Nenhum idioma encontrado', tok_pisin: 'I no gat tokples', - indonesian: 'Tidak ada bahasa ditemukan' + indonesian: 'Tidak ada bahasa ditemukan', + nepali: 'เค•เฅเคจเฅˆ เคญเคพเคทเคพ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, noLanguagesInRegion: { english: @@ -1120,56 +1309,65 @@ export const localizations = { tok_pisin: 'I no gat tokples long dispela region. Yu ken mekim nupela tokples long bihain.', indonesian: - 'Tidak ada bahasa ditemukan di wilayah ini. Anda dapat membuat bahasa baru di bawah ini.' + 'Tidak ada bahasa ditemukan di wilayah ini. Anda dapat membuat bahasa baru di bawah ini.', + nepali: + 'เคฏเคธ เค•เฅเคทเฅ‡เคคเฅเคฐเคฎเคพ เค•เฅเคจเฅˆ เคญเคพเคทเคพ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจเฅค เคคเคชเคพเคˆเค‚ เคคเคฒ เคจเคฏเคพเค เคญเคพเคทเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, typeToSearch: { english: 'Type at least {min} characters to search', spanish: 'Escriba al menos {min} caracteres para buscar', brazilian_portuguese: 'Digite pelo menos {min} caracteres para pesquisar', tok_pisin: 'Raitim {min} leta bipo painim', - indonesian: 'Ketik setidaknya {min} karakter untuk mencari' + indonesian: 'Ketik setidaknya {min} karakter untuk mencari', + nepali: 'เค–เฅ‹เคœเฅเคจ เค•เคฎเฅเคคเคฟเคฎเคพ {min} เค…เค•เฅเคทเคฐ เคŸเคพเค‡เคช เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectTemplate: { english: 'Please select a template', spanish: 'Por favor seleccione una plantilla', brazilian_portuguese: 'Por favor, selecione uma planta', tok_pisin: 'Plis makim wanpela template', - indonesian: 'Silakan pilih template' + indonesian: 'Silakan pilih template', + nepali: 'เค•เฅƒเคชเคฏเคพ เคเค‰เคŸเคพ เคŸเฅ‡เคฎเฅเคชเฅเคฒเฅ‡เคŸ เค›เคพเคจเฅเคจเฅเคนเฅ‹เคธเฅ' }, sendResetEmail: { english: 'Send Reset Email', spanish: 'Enviar correo de restablecimiento', brazilian_portuguese: 'Enviar E-mail de Redefiniรงรฃo', tok_pisin: 'Salim Reset Email', - indonesian: 'Kirim Email Reset' + indonesian: 'Kirim Email Reset', + nepali: 'เคฐเคฟเคธเฅ‡เคŸ เค‡เคฎเฅ‡เคฒ เคชเค เคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, signIn: { english: 'Sign In', spanish: 'Iniciar Sesiรณn', brazilian_portuguese: 'Entrar', tok_pisin: 'Sign In', - indonesian: 'Masuk' + indonesian: 'Masuk', + nepali: 'เคธเคพเค‡เคจ เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, signInToSaveOrContribute: { english: 'Sign in to save or contribute to projects', spanish: 'Inicia sesiรณn para guardar o contribuir a proyectos', brazilian_portuguese: 'Entre para salvar ou contribuir com projetos', tok_pisin: 'Sign in long seivim o helpim ol project', - indonesian: 'Masuk untuk menyimpan atau berkontribusi pada proyek' + indonesian: 'Masuk untuk menyimpan atau berkontribusi pada proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚ เคธเฅ‡เคญ เค—เคฐเฅเคจ เคตเคพ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคธเคพเค‡เคจ เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, orBrowseAllProjects: { english: 'Or browse all public projects', spanish: 'O navega todos los proyectos pรบblicos', brazilian_portuguese: 'Ou navegue por todos os projetos pรบblicos', tok_pisin: 'O lukluk long olgeta public project', - indonesian: 'Atau jelajahi semua proyek publik' + indonesian: 'Atau jelajahi semua proyek publik', + nepali: 'เคตเคพ เคธเคฌเฅˆ เคธเคพเคฐเฅเคตเคœเคจเคฟเค• เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚ เคฌเฅเคฐเคพเค‰เคœ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, viewAllProjects: { english: 'View All Projects', spanish: 'Ver Todos los Proyectos', brazilian_portuguese: 'Ver Todos os Projetos', tok_pisin: 'Lukim Olgeta Project', - indonesian: 'Lihat Semua Proyek' + indonesian: 'Lihat Semua Proyek', + nepali: 'เคธเคฌเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, signInError: { english: 'Something went wrongโ€ฆ Please, check your email and password.', @@ -1178,129 +1376,247 @@ export const localizations = { 'Algo deu erradoโ€ฆ Por favor, verifique seu e-mail e senha.', tok_pisin: 'Samting i rong... Plis checkum email na password bilong yu.', indonesian: - 'Terjadi kesalahan... Silakan periksa email dan kata sandi Anda.' + 'Terjadi kesalahan... Silakan periksa email dan kata sandi Anda.', + nepali: 'เค•เฅ‡เคนเฅ€ เค—เคฒเคค เคญเคฏเฅ‹โ€ฆ เค•เฅƒเคชเคฏเคพ เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เคฐ เคชเคพเคธเคตเคฐเฅเคก เคœเคพเคเคš เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, logOut: { english: 'Log Out', spanish: 'Cerrar Sesiรณn', brazilian_portuguese: 'Sair', tok_pisin: 'Log Out', - indonesian: 'Keluar' + indonesian: 'Keluar', + nepali: 'เคฒเค— เค†เค‰เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, sortBy: { english: 'Sort by', spanish: 'Ordenar por', brazilian_portuguese: 'Ordenar por', tok_pisin: 'Sortim long', - indonesian: 'Urutkan berdasarkan' + indonesian: 'Urutkan berdasarkan', + nepali: 'เค•เฅเคฐเคฎเคฌเคฆเฅเคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, source: { english: 'Source', spanish: 'Fuente', brazilian_portuguese: 'Fonte', tok_pisin: 'Source', - indonesian: 'Sumber' + indonesian: 'Sumber', + nepali: 'เคธเฅเคฐเฅ‹เคค' }, submit: { english: 'Submit', spanish: 'Enviar', brazilian_portuguese: 'Enviar', tok_pisin: 'Salim', - indonesian: 'Kirim' + indonesian: 'Kirim', + nepali: 'เคชเฅ‡เคถ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, success: { english: 'Success', spanish: 'ร‰xito', brazilian_portuguese: 'Sucesso', tok_pisin: 'Orait', - indonesian: 'Berhasil' + indonesian: 'Berhasil', + nepali: 'เคธเคซเคฒ' }, target: { english: 'Target', spanish: 'Objetivo', brazilian_portuguese: 'Alvo', tok_pisin: 'Target', - indonesian: 'Target' + indonesian: 'Target', + nepali: 'เคฒเค•เฅเคทเฅเคฏ' }, username: { english: 'Username', spanish: 'Nombre de usuario', brazilian_portuguese: 'Nome de usuรกrio', tok_pisin: 'Username', - indonesian: 'Nama pengguna' + indonesian: 'Nama pengguna', + nepali: 'เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคจเคพเคฎ' }, usernameRequired: { english: 'Username is required', spanish: 'Se requiere nombre de usuario', brazilian_portuguese: 'Nome de usuรกrio รฉ obrigatรณrio', tok_pisin: 'Username i mas', - indonesian: 'Nama pengguna diperlukan' + indonesian: 'Nama pengguna diperlukan', + nepali: 'เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคจเคพเคฎ เค†เคตเคถเฅเคฏเค• เค›' }, votes: { english: 'Votes', spanish: 'Votos', brazilian_portuguese: 'Votos', tok_pisin: 'Ol Vote', - indonesian: 'Suara' + indonesian: 'Suara', + nepali: 'เคฎเคคเคนเคฐเฅ‚' }, voting: { english: 'Voting', spanish: 'Votaciรณn', - brazilian_portuguese: 'Votaรงรฃo' + brazilian_portuguese: 'Votaรงรฃo', + nepali: 'เคฎเคคเคฆเคพเคจ' }, warning: { english: 'Warning', spanish: 'Advertencia', brazilian_portuguese: 'Aviso', tok_pisin: 'Warning', - indonesian: 'Peringatan' + indonesian: 'Peringatan', + nepali: 'เคšเฅ‡เคคเคพเคตเคจเฅ€' }, welcome: { english: 'Welcome back, hero!', spanish: 'ยกBienvenido de nuevo, hรฉroe!', brazilian_portuguese: 'Bem-vindo de volta, herรณi!', tok_pisin: 'Welkam bek, hero!', - indonesian: 'Selamat datang kembali, pahlawan!' + indonesian: 'Selamat datang kembali, pahlawan!', + nepali: 'เคซเฅ‡เคฐเคฟ เคธเฅเคตเคพเค—เคค เค›, เคจเคพเคฏเค•!' }, welcomeToApp: { english: 'Welcome', spanish: 'Bienvenido', brazilian_portuguese: 'Bem-vindo', tok_pisin: 'Welkam', - indonesian: 'Selamat datang' + indonesian: 'Selamat datang', + nepali: 'เคธเฅเคตเคพเค—เคค เค›' }, recentlyVisited: { english: 'Recently Visited', spanish: 'Recientemente visitado', brazilian_portuguese: 'Visitados Recentemente', tok_pisin: 'Nupela taim visitim', - indonesian: 'Baru Dikunjungi' + indonesian: 'Baru Dikunjungi', + nepali: 'เคนเคพเคฒเคธเคพเคฒเฅˆ เคญเฅเคฐเคฎเคฃ เค—เคฐเคฟเคเค•เฅ‹' }, assets: { english: 'Assets', spanish: 'Recursos', brazilian_portuguese: 'Recursos', tok_pisin: 'Ol Asset', - indonesian: 'Aset' + indonesian: 'Aset', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚' }, asset: { english: 'Asset', spanish: 'Recurso', - brazilian_portuguese: 'Recurso' + brazilian_portuguese: 'Recurso', + nepali: 'เคเคธเฅ‡เคŸ' + }, + deleteAssets: { + english: 'Delete Assets', + spanish: 'Eliminar recursos', + brazilian_portuguese: 'Excluir recursos', + tok_pisin: 'Rausim ol asset', + indonesian: 'Hapus aset', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฎเฅ‡เคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' + }, + deleteAssetsConfirmation: { + english: + 'Are you sure you want to delete {count} asset(s)? This action cannot be undone.', + spanish: + 'ยฟEstรกs seguro de que deseas eliminar {count} recurso(s)? Esta acciรณn no se puede deshacer.', + brazilian_portuguese: + 'Tem certeza de que deseja excluir {count} recurso(s)? Esta aรงรฃo nรฃo pode ser desfeita.', + tok_pisin: + 'Yu sua long rausim {count} asset? Dispela action i no inap senisim bek.', + indonesian: + 'Apakah Anda yakin ingin menghapus {count} aset? Tindakan ini tidak dapat dibatalkan.', + nepali: + 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค› เค•เคฟ เคคเคชเคพเคˆเค‚ {count} เคเคธเฅ‡เคŸ(เคนเคฐเฅ‚) เคฎเฅ‡เคŸเคพเค‰เคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›? เคฏเฅ‹ เค•เคพเคฐเฅเคฏ เคชเฅ‚เคฐเฅเคตเคตเคค เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' + }, + delete: { + english: 'Delete', + spanish: 'Eliminar', + brazilian_portuguese: 'Excluir', + tok_pisin: 'Rausim', + indonesian: 'Hapus', + nepali: 'เคฎเฅ‡เคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' + }, + mergeAssets: { + english: 'Merge Assets', + spanish: 'Combinar recursos', + brazilian_portuguese: 'Mesclar recursos', + tok_pisin: 'Joinim ol asset', + indonesian: 'Gabungkan aset', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฎเคฐเฅเคœ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' + }, + mergeAssetsConfirmation: { + english: + 'Are you sure you want to merge {count} assets? The audio segments will be combined into the first selected asset, and the others will be deleted.', + spanish: + 'ยฟEstรกs seguro de que deseas combinar {count} recursos? Los segmentos de audio se combinarรกn en el primer recurso seleccionado y los demรกs se eliminarรกn.', + brazilian_portuguese: + 'Tem certeza de que deseja mesclar {count} recursos? Os segmentos de รกudio serรฃo combinados no primeiro recurso selecionado e os outros serรฃo excluรญdos.', + tok_pisin: + 'Yu sua long joinim {count} asset? Ol audio segment bai joinim wantaim first asset yu makim, na ol narapela bai raus.', + indonesian: + 'Apakah Anda yakin ingin menggabungkan {count} aset? Segmen audio akan digabungkan ke aset pertama yang dipilih, dan yang lainnya akan dihapus.', + nepali: + 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค› เค•เคฟ เคคเคชเคพเคˆเค‚ {count} เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฎเคฐเฅเคœ เค—เคฐเฅเคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›? เค…เคกเคฟเคฏเฅ‹ เค–เคฃเฅเคกเคนเคฐเฅ‚ เคชเคนเคฟเคฒเฅ‹ เคšเคฏเคจ เค—เคฐเคฟเคเค•เฅ‹ เคเคธเฅ‡เคŸเคฎเคพ เคธเค‚เคฏเฅ‹เคœเคจ เคนเฅเคจเฅ‡เค›เคจเฅ, เคฐ เค…เคฐเฅ‚เคนเคฐเฅ‚ เคฎเฅ‡เคŸเคฟเคจเฅ‡เค›เคจเฅเฅค' + }, + merge: { + english: 'Merge', + spanish: 'Combinar', + brazilian_portuguese: 'Mesclar', + tok_pisin: 'Joinim', + indonesian: 'Gabungkan', + nepali: 'เคฎเคฐเฅเคœ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' + }, + failedToMergeAssets: { + english: 'Failed to merge assets. Please try again.', + spanish: 'Error al combinar los recursos. Por favor, intรฉntalo de nuevo.', + brazilian_portuguese: + 'Falha ao mesclar recursos. Por favor, tente novamente.', + tok_pisin: 'I no inap joinim ol asset. Plis traim gen.', + indonesian: 'Gagal menggabungkan aset. Silakan coba lagi.', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฎเคฐเฅเคœ เค—เคฐเฅเคจ เค…เคธเคซเคฒเฅค เค•เฅƒเคชเคฏเคพ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' + }, + failedToDeleteAssets: { + english: 'Failed to delete assets. Please try again.', + spanish: 'Error al eliminar los recursos. Por favor, intรฉntalo de nuevo.', + brazilian_portuguese: + 'Falha ao excluir recursos. Por favor, tente novamente.', + tok_pisin: 'I no inap rausim ol asset. Plis traim gen.', + indonesian: 'Gagal menghapus aset. Silakan coba lagi.', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฎเฅ‡เคŸเคพเค‰เคจ เค…เคธเคซเคฒเฅค เค•เฅƒเคชเคฏเคพ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' + }, + errorLoadingAssets: { + english: 'Error loading assets', + spanish: 'Error al cargar los recursos', + brazilian_portuguese: 'Erro ao carregar recursos', + tok_pisin: 'Rong long loadim ol asset', + indonesian: 'Kesalahan memuat aset', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคจ เคคเฅเคฐเฅเคŸเคฟ' + }, + noAssetsYetStartRecording: { + english: 'No assets yet. Start recording to create your first asset.', + spanish: + 'Aรบn no hay recursos. Comienza a grabar para crear tu primer recurso.', + brazilian_portuguese: + 'Ainda nรฃo hรก recursos. Comece a gravar para criar seu primeiro recurso.', + tok_pisin: + 'I no gat asset yet. Statim recording long kamapim first asset bilong yu.', + indonesian: + 'Belum ada aset. Mulai merekam untuk membuat aset pertama Anda.', + nepali: + 'เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เคเคธเฅ‡เคŸ เค›เฅˆเคจเฅค เคคเคชเคพเคˆเค‚เค•เฅ‹ เคชเคนเคฟเคฒเฅ‹ เคเคธเฅ‡เคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคธเฅเคฐเฅ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, remaining: { english: 'remaining', spanish: 'restante', brazilian_portuguese: 'restante', tok_pisin: 'stap yet', - indonesian: 'tersisa' + indonesian: 'tersisa', + nepali: 'เคฌเคพเคเค•เฅ€' }, noNotifications: { english: 'No notifications', spanish: 'No hay notificaciones', brazilian_portuguese: 'Nenhuma notificaรงรฃo', tok_pisin: 'No gat notification', - indonesian: 'Tidak ada notifikasi' + indonesian: 'Tidak ada notifikasi', + nepali: 'เค•เฅเคจเฅˆ เคธเฅ‚เคšเคจเคพ เค›เฅˆเคจ' }, noNotificationsSubtext: { english: "You'll see project invitations and join requests here", @@ -1309,49 +1625,57 @@ export const localizations = { 'Aqui vocรช verรก convites para projetos e solicitaรงรตes de uniรฃo', tok_pisin: 'Yu bai lukim ol project invitation na join request long hia', indonesian: - 'Anda akan melihat undangan proyek dan permintaan bergabung di sini' + 'Anda akan melihat undangan proyek dan permintaan bergabung di sini', + nepali: + 'เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคฏเคนเคพเค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคฎเคจเฅเคคเฅเคฐเคฃเคพ เคฐ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจเฅ‡ เค…เคจเฅเคฐเฅ‹เคงเคนเคฐเฅ‚ เคฆเฅ‡เค–เฅเคจเฅเคนเฅเคจเฅ‡เค›' }, notifications: { english: 'Notifications', spanish: 'Notificaciones', brazilian_portuguese: 'Notificaรงรตes', tok_pisin: 'Ol Notification', - indonesian: 'Notifikasi' + indonesian: 'Notifikasi', + nepali: 'เคธเฅ‚เคšเคจเคพเคนเคฐเฅ‚' }, profile: { english: 'Profile', spanish: 'Perfil', brazilian_portuguese: 'Perfil', tok_pisin: 'Profile', - indonesian: 'Profil' + indonesian: 'Profil', + nepali: 'เคชเฅเคฐเฅ‹เคซเคพเค‡เคฒ' }, settings: { english: 'Settings', spanish: 'Configuraciรณn', brazilian_portuguese: 'Configuraรงรตes', tok_pisin: 'Settings', - indonesian: 'Pengaturan' + indonesian: 'Pengaturan', + nepali: 'เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚' }, changePassword: { english: 'Change Password', spanish: 'Cambiar Contraseรฑa', brazilian_portuguese: 'Alterar Senha', tok_pisin: 'Senisim Password', - indonesian: 'Ubah Kata Sandi' + indonesian: 'Ubah Kata Sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคชเคฐเคฟเคตเคฐเฅเคคเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, currentPassword: { english: 'Current Password', spanish: 'Contraseรฑa Actual', brazilian_portuguese: 'Senha Atual', tok_pisin: 'Password bilong nau', - indonesian: 'Kata Sandi Saat Ini' + indonesian: 'Kata Sandi Saat Ini', + nepali: 'เคนเคพเคฒเค•เฅ‹ เคชเคพเคธเคตเคฐเฅเคก' }, newPassword: { english: 'New Password', spanish: 'Nueva Contraseรฑa', brazilian_portuguese: 'Nova Senha', tok_pisin: 'Nupela Password', - indonesian: 'Kata Sandi Baru' + indonesian: 'Kata Sandi Baru', + nepali: 'เคจเคฏเคพเค เคชเคพเคธเคตเคฐเฅเคก' }, onlineOnlyFeatures: { english: 'Password changes are only available when online', @@ -1360,56 +1684,64 @@ export const localizations = { brazilian_portuguese: 'Alteraรงรตes de senha sรณ estรฃo disponรญveis quando vocรช estรก online', tok_pisin: 'Password senisim i ken long taim yu gat internet tasol', - indonesian: 'Perubahan kata sandi hanya tersedia saat online' + indonesian: 'Perubahan kata sandi hanya tersedia saat online', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคชเคฐเคฟเคตเคฐเฅเคคเคจ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคเคฆเคพ เคฎเคพเคคเฅเคฐ เค‰เคชเคฒเคฌเฅเคง เค›' }, accountDeletionRequiresOnline: { english: 'You must be online to delete your account', spanish: 'Debes estar en lรญnea para eliminar tu cuenta', brazilian_portuguese: 'Vocรช deve estar online para excluir sua conta', tok_pisin: 'Yu mas gat internet long rausim account bilong yu', - indonesian: 'Anda harus online untuk menghapus akun Anda' + indonesian: 'Anda harus online untuk menghapus akun Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‰เคจ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›' }, termsAndPrivacyTitle: { english: 'Terms & Privacy', spanish: 'Tรฉrminos y Privacidad', brazilian_portuguese: 'Termos e Privacidade', tok_pisin: 'Terms na Privacy', - indonesian: 'Syarat & Privasi' + indonesian: 'Syarat & Privasi', + nepali: 'เคธเคฐเฅเคคเคนเคฐเฅ‚ เคฐ เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ' }, verificationRequired: { english: 'Verification Required', spanish: 'Verificaciรณn Requerida', brazilian_portuguese: 'Verificaรงรฃo Necessรกria', tok_pisin: 'Verification i mas', - indonesian: 'Verifikasi Diperlukan' + indonesian: 'Verifikasi Diperlukan', + nepali: 'เคชเฅเคฐเคฎเคพเคฃเฅ€เค•เคฐเคฃ เค†เคตเคถเฅเคฏเค• เค›' }, agreeToTerms: { english: 'I have read and agree to the Terms & Privacy', spanish: 'He leรญdo y acepto los Tรฉrminos y Privacidad', brazilian_portuguese: 'Eu li e concordo com os Termos e Privacidade', tok_pisin: 'Mi ridim na agri long Terms na Privacy', - indonesian: 'Saya telah membaca dan menyetujui Syarat & Privasi' + indonesian: 'Saya telah membaca dan menyetujui Syarat & Privasi', + nepali: 'เคฎเฅˆเคฒเฅ‡ เคธเคฐเฅเคคเคนเคฐเฅ‚ เคฐ เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคชเคขเฅ‡เค•เฅ‹ เค›เฅ เคฐ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเค›เฅ' }, viewTerms: { english: 'View Terms and Privacy', spanish: 'Ver Tรฉrminos y Privacidad', brazilian_portuguese: 'Ver Termos e Privacidade', tok_pisin: 'Lukim Terms na Privacy', - indonesian: 'Lihat Syarat dan Privasi' + indonesian: 'Lihat Syarat dan Privasi', + nepali: 'เคธเคฐเฅเคคเคนเคฐเฅ‚ เคฐ เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, termsRequired: { english: 'You must agree to the Terms and Privacy', spanish: 'Debe aceptar los Tรฉrminos y Privacidad', brazilian_portuguese: 'Vocรช deve concordar com os Termos e Privacidade', tok_pisin: 'Yu mas agri long Terms na Privacy', - indonesian: 'Anda harus menyetujui Syarat dan Privasi' + indonesian: 'Anda harus menyetujui Syarat dan Privasi', + nepali: 'เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคธเคฐเฅเคคเคนเคฐเฅ‚ เคฐ เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจเฅเคชเคฐเฅเค›' }, processing: { english: 'Processing...', spanish: 'Procesando...', brazilian_portuguese: 'Processando...', tok_pisin: 'Processing...', - indonesian: 'Memproses...' + indonesian: 'Memproses...', + nepali: 'เคชเฅเคฐเคถเฅ‹เคงเคจ เคนเฅเคเคฆเฅˆเค›...' }, termsContributionInfo: { english: @@ -1421,7 +1753,9 @@ export const localizations = { tok_pisin: 'Long akseptim ol dispela terms, yu agri long olgeta content yu contributim long LangQuest bai stap fri long olgeta hap long CC0 1.0 Universal (CC0 1.0) Public Domain Dedication.', indonesian: - 'Dengan menerima syarat ini, Anda setuju bahwa semua konten yang Anda kontribusikan ke LangQuest akan tersedia secara gratis di seluruh dunia di bawah CC0 1.0 Universal (CC0 1.0) Public Domain Dedication.' + 'Dengan menerima syarat ini, Anda setuju bahwa semua konten yang Anda kontribusikan ke LangQuest akan tersedia secara gratis di seluruh dunia di bawah CC0 1.0 Universal (CC0 1.0) Public Domain Dedication.', + nepali: + 'เคฏเฅ€ เคธเคฐเฅเคคเคนเคฐเฅ‚ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅ‡เคฐ, เคคเคชเคพเคˆเค‚ เคธเคนเคฎเคค เคนเฅเคจเฅเคนเฅเคจเฅเค› เค•เคฟ เคคเคชเคพเคˆเค‚เคฒเฅ‡ LangQuest เคฎเคพ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจเฅ‡ เคธเคฌเฅˆ เคธเคพเคฎเค—เฅเคฐเฅ€ CC0 1.0 Universal (CC0 1.0) Public Domain Dedication เค…เคจเฅเคคเคฐเฅเค—เคค เคตเคฟเคถเฅเคตเคตเฅเคฏเคพเคชเฅ€ เคฐเฅ‚เคชเคฎเคพ เคจเคฟ:เคถเฅเคฒเฅเค• เค‰เคชเคฒเคฌเฅเคง เคนเฅเคจเฅ‡เค›เฅค' }, termsDataInfo: { english: @@ -1433,7 +1767,9 @@ export const localizations = { tok_pisin: 'Dispela i min ol contribution bilong yu ol man ken usim long wanem samting tasol na no nid long tokaut nem bilong yu. Mipela kisim liklik user data tasol: email bilong yu (long kamap bek account) na newsletter subscription sapos yu laik.', indonesian: - 'Ini berarti kontribusi Anda dapat digunakan oleh siapa saja untuk tujuan apa pun tanpa atribusi. Kami mengumpulkan data pengguna minimal: hanya email Anda (untuk pemulihan akun) dan langganan newsletter jika dipilih.' + 'Ini berarti kontribusi Anda dapat digunakan oleh siapa saja untuk tujuan apa pun tanpa atribusi. Kami mengumpulkan data pengguna minimal: hanya email Anda (untuk pemulihan akun) dan langganan newsletter jika dipilih.', + nepali: + 'เคฏเคธเค•เฅ‹ เคฎเคคเคฒเคฌ เคคเคชเคพเคˆเค‚เค•เฅ‹ เคฏเฅ‹เค—เคฆเคพเคจ เค•เฅเคจเฅˆ เคชเคจเคฟ เคตเฅเคฏเค•เฅเคคเคฟเคฒเฅ‡ เค•เฅเคจเฅˆ เคชเคจเคฟ เค‰เคฆเฅเคฆเฅ‡เคถเฅเคฏเค•เฅ‹ เคฒเคพเค—เคฟ เคถเฅเคฐเฅ‡เคฏ เคฌเคฟเคจเคพ เคชเฅเคฐเคฏเฅ‹เค— เค—เคฐเฅเคจ เคธเค•เฅเค›เฅค เคนเคพเคฎเฅ€ เคจเฅเคฏเฅ‚เคจเคคเคฎ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคกเคพเคŸเคพ เคธเค™เฅเค•เคฒเคจ เค—เคฐเฅเค›เฅŒเค‚: เคคเคชเคพเคˆเค‚เค•เฅ‹ เค‡เคฎเฅ‡เคฒ เคฎเคพเคคเฅเคฐ (เค–เคพเคคเคพ เคชเฅเคจเคฐเฅเคชเฅเคฐเคพเคชเฅเคคเคฟเค•เฅ‹ เคฒเคพเค—เคฟ) เคฐ เคจเฅเคฏเฅ‚เคœเคฒเฅ‡เคŸเคฐ เคธเคฆเคธเฅเคฏเคคเคพ เคฏเคฆเคฟ เคฐเฅ‹เคœเคฟเคเค•เฅ‹ เค› เคญเคจเฅ‡เฅค' }, analyticsInfo: { english: @@ -1445,132 +1781,153 @@ export const localizations = { tok_pisin: 'Mipela kisim analytics na diagnostic data long mekim app na experience bilong yu i gutpela moa. Yu ken stopim analytics long wanem taim long profile settings bilong yu. Data bilong yu i go long United States.', indonesian: - 'Kami mengumpulkan data analitik dan diagnostik untuk meningkatkan aplikasi dan pengalaman Anda. Anda dapat memilih keluar dari analitik kapan saja di pengaturan profil Anda. Data Anda diproses dan disimpan di Amerika Serikat.' + 'Kami mengumpulkan data analitik dan diagnostik untuk meningkatkan aplikasi dan pengalaman Anda. Anda dapat memilih keluar dari analitik kapan saja di pengaturan profil Anda. Data Anda diproses dan disimpan di Amerika Serikat.', + nepali: + 'เคนเคพเคฎเฅ€ เคเคช เคฐ เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคจเฅเคญเคต เคธเฅเคงเคพเคฐ เค—เคฐเฅเคจ เคตเคฟเคถเฅเคฒเฅ‡เคทเคฃ เคฐ เคจเคฟเคฆเคพเคจ เคกเคพเคŸเคพ เคธเค™เฅเค•เคฒเคจ เค—เคฐเฅเค›เฅŒเค‚เฅค เคคเคชเคพเคˆเค‚ เค†เคซเฅเคจเฅ‹ เคชเฅเคฐเฅ‹เคซเคพเค‡เคฒ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚เคฎเคพ เคœเฅเคจเคธเฅเค•เฅˆ เคธเคฎเคฏ เคตเคฟเคถเฅเคฒเฅ‡เคทเคฃเคฌเคพเคŸ เค…เคชเฅเคŸ เค†เค‰เคŸ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค เคคเคชเคพเคˆเค‚เค•เฅ‹ เคกเคพเคŸเคพ เคธเค‚เคฏเฅเค•เฅเคค เคฐเคพเคœเฅเคฏ เค…เคฎเฅ‡เคฐเคฟเค•เคพเคฎเคพ เคชเฅเคฐเคถเฅ‹เคงเคจ เคฐ เคญเคฃเฅเคกเคพเคฐเคฃ เค—เคฐเคฟเคจเฅเค›เฅค' }, viewFullTerms: { english: 'View Full Terms', spanish: 'Ver Tรฉrminos Completos', brazilian_portuguese: 'Ver Termos Completos', tok_pisin: 'Lukim Olgeta Terms', - indonesian: 'Lihat Syarat Lengkap' + indonesian: 'Lihat Syarat Lengkap', + nepali: 'เคชเฅ‚เคฐเฅเคฃ เคธเคฐเฅเคคเคนเคฐเฅ‚ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, viewFullPrivacy: { english: 'View Full Privacy', spanish: 'Ver Privacidad Completa', brazilian_portuguese: 'Ver Privacidade Completa', tok_pisin: 'Lukim Olgeta Privacy', - indonesian: 'Lihat Privasi Lengkap' + indonesian: 'Lihat Privasi Lengkap', + nepali: 'เคชเฅ‚เคฐเฅเคฃ เค—เฅ‹เคชเคจเฅ€เคฏเคคเคพ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, submitFeedback: { english: 'Submit Feedback', spanish: 'Enviar Feedback', brazilian_portuguese: 'Enviar Feedback', tok_pisin: 'Salim Feedback', - indonesian: 'Kirim Umpan Balik' + indonesian: 'Kirim Umpan Balik', + nepali: 'เคชเฅเคฐเคคเคฟเค•เฅเคฐเคฟเคฏเคพ เคชเฅ‡เคถ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, reportProject: { english: 'Report Project', spanish: 'Reportar Proyecto', - brazilian_portuguese: 'Reportar Projeto' + brazilian_portuguese: 'Reportar Projeto', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, reportQuest: { english: 'Report Quest', spanish: 'Reportar Quest', - brazilian_portuguese: 'Reportar Quest' + brazilian_portuguese: 'Reportar Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, reportAsset: { english: 'Report Asset', spanish: 'Reportar Recurso', - brazilian_portuguese: 'Reportar Recurso' + brazilian_portuguese: 'Reportar Recurso', + nepali: 'เคเคธเฅ‡เคŸ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, reportTranslation: { english: 'Report Translation', spanish: 'Reportar Traducciรณn', brazilian_portuguese: 'Reportar Traduรงรฃo', tok_pisin: 'Reportim Translation', - indonesian: 'Laporkan Terjemahan' + indonesian: 'Laporkan Terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, reportGeneric: { english: 'Report', spanish: 'Reportar', - brazilian_portuguese: 'Reportar' + brazilian_portuguese: 'Reportar', + nepali: 'เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, selectReasonLabel: { english: 'Select a reason', spanish: 'Seleccione un motivo', brazilian_portuguese: 'Selecione um motivo', tok_pisin: 'Makim wanpela reson', - indonesian: 'Pilih alasan' + indonesian: 'Pilih alasan', + nepali: 'เคเค‰เคŸเคพ เค•เคพเคฐเคฃ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, additionalDetails: { english: 'Additional Details', spanish: 'Detalles Adicionales', brazilian_portuguese: 'Detalhes Adicionais', tok_pisin: 'Moa Details', - indonesian: 'Detail Tambahan' + indonesian: 'Detail Tambahan', + nepali: 'เคฅเคช เคตเคฟเคตเคฐเคฃเคนเคฐเฅ‚' }, additionalDetailsPlaceholder: { english: 'Provide any additional information...', spanish: 'Proporcionar cualquier informaciรณn adicional...', brazilian_portuguese: 'Forneรงa qualquer informaรงรฃo adicional...', tok_pisin: 'Givim narapela information...', - indonesian: 'Berikan informasi tambahan...' + indonesian: 'Berikan informasi tambahan...', + nepali: 'เค•เฅเคจเฅˆ เคฅเคช เคœเคพเคจเค•เคพเคฐเฅ€ เคชเฅเคฐเคฆเคพเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ...' }, submitReport: { english: 'Submit Report', spanish: 'Enviar Reporte', brazilian_portuguese: 'Enviar Relatรณrio', tok_pisin: 'Salim Report', - indonesian: 'Kirim Laporan' + indonesian: 'Kirim Laporan', + nepali: 'เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เคชเฅ‡เคถ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, submitting: { english: 'Submitting...', spanish: 'Enviando...', brazilian_portuguese: 'Enviando...', tok_pisin: 'Salim...', - indonesian: 'Mengirim...' + indonesian: 'Mengirim...', + nepali: 'เคชเฅ‡เคถ เค—เคฐเฅเคฆเฅˆ...' }, reportSubmitted: { english: 'Report submitted successfully', spanish: 'Reporte enviado exitosamente', brazilian_portuguese: 'Relatรณrio enviado com sucesso', tok_pisin: 'Report i go gut', - indonesian: 'Laporan berhasil dikirim' + indonesian: 'Laporan berhasil dikirim', + nepali: 'เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเฅ‡เคถ เค—เคฐเคฟเคฏเฅ‹' }, enterEmailForPasswordReset: { english: 'Enter your email to reset your password', spanish: 'Ingrese su email para restablecer su contraseรฑa', brazilian_portuguese: 'Digite seu e-mail para redefinir sua senha', tok_pisin: 'Putim email bilong yu long resetim password', - indonesian: 'Masukkan email Anda untuk mereset kata sandi' + indonesian: 'Masukkan email Anda untuk mereset kata sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจ เค†เคซเฅเคจเฅ‹ เค‡เคฎเฅ‡เคฒ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, failedToSubmitReport: { english: 'Failed to submit report', spanish: 'Error al enviar el reporte', brazilian_portuguese: 'Falha ao enviar relatรณrio', tok_pisin: 'I no inap salim report', - indonesian: 'Gagal mengirim laporan' + indonesian: 'Gagal mengirim laporan', + nepali: 'เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เคชเฅ‡เคถ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, logInToReport: { english: 'You must be logged in to report translations', spanish: 'Debe iniciar sesiรณn para reportar traducciones', brazilian_portuguese: 'Vocรช deve estar logado para reportar traduรงรตes', tok_pisin: 'Yu mas login pastaim long reportim ol translation', - indonesian: 'Anda harus masuk untuk melaporkan terjemahan' + indonesian: 'Anda harus masuk untuk melaporkan terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคฒเค— เค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›' }, selectReason: { english: 'Please select a reason for the report', spanish: 'Por favor seleccione un motivo para el reporte', brazilian_portuguese: 'Por favor, selecione um motivo para o relatรณrio', tok_pisin: 'Plis makim wanpela reson long report', - indonesian: 'Silakan pilih alasan untuk laporan' + indonesian: 'Silakan pilih alasan untuk laporan', + nepali: 'เค•เฅƒเคชเคฏเคพ เคฐเคฟเคชเฅ‹เคฐเฅเคŸเค•เฅ‹ เคฒเคพเค—เคฟ เคเค‰เคŸเคพ เค•เคพเคฐเคฃ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enableAnalytics: { english: 'Enable Analytics', spanish: 'Habilitar Anรกlisis', brazilian_portuguese: 'Habilitar Anรกlise', tok_pisin: 'Onim Analytics', - indonesian: 'Aktifkan Analitik' + indonesian: 'Aktifkan Analitik', + nepali: 'เคตเคฟเคถเฅเคฒเฅ‡เคทเคฃ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, analyticsDescription: { english: @@ -1582,56 +1939,65 @@ export const localizations = { tok_pisin: 'Sapos yu ofim, mipela no bai kisim usage data long mekim app i gutpela.', indonesian: - 'Ketika dinonaktifkan, kami tidak akan mengumpulkan data penggunaan untuk meningkatkan aplikasi.' + 'Ketika dinonaktifkan, kami tidak akan mengumpulkan data penggunaan untuk meningkatkan aplikasi.', + nepali: + 'เค…เคธเค•เฅเคทเคฎ เค—เคฐเคฟเคเค•เฅ‹ เคฌเฅ‡เคฒเคพ, เคนเคพเคฎเฅ€ เคเคช เคธเฅเคงเคพเคฐ เค—เคฐเฅเคจ เคชเฅเคฐเคฏเฅ‹เค— เคกเคพเคŸเคพ เคธเค™เฅเค•เคฒเคจ เค—เคฐเฅเคจเฅ‡ เค›เฅˆเคจเฅŒเค‚เฅค' }, sessionExpired: { english: 'Session expired', spanish: 'Sesiรณn expirada', brazilian_portuguese: 'Sessรฃo expirada', tok_pisin: 'Session i pinis', - indonesian: 'Sesi kedaluwarsa' + indonesian: 'Sesi kedaluwarsa', + nepali: 'เคธเคคเฅเคฐ เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹' }, 'reportReason.inappropriate_content': { english: 'Inappropriate Content', spanish: 'Contenido Inapropiado', brazilian_portuguese: 'Conteรบdo Inapropriado', tok_pisin: 'Content i no gutpela', - indonesian: 'Konten Tidak Pantas' + indonesian: 'Konten Tidak Pantas', + nepali: 'เค…เคจเฅเคšเคฟเคค เคธเคพเคฎเค—เฅเคฐเฅ€' }, 'reportReason.spam': { english: 'Spam', spanish: 'Spam', brazilian_portuguese: 'Spam', tok_pisin: 'Spam', - indonesian: 'Spam' + indonesian: 'Spam', + nepali: 'เคธเฅเคชเฅเคฏเคพเคฎ' }, 'reportReason.other': { english: 'Other', spanish: 'Otro', brazilian_portuguese: 'Outro', tok_pisin: 'Narapela', - indonesian: 'Lainnya' + indonesian: 'Lainnya', + nepali: 'เค…เคจเฅเคฏ' }, updatePassword: { english: 'Update Password', spanish: 'Actualizar Contraseรฑa', brazilian_portuguese: 'Atualizar Senha', tok_pisin: 'Updateim Password', - indonesian: 'Perbarui Kata Sandi' + indonesian: 'Perbarui Kata Sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, createNewPassword: { english: 'Create New Password', spanish: 'Crear nueva contraseรฑa', brazilian_portuguese: 'Criar nova senha', tok_pisin: 'Mekim nupela password', - indonesian: 'Buat Kata Sandi Baru' + indonesian: 'Buat Kata Sandi Baru', + nepali: 'เคจเคฏเคพเค เคชเคพเคธเคตเคฐเฅเคก เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, downloadLimitExceeded: { english: 'Download Limit Exceeded', spanish: 'Lรญmite de descarga excedido', brazilian_portuguese: 'Limite de download excedido', tok_pisin: 'Download limit i pinis', - indonesian: 'Batas Unduhan Terlampaui' + indonesian: 'Batas Unduhan Terlampaui', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เคธเฅ€เคฎเคพ เคจเคพเค˜เฅเคฏเฅ‹' }, downloadLimitMessage: { english: @@ -1643,14 +2009,17 @@ export const localizations = { tok_pisin: 'Yu traim long download {newDownloads} attachments long total {totalDownloads}, tasol limit i {limit}. Plis unselectim sampela downloads na traim gen.', indonesian: - 'Anda mencoba mengunduh {newDownloads} lampiran untuk total {totalDownloads}, tetapi batasnya adalah {limit}. Silakan batalkan pilihan beberapa unduhan dan coba lagi.' + 'Anda mencoba mengunduh {newDownloads} lampiran untuk total {totalDownloads}, tetapi batasnya adalah {limit}. Silakan batalkan pilihan beberapa unduhan dan coba lagi.', + nepali: + 'เคคเคชเคพเคˆเค‚ {totalDownloads} เค•เฅ‹ เค•เฅเคฒ เคฒเคพเค—เคฟ {newDownloads} เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคฆเฅˆ เคนเฅเคจเฅเคนเฅเคจเฅเค›, เคคเคฐ เคธเฅ€เคฎเคพ {limit} เคนเฅ‹เฅค เค•เฅƒเคชเคฏเคพ เค•เฅ‡เคนเฅ€ เคกเคพเค‰เคจเคฒเฅ‹เคกเคนเคฐเฅ‚ เค…เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคฐ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, offlineUndownloadWarning: { english: 'Offline Undownload Warning', spanish: 'Advertencia de eliminaciรณn sin conexiรณn', brazilian_portuguese: 'Aviso de remoรงรฃo de download offline', tok_pisin: 'Offline Undownload Warning', - indonesian: 'Peringatan Batalkan Unduhan Offline' + indonesian: 'Peringatan Batalkan Unduhan Offline', + nepali: 'เค…เคซเคฒเคพเค‡เคจ เค…เคจเคกเคพเค‰เคจเคฒเฅ‹เคก เคšเฅ‡เคคเคพเคตเคจเฅ€' }, offlineUndownloadMessage: { english: @@ -1662,42 +2031,65 @@ export const localizations = { tok_pisin: 'Yu no gat internet nau. Sapos yu rausim dispela download, yu no inap download gen inap yu gat internet gen. Ol contribution bilong yu i no sync yet bai no kena.', indonesian: - 'Anda sedang offline. Jika Anda menghapus unduhan ini, Anda tidak akan dapat mengunduhnya lagi sampai Anda kembali online. Kontribusi yang belum disinkronkan tidak akan terpengaruh.' + 'Anda sedang offline. Jika Anda menghapus unduhan ini, Anda tidak akan dapat mengunduhnya lagi sampai Anda kembali online. Kontribusi yang belum disinkronkan tidak akan terpengaruh.', + nepali: + 'เคคเคชเคพเคˆเค‚ เค…เคนเคฟเคฒเฅ‡ เค…เคซเคฒเคพเค‡เคจ เคนเฅเคจเฅเคนเฅเคจเฅเค›เฅค เคฏเคฆเคฟ เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคฏเฅ‹ เคกเคพเค‰เคจเคฒเฅ‹เคก เคนเคŸเคพเค‰เคจเฅเคญเคฏเฅ‹ เคญเคจเฅ‡, เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคจเคญเคเคธเคฎเฅเคฎ เคชเฅเคจ: เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เฅเคทเคฎ เคนเฅเคจเฅเคนเฅเคจเฅ‡ เค›เฅˆเคจเฅค เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฟเค™เฅเค• เคจเคญเคเค•เคพ เคฏเฅ‹เค—เคฆเคพเคจเคนเคฐเฅ‚ เคชเฅเคฐเคญเคพเคตเคฟเคค เคนเฅเคจเฅ‡ เค›เฅˆเคจเคจเฅเฅค' }, dontShowAgain: { english: "Don't show this message again", spanish: 'No mostrar este mensaje nuevamente', brazilian_portuguese: 'Nรฃo mostrar esta mensagem novamente', tok_pisin: 'No soim dispela message gen', - indonesian: 'Jangan tampilkan pesan ini lagi' + indonesian: 'Jangan tampilkan pesan ini lagi', + nepali: 'เคฏเฅ‹ เคธเคจเฅเคฆเฅ‡เคถ เคซเฅ‡เคฐเคฟ เคจเคฆเฅ‡เค–เคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, cancel: { english: 'Cancel', spanish: 'Cancelar', brazilian_portuguese: 'Cancelar', tok_pisin: 'Cancel', - indonesian: 'Batal' + indonesian: 'Batal', + nepali: 'เคฐเคฆเฅเคฆ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' + }, + yes: { + english: 'Yes', + spanish: 'Sรญ', + brazilian_portuguese: 'Sim', + tok_pisin: 'Yes', + indonesian: 'Ya', + nepali: 'เคนเฅ‹' + }, + no: { + english: 'No', + spanish: 'No', + brazilian_portuguese: 'Nรฃo', + tok_pisin: 'Nogat', + indonesian: 'Tidak', + nepali: 'เคนเฅ‹เค‡เคจ' }, confirm: { english: 'Confirm', spanish: 'Confirmar', brazilian_portuguese: 'Confirmar', tok_pisin: 'Confirm', - indonesian: 'Konfirmasi' + indonesian: 'Konfirmasi', + nepali: 'เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, blockThisContent: { english: 'Block this content', spanish: 'Bloquear este contenido', brazilian_portuguese: 'Bloquear este conteรบdo', tok_pisin: 'Blokim dispela content', - indonesian: 'Blokir konten ini' + indonesian: 'Blokir konten ini', + nepali: 'เคฏเฅ‹ เคธเคพเคฎเค—เฅเคฐเฅ€ เคฌเฅเคฒเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, blockThisUser: { english: 'Block this user', spanish: 'Bloquear este usuario', brazilian_portuguese: 'Bloquear este usuรกrio', tok_pisin: 'Blokim dispela user', - indonesian: 'Blokir pengguna ini' + indonesian: 'Blokir pengguna ini', + nepali: 'เคฏเฅ‹ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคฌเฅเคฒเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, // New backup-related translations backup: { @@ -1705,35 +2097,40 @@ export const localizations = { spanish: 'Respaldo', brazilian_portuguese: 'Backup', tok_pisin: 'Backup', - indonesian: 'Cadangan' + indonesian: 'Cadangan', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช' }, backingUp: { english: 'Backing Up...', spanish: 'Respaldando...', brazilian_portuguese: 'Fazendo Backup...', tok_pisin: 'Backup...', - indonesian: 'Mencadangkan...' + indonesian: 'Mencadangkan...', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เค—เคฐเฅเคฆเฅˆ...' }, restoreBackup: { english: 'Restore Backup', spanish: 'Restaurar Respaldo', brazilian_portuguese: 'Restaurar Backup', tok_pisin: 'Restore Backup', - indonesian: 'Pulihkan Cadangan' + indonesian: 'Pulihkan Cadangan', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, restoring: { english: 'Restoring...', spanish: 'Restaurando...', brazilian_portuguese: 'Restaurando...', tok_pisin: 'Restore...', - indonesian: 'Memulihkan...' + indonesian: 'Memulihkan...', + nepali: 'เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคฆเฅˆ...' }, startBackupTitle: { english: 'Create Backup', spanish: 'Crear Respaldo', brazilian_portuguese: 'Criar Backup', tok_pisin: 'Mekim Backup', - indonesian: 'Buat Cadangan' + indonesian: 'Buat Cadangan', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, startBackupMessageAudioOnly: { english: 'Would you like to back up your unsynced audio recordings?', @@ -1743,28 +2140,33 @@ export const localizations = { 'Gostaria de fazer backup das suas gravaรงรตes de รกudio nรฃo sincronizadas?', tok_pisin: 'Yu laik backup ol audio recording bilong yu i no sync yet?', indonesian: - 'Apakah Anda ingin mencadangkan rekaman audio yang belum disinkronkan?' + 'Apakah Anda ingin mencadangkan rekaman audio yang belum disinkronkan?', + nepali: + 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เค†เคซเฅเคจเฅ‹ เคธเคฟเค™เฅเค• เคจเคญเคเค•เคพ เค…เคกเคฟเคฏเฅ‹ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™เคนเคฐเฅ‚ เคฌเฅเคฏเคพเค•เค…เคช เค—เคฐเฅเคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›?' }, backupAudioAction: { english: 'Backup audio and text', spanish: 'Respaldar audio y texto', brazilian_portuguese: 'Backup de รกudio e texto', tok_pisin: 'Backup audio na text', - indonesian: 'Cadangkan audio dan teks' + indonesian: 'Cadangkan audio dan teks', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคฐ เคŸเฅ‡เค•เฅเคธเฅเคŸ เคฌเฅเคฏเคพเค•เค…เคช เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, backupErrorTitle: { english: 'Backup Error', spanish: 'Error de Respaldo', brazilian_portuguese: 'Erro de Backup', tok_pisin: 'Backup Rong', - indonesian: 'Kesalahan Cadangan' + indonesian: 'Kesalahan Cadangan', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เคคเฅเคฐเฅเคŸเคฟ' }, backupCompleteTitle: { english: 'Backup Complete', spanish: 'Respaldo Completado', brazilian_portuguese: 'Backup Concluรญdo', tok_pisin: 'Backup Pinis', - indonesian: 'Cadangan Selesai' + indonesian: 'Cadangan Selesai', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹' }, audioBackupStatus: { english: 'Successfully backed up {count} audio recordings', @@ -1772,14 +2174,16 @@ export const localizations = { brazilian_portuguese: 'Backup de {count} gravaรงรตes de รกudio concluรญdo com sucesso', tok_pisin: 'Backup {count} audio recordings gut', - indonesian: 'Berhasil mencadangkan {count} rekaman audio' + indonesian: 'Berhasil mencadangkan {count} rekaman audio', + nepali: '{count} เค…เคกเคฟเคฏเฅ‹ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™เคนเคฐเฅ‚ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคฌเฅเคฏเคพเค•เค…เคช เค—เคฐเคฟเคฏเฅ‹' }, criticalBackupError: { english: 'A critical error occurred: {error}', spanish: 'Ocurriรณ un error crรญtico: {error}', brazilian_portuguese: 'Ocorreu um erro crรญtico: {error}', tok_pisin: 'Bikpela rong i kamap: {error}', - indonesian: 'Terjadi kesalahan kritis: {error}' + indonesian: 'Terjadi kesalahan kritis: {error}', + nepali: 'เคเค‰เคŸเคพ เค—เคฎเฅเคญเฅ€เคฐ เคคเฅเคฐเฅเคŸเคฟ เคญเคฏเฅ‹: {error}' }, databaseNotReady: { english: 'Database is not ready. Please try again later.', @@ -1787,7 +2191,8 @@ export const localizations = { brazilian_portuguese: 'O banco de dados nรฃo estรก pronto. Por favor, tente novamente mais tarde.', tok_pisin: 'Database i no redi yet. Plis traim gen bihain.', - indonesian: 'Database belum siap. Silakan coba lagi nanti.' + indonesian: 'Database belum siap. Silakan coba lagi nanti.', + nepali: 'เคกเคพเคŸเคพเคฌเฅ‡เคธ เคคเคฏเคพเคฐ เค›เฅˆเคจเฅค เค•เฅƒเคชเคฏเคพ เคชเค›เคฟ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, storagePermissionDenied: { english: 'Storage permission denied. Backup cannot proceed.', @@ -1796,7 +2201,8 @@ export const localizations = { brazilian_portuguese: 'Permissรฃo de armazenamento negada. O backup nรฃo pode prosseguir.', tok_pisin: 'Storage permission i no. Backup i no inap go.', - indonesian: 'Izin penyimpanan ditolak. Cadangan tidak dapat dilanjutkan.' + indonesian: 'Izin penyimpanan ditolak. Cadangan tidak dapat dilanjutkan.', + nepali: 'เคญเคฃเฅเคกเคพเคฐเคฃ เค…เคจเฅเคฎเคคเคฟ เค…เคธเฅเคตเฅ€เค•เฅƒเคคเฅค เคฌเฅเคฏเคพเค•เค…เคช เค…เค—เคพเคกเคฟ เคฌเคขเฅเคจ เคธเค•เฅเคฆเฅˆเคจเฅค' }, // Adding missing translation keys initializing: { @@ -1804,12 +2210,14 @@ export const localizations = { spanish: 'Inicializando', brazilian_portuguese: 'Inicializando', tok_pisin: 'Initializing', - indonesian: 'Menginisialisasi' + indonesian: 'Menginisialisasi', + nepali: 'เคธเฅเคฐเฅเคตเคพเคค เค—เคฐเฅเคฆเฅˆ' }, syncComplete: { english: 'Sync complete', spanish: 'Sincronizaciรณn completa', brazilian_portuguese: 'Sincronizaรงรฃo completa', + nepali: 'เคธเคฟเค™เฅเค• เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹', tok_pisin: 'Sync pinis', indonesian: 'Sinkronisasi selesai' }, @@ -1818,35 +2226,40 @@ export const localizations = { spanish: '{current} de {total} archivos', brazilian_portuguese: '{current} de {total} arquivos', tok_pisin: '{current} long {total} files', - indonesian: '{current} dari {total} file' + indonesian: '{current} dari {total} file', + nepali: '{total} เคฎเคงเฅเคฏเฅ‡ {current} เคซเคพเค‡เคฒเคนเคฐเฅ‚' }, userNotLoggedIn: { english: 'You must be logged in to perform this action', spanish: 'Debe iniciar sesiรณn para realizar esta acciรณn', brazilian_portuguese: 'Vocรช deve estar logado para realizar esta aรงรฃo', tok_pisin: 'Yu mas login pastaim long mekim dispela samting', - indonesian: 'Anda harus masuk untuk melakukan tindakan ini' + indonesian: 'Anda harus masuk untuk melakukan tindakan ini', + nepali: 'เคฏเฅ‹ เค•เคพเคฐเฅเคฏ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคฒเค— เค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›' }, cannotReportOwnTranslation: { english: 'You cannot report your own translation', spanish: 'No puede reportar su propia traducciรณn', brazilian_portuguese: 'Vocรช nรฃo pode reportar sua prรณpria traduรงรฃo', tok_pisin: 'Yu no inap reportim translation bilong yu yet', - indonesian: 'Anda tidak dapat melaporkan terjemahan Anda sendiri' + indonesian: 'Anda tidak dapat melaporkan terjemahan Anda sendiri', + nepali: 'เคคเคชเคพเคˆเค‚ เค†เคซเฅเคจเฅ‹ เค…เคจเฅเคตเคพเคฆ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเคจ' }, cannotReportInactiveTranslation: { english: 'You cannot report inactive translation', spanish: 'No puede reportar traducciรณn inactiva', brazilian_portuguese: 'Vocรช nรฃo pode reportar traduรงรฃo inativa', tok_pisin: 'Yu no inap reportim translation i no active', - indonesian: 'Anda tidak dapat melaporkan terjemahan yang tidak aktif' + indonesian: 'Anda tidak dapat melaporkan terjemahan yang tidak aktif', + nepali: 'เคคเคชเคพเคˆเค‚ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เค…เคจเฅเคตเคพเคฆ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเคจ' }, cannotIdentifyUser: { english: 'Unable to identify user', spanish: 'No se puede identificar al usuario', brazilian_portuguese: 'Nรฃo foi possรญvel identificar o usuรกrio', tok_pisin: 'No inap save user', - indonesian: 'Tidak dapat mengidentifikasi pengguna' + indonesian: 'Tidak dapat mengidentifikasi pengguna', + nepali: 'เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพ เคชเคนเคฟเคšเคพเคจ เค—เคฐเฅเคจ เค…เคธเคฎเคฐเฅเคฅ' }, cannotChangeTranslationSettings: { english: 'Unathorized to change settings for this translation', @@ -1856,175 +2269,200 @@ export const localizations = { 'Vocรช nรฃo tem autorizaรงรฃo para alterar as configuraรงรตes desta traduรงรฃo', tok_pisin: 'Yu no gat rait long senisim settings bilong dispela translation', - indonesian: 'Tidak berwenang untuk mengubah pengaturan terjemahan ini' + indonesian: 'Tidak berwenang untuk mengubah pengaturan terjemahan ini', + nepali: 'เคฏเฅ‹ เค…เคจเฅเคตเคพเคฆเค•เฅ‹ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เคชเคฐเคฟเคตเคฐเฅเคคเคจ เค—เคฐเฅเคจ เค…เคจเคงเคฟเค•เฅƒเคค' }, alreadyReportedTranslation: { english: 'You have already reported this translation', spanish: 'Ya ha reportado esta traducciรณn', brazilian_portuguese: 'Vocรช jรก reportou esta traduรงรฃo', tok_pisin: 'Yu reportim dispela translation pinis', - indonesian: 'Anda sudah melaporkan terjemahan ini' + indonesian: 'Anda sudah melaporkan terjemahan ini', + nepali: 'เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคชเคนเคฟเคฒเฅ‡ เคจเฅˆ เคฏเฅ‹ เค…เคจเฅเคตเคพเคฆ เคฐเคฟเคชเฅ‹เคฐเฅเคŸ เค—เคฐเคฟเคธเค•เฅเคจเฅเคญเคฏเฅ‹' }, failedSaveAnalyticsPreference: { english: 'Failed to save analytics preference', spanish: 'Error al guardar la preferencia de anรกlisis', brazilian_portuguese: 'Falha ao salvar preferรชncia de anรกlise', tok_pisin: 'I no inap seivim analytics preference', - indonesian: 'Gagal menyimpan preferensi analitik' + indonesian: 'Gagal menyimpan preferensi analitik', + nepali: 'เคตเคฟเคถเฅเคฒเฅ‡เคทเคฃ เคชเฅเคฐเคพเคฅเคฎเคฟเค•เคคเคพ เคธเฅ‡เคญ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, currentPasswordRequired: { english: 'Current password is required', spanish: 'Se requiere la contraseรฑa actual', brazilian_portuguese: 'A senha atual รฉ obrigatรณria', tok_pisin: 'Password bilong nau i mas', - indonesian: 'Kata sandi saat ini diperlukan' + indonesian: 'Kata sandi saat ini diperlukan', + nepali: 'เคนเคพเคฒเค•เฅ‹ เคชเคพเคธเคตเคฐเฅเคก เค†เคตเคถเฅเคฏเค• เค›' }, profileUpdateSuccess: { english: 'Profile updated successfully', spanish: 'Perfil actualizado con รฉxito', brazilian_portuguese: 'Perfil atualizado com sucesso', tok_pisin: 'Profile i update gut', - indonesian: 'Profil berhasil diperbarui' + indonesian: 'Profil berhasil diperbarui', + nepali: 'เคชเฅเคฐเฅ‹เคซเคพเค‡เคฒ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เค…เคชเคกเฅ‡เคŸ เค—เคฐเคฟเคฏเฅ‹' }, failedUpdateProfile: { english: 'Failed to update profile', spanish: 'Error al actualizar el perfil', brazilian_portuguese: 'Falha ao atualizar perfil', tok_pisin: 'I no inap updateim profile', - indonesian: 'Gagal memperbarui profil' + indonesian: 'Gagal memperbarui profil', + nepali: 'เคชเฅเคฐเฅ‹เคซเคพเค‡เคฒ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, assetNotFound: { english: 'Asset not found', spanish: 'Recurso no encontrado', brazilian_portuguese: 'Recurso nรฃo encontrado', tok_pisin: 'Asset i no stap', - indonesian: 'Aset tidak ditemukan' + indonesian: 'Aset tidak ditemukan', + nepali: 'เคเคธเฅ‡เคŸ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, failedLoadAssetData: { english: 'Failed to load asset data', spanish: 'Error al cargar datos del recurso', brazilian_portuguese: 'Falha ao carregar dados do recurso', tok_pisin: 'I no inap loadim asset data', - indonesian: 'Gagal memuat data aset' + indonesian: 'Gagal memuat data aset', + nepali: 'เคเคธเฅ‡เคŸ เคกเคพเคŸเคพ เคฒเฅ‹เคก เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedLoadAssets: { english: 'Failed to load assets', spanish: 'Error al cargar recursos', brazilian_portuguese: 'Falha ao carregar recursos', tok_pisin: 'I no inap loadim ol asset', - indonesian: 'Gagal memuat aset' + indonesian: 'Gagal memuat aset', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, projectMembers: { english: 'Project Members', spanish: 'Miembros del Proyecto', brazilian_portuguese: 'Membros do Projeto', tok_pisin: 'Ol Member bilong Project', - indonesian: 'Anggota Proyek' + indonesian: 'Anggota Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚' }, members: { english: 'Members', spanish: 'Miembros', brazilian_portuguese: 'Membros', tok_pisin: 'Ol Member', - indonesian: 'Anggota' + indonesian: 'Anggota', + nepali: 'เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚' }, invited: { english: 'Invited', spanish: 'Invitados', brazilian_portuguese: 'Convidados', tok_pisin: 'Ol i invitim', - indonesian: 'Diundang' + indonesian: 'Diundang', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค' }, viewInvitation: { english: 'View Invitation', spanish: 'Ver Invitaciรณn', brazilian_portuguese: 'Ver Convite', tok_pisin: 'Lukim Invitation', - indonesian: 'Lihat Undangan' + indonesian: 'Lihat Undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, inviteMembers: { english: 'Invite Members', spanish: 'Invitar Miembros', brazilian_portuguese: 'Convidar Membros', tok_pisin: 'Invitim ol Member', - indonesian: 'Undang Anggota' + indonesian: 'Undang Anggota', + nepali: 'เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฒเคพเคˆ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, inviteAsOwner: { english: 'Invite as owner', spanish: 'Invitar como propietario', brazilian_portuguese: 'Convidar como proprietรกrio', tok_pisin: 'Invitim olsem owner', - indonesian: 'Undang sebagai pemilik' + indonesian: 'Undang sebagai pemilik', + nepali: 'เคฎเคพเคฒเคฟเค•เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, sendInvitation: { english: 'Send Invitation', spanish: 'Enviar Invitaciรณn', brazilian_portuguese: 'Enviar Convite', tok_pisin: 'Salim Invitation', - indonesian: 'Kirim Undangan' + indonesian: 'Kirim Undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคชเค เคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, owner: { english: 'Owner', spanish: 'Propietario', brazilian_portuguese: 'Proprietรกrio', tok_pisin: 'Owner', - indonesian: 'Pemilik' + indonesian: 'Pemilik', + nepali: 'เคฎเคพเคฒเคฟเค•' }, member: { english: 'Member', spanish: 'Miembro', brazilian_portuguese: 'Membro', tok_pisin: 'Member', - indonesian: 'Anggota' + indonesian: 'Anggota', + nepali: 'เคธเคฆเคธเฅเคฏ' }, makeOwner: { english: 'Make Owner', spanish: 'Hacer Propietario', brazilian_portuguese: 'Tornar Proprietรกrio', tok_pisin: 'Mekim Owner', - indonesian: 'Jadikan Pemilik' + indonesian: 'Jadikan Pemilik', + nepali: 'เคฎเคพเคฒเคฟเค• เคฌเคจเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, remove: { english: 'Remove', spanish: 'Eliminar', brazilian_portuguese: 'Remover', tok_pisin: 'Rausim', - indonesian: 'Hapus' + indonesian: 'Hapus', + nepali: 'เคนเคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, withdrawInvite: { english: 'Withdraw Invite', spanish: 'Retirar Invitaciรณn', brazilian_portuguese: 'Retirar Convite', tok_pisin: 'Rausim Invite', - indonesian: 'Tarik Undangan' + indonesian: 'Tarik Undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจเฅเคนเฅ‹เคธเฅ' }, you: { english: 'You', spanish: 'Tรบ', brazilian_portuguese: 'Vocรช', tok_pisin: 'Yu', - indonesian: 'Anda' + indonesian: 'Anda', + nepali: 'เคคเคชเคพเคˆเค‚' }, pendingInvitation: { english: 'Pending', spanish: 'Pendiente', brazilian_portuguese: 'Pendente', tok_pisin: 'Wet', - indonesian: 'Tertunda' + indonesian: 'Tertunda', + nepali: 'เคฌเคพเคเค•เฅ€' }, noMembers: { english: 'No members yet', spanish: 'No hay miembros todavรญa', brazilian_portuguese: 'Ainda nรฃo hรก membros', tok_pisin: 'No gat member yet', - indonesian: 'Belum ada anggota' + indonesian: 'Belum ada anggota', + nepali: 'เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เคธเคฆเคธเฅเคฏ เค›เฅˆเคจ' }, noInvitations: { english: 'No pending invitations', spanish: 'No hay invitaciones pendientes', brazilian_portuguese: 'Nenhum convite pendente', tok_pisin: 'No gat invitation i wet', - indonesian: 'Tidak ada undangan tertunda' + indonesian: 'Tidak ada undangan tertunda', + nepali: 'เค•เฅเคจเฅˆ เคฌเคพเคเค•เฅ€ เค†เคฎเคจเฅเคคเฅเคฐเคฃ เค›เฅˆเคจ' }, ownerTooltip: { english: @@ -2036,7 +2474,9 @@ export const localizations = { tok_pisin: 'Ol owner ken mekim content, invitim na promotim narapela member, na ol narapela member no inap daunim o rausim ol long project.', indonesian: - 'Pemilik dapat membuat konten, mengundang dan mempromosikan anggota lain, dan tidak dapat diturunkan kembali ke keanggotaan atau dihapus dari proyek oleh anggota lain.' + 'Pemilik dapat membuat konten, mengundang dan mempromosikan anggota lain, dan tidak dapat diturunkan kembali ke keanggotaan atau dihapus dari proyek oleh anggota lain.', + nepali: + 'เคฎเคพเคฒเคฟเค•เคนเคฐเฅ‚เคฒเฅ‡ เคธเคพเคฎเค—เฅเคฐเฅ€ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ, เค…เคจเฅเคฏ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฒเคพเคˆ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเฅเคจ เคฐ เคชเฅเคฐเคตเคฐเฅเคฆเฅเคงเคจ เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅ, เคฐ เค…เคจเฅเคฏ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฆเฅเคตเคพเคฐเคพ เคธเคฆเคธเฅเคฏเคคเคพเคฎเคพ เคซเคฟเคฐเฅเคคเคพ เค—เคฟเคฐเคพเค‰เคจ เคตเคพ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฌเคพเคŸ เคนเคŸเคพเค‰เคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, confirmRemoveMessage: { english: 'Are you sure you want to remove {name} from this project?', @@ -2044,14 +2484,17 @@ export const localizations = { brazilian_portuguese: 'Tem certeza de que deseja remover {name} deste projeto?', tok_pisin: 'Yu tru laik rausim {name} long dispela project?', - indonesian: 'Apakah Anda yakin ingin menghapus {name} dari proyek ini?' + indonesian: 'Apakah Anda yakin ingin menghapus {name} dari proyek ini?', + nepali: + 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค› เค•เคฟ เคคเคชเคพเคˆเค‚ {name} เคฒเคพเคˆ เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฌเคพเคŸ เคนเคŸเคพเค‰เคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›?' }, confirmPromote: { english: 'Confirm Promote', spanish: 'Confirmar Promociรณn', brazilian_portuguese: 'Confirmar Promoรงรฃo', tok_pisin: 'Confirm Promote', - indonesian: 'Konfirmasi Promosi' + indonesian: 'Konfirmasi Promosi', + nepali: 'เคชเฅเคฐเคฎเฅ‹เคถเคจ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmPromoteMessage: { english: @@ -2063,21 +2506,25 @@ export const localizations = { tok_pisin: 'Yu tru laik mekim {name} i owner? Dispela samting yu no inap senisim bek.', indonesian: - 'Apakah Anda yakin ingin menjadikan {name} sebagai pemilik? Tindakan ini tidak dapat dibatalkan.' + 'Apakah Anda yakin ingin menjadikan {name} sebagai pemilik? Tindakan ini tidak dapat dibatalkan.', + nepali: + 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค› เค•เคฟ เคคเคชเคพเคˆเค‚ {name} เคฒเคพเคˆ เคฎเคพเคฒเคฟเค• เคฌเคจเคพเค‰เคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›? เคฏเฅ‹ เค•เคพเคฐเฅเคฏ เคชเฅ‚เคฐเฅเคตเคตเคค เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, confirmLeave: { english: 'Leave Project', spanish: 'Abandonar Proyecto', brazilian_portuguese: 'Sair do Projeto', tok_pisin: 'Lusim Project', - indonesian: 'Tinggalkan Proyek' + indonesian: 'Tinggalkan Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เฅ‹เคกเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmLeaveMessage: { english: 'Are you sure you want to leave this project?', spanish: 'ยฟEstรก seguro de que desea abandonar este proyecto?', brazilian_portuguese: 'Tem certeza de que deseja sair deste projeto?', tok_pisin: 'Yu tru laik lusim dispela project?', - indonesian: 'Apakah Anda yakin ingin meninggalkan proyek ini?' + indonesian: 'Apakah Anda yakin ingin meninggalkan proyek ini?', + nepali: 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เฅ‹เคกเฅเคจ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค›?' }, cannotLeaveAsOnlyOwner: { english: @@ -2089,7 +2536,9 @@ export const localizations = { tok_pisin: 'Yu no inap lusim dispela project bilong yu stap owner tasol. Plis promotim narapela member i kamap owner pastaim.', indonesian: - 'Anda tidak dapat meninggalkan proyek ini karena Anda adalah satu-satunya pemilik. Silakan promosikan anggota lain menjadi pemilik terlebih dahulu.' + 'Anda tidak dapat meninggalkan proyek ini karena Anda adalah satu-satunya pemilik. Silakan promosikan anggota lain menjadi pemilik terlebih dahulu.', + nepali: + 'เคคเคชเคพเคˆเค‚ เคเค•เฅเคฒเฅ‹ เคฎเคพเคฒเคฟเค• เคญเคเค•เฅ‹ เคนเฅเคจเคพเคฒเฅ‡ เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เฅ‹เคกเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเคจเฅค เค•เฅƒเคชเคฏเคพ เคชเคนเคฟเคฒเฅ‡ เค…เคฐเฅเค•เฅ‹ เคธเคฆเคธเฅเคฏเคฒเคพเคˆ เคฎเคพเคฒเคฟเค• เคฌเคจเคพเค‰เคจเฅเคนเฅ‹เคธเฅเฅค' }, invitationAlreadySent: { english: 'An invitation has already been sent to this email address.', @@ -2098,84 +2547,96 @@ export const localizations = { brazilian_portuguese: 'Um convite jรก foi enviado para este endereรงo de e-mail.', tok_pisin: 'Invitation i go pinis long dispela email adres.', - indonesian: 'Undangan sudah dikirim ke alamat email ini.' + indonesian: 'Undangan sudah dikirim ke alamat email ini.', + nepali: 'เคฏเฅ‹ เค‡เคฎเฅ‡เคฒ เค เฅ‡เค—เคพเคจเคพเคฎเคพ เคชเคนเคฟเคฒเฅ‡ เคจเฅˆ เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคชเค เคพเค‡เคธเค•เคฟเคเค•เฅ‹ เค›เฅค' }, invitationSent: { english: 'Invitation sent successfully', spanish: 'Invitaciรณn enviada con รฉxito', brazilian_portuguese: 'Convite enviado com sucesso', tok_pisin: 'Invitation i go gut', - indonesian: 'Undangan berhasil dikirim' + indonesian: 'Undangan berhasil dikirim', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเค เคพเค‡เคฏเฅ‹' }, expiredInvitation: { english: 'Expired', spanish: 'Expirado', brazilian_portuguese: 'Expirado', tok_pisin: 'Pinis', - indonesian: 'Kedaluwarsa' + indonesian: 'Kedaluwarsa', + nepali: 'เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹' }, declinedInvitation: { english: 'Declined', spanish: 'Rechazado', brazilian_portuguese: 'Recusado', tok_pisin: 'Refusim', - indonesian: 'Ditolak' + indonesian: 'Ditolak', + nepali: 'เค…เคธเฅเคตเฅ€เค•เฅƒเคค' }, withdrawnInvitation: { english: 'Withdrawn', spanish: 'Retirado', brazilian_portuguese: 'Retirado', tok_pisin: 'Rausim', - indonesian: 'Ditarik' + indonesian: 'Ditarik', + nepali: 'เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเค‡เคเค•เฅ‹' }, sending: { english: 'Sending...', spanish: 'Enviando...', brazilian_portuguese: 'Enviando...', tok_pisin: 'Salim...', - indonesian: 'Mengirim...' + indonesian: 'Mengirim...', + nepali: 'เคชเค เคพเค‰เคเคฆเฅˆ...' }, failedToRemoveMember: { english: 'Failed to remove member', spanish: 'Error al eliminar miembro', brazilian_portuguese: 'Falha ao remover membro', tok_pisin: 'I no inap rausim member', - indonesian: 'Gagal menghapus anggota' + indonesian: 'Gagal menghapus anggota', + nepali: 'เคธเคฆเคธเฅเคฏ เคนเคŸเคพเค‰เคจ เค…เคธเคซเคฒ' }, failedToPromoteMember: { english: 'Failed to promote member', spanish: 'Error al promover miembro', brazilian_portuguese: 'Falha ao promover membro', tok_pisin: 'I no inap promotim member', - indonesian: 'Gagal mempromosikan anggota' + indonesian: 'Gagal mempromosikan anggota', + nepali: 'เคธเคฆเคธเฅเคฏเคฒเคพเคˆ เคชเฅเคฐเคฎเฅ‹เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedToLeaveProject: { english: 'Failed to leave project', spanish: 'Error al abandonar el proyecto', brazilian_portuguese: 'Falha ao sair do projeto', tok_pisin: 'I no inap lusim project', - indonesian: 'Gagal meninggalkan proyek' + indonesian: 'Gagal meninggalkan proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เฅ‹เคกเฅเคจ เค…เคธเคซเคฒ' }, failedToWithdrawInvitation: { english: 'Failed to withdraw invitation', spanish: 'Error al retirar la invitaciรณn', brazilian_portuguese: 'Falha ao retirar o convite', tok_pisin: 'I no inap rausim invitation', - indonesian: 'Gagal menarik undangan' + indonesian: 'Gagal menarik undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจ เค…เคธเคซเคฒ' }, failedToSendInvitation: { english: 'Failed to send invitation', spanish: 'Error al enviar la invitaciรณn', brazilian_portuguese: 'Falha ao enviar o convite', tok_pisin: 'I no inap salim invitation', - indonesian: 'Gagal mengirim undangan' + indonesian: 'Gagal mengirim undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคชเค เคพเค‰เคจ เค…เคธเคซเคฒ' }, privateProject: { english: 'Private Project', spanish: 'Proyecto Privado', brazilian_portuguese: 'Projeto Privado', tok_pisin: 'Private Project', - indonesian: 'Proyek Pribadi' + indonesian: 'Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ' }, privateProjectDescription: { english: @@ -2187,7 +2648,9 @@ export const localizations = { tok_pisin: 'Dispela project i open long olgeta man long lukim, tasol ol member tasol ken contributim.', indonesian: - 'Proyek terbuka untuk dilihat oleh siapa saja, tetapi hanya anggota yang dapat berkontribusi.' + 'Proyek terbuka untuk dilihat oleh siapa saja, tetapi hanya anggota yang dapat berkontribusi.', + nepali: + 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคœเฅ‹เคธเฅเค•เฅˆเคฒเฅ‡ เคนเฅ‡เคฐเฅเคจเค•เฅ‹ เคฒเคพเค—เคฟ เค–เฅเคฒเคพ เค›, เคคเคฐ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฒเฅ‡ เคฎเคพเคคเฅเคฐ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅเฅค' }, privateProjectInfo: { english: @@ -2199,7 +2662,9 @@ export const localizations = { tok_pisin: 'Long contributim long dispela project, yu mas askim membership. Ol owner bilong project bai lukim request bilong yu.', indonesian: - 'Untuk berkontribusi pada proyek ini, Anda perlu meminta keanggotaan. Pemilik proyek akan meninjau permintaan Anda.' + 'Untuk berkontribusi pada proyek ini, Anda perlu meminta keanggotaan. Pemilik proyek akan meninjau permintaan Anda.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ, เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคชเคฐเฅเค›เฅค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฎเคพเคฒเคฟเค•เคนเคฐเฅ‚เคฒเฅ‡ เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคจเฅเคฐเฅ‹เคง เคธเคฎเฅ€เค•เฅเคทเคพ เค—เคฐเฅเคจเฅ‡เค›เคจเฅเฅค' }, privateProjectNotLoggedIn: { english: @@ -2211,7 +2676,8 @@ export const localizations = { tok_pisin: 'Dispela i private project. Yu mas login pastaim long askim access.', indonesian: - 'Ini adalah proyek pribadi. Anda harus masuk untuk meminta akses.' + 'Ini adalah proyek pribadi. Anda harus masuk untuk meminta akses.', + nepali: 'เคฏเฅ‹ เคเค‰เคŸเคพ เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคนเฅ‹เฅค เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคฒเค— เค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค' }, privateProjectLoginRequired: { english: 'Please sign in to request membership to this private project.', @@ -2221,28 +2687,32 @@ export const localizations = { 'Por favor, faรงa login para solicitar associaรงรฃo a este projeto privado.', tok_pisin: 'Plis sign in long askim membership long dispela private project.', - indonesian: 'Silakan masuk untuk meminta keanggotaan proyek pribadi ini.' + indonesian: 'Silakan masuk untuk meminta keanggotaan proyek pribadi ini.', + nepali: 'เค•เฅƒเคชเคฏเคพ เคฏเฅ‹ เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจ เคธเคพเค‡เคจ เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, requestMembership: { english: 'Request Membership', spanish: 'Solicitar Membresรญa', brazilian_portuguese: 'Solicitar Associaรงรฃo', tok_pisin: 'Askim Membership', - indonesian: 'Minta Keanggotaan' + indonesian: 'Minta Keanggotaan', + nepali: 'เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, requesting: { english: 'Requesting...', spanish: 'Solicitando...', brazilian_portuguese: 'Solicitando...', tok_pisin: 'Askim...', - indonesian: 'Meminta...' + indonesian: 'Meminta...', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคฆเฅˆ...' }, requestPending: { english: 'Request Pending', spanish: 'Solicitud Pendiente', brazilian_portuguese: 'Solicitaรงรฃo Pendente', tok_pisin: 'Request i wet', - indonesian: 'Permintaan Tertunda' + indonesian: 'Permintaan Tertunda', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคฌเคพเคเค•เฅ€ เค›' }, requestPendingDescription: { english: 'Your membership request is pending review by the project owners.', @@ -2253,28 +2723,33 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i wet long ol owner bilong project lukim.', indonesian: - 'Permintaan keanggotaan Anda sedang menunggu tinjauan oleh pemilik proyek.' + 'Permintaan keanggotaan Anda sedang menunggu tinjauan oleh pemilik proyek.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฎเคพเคฒเคฟเค•เคนเคฐเฅ‚เคฆเฅเคตเคพเคฐเคพ เคธเคฎเฅ€เค•เฅเคทเคพเค•เฅ‹ เคฒเคพเค—เคฟ เคชเคฐเฅเค–เคฟเคฐเคนเฅ‡เค•เฅ‹ เค›เฅค' }, withdrawRequest: { english: 'Withdraw Request', spanish: 'Retirar Solicitud', brazilian_portuguese: 'Retirar Solicitaรงรฃo', tok_pisin: 'Rausim Request', - indonesian: 'Tarik Permintaan' + indonesian: 'Tarik Permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจเฅเคนเฅ‹เคธเฅ' }, withdrawing: { english: 'Withdrawing...', spanish: 'Retirando...', brazilian_portuguese: 'Retirando...', tok_pisin: 'Rausim...', - indonesian: 'Menarik...' + indonesian: 'Menarik...', + nepali: 'เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคเคฆเฅˆ...' }, confirmWithdraw: { english: 'Withdraw Request', spanish: 'Retirar Solicitud', brazilian_portuguese: 'Retirar Solicitaรงรฃo', tok_pisin: 'Rausim Request', - indonesian: 'Tarik Permintaan' + indonesian: 'Tarik Permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจเฅเคนเฅ‹เคธเฅ' }, confirmWithdrawRequestMessage: { english: 'Are you sure you want to withdraw your membership request?', @@ -2282,21 +2757,24 @@ export const localizations = { brazilian_portuguese: 'Tem certeza de que deseja retirar sua solicitaรงรฃo de associaรงรฃo?', tok_pisin: 'Yu tru laik rausim membership request bilong yu?', - indonesian: 'Apakah Anda yakin ingin menarik permintaan keanggotaan Anda?' + indonesian: 'Apakah Anda yakin ingin menarik permintaan keanggotaan Anda?', + nepali: 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เค†เคซเฅเคจเฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค›?' }, requestWithdrawn: { english: 'Request withdrawn successfully', spanish: 'Solicitud retirada con รฉxito', brazilian_portuguese: 'Solicitaรงรฃo retirada com sucesso', tok_pisin: 'Request i rausim gut', - indonesian: 'Permintaan berhasil ditarik' + indonesian: 'Permintaan berhasil ditarik', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเค‡เคฏเฅ‹' }, requestExpired: { english: 'Request Expired', spanish: 'Solicitud Expirada', brazilian_portuguese: 'Solicitaรงรฃo Expirada', tok_pisin: 'Request i pinis', - indonesian: 'Permintaan Kedaluwarsa' + indonesian: 'Permintaan Kedaluwarsa', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹' }, requestExpiredDescription: { english: @@ -2308,21 +2786,25 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i pinis. Yu ken salim nupela request.', indonesian: - 'Permintaan keanggotaan Anda telah kedaluwarsa. Anda dapat mengirim permintaan baru.' + 'Permintaan keanggotaan Anda telah kedaluwarsa. Anda dapat mengirim permintaan baru.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚ เคจเคฏเคพเค เค…เคจเฅเคฐเฅ‹เคง เคชเฅ‡เคถ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, requestAgain: { english: 'Request Again', spanish: 'Solicitar Nuevamente', brazilian_portuguese: 'Solicitar Novamente', tok_pisin: 'Askim Gen', - indonesian: 'Minta Lagi' + indonesian: 'Minta Lagi', + nepali: 'เคซเฅ‡เคฐเคฟ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, requestDeclined: { english: 'Request Declined', spanish: 'Solicitud Rechazada', brazilian_portuguese: 'Solicitaรงรฃo Recusada', tok_pisin: 'Request i no', - indonesian: 'Permintaan Ditolak' + indonesian: 'Permintaan Ditolak', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เฅƒเคค' }, requestDeclinedCanRetry: { english: @@ -2334,7 +2816,9 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i no. Yu gat {attempts} moa chance long askim membership.', indonesian: - 'Permintaan keanggotaan Anda ditolak. Anda memiliki {attempts} percobaan lagi untuk meminta keanggotaan.' + 'Permintaan keanggotaan Anda ditolak. Anda memiliki {attempts} percobaan lagi untuk meminta keanggotaan.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚เคธเคเค— เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจ {attempts} เคฅเคช เคชเฅเคฐเคฏเคพเคธเคนเคฐเฅ‚ เคฌเคพเคเค•เฅ€ เค›เคจเฅเฅค' }, requestDeclinedNoRetry: { english: @@ -2346,12 +2830,15 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i no na yu kamap long maximum number bilong chance.', indonesian: - 'Permintaan keanggotaan Anda ditolak dan Anda telah mencapai jumlah maksimum percobaan.' + 'Permintaan keanggotaan Anda ditolak dan Anda telah mencapai jumlah maksimum percobaan.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹ เคฐ เคคเคชเคพเคˆเค‚เคฒเฅ‡ เค…เคงเคฟเค•เคคเคฎ เคชเฅเคฐเคฏเคพเคธเคนเคฐเฅ‚เค•เฅ‹ เคธเฅ€เคฎเคพ เคชเฅเค—เคฟเคธเค•เฅเคจเฅเคญเคฏเฅ‹เฅค' }, requestWithdrawnTitle: { english: 'Request Withdrawn', spanish: 'Solicitud Retirada', brazilian_portuguese: 'Solicitaรงรฃo Retirada', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเค‡เคฏเฅ‹', tok_pisin: 'Request i Rausim', indonesian: 'Permintaan Ditarik' }, @@ -2365,56 +2852,65 @@ export const localizations = { tok_pisin: 'Yu rausim membership request bilong yu. Yu ken salim nupela request long wanem taim.', indonesian: - 'Anda telah menarik permintaan keanggotaan Anda. Anda dapat mengirim permintaan baru kapan saja.' + 'Anda telah menarik permintaan keanggotaan Anda. Anda dapat mengirim permintaan baru kapan saja.', + nepali: + 'เคคเคชเคพเคˆเค‚เคฒเฅ‡ เค†เคซเฅเคจเฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจเฅเคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚ เคœเฅเคจเคธเฅเค•เฅˆ เคธเคฎเคฏ เคจเคฏเคพเค เค…เคจเฅเคฐเฅ‹เคง เคชเฅ‡เคถ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, membershipRequestSent: { english: 'Membership request sent successfully', spanish: 'Solicitud de membresรญa enviada con รฉxito', brazilian_portuguese: 'Solicitaรงรฃo de associaรงรฃo enviada com sucesso', tok_pisin: 'Membership request i go gut', - indonesian: 'Permintaan keanggotaan berhasil dikirim' + indonesian: 'Permintaan keanggotaan berhasil dikirim', + nepali: 'เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเค เคพเค‡เคฏเฅ‹' }, failedToRequestMembership: { english: 'Failed to request membership', spanish: 'Error al solicitar membresรญa', brazilian_portuguese: 'Falha ao solicitar associaรงรฃo', tok_pisin: 'I no inap askim membership', - indonesian: 'Gagal meminta keanggotaan' + indonesian: 'Gagal meminta keanggotaan', + nepali: 'เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedToWithdrawRequest: { english: 'Failed to withdraw request', spanish: 'Error al retirar la solicitud', brazilian_portuguese: 'Falha ao retirar a solicitaรงรฃo', tok_pisin: 'I no inap rausim request', - indonesian: 'Gagal menarik permintaan' + indonesian: 'Gagal menarik permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจ เค…เคธเคซเคฒ' }, goBack: { english: 'Go Back', spanish: 'Volver', brazilian_portuguese: 'Voltar', tok_pisin: 'Go Bek', - indonesian: 'Kembali' + indonesian: 'Kembali', + nepali: 'เคชเค›เคพเคกเคฟ เคœเคพเคจเฅเคนเฅ‹เคธเฅ' }, back: { english: 'Back', spanish: 'Atrรกs', brazilian_portuguese: 'Voltar', tok_pisin: 'Go Bek', - indonesian: 'Kembali' + indonesian: 'Kembali', + nepali: 'เคชเค›เคพเคกเคฟ' }, confirmRemove: { english: 'Confirm Remove', spanish: 'Confirmar Eliminaciรณn', brazilian_portuguese: 'Confirmar Remoรงรฃo', tok_pisin: 'Confirm Rausim', - indonesian: 'Konfirmasi Hapus' + indonesian: 'Konfirmasi Hapus', + nepali: 'เคนเคŸเคพเค‰เคจเฅ‡ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, invitationResent: { english: 'Invitation resent successfully', spanish: 'Invitaciรณn reenviada con รฉxito', brazilian_portuguese: 'Convite reenviado com sucesso', tok_pisin: 'Invitation i salim gen gut', - indonesian: 'Undangan berhasil dikirim ulang' + indonesian: 'Undangan berhasil dikirim ulang', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเฅเคจ: เคชเค เคพเค‡เคฏเฅ‹' }, maxInviteAttemptsReached: { english: 'Maximum invitation attempts reached for this email', @@ -2423,7 +2919,8 @@ export const localizations = { brazilian_portuguese: 'Nรบmero mรกximo de tentativas de convite atingido para este e-mail', tok_pisin: 'Maximum invitation chance i pinis long dispela email', - indonesian: 'Percobaan undangan maksimum tercapai untuk email ini' + indonesian: 'Percobaan undangan maksimum tercapai untuk email ini', + nepali: 'เคฏเฅ‹ เค‡เคฎเฅ‡เคฒเค•เฅ‹ เคฒเคพเค—เคฟ เค…เคงเคฟเค•เคคเคฎ เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคชเฅเคฐเคฏเคพเคธเคนเคฐเฅ‚เค•เฅ‹ เคธเฅ€เคฎเคพ เคชเฅเค—เฅเคฏเฅ‹' }, invitationAcceptedButDownloadFailed: { english: @@ -2435,42 +2932,49 @@ export const localizations = { tok_pisin: 'Invitation i orait, tasol project download i no inap. Yu ken download em bihain long projects page.', indonesian: - 'Undangan diterima, tetapi unduhan proyek gagal. Anda dapat mengunduhnya nanti dari halaman proyek.' + 'Undangan diterima, tetapi unduhan proyek gagal. Anda dapat mengunduhnya nanti dari halaman proyek.', + nepali: + 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹, เคคเคฐ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค…เคธเคซเคฒ เคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚ เคฏเคธเคฒเคพเคˆ เคชเค›เคฟ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚ เคชเฅƒเคทเฅเค เคฌเคพเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, invitationAcceptedSuccess: { english: 'Invitation accepted successfully!', spanish: 'ยกInvitaciรณn aceptada con รฉxito!', brazilian_portuguese: 'Convite aceito com sucesso!', tok_pisin: 'Invitation i akseptim gut!', - indonesian: 'Undangan berhasil diterima!' + indonesian: 'Undangan berhasil diterima!', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹!' }, invitationDeclined: { english: 'Invitation declined.', spanish: 'Invitaciรณn rechazada.', brazilian_portuguese: 'Convite recusado.', tok_pisin: 'Invitation i no.', - indonesian: 'Undangan ditolak.' + indonesian: 'Undangan ditolak.', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เค…เคธเฅเคตเฅ€เค•เฅƒเคคเฅค' }, joinRequest: { english: 'Join Request', spanish: 'Solicitud de Uniรณn', brazilian_portuguese: 'Solicitaรงรฃo de Adesรฃo', tok_pisin: 'Join Request', - indonesian: 'Permintaan Bergabung' + indonesian: 'Permintaan Bergabung', + nepali: 'เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจเฅ‡ เค…เคจเฅเคฐเฅ‹เคง' }, privateProjectAccess: { english: 'Private Project Access', spanish: 'Acceso a Proyecto Privado', brazilian_portuguese: 'Acesso ao Projeto Privado', tok_pisin: 'Private Project Access', - indonesian: 'Akses Proyek Pribadi' + indonesian: 'Akses Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคชเคนเฅเคเคš' }, privateProjectDownload: { english: 'Private Project Download', spanish: 'Descarga de Proyecto Privado', brazilian_portuguese: 'Download de Projeto Privado', tok_pisin: 'Private Project Download', - indonesian: 'Unduh Proyek Pribadi' + indonesian: 'Unduh Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก' }, privateProjectDownloadMessage: { english: @@ -2482,14 +2986,17 @@ export const localizations = { tok_pisin: 'Dispela project i private. Yu ken download content tasol yu no inap contributim translation o vote. Askim access long joinim dispela project na startim contributim.', indonesian: - 'Proyek ini pribadi. Anda dapat mengunduh konten tetapi tidak akan dapat berkontribusi terjemahan atau suara. Minta akses untuk bergabung dengan proyek ini dan mulai berkontribusi.' + 'Proyek ini pribadi. Anda dapat mengunduh konten tetapi tidak akan dapat berkontribusi terjemahan atau suara. Minta akses untuk bergabung dengan proyek ini dan mulai berkontribusi.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคœเฅ€ เค›เฅค เคคเคชเคพเคˆเค‚ เคธเคพเคฎเค—เฅเคฐเฅ€ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค› เคคเคฐ เค…เคจเฅเคตเคพเคฆ เคตเคพ เคฎเคคเคนเคฐเฅ‚ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคธเค•เฅเคทเคฎ เคนเฅเคจเฅเคนเฅเคจเฅ‡ เค›เฅˆเคจเฅค เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคฐ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคธเฅเคฐเฅ เค—เคฐเฅเคจ เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, privateProjectEditing: { english: 'Private Project Editing', spanish: 'Ediciรณn de Proyecto Privado', brazilian_portuguese: 'Ediรงรฃo de Projeto Privado', tok_pisin: 'Private Project Editing', - indonesian: 'Pengeditan Proyek Pribadi' + indonesian: 'Pengeditan Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฎเฅเคชเคพเคฆเคจ' }, privateProjectEditingMessage: { english: @@ -2501,7 +3008,9 @@ export const localizations = { tok_pisin: 'Dispela project i private. Yu mas stap member long editim transcription. Askim access long joinim dispela project.', indonesian: - 'Proyek ini pribadi. Anda perlu menjadi anggota untuk mengedit transkripsi. Minta akses untuk bergabung dengan proyek ini.' + 'Proyek ini pribadi. Anda perlu menjadi anggota untuk mengedit transkripsi. Minta akses untuk bergabung dengan proyek ini.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคœเฅ€ เค›เฅค เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ เคธเคฎเฅเคชเคพเคฆเคจ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคธเคฆเคธเฅเคฏ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, privateProjectGenericMessage: { english: @@ -2513,14 +3022,17 @@ export const localizations = { tok_pisin: 'Dispela project i private. Yu mas stap member long usim dispela feature. Askim access long joinim dispela project.', indonesian: - 'Proyek ini pribadi. Anda perlu menjadi anggota untuk mengakses fitur ini. Minta akses untuk bergabung dengan proyek ini.' + 'Proyek ini pribadi. Anda perlu menjadi anggota untuk mengakses fitur ini. Minta akses untuk bergabung dengan proyek ini.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคœเฅ€ เค›เฅค เคฏเฅ‹ เคธเฅเคตเคฟเคงเคพ เคชเคนเฅเคเคš เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคธเคฆเคธเฅเคฏ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, privateProjectMembers: { english: 'Private Project Members', spanish: 'Miembros del Proyecto Privado', brazilian_portuguese: 'Membros do Projeto Privado', tok_pisin: 'Private Project Members', - indonesian: 'Anggota Proyek Pribadi' + indonesian: 'Anggota Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚' }, privateProjectMembersMessage: { english: @@ -2532,7 +3044,9 @@ export const localizations = { tok_pisin: 'Yu mas stap member long lukim member list na salim invitation. Askim access long joinim dispela project.', indonesian: - 'Anda perlu menjadi anggota untuk melihat daftar anggota dan mengirim undangan. Minta akses untuk bergabung dengan proyek ini.' + 'Anda perlu menjadi anggota untuk melihat daftar anggota dan mengirim undangan. Minta akses untuk bergabung dengan proyek ini.', + nepali: + 'เคธเคฆเคธเฅเคฏ เคธเฅ‚เคšเฅ€ เคนเฅ‡เคฐเฅเคจ เคฐ เค†เคฎเคจเฅเคคเฅเคฐเคฃเคนเคฐเฅ‚ เคชเค เคพเค‰เคจ เคคเคชเคพเคˆเค‚ เคธเคฆเคธเฅเคฏ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, privateProjectNotLoggedInInline: { english: 'You need to be logged in to access this private project.', @@ -2540,14 +3054,16 @@ export const localizations = { brazilian_portuguese: 'Vocรช precisa estar logado para acessar este projeto privado.', tok_pisin: 'Yu mas login pastaim long access dispela private project.', - indonesian: 'Anda perlu masuk untuk mengakses proyek pribadi ini.' + indonesian: 'Anda perlu masuk untuk mengakses proyek pribadi ini.', + nepali: 'เคฏเฅ‹ เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคชเคนเฅเคเคš เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคฒเค— เค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค' }, privateProjectTranslation: { english: 'Private Project Translation', spanish: 'Traducciรณn de Proyecto Privado', brazilian_portuguese: 'Traduรงรฃo de Projeto Privado', tok_pisin: 'Private Project Translation', - indonesian: 'Terjemahan Proyek Pribadi' + indonesian: 'Terjemahan Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค…เคจเฅเคตเคพเคฆ' }, privateProjectTranslationMessage: { english: @@ -2559,14 +3075,17 @@ export const localizations = { tok_pisin: 'Dispela project i private. Yu mas stap member long salim translation. Askim access long joinim dispela project.', indonesian: - 'Proyek ini pribadi. Anda perlu menjadi anggota untuk mengirim terjemahan. Minta akses untuk bergabung dengan proyek ini.' + 'Proyek ini pribadi. Anda perlu menjadi anggota untuk mengirim terjemahan. Minta akses untuk bergabung dengan proyek ini.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคœเฅ€ เค›เฅค เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚ เคชเฅ‡เคถ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคธเคฆเคธเฅเคฏ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, privateProjectVoting: { english: 'Private Project Voting', spanish: 'Votaciรณn de Proyecto Privado', brazilian_portuguese: 'Votaรงรฃo de Projeto Privado', tok_pisin: 'Private Project Voting', - indonesian: 'Pemungutan Suara Proyek Pribadi' + indonesian: 'Pemungutan Suara Proyek Pribadi', + nepali: 'เคจเคฟเคœเฅ€ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฎเคคเคฆเคพเคจ' }, privateProjectVotingMessage: { english: @@ -2578,14 +3097,17 @@ export const localizations = { tok_pisin: 'Dispela project i private. Yu mas stap member long vote long translation. Askim access long joinim dispela project.', indonesian: - 'Proyek ini pribadi. Anda perlu menjadi anggota untuk memilih terjemahan. Minta akses untuk bergabung dengan proyek ini.' + 'Proyek ini pribadi. Anda perlu menjadi anggota untuk memilih terjemahan. Minta akses untuk bergabung dengan proyek ini.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคœเฅ€ เค›เฅค เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚เคฎเคพ เคฎเคคเคฆเคพเคจ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เคธเคฆเคธเฅเคฏ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคชเคนเฅเคเคš เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, projectInvitation: { english: 'Project Invitation', spanish: 'Invitaciรณn al Proyecto', brazilian_portuguese: 'Convite para o Projeto', tok_pisin: 'Project Invitation', - indonesian: 'Undangan Proyek' + indonesian: 'Undangan Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค†เคฎเคจเฅเคคเฅเคฐเคฃ' }, projectInvitationFrom: { english: '{sender} has invited you to join project "{project}" as {role}', @@ -2596,21 +3118,26 @@ export const localizations = { tok_pisin: '{sender} i salim yu long joinim project "{project}" long {role}', indonesian: - '{sender} mengundang Anda untuk bergabung dengan proyek "{project}" sebagai {role}' + '{sender} mengundang Anda untuk bergabung dengan proyek "{project}" sebagai {role}', + nepali: + '{sender} เคฒเฅ‡ เคคเคชเคพเคˆเค‚เคฒเคพเคˆ "{project}" เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ {role} เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค†เคฎเคจเฅเคคเฅเคฐเคฃ เค—เคฐเฅเคจเฅเคญเคฏเฅ‹' }, projectJoinRequestFrom: { english: '{sender} has requested to join project "{project}" as {role}', spanish: '{sender} ha solicitado unirse al proyecto "{project}" como {role}', brazilian_portuguese: - '{sender} solicitou participar do projeto "{project}" como {role}' + '{sender} solicitou participar do projeto "{project}" como {role}', + nepali: + '{sender} เคฒเฅ‡ "{project}" เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ {role} เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคญเคฏเฅ‹' }, projectWillRemainDownloaded: { english: 'Project will remain downloaded', spanish: 'El proyecto permanecerรก descargado', brazilian_portuguese: 'O projeto permanecerรก baixado', tok_pisin: 'Project i pinis download', - indonesian: 'Proyek akan tetap diunduh' + indonesian: 'Proyek akan tetap diunduh', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เคญเคเค•เฅ‹ เคฐเคนเคจเฅ‡เค›' }, requestExpiredAttemptsRemaining: { english: @@ -2622,7 +3149,9 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i pinis long 7 days. Yu gat {attempts} chance long attempt{plural}.', indonesian: - 'Permintaan keanggotaan Anda telah kedaluwarsa setelah 7 hari. Anda memiliki {attempts} percobaan{plural} tersisa.' + 'Permintaan keanggotaan Anda telah kedaluwarsa setelah 7 hari. Anda memiliki {attempts} percobaan{plural} tersisa.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคจเฅเคฐเฅ‹เคง เฅญ เคฆเคฟเคจ เคชเค›เคฟ เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚เคธเคเค— {attempts} เคชเฅเคฐเคฏเคพเคธ{plural} เคฌเคพเคเค•เฅ€ เค›เฅค' }, requestExpiredInline: { english: @@ -2634,7 +3163,9 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i pinis long 7 days. Yu gat {attempts} chance long attempt{plural}.', indonesian: - 'Permintaan keanggotaan Anda sebelumnya telah kedaluwarsa setelah 7 hari. Anda memiliki {attempts} percobaan{plural} tersisa.' + 'Permintaan keanggotaan Anda sebelumnya telah kedaluwarsa setelah 7 hari. Anda memiliki {attempts} percobaan{plural} tersisa.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เค˜เคฟเคฒเฅเคฒเฅ‹ เค…เคจเฅเคฐเฅ‹เคง เฅญ เคฆเคฟเคจ เคชเค›เคฟ เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚เคธเคเค— {attempts} เคชเฅเคฐเคฏเคพเคธ{plural} เคฌเคพเคเค•เฅ€ เค›เฅค' }, requestExpiredNoAttempts: { english: 'Your request expired and you have no more attempts remaining.', @@ -2644,7 +3175,8 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i pinis na yu no gat moa chance long attempt.', indonesian: - 'Permintaan keanggotaan Anda telah kedaluwarsa dan Anda tidak memiliki percobaan tersisa.' + 'Permintaan keanggotaan Anda telah kedaluwarsa dan Anda tidak memiliki percobaan tersisa.', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคจเฅเคฐเฅ‹เคง เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹ เคฐ เคคเคชเคพเคˆเค‚เคธเคเค— เคฅเคช เคชเฅเคฐเคฏเคพเคธเคนเคฐเฅ‚ เคฌเคพเคเค•เฅ€ เค›เฅˆเคจเฅค' }, requestExpiredNoAttemptsInline: { english: @@ -2656,7 +3188,9 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i pinis long 7 days na yu no gat moa chance long attempt.', indonesian: - 'Permintaan keanggotaan Anda sebelumnya telah kedaluwarsa setelah 7 hari dan Anda tidak memiliki percobaan tersisa.' + 'Permintaan keanggotaan Anda sebelumnya telah kedaluwarsa setelah 7 hari dan Anda tidak memiliki percobaan tersisa.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เค˜เคฟเคฒเฅเคฒเฅ‹ เค…เคจเฅเคฐเฅ‹เคง เฅญ เคฆเคฟเคจ เคชเค›เคฟ เคธเคฎเคพเคชเฅเคค เคญเคฏเฅ‹ เคฐ เคคเคชเคพเคˆเค‚เคธเคเค— เคฅเคช เคชเฅเคฐเคฏเคพเคธเคนเคฐเฅ‚ เคฌเคพเคเค•เฅ€ เค›เฅˆเคจเฅค' }, requestPendingInline: { english: @@ -2668,7 +3202,9 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i pinis long approval. Yu ken salim notification long review.', indonesian: - 'Permintaan keanggotaan Anda sedang menunggu persetujuan. Anda akan diberitahu ketika sudah diperiksa.' + 'Permintaan keanggotaan Anda sedang menunggu persetujuan. Anda akan diberitahu ketika sudah diperiksa.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เคธเฅเคตเฅ€เค•เฅƒเคคเคฟเค•เฅ‹ เคฒเคพเค—เคฟ เคชเคฐเฅเค–เคฟเคฐเคนเฅ‡เค•เฅ‹ เค›เฅค เคฏเคธเค•เฅ‹ เคธเคฎเฅ€เค•เฅเคทเคพ เคนเฅเคเคฆเคพ เคคเคชเคพเคˆเค‚เคฒเคพเคˆ เคธเฅ‚เคšเคฟเคค เค—เคฐเคฟเคจเฅ‡เค›เฅค' }, requestDeclinedInline: { english: @@ -2680,18 +3216,21 @@ export const localizations = { tok_pisin: 'Membership request bilong yu i no. Yu gat {attempts} chance long attempt{plural}.', indonesian: - 'Permintaan keanggotaan Anda ditolak. Anda memiliki {attempts} percobaan{plural} tersisa.' + 'Permintaan keanggotaan Anda ditolak. Anda memiliki {attempts} percobaan{plural} tersisa.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚เคธเคเค— {attempts} เคชเฅเคฐเคฏเคพเคธ{plural} เคฌเคพเคเค•เฅ€ เค›เฅค' }, requestDeclinedNoRetryInline: { english: 'Your request was declined and you have no more attempts remaining.', spanish: 'Su solicitud fue rechazada y no te quedan mรกs intentos.', brazilian_portuguese: - 'Sua solicitaรงรฃo foi recusada e vocรช nรฃo tem mais tentativas restantes.', + 'Sua solicitaciรณn fue recusada e vocรช nรฃo tem mais tentativas restantes.', tok_pisin: 'Membership request bilong yu i no na yu no gat moa chance long attempt.', indonesian: - 'Permintaan keanggotaan Anda ditolak dan Anda tidak memiliki percobaan tersisa.' + 'Permintaan keanggotaan Anda ditolak dan Anda tidak memiliki percobaan tersisa.', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹ เคฐ เคคเคชเคพเคˆเค‚เคธเคเค— เคฅเคช เคชเฅเคฐเคฏเคพเคธเคนเคฐเฅ‚ เคฌเคพเคเค•เฅ€ เค›เฅˆเคจเฅค' }, requestWithdrawnInline: { english: @@ -2703,21 +3242,25 @@ export const localizations = { tok_pisin: 'Yu rausim membership request bilong yu. Yu ken salim nupela request long wanem taim.', indonesian: - 'Anda telah menarik permintaan keanggotaan Anda sebelumnya. Anda dapat mengirim permintaan baru kapan saja.' + 'Anda telah menarik permintaan keanggotaan Anda sebelumnya. Anda dapat mengirim permintaan baru kapan saja.', + nepali: + 'เคคเคชเคพเคˆเค‚เคฒเฅ‡ เค†เคซเฅเคจเฅ‹ เค…เค˜เคฟเคฒเฅเคฒเฅ‹ เค…เคจเฅเคฐเฅ‹เคง เคซเคฟเคฐเฅเคคเคพ เคฒเคฟเคจเฅเคญเคฏเฅ‹เฅค เคคเคชเคพเคˆเค‚ เคœเฅเคจเคธเฅเค•เฅˆ เคฌเฅ‡เคฒเคพ เคจเคฏเคพเค เค…เคจเฅเคฐเฅ‹เคง เคชเค เคพเค‰เคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, viewProject: { english: 'View Project', spanish: 'Ver Proyecto', brazilian_portuguese: 'Ver Projeto', tok_pisin: 'View Project', - indonesian: 'Lihat Proyek' + indonesian: 'Lihat Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, loadingProjectDetails: { english: 'Loading project details...', spanish: 'Cargando detalles del proyecto...', brazilian_portuguese: 'Carregando detalhes do projeto...', tok_pisin: 'Loadim project details...', - indonesian: 'Memuat detail proyek...' + indonesian: 'Memuat detail proyek...', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคตเคฟเคตเคฐเคฃ เคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ...' }, onlyOwnersCanInvite: { english: 'Only project owners can invite new members', @@ -2726,14 +3269,16 @@ export const localizations = { brazilian_portuguese: 'Apenas proprietรกrios do projeto podem convidar novos membros', tok_pisin: 'Only owner i project i salim member', - indonesian: 'Hanya pemilik proyek yang dapat mengundang anggota baru' + indonesian: 'Hanya pemilik proyek yang dapat mengundang anggota baru', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฎเคพเคฒเคฟเค•เคนเคฐเฅ‚เคฒเฅ‡ เคฎเคพเคคเฅเคฐ เคจเคฏเคพเค เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เคฒเคพเคˆ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅ' }, failedToResendInvitation: { english: 'Failed to resend invitation', spanish: 'Error al reenviar invitaciรณn', brazilian_portuguese: 'Falha ao reenviar convite', tok_pisin: 'I no inap resendim invitation', - indonesian: 'Gagal mengirim ulang undangan' + indonesian: 'Gagal mengirim ulang undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคชเฅเคจ: เคชเค เคพเค‰เคจ เค…เคธเคซเคฒ' }, // Restore-related translations restoreAndroidOnly: { @@ -2741,42 +3286,48 @@ export const localizations = { spanish: 'La restauraciรณn solo estรก disponible en Android', brazilian_portuguese: 'A restauraรงรฃo sรณ estรก disponรญvel no Android', tok_pisin: 'Restore i pinis long Android', - indonesian: 'Pemulihan hanya tersedia di Android' + indonesian: 'Pemulihan hanya tersedia di Android', + nepali: 'เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เคเคจเฅเคกเฅเคฐเฅ‹เค‡เคกเคฎเคพ เคฎเคพเคคเฅเคฐ เค‰เคชเคฒเคฌเฅเคง เค›' }, backupAndroidOnly: { english: 'Backup is only available on Android', spanish: 'El respaldo solo estรก disponible en Android', brazilian_portuguese: 'O backup sรณ estรก disponรญvel no Android', tok_pisin: 'Backup i pinis long Android', - indonesian: 'Cadangan hanya tersedia di Android' + indonesian: 'Cadangan hanya tersedia di Android', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เคเคจเฅเคกเฅเคฐเฅ‹เค‡เคกเคฎเคพ เคฎเคพเคคเฅเคฐ เค‰เคชเคฒเคฌเฅเคง เค›' }, permissionDenied: { english: 'Permission Denied', spanish: 'Permiso Denegado', brazilian_portuguese: 'Permissรฃo Negada', tok_pisin: 'Permission i no', - indonesian: 'Izin Ditolak' + indonesian: 'Izin Ditolak', + nepali: 'เค…เคจเฅเคฎเคคเคฟ เค…เคธเฅเคตเฅ€เค•เฅƒเคค' }, grantMicrophonePermission: { english: 'Grant Microphone Permission', spanish: 'Otorgar Permiso de Microfono', brazilian_portuguese: 'Conceder Permissรฃo de Microfone', tok_pisin: 'Grant Microphone Permission', - indonesian: 'Mengakses Mikrofon' + indonesian: 'Mengakses Mikrofon', + nepali: 'เคฎเคพเค‡เค•เฅเคฐเฅ‹เคซเฅ‹เคจ เค…เคจเฅเคฎเคคเคฟ เคฆเคฟเคจเฅเคนเฅ‹เคธเฅ' }, autoCalibrate: { english: 'Auto-Calibrate', spanish: 'Auto-Calibrar', brazilian_portuguese: 'Auto-Calibrar', tok_pisin: 'Olsem wanem yet', - indonesian: 'Auto-Kalibrasi' + indonesian: 'Auto-Kalibrasi', + nepali: 'เคธเฅเคตเคค: เค•เฅเคฏเคพเคฒเคฟเคฌเฅเคฐเฅ‡เคŸ' }, calibrateMicrophone: { english: 'Calibrate your microphone', spanish: 'Calibra tu micrรณfono', brazilian_portuguese: 'Calibre seu microfone', tok_pisin: 'Stretim mikrofon bilong yu', - indonesian: 'Kalibrasi mikrofon Anda' + indonesian: 'Kalibrasi mikrofon Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคฎเคพเค‡เค•เฅเคฐเฅ‹เคซเฅ‹เคจ เค•เฅเคฏเคพเคฒเคฟเคฌเฅเคฐเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, calibrateMicrophoneDescription: { english: 'Let us automatically adjust the sensitivity for your environment', @@ -2787,21 +3338,25 @@ export const localizations = { tok_pisin: 'Larim mipela stretim sensetiviti bilong mikrofon long ples bilong yu', indonesian: - 'Biarkan kami secara otomatis menyesuaikan sensitivitas untuk lingkungan Anda' + 'Biarkan kami secara otomatis menyesuaikan sensitivitas untuk lingkungan Anda', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคตเคพเคคเคพเคตเคฐเคฃเค•เฅ‹ เคฒเคพเค—เคฟ เคธเฅเคตเคšเคพเคฒเคฟเคค เคฐเฅ‚เคชเคฎเคพ เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒเคคเคพ เคธเคฎเคพเคฏเฅ‹เคœเคจ เค—เคฐเฅเคจ เคฆเคฟเคจเฅเคนเฅ‹เคธเฅ' }, skip: { english: 'Skip', spanish: 'Omitir', brazilian_portuguese: 'Pular', tok_pisin: 'Lusim', - indonesian: 'Lewati' + indonesian: 'Lewati', + nepali: 'เค›เฅ‹เคกเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmAudioRestore: { english: 'Confirm Audio Restore', spanish: 'Confirmar Restauraciรณn de Audio', brazilian_portuguese: 'Confirmar Restauraรงรฃo de รudio', tok_pisin: 'Confirm Audio Restore', - indonesian: 'Konfirmasi Pemulihan Audio' + indonesian: 'Konfirmasi Pemulihan Audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmAudioRestoreMessage: { english: 'This will restore your audio files from the backup. Continue?', @@ -2810,21 +3365,25 @@ export const localizations = { brazilian_portuguese: 'Isso restaurarรก seus arquivos de รกudio do backup. Continuar?', tok_pisin: 'This i restore audio file bilong backup. Continue?', - indonesian: 'Ini akan memulihkan file audio Anda dari cadangan. Lanjutkan?' + indonesian: 'Ini akan memulihkan file audio Anda dari cadangan. Lanjutkan?', + nepali: + 'เคฏเคธเคฒเฅ‡ เคคเคชเคพเคˆเค‚เค•เฅ‹ เค…เคกเคฟเคฏเฅ‹ เคซเคพเค‡เคฒเคนเคฐเฅ‚ เคฌเฅเคฏเคพเค•เค…เคชเคฌเคพเคŸ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคฟเคค เค—เคฐเฅเคจเฅ‡เค›เฅค เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจเฅ‡?' }, restoreAudioOnly: { english: 'Restore Audio', spanish: 'Restaurar Audio', brazilian_portuguese: 'Restaurar รudio', tok_pisin: 'Restore Audio', - indonesian: 'Pemulihan Audio' + indonesian: 'Pemulihan Audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, failedRestore: { english: 'Failed to restore: {error}', spanish: 'Error al restaurar: {error}', brazilian_portuguese: 'Falha ao restaurar: {error}', tok_pisin: 'I no inap restore: {error}', - indonesian: 'Gagal memulihkan: {error}' + indonesian: 'Gagal memulihkan: {error}', + nepali: 'เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ: {error}' }, restoreCompleteBase: { english: @@ -2836,42 +3395,49 @@ export const localizations = { tok_pisin: 'Restore i pinis long {audioCopied} audio file i copy. {audioSkippedDueToError} i skip long error.', indonesian: - 'Pemulihan selesai: {audioCopied} file audio disalin, {audioSkippedDueToError} dilewatkan karena kesalahan' + 'Pemulihan selesai: {audioCopied} file audio disalin, {audioSkippedDueToError} dilewatkan karena kesalahan', + nepali: + 'เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹: {audioCopied} เค…เคกเคฟเคฏเฅ‹ เคซเคพเค‡เคฒเคนเคฐเฅ‚ เค•เคชเฅ€ เค—เคฐเคฟเคฏเฅ‹, {audioSkippedDueToError} เคคเฅเคฐเฅเคŸเคฟเคนเคฐเฅ‚เค•เคพ เค•เคพเคฐเคฃ เค›เฅ‹เคกเคฟเคฏเฅ‹' }, restoreSkippedLocallyPart: { english: ', {audioSkippedLocally} skipped (already exists)', spanish: ', {audioSkippedLocally} omitidos (ya existen)', brazilian_portuguese: ', {audioSkippedLocally} ignorados (jรก existem)', tok_pisin: ', {audioSkippedLocally} i skip long local.', - indonesian: ', {audioSkippedLocally} dilewatkan (sudah ada)' + indonesian: ', {audioSkippedLocally} dilewatkan (sudah ada)', + nepali: ', {audioSkippedLocally} เค›เฅ‹เคกเคฟเคฏเฅ‹ (เคชเคนเคฟเคฒเฅ‡ เคจเฅˆ เค…เคตเคธเฅเคฅเคฟเคค เค›)' }, restoreCompleteTitle: { english: 'Restore Complete', spanish: 'Restauraciรณn Completa', brazilian_portuguese: 'Restauraรงรฃo Concluรญda', tok_pisin: 'Restore Complete', - indonesian: 'Pemulihan Selesai' + indonesian: 'Pemulihan Selesai', + nepali: 'เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹' }, restoreFailedTitle: { english: 'Restore Failed: {error}', spanish: 'Restauraciรณn Fallida: {error}', brazilian_portuguese: 'Restauraรงรฃo Falhou: {error}', tok_pisin: 'Restore i no: {error}', - indonesian: 'Pemulihan Gagal: {error}' + indonesian: 'Pemulihan Gagal: {error}', + nepali: 'เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค…เคธเคซเคฒ: {error}' }, projectInvitationTitle: { english: 'Project Invitation', spanish: 'Invitaciรณn al Proyecto', brazilian_portuguese: 'Convite para o Projeto', tok_pisin: 'Project Invitation', - indonesian: 'Undangan Proyek' + indonesian: 'Undangan Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค†เคฎเคจเฅเคคเฅเคฐเคฃ' }, joinRequestTitle: { english: 'Join Request', spanish: 'Solicitud de Uniรณn', brazilian_portuguese: 'Solicitaรงรฃo de Adesรฃo', tok_pisin: 'Join Request', - indonesian: 'Permintaan Bergabung' + indonesian: 'Permintaan Bergabung', + nepali: 'เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจเฅ‡ เค…เคจเฅเคฐเฅ‹เคง' }, invitedYouToJoin: { english: '{sender} invited you to join "{project}" as {role}', @@ -2881,7 +3447,9 @@ export const localizations = { tok_pisin: '{sender} i salim yu long joinim project "{project}" long {role}', indonesian: - '{sender} mengundang Anda untuk bergabung dengan proyek "{project}" sebagai {role}' + '{sender} mengundang Anda untuk bergabung dengan proyek "{project}" sebagai {role}', + nepali: + '{sender} เคฒเฅ‡ เคคเคชเคพเคˆเค‚เคฒเคพเคˆ "{project}" เคฎเคพ {role} เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเฅเคจเฅเคญเคฏเฅ‹' }, requestedToJoin: { english: '{sender} requested to join "{project}" as {role}', @@ -2891,28 +3459,33 @@ export const localizations = { tok_pisin: '{sender} i requestim long joinim project "{project}" long {role}', indonesian: - '{sender} meminta untuk bergabung dengan proyek "{project}" sebagai {role}' + '{sender} meminta untuk bergabung dengan proyek "{project}" sebagai {role}', + nepali: + '{sender} เคฒเฅ‡ "{project}" เคฎเคพ {role} เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅเคจเฅเคญเคฏเฅ‹' }, downloadProjectLabel: { english: 'Download Project', spanish: 'Descargar Proyecto', brazilian_portuguese: 'Baixar Projeto', tok_pisin: 'Download Project', - indonesian: 'Unduh Proyek' + indonesian: 'Unduh Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, projectNotAvailableOfflineWarning: { english: 'Project will not be available offline without download', spanish: 'El proyecto no estarรก disponible sin conexiรณn sin descarga', brazilian_portuguese: 'O projeto nรฃo estarรก disponรญel offline sem download', tok_pisin: 'Project i no pinis long download', - indonesian: 'Proyek tidak akan tersedia secara offline tanpa unduhan' + indonesian: 'Proyek tidak akan tersedia secara offline tanpa unduhan', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เคฌเคฟเคจเคพ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค…เคซเคฒเคพเค‡เคจ เค‰เคชเคฒเคฌเฅเคง เคนเฅเคจเฅ‡ เค›เฅˆเคจ' }, noNotificationsTitle: { english: 'No Notifications', spanish: 'Sin Notificaciones', brazilian_portuguese: 'Sem Notificaรงรตes', tok_pisin: 'No Notification', - indonesian: 'Tidak Ada Notifikasi' + indonesian: 'Tidak Ada Notifikasi', + nepali: 'เค•เฅเคจเฅˆ เคธเฅ‚เคšเคจเคพ เค›เฅˆเคจ' }, noNotificationsMessage: { english: "You'll see project invitations and join requests here", @@ -2922,63 +3495,73 @@ export const localizations = { tok_pisin: 'Yu ken salim invitation long project na yu ken salim joinim request long project.', indonesian: - 'Anda akan melihat undangan ke proyek dan permintaan bergabung di sini' + 'Anda akan melihat undangan ke proyek dan permintaan bergabung di sini', + nepali: + 'เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคฏเคนเคพเค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค†เคฎเคจเฅเคคเฅเคฐเคฃเคนเคฐเฅ‚ เคฐ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจเฅ‡ เค…เคจเฅเคฐเฅ‹เคงเคนเคฐเฅ‚ เคฆเฅ‡เค–เฅเคจเฅเคนเฅเคจเฅ‡เค›' }, invitationAcceptedSuccessfully: { english: 'Invitation accepted successfully', spanish: 'Invitaciรณn aceptada exitosamente', brazilian_portuguese: 'Convite aceito com sucesso', tok_pisin: 'Invitation i accept gut', - indonesian: 'Undangan diterima dengan sukses' + indonesian: 'Undangan diterima dengan sukses', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹' }, invitationDeclinedSuccessfully: { english: 'Invitation declined', spanish: 'Invitaciรณn rechazada', brazilian_portuguese: 'Convite recusado', tok_pisin: 'Invitation i no', - indonesian: 'Undangan ditolak' + indonesian: 'Undangan ditolak', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เค…เคธเฅเคตเฅ€เค•เฅƒเคค' }, failedToAcceptInvite: { english: 'Failed to accept invitation', spanish: 'Error al aceptar invitaciรณn', brazilian_portuguese: 'Falha ao aceitar convite', tok_pisin: 'I no inap accept invitation', - indonesian: 'Gagal menerima undangan' + indonesian: 'Gagal menerima undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedToDeclineInvite: { english: 'Failed to decline invitation', spanish: 'Error al rechazar invitaciรณn', brazilian_portuguese: 'Falha ao recusar convite', tok_pisin: 'I no inap decline invitation', - indonesian: 'Gagal menolak undangan' + indonesian: 'Gagal menolak undangan', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, invitationAcceptedDownloadFailed: { english: 'Invitation accepted but download failed', spanish: 'Invitaciรณn aceptada pero la descarga fallรณ', brazilian_portuguese: 'Convite aceito mas o download falhou', tok_pisin: 'Invitation i accept but i no inap download', - indonesian: 'Undangan diterima tapi unduhan gagal' + indonesian: 'Undangan diterima tapi unduhan gagal', + nepali: 'เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเคฟเคฏเฅ‹ เคคเคฐ เคกเคพเค‰เคจเคฒเฅ‹เคก เค…เคธเคซเคฒ เคญเคฏเฅ‹' }, unknownProject: { english: 'Unknown Project', spanish: 'Proyecto Desconocido', brazilian_portuguese: 'Projeto Desconhecido', tok_pisin: 'Unknown Project', - indonesian: 'Proyek Tidak Dikenal' + indonesian: 'Proyek Tidak Dikenal', + nepali: 'เค…เคœเฅเคžเคพเคค เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ' }, ownerRole: { english: 'owner', spanish: 'propietario', brazilian_portuguese: 'proprietรกrio', tok_pisin: 'owner', - indonesian: 'pemilik' + indonesian: 'pemilik', + nepali: 'เคฎเคพเคฒเคฟเค•' }, memberRole: { english: 'member', spanish: 'miembro', brazilian_portuguese: 'membro', tok_pisin: 'member', - indonesian: 'anggota' + indonesian: 'anggota', + nepali: 'เคธเคฆเคธเฅเคฏ' }, offlineNotificationMessage: { english: @@ -2990,287 +3573,329 @@ export const localizations = { tok_pisin: 'Yu i no pinis long online. Yu ken salim any changes yu make long sync when yu back online.', indonesian: - 'Anda sedang offline. Perubahan apa pun yang Anda buat akan disinkronkan ketika Anda kembali online.' + 'Anda sedang offline. Perubahan apa pun yang Anda buat akan disinkronkan ketika Anda kembali online.', + nepali: + 'เคคเคชเคพเคˆเค‚ เค…เคซเคฒเคพเค‡เคจ เคนเฅเคจเฅเคนเฅเคจเฅเค›เฅค เคคเคชเคพเคˆเค‚เคฒเฅ‡ เค—เคฐเฅเคจเฅเคญเคเค•เคพ เค•เฅเคจเฅˆ เคชเคจเคฟ เคชเคฐเคฟเคตเคฐเฅเคคเคจเคนเคฐเฅ‚ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคซเคฐเฅเค•เคฟเคเคฆเคพ เคธเคฟเค™เฅเค• เคนเฅเคจเฅ‡เค›เคจเฅเฅค' }, filesDownloaded: { english: 'files downloaded', spanish: 'archivos descargados', brazilian_portuguese: 'arquivos baixados', tok_pisin: 'ol fail i download pinis', - indonesian: 'file diunduh' + indonesian: 'file diunduh', + nepali: 'เคซเคพเค‡เคฒเคนเคฐเฅ‚ เคกเคพเค‰เคจเคฒเฅ‹เคก เคญเคฏเฅ‹' }, downloading: { english: 'downloading', spanish: 'descargando', brazilian_portuguese: 'baixando', tok_pisin: 'i download nau', - indonesian: 'mengunduh' + indonesian: 'mengunduh', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ' }, uploading: { english: 'uploading', spanish: 'subiendo', brazilian_portuguese: 'enviando', tok_pisin: 'i upload nau', - indonesian: 'mengunggah' + indonesian: 'mengunggah', + nepali: 'เค…เคชเคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ' }, files: { english: 'files', spanish: 'archivos', brazilian_portuguese: 'arquivos', tok_pisin: 'ol fail', - indonesian: 'file' + indonesian: 'file', + nepali: 'เคซเคพเค‡เคฒเคนเคฐเฅ‚' }, syncingDatabase: { english: 'syncing database', spanish: 'sincronizando base de datos', brazilian_portuguese: 'sincronizando banco de dados', tok_pisin: 'i sync database nau', - indonesian: 'mengosinkronkan basis data' + indonesian: 'mengosinkronkan basis data', + nepali: 'เคกเคพเคŸเคพเคฌเฅ‡เคธ เคธเคฟเค™เฅเค• เค—เคฐเฅเคฆเฅˆ' }, lastSync: { english: 'last sync', spanish: 'รบltima sincronizaciรณn', brazilian_portuguese: 'รบltima sincronizaรงรฃo', tok_pisin: 'las sync', - indonesian: 'sinkron terakhir' + indonesian: 'sinkron terakhir', + nepali: 'เค…เคจเฅเคคเคฟเคฎ เคธเคฟเค™เฅเค•' }, never: { english: 'Never', spanish: 'Nunca', brazilian_portuguese: 'Nunca', tok_pisin: 'Nogat', - indonesian: 'Tidak pernah' + indonesian: 'Tidak pernah', + nepali: 'เค•เคนเคฟเคฒเฅเคฏเฅˆ เคนเฅ‹เค‡เคจ' }, unknown: { english: 'unknown', spanish: 'desconocido', brazilian_portuguese: 'desconhecido', tok_pisin: 'mi no save', - indonesian: 'tidak dikenal' + indonesian: 'tidak dikenal', + nepali: 'เค…เคœเฅเคžเคพเคค' }, notSynced: { english: 'not synced', spanish: 'no sincronizado', brazilian_portuguese: 'nรฃo sincronizado', tok_pisin: 'i no sync yet', - indonesian: 'tidak disinkronkan' + indonesian: 'tidak disinkronkan', + nepali: 'เคธเคฟเค™เฅเค• เคญเคเค•เฅ‹ เค›เฅˆเคจ' }, connecting: { english: 'connecting', spanish: 'conectando', brazilian_portuguese: 'conectando', tok_pisin: 'i try long connect', - indonesian: 'menghubungkan' + indonesian: 'menghubungkan', + nepali: 'เคœเคกเคพเคจ เค—เคฐเฅเคฆเฅˆ' }, disconnected: { english: 'disconnected', spanish: 'desconectado', brazilian_portuguese: 'desconectado', tok_pisin: 'i no connect', - indonesian: 'terputus' + indonesian: 'terputus', + nepali: 'เคตเคฟเคšเฅเค›เฅ‡เคฆ เคญเคฏเฅ‹' }, syncingAttachments: { english: 'syncing attachments', spanish: 'sincronizando archivos adjuntos', brazilian_portuguese: 'sincronizando anexos', tok_pisin: 'i sync ol attachment', - indonesian: 'mengosinkronkan lampiran' + indonesian: 'mengosinkronkan lampiran', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเคฟเค™เฅเค• เค—เคฐเฅเคฆเฅˆ' }, attachmentSync: { english: 'attachment sync', spanish: 'sincronizaciรณn de archivos adjuntos', brazilian_portuguese: 'sincronizaรงรฃo de anexos', tok_pisin: 'attachment sync', - indonesian: 'sinkron lampiran' + indonesian: 'sinkron lampiran', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค• เคธเคฟเค™เฅเค•' }, databaseSyncError: { english: 'database sync error', spanish: 'error de sincronizaciรณn de base de datos', brazilian_portuguese: 'erro de sincronizaรงรฃo de banco de dados', tok_pisin: 'database sync i gat problem', - indonesian: 'kesalahan sinkron basis data' + indonesian: 'kesalahan sinkron basis data', + nepali: 'เคกเคพเคŸเคพเคฌเฅ‡เคธ เคธเคฟเค™เฅเค• เคคเฅเคฐเฅเคŸเคฟ' }, attachmentSyncError: { english: 'attachment sync error', spanish: 'error de sincronizaciรณn de archivos adjuntos', brazilian_portuguese: 'erro de sincronizaรงรฃo de anexos', tok_pisin: 'attachment sync i gat problem', - indonesian: 'kesalahan sinkron lampiran' + indonesian: 'kesalahan sinkron lampiran', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค• เคธเคฟเค™เฅเค• เคคเฅเคฐเฅเคŸเคฟ' }, uploadingData: { english: 'uploading data', spanish: 'subiendo datos', brazilian_portuguese: 'enviando dados', tok_pisin: 'i upload data', - indonesian: 'mengunggah data' + indonesian: 'mengunggah data', + nepali: 'เคกเคพเคŸเคพ เค…เคชเคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ' }, downloadingData: { english: 'downloading data', spanish: 'descargando datos', brazilian_portuguese: 'baixando dados', tok_pisin: 'i download data', - indonesian: 'mengunduh data' + indonesian: 'mengunduh data', + nepali: 'เคกเคพเคŸเคพ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ' }, syncError: { english: 'sync error', spanish: 'error de sincronizaciรณn', brazilian_portuguese: 'erro de sincronizaรงรฃo', tok_pisin: 'sync i gat problem', - indonesian: 'kesalahan sinkron' + indonesian: 'kesalahan sinkron', + nepali: 'เคธเคฟเค™เฅเค• เคคเฅเคฐเฅเคŸเคฟ' }, tapForDetails: { english: 'tap for details', spanish: 'toca para ver detalles', brazilian_portuguese: 'toque para detalhes', tok_pisin: 'presim long lukim moa', - indonesian: 'ketuk untuk detail' + indonesian: 'ketuk untuk detail', + nepali: 'เคตเคฟเคตเคฐเคฃเค•เฅ‹ เคฒเคพเค—เคฟ เคŸเฅเคฏเคพเคช เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, downloadComplete: { english: 'download complete', spanish: 'descarga completa', brazilian_portuguese: 'download completo', tok_pisin: 'download i pinis', - indonesian: 'unduhan selesai' + indonesian: 'unduhan selesai', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹' }, queued: { english: 'queued', spanish: 'en cola', brazilian_portuguese: 'em fila', tok_pisin: 'i wet long lain', - indonesian: 'dalam antrian' + indonesian: 'dalam antrian', + nepali: 'เคชเค™เฅเค•เฅเคคเคฟเคฎเคพ เค›' }, queuedForDownload: { english: 'queued for download', spanish: 'en cola para descargar', brazilian_portuguese: 'em fila para baixar', tok_pisin: 'i wet long lain long download', - indonesian: 'dalam antrian untuk unduhan' + indonesian: 'dalam antrian untuk unduhan', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคกเค•เฅ‹ เคฒเคพเค—เคฟ เคชเค™เฅเค•เฅเคคเคฟเคฎเคพ' }, complete: { english: 'complete', spanish: 'completo', brazilian_portuguese: 'completo', tok_pisin: 'pinis', - indonesian: 'selesai' + indonesian: 'selesai', + nepali: 'เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹' }, loadMore: { english: 'load more', spanish: 'cargar mรกs', brazilian_portuguese: 'carregar mais', tok_pisin: 'bringim moa', - indonesian: 'muat lebih banyak' + indonesian: 'muat lebih banyak', + nepali: 'เคฅเคช เคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, loading: { english: 'loading', spanish: 'cargando', brazilian_portuguese: 'carregando', tok_pisin: 'loadim', - indonesian: 'memuat' + indonesian: 'memuat', + nepali: 'เคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ' }, assetMadeInvisibleAllQuests: { english: 'The asset has been made invisible for all quests', spanish: 'El asset ha sido hecho invisible para todas las quests', brazilian_portuguese: 'O asset foi feito invisรญvel para todas as quests', tok_pisin: 'Asset i make invisible long all quest', - indonesian: 'Asset dibuat tidak terlihat untuk semua quest' + indonesian: 'Asset dibuat tidak terlihat untuk semua quest', + nepali: 'เคเคธเฅ‡เคŸ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เค…เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetMadeVisibleAllQuests: { english: 'The asset has been made visible for all quests', spanish: 'El asset ha sido hecho visible para todas las quests', brazilian_portuguese: 'O asset foi feito visรญvel para todas as quests', tok_pisin: 'Asset i make visible long all quest', - indonesian: 'Asset dibuat terlihat untuk semua quest' + indonesian: 'Asset dibuat terlihat untuk semua quest', + nepali: 'เคเคธเฅ‡เคŸ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetMadeInactiveAllQuests: { english: 'The asset has been made inactive for all quests', spanish: 'El asset ha sido hecho inactivo para todas las quests', brazilian_portuguese: 'O asset foi feito inativo para todas as quests', tok_pisin: 'Asset i make inactive long all quest', - indonesian: 'Asset dibuat tidak aktif untuk semua quest' + indonesian: 'Asset dibuat tidak aktif untuk semua quest', + nepali: 'เคเคธเฅ‡เคŸ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetMadeActiveAllQuests: { english: 'The asset has been made active for all quests', spanish: 'El asset ha sido hecho activo para todas las quests', brazilian_portuguese: 'O asset foi feito ativo para todas as quests', tok_pisin: 'Asset i make active long all quest', - indonesian: 'Asset dibuat aktif untuk semua quest' + indonesian: 'Asset dibuat aktif untuk semua quest', + nepali: 'เคเคธเฅ‡เคŸ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เคธเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, failedToUpdateAssetSettings: { english: 'Failed to update asset settings', spanish: 'Error al actualizar los ajustes del asset', brazilian_portuguese: 'Falha ao atualizar os ajustes do asset', tok_pisin: 'I no inap update asset settings', - indonesian: 'Gagal mengupdate pengaturan asset' + indonesian: 'Gagal mengupdate pengaturan asset', + nepali: 'เคเคธเฅ‡เคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, assetMadeInvisibleQuest: { english: 'The asset has been made invisible for this quest', spanish: 'El asset ha sido hecho invisible para esta quest', brazilian_portuguese: 'O asset foi feito invisรญvel para esta quest', tok_pisin: 'Asset i make invisible long quest', - indonesian: 'Asset dibuat tidak terlihat untuk quest ini' + indonesian: 'Asset dibuat tidak terlihat untuk quest ini', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเค•เฅ‹ เคฒเคพเค—เคฟ เคเคธเฅ‡เคŸ เค…เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetMadeVisibleQuest: { english: 'The asset has been made visible for this quest', spanish: 'El asset ha sido hecho visible para esta quest', brazilian_portuguese: 'O asset foi feito visรญvel para esta quest', tok_pisin: 'Asset i make visible long quest', - indonesian: 'Asset dibuat terlihat untuk quest ini' + indonesian: 'Asset dibuat terlihat untuk quest ini', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเค•เฅ‹ เคฒเคพเค—เคฟ เคเคธเฅ‡เคŸ เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetMadeInactiveQuest: { english: 'The asset has been made inactive for this quest', spanish: 'El asset ha sido hecho inactivo para esta quest', brazilian_portuguese: 'O asset foi feito inativo para esta quest', tok_pisin: 'Asset i make inactive long quest', - indonesian: 'Asset dibuat tidak aktif untuk quest ini' + indonesian: 'Asset dibuat tidak aktif untuk quest ini', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเค•เฅ‹ เคฒเคพเค—เคฟ เคเคธเฅ‡เคŸ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetMadeActiveQuest: { english: 'The asset has been made active for this quest', spanish: 'El asset ha sido hecho activo para esta quest', brazilian_portuguese: 'O asset foi feito ativo para esta quest', tok_pisin: 'Asset i make active long quest', - indonesian: 'Asset dibuat aktif untuk quest ini' + indonesian: 'Asset dibuat aktif untuk quest ini', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเค•เฅ‹ เคฒเคพเค—เคฟ เคเคธเฅ‡เคŸ เคธเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, assetSettings: { english: 'Asset Settings', spanish: 'Ajustes del Asset', brazilian_portuguese: 'Ajustes do Asset', tok_pisin: 'Asset Settings', - indonesian: 'Pengaturan Asset' + indonesian: 'Pengaturan Asset', + nepali: 'เคเคธเฅ‡เคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚' }, assetSettingsLoadError: { english: 'Error loading asset settings.', spanish: 'Error al cargar la configuraciรณn de asset.', brazilian_portuguese: 'Erro ao carregar as configuraรงรตes do asset.', tok_pisin: 'I no inap load asset settings', - indonesian: 'Gagal memuat pengaturan asset.' + indonesian: 'Gagal memuat pengaturan asset.', + nepali: 'เคเคธเฅ‡เคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคฆเคพ เคคเฅเคฐเฅเคŸเคฟเฅค' }, general: { english: 'General', spanish: 'General', brazilian_portuguese: 'Geral', tok_pisin: 'General', - indonesian: 'Umum' + indonesian: 'Umum', + nepali: 'เคธเคพเคฎเคพเคจเฅเคฏ' }, currentQuest: { english: 'Current Quest', spanish: 'Quest Actual', brazilian_portuguese: 'Quest Atual', tok_pisin: 'Current Quest', - indonesian: 'Quest Saat Ini' + indonesian: 'Quest Saat Ini', + nepali: 'เคนเคพเคฒเค•เฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ' }, visibility: { english: 'Visibility', spanish: 'Visibilidad', brazilian_portuguese: 'Visibilidade', tok_pisin: 'Visibility', - indonesian: 'Visibilitas' + indonesian: 'Visibilitas', + nepali: 'เคฆเฅƒเคถเฅเคฏเคคเคพ' }, active: { english: 'Active', spanish: 'Activo', brazilian_portuguese: 'Ativo', tok_pisin: 'Active', - indonesian: 'Aktif' + indonesian: 'Aktif', + nepali: 'เคธเค•เฅเคฐเคฟเคฏ' }, visibilityDescription: { english: @@ -3281,7 +3906,9 @@ export const localizations = { 'O asset รฉ visรญvel por padrรฃo em todas as quests, a menos que seja ocultado individualmente.', tok_pisin: 'Asset i save long olgeta quest, sapos yu no haitim wanwan.', indonesian: - 'Asset terlihat secara default di semua quest, kecuali disembunyikan secara individual.' + 'Asset terlihat secara default di semua quest, kecuali disembunyikan secara individual.', + nepali: + 'เคเคธเฅ‡เคŸ เคชเฅ‚เคฐเฅเคตเคจเคฟเคฐเฅเคงเคพเคฐเคฟเคค เคฐเฅ‚เคชเคฎเคพ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เคฎเคพ เคฆเฅ‡เค–เคฟเคจเฅเค›, เคตเฅเคฏเค•เฅเคคเคฟเค—เคค เคฐเฅ‚เคชเคฎเคพ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เคฌเคพเคนเฅ‡เค•เฅค' }, activeDescription: { english: @@ -3293,7 +3920,9 @@ export const localizations = { tok_pisin: 'Asset i active na yu ken usim long olgeta quest, sapos yu no stopim wanwan.', indonesian: - 'Asset aktif dan dapat digunakan di semua quest, kecuali dinonaktifkan secara individual.' + 'Asset aktif dan dapat digunakan di semua quest, kecuali dinonaktifkan secara individual.', + nepali: + 'เคเคธเฅ‡เคŸ เคธเค•เฅเคฐเคฟเคฏ เค› เคฐ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เคฎเคพ เคชเฅเคฐเคฏเฅ‹เค— เค—เคฐเฅเคจ เคธเค•เคฟเคจเฅเค›, เคตเฅเคฏเค•เฅเคคเคฟเค—เคค เคฐเฅ‚เคชเคฎเคพ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคชเคพเคฐเคฟเคเค•เฅ‹ เคฌเคพเคนเฅ‡เค•เฅค' }, visibilityDescriptionQuest: { english: @@ -3304,7 +3933,9 @@ export const localizations = { 'O asset รฉ visรญvel por padrรฃo nesta quest, a menos que seja ocultado individualmente.', tok_pisin: 'Asset i save long dispela quest, sapos yu no haitim wanwan.', indonesian: - 'Asset terlihat secara default di quest ini, kecuali disembunyikan secara individual.' + 'Asset terlihat secara default di quest ini, kecuali disembunyikan secara individual.', + nepali: + 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเคฎเคพ เคเคธเฅ‡เคŸ เคชเฅ‚เคฐเฅเคตเคจเคฟเคฐเฅเคงเคพเคฐเคฟเคค เคฐเฅ‚เคชเคฎเคพ เคฆเฅ‡เค–เคฟเคจเฅเค›, เคตเฅเคฏเค•เฅเคคเคฟเค—เคค เคฐเฅ‚เคชเคฎเคพ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เคฌเคพเคนเฅ‡เค•เฅค' }, assetHiddenAllQuests: { english: @@ -3316,7 +3947,8 @@ export const localizations = { tok_pisin: 'Asset i hait long olgeta quest na yu no ken mekim save long wanpela.', indonesian: - 'Asset disembunyikan di semua quest dan tidak dapat dibuat terlihat di salah satunya.' + 'Asset disembunyikan di semua quest dan tidak dapat dibuat terlihat di salah satunya.', + nepali: 'เคเคธเฅ‡เคŸ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เคฎเคพ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เค› เคฐ เค•เฅเคจเฅˆเคฎเคพ เคชเคจเคฟ เคฆเฅ‡เค–เคพเค‰เคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, assetDisabledAllQuests: { english: @@ -3328,7 +3960,8 @@ export const localizations = { tok_pisin: 'Asset i stop long olgeta quest na yu no ken usim long wanpela hap.', indonesian: - 'Asset dinonaktifkan di semua quest dan tidak dapat digunakan di mana pun.' + 'Asset dinonaktifkan di semua quest dan tidak dapat digunakan di mana pun.', + nepali: 'เคเคธเฅ‡เคŸ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เคฎเคพ เค…เคธเค•เฅเคทเคฎ เค› เคฐ เค•เคนเฅ€เค เคชเคจเคฟ เคชเฅเคฐเคฏเฅ‹เค— เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, assetGeneralSettingsDescription: { english: 'These settings affect how the asset behaves across all quests.', @@ -3339,7 +3972,8 @@ export const localizations = { tok_pisin: 'Ol dispela setting i senisim how asset i wok long olgeta quest.', indonesian: - 'Pengaturan ini mempengaruhi bagaimana asset berperilaku di semua quest.' + 'Pengaturan ini mempengaruhi bagaimana asset berperilaku di semua quest.', + nepali: 'เคฏเฅ€ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚เคฒเฅ‡ เคธเคฌเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚เคฎเคพ เคเคธเฅ‡เคŸเค•เฅ‹ เคตเฅเคฏเคตเคนเคพเคฐเคฒเคพเคˆ เคชเฅเคฐเคญเคพเคต เคชเคพเคฐเฅเค›เคจเฅเฅค' }, questSpecificSettingsDescription: { english: @@ -3351,7 +3985,8 @@ export const localizations = { tok_pisin: 'Ol dispela setting i senisim how asset i wok long dispela quest.', indonesian: - 'Pengaturan ini mempengaruhi bagaimana asset berperilaku di quest spesifik ini.' + 'Pengaturan ini mempengaruhi bagaimana asset berperilaku di quest spesifik ini.', + nepali: 'เคฏเฅ€ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚เคฒเฅ‡ เคฏเฅ‹ เคตเคฟเคถเฅ‡เคท เค•เฅเคตเฅ‡เคธเฅเคŸเคฎเคพ เคเคธเฅ‡เคŸเค•เฅ‹ เคตเฅเคฏเคตเคนเคพเคฐเคฒเคพเคˆ เคชเฅเคฐเคญเคพเคต เคชเคพเคฐเฅเค›เคจเฅเฅค' }, assetDisabledWarning: { english: @@ -3363,7 +3998,9 @@ export const localizations = { tok_pisin: 'Dispela asset i stop olgeta ples. Yu no inap senisim setting bilong em long dispela quest.', indonesian: - 'Asset ini dinonaktifkan secara global. Anda tidak dapat mengubah pengaturannya untuk quest ini.' + 'Asset ini dinonaktifkan secara global. Anda tidak dapat mengubah pengaturannya untuk quest ini.', + nepali: + 'เคฏเฅ‹ เคเคธเฅ‡เคŸ เคตเคฟเคถเฅเคตเคตเฅเคฏเคพเคชเฅ€ เคฐเฅ‚เคชเคฎเคพ เค…เคธเค•เฅเคทเคฎ เค›เฅค เคคเคชเคพเคˆเค‚ เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเค•เฅ‹ เคฒเคพเค—เคฟ เคฏเคธเค•เฅ‹ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เคชเคฐเคฟเคตเคฐเฅเคคเคจ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเคจเฅค' }, assetVisibleThisQuest: { english: 'The asset is shown in this quest. Unless hidden globally.', @@ -3374,14 +4011,16 @@ export const localizations = { tok_pisin: 'Asset i save long dispela quest. Sapos i no hait long olgeta hap.', indonesian: - 'Asset ditampilkan di quest ini. Kecuali disembunyikan secara global.' + 'Asset ditampilkan di quest ini. Kecuali disembunyikan secara global.', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเคฎเคพ เคเคธเฅ‡เคŸ เคฆเฅ‡เค–เคพเค‡เคเค•เฅ‹ เค›เฅค เคตเคฟเคถเฅเคตเคตเฅเคฏเคพเคชเฅ€ เคฐเฅ‚เคชเคฎเคพ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เคฌเคพเคนเฅ‡เค•เฅค' }, assetHiddenThisQuest: { english: 'The asset is hidden in this quest.', spanish: 'El asset estรก oculto en esta quest.', brazilian_portuguese: 'O asset estรก oculto nesta quest.', tok_pisin: 'Asset i hait long dispela quest.', - indonesian: 'Asset disembunyikan di quest ini.' + indonesian: 'Asset disembunyikan di quest ini.', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเคฎเคพ เคเคธเฅ‡เคŸ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เค›เฅค' }, assetActiveThisQuest: { english: @@ -3393,224 +4032,249 @@ export const localizations = { tok_pisin: 'Yu ken usim asset long dispela quest. Sapos i no stop long olgeta hap.', indonesian: - 'Asset dapat digunakan di quest ini. Kecuali dinonaktifkan secara global.' + 'Asset dapat digunakan di quest ini. Kecuali dinonaktifkan secara global.', + nepali: + 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเคฎเคพ เคเคธเฅ‡เคŸ เคชเฅเคฐเคฏเฅ‹เค— เค—เคฐเฅเคจ เคธเค•เคฟเคจเฅเค›เฅค เคตเคฟเคถเฅเคตเคตเฅเคฏเคพเคชเฅ€ เคฐเฅ‚เคชเคฎเคพ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคชเคพเคฐเคฟเคเค•เฅ‹ เคฌเคพเคนเฅ‡เค•เฅค' }, assetInactiveThisQuest: { english: 'The asset is not available in this quest.', spanish: 'El asset no estรก disponible en esta quest.', brazilian_portuguese: 'O asset nรฃo estรก disponรญvel nesta quest.', tok_pisin: 'Asset i no stap long dispela quest.', - indonesian: 'Asset tidak tersedia di quest ini.' + indonesian: 'Asset tidak tersedia di quest ini.', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸเคฎเคพ เคเคธเฅ‡เคŸ เค‰เคชเคฒเคฌเฅเคง เค›เฅˆเคจเฅค' }, downloadProjectConfirmation: { english: 'Download this project for offline use?', spanish: 'ยฟDescargar este proyecto para uso sin conexiรณn?', brazilian_portuguese: 'Baixar este projeto para uso offline?', tok_pisin: 'Daunim dispela project long usim taim i no gat internet?', - indonesian: 'Unduh proyek ini untuk penggunaan offline?' + indonesian: 'Unduh proyek ini untuk penggunaan offline?', + nepali: 'เค…เคซเคฒเคพเค‡เคจ เคชเฅเคฐเคฏเฅ‹เค—เค•เฅ‹ เคฒเคพเค—เคฟ เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅ‡?' }, downloadQuestConfirmation: { english: 'Download this quest for offline use?', spanish: 'ยฟDescargar esta quest para uso sin conexiรณn?', brazilian_portuguese: 'Baixar esta quest para uso offline?', tok_pisin: 'Daunim dispela quest long usim taim i no gat internet?', - indonesian: 'Unduh quest ini untuk penggunaan offline?' + indonesian: 'Unduh quest ini untuk penggunaan offline?', + nepali: 'เค…เคซเคฒเคพเค‡เคจ เคชเฅเคฐเคฏเฅ‹เค—เค•เฅ‹ เคฒเคพเค—เคฟ เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅ‡?' }, thisWillDownload: { english: 'This will download:', spanish: 'Esto descargarรก:', brazilian_portuguese: 'Isso baixarรก:', tok_pisin: 'Dispela bai daunim:', - indonesian: 'Ini akan mengunduh:' + indonesian: 'Ini akan mengunduh:', + nepali: 'เคฏเคธเคฒเฅ‡ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅ‡เค›:' }, translations: { english: 'Translations', spanish: 'Traducciones', brazilian_portuguese: 'Traduรงรตes', tok_pisin: 'Ol Translation', - indonesian: 'Terjemahan' + indonesian: 'Terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚' }, doRecord: { english: 'Record', spanish: 'Grabar', brazilian_portuguese: 'Gravar', tok_pisin: 'Rekodem', - indonesian: 'Rekam' + indonesian: 'Rekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, isRecording: { english: 'Recording...', spanish: 'Grabando...', brazilian_portuguese: 'Gravando...', tok_pisin: 'Recording...', - indonesian: 'Merekam...' + indonesian: 'Merekam...', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคฆเฅˆ...' }, recordTo: { english: 'Record to', spanish: 'Grabar en', brazilian_portuguese: 'Gravar em', tok_pisin: 'Rekodem long', - indonesian: 'Rekam ke' + indonesian: 'Rekam ke', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, noLabelSelected: { english: 'No label selected', spanish: 'Sin etiqueta seleccionada', brazilian_portuguese: 'Nenhum rรณtulo selecionado', tok_pisin: 'No label i stap', - indonesian: 'Tidak ada label dipilih' + indonesian: 'Tidak ada label dipilih', + nepali: 'เค•เฅเคจเฅˆ เคฒเฅ‡เคฌเคฒ เคšเคฏเคจ เค—เคฐเคฟเคเค•เฅ‹ เค›เฅˆเคจ' }, startRecordingSession: { english: 'Start Recording Session', spanish: 'Iniciar Sesiรณn de Grabaciรณn', brazilian_portuguese: 'Iniciar Sessรฃo de Gravaรงรฃo', tok_pisin: 'Stat Rekodem Taim', - indonesian: 'Mulai Sesi Rekaman' + indonesian: 'Mulai Sesi Rekaman', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคธเคคเฅเคฐ เคธเฅเคฐเฅ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, typeToConfirm: { english: 'Type {text} to confirm', spanish: 'Escriba {text} para confirmar', brazilian_portuguese: 'Digite {text} para confirmar', tok_pisin: 'Raitim {text} bilong siaim', - indonesian: 'Ketik {text} untuk mengkonfirmasi' + indonesian: 'Ketik {text} untuk mengkonfirmasi', + nepali: 'เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจ {text} เคŸเคพเค‡เคช เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmDeletion: { english: 'Confirm Deletion', spanish: 'Confirmar Eliminaciรณn', brazilian_portuguese: 'Confirmar Exclusรฃo', tok_pisin: 'Siaim Rausim', - indonesian: 'Konfirmasi Penghapusan' + indonesian: 'Konfirmasi Penghapusan', + nepali: 'เคฎเฅ‡เคŸเคพเค‰เคจเฅ‡ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, deleting: { english: 'Deleting...', spanish: 'Eliminando...', brazilian_portuguese: 'Excluindo...', tok_pisin: 'Rausim nau...', - indonesian: 'Menghapus...' + indonesian: 'Menghapus...', + nepali: 'เคฎเฅ‡เคŸเคพเค‰เคเคฆเฅˆ...' }, audioSegments: { english: 'Audio Segments', spanish: 'Pistas de Audio', brazilian_portuguese: 'Pistas de รudio', tok_pisin: 'Ol audio track', - indonesian: 'Trek audio' + indonesian: 'Trek audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เค–เคฃเฅเคกเคนเคฐเฅ‚' }, audioSegment: { english: 'Audio Segment', spanish: 'Pista de Audio', brazilian_portuguese: 'Pista de รudio', tok_pisin: 'Ol audio track', - indonesian: 'Trek audio' + indonesian: 'Trek audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เค–เคฃเฅเคก' }, asAssets: { english: 'as Assets', spanish: 'como Assets', brazilian_portuguese: 'como Assets', tok_pisin: 'as Assets', - indonesian: 'sebagai Assets' + indonesian: 'sebagai Assets', + nepali: 'เคเคธเฅ‡เคŸเคนเคฐเฅ‚เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ' }, asAsset: { english: 'as Asset', spanish: 'como Asset', brazilian_portuguese: 'como Asset', tok_pisin: 'as Asset', - indonesian: 'sebagai Asset' + indonesian: 'sebagai Asset', + nepali: 'เคเคธเฅ‡เคŸเค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ' }, save: { english: 'Save', spanish: 'Guardar', brazilian_portuguese: 'Salvar', tok_pisin: 'Save', - indonesian: 'Simpan' - }, - merge: { - english: 'Merge', - spanish: 'Fusionar', - brazilian_portuguese: 'Mesclar', - tok_pisin: 'Merge', - indonesian: 'Menggabungkan' + indonesian: 'Simpan', + nepali: 'เคธเฅเคฐเค•เฅเคทเคฟเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, projectDirectory: { english: 'Project Directory', spanish: 'Directorio de Proyecto', brazilian_portuguese: 'Diretรณrio de Projeto', tok_pisin: 'Project Directory', - indonesian: 'Direktori Proyek' + indonesian: 'Direktori Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‡เคฐเฅ‡เค•เฅเคŸเคฐเฅ€' }, projectMadePublic: { english: 'The project has been made public', spanish: 'El proyecto se ha hecho pรบblico', brazilian_portuguese: 'O projeto foi tornado pรบblico', tok_pisin: 'Project i mekim public nau', - indonesian: 'Proyek telah dibuat publik' + indonesian: 'Proyek telah dibuat publik', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคพเคฐเฅเคตเคœเคจเคฟเค• เคฌเคจเคพเค‡เคฏเฅ‹' }, projectMadePrivate: { english: 'The project has been made private', spanish: 'El proyecto se ha hecho privado', brazilian_portuguese: 'O projeto foi tornado privado', tok_pisin: 'Project i mekim private nau', - indonesian: 'Proyek telah dibuat pribadi' + indonesian: 'Proyek telah dibuat pribadi', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคœเฅ€ เคฌเคจเคพเค‡เคฏเฅ‹' }, projectMadeInvisible: { english: 'The project has been made invisible', spanish: 'El proyecto se ha hecho invisible', brazilian_portuguese: 'O projeto foi tornado invisรญvel', tok_pisin: 'Project i mekim hait nau', - indonesian: 'Proyek telah dibuat tidak terlihat' + indonesian: 'Proyek telah dibuat tidak terlihat', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค…เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, projectMadeVisible: { english: 'The project has been made visible', spanish: 'El proyecto se ha hecho visible', brazilian_portuguese: 'O projeto foi tornado visรญvel', tok_pisin: 'Project i mekim save nau', - indonesian: 'Proyek telah dibuat terlihat' + indonesian: 'Proyek telah dibuat terlihat', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, projectMadeInactive: { english: 'The project has been made inactive', spanish: 'El proyecto se ha hecho inactivo', brazilian_portuguese: 'O projeto foi tornado inativo', tok_pisin: 'Project i mekim stop nau', - indonesian: 'Proyek telah dibuat tidak aktif' + indonesian: 'Proyek telah dibuat tidak aktif', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, projectMadeActive: { english: 'The project has been made active', spanish: 'El proyecto se ha hecho activo', brazilian_portuguese: 'O projeto foi tornado ativo', tok_pisin: 'Project i mekim active nau', - indonesian: 'Proyek telah dibuat aktif' + indonesian: 'Proyek telah dibuat aktif', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, failedToUpdateProjectSettings: { english: 'Failed to update project settings', spanish: 'Error al actualizar la configuraciรณn del proyecto', brazilian_portuguese: 'Falha ao atualizar as configuraรงรตes do projeto', tok_pisin: 'I no inap update project settings', - indonesian: 'Gagal mengupdate pengaturan proyek' + indonesian: 'Gagal mengupdate pengaturan proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedToUpdateProjectVisibility: { english: 'Failed to update project visibility', spanish: 'Error al actualizar la visibilidad del proyecto', brazilian_portuguese: 'Falha ao atualizar a visibilidade do projeto', tok_pisin: 'I no inap update project visibility', - indonesian: 'Gagal mengupdate visibilitas proyek' + indonesian: 'Gagal mengupdate visibilitas proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฆเฅƒเคถเฅเคฏเคคเคพ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedToUpdateProjectActiveStatus: { english: 'Failed to update project active status', spanish: 'Error al actualizar el estado activo del proyecto', brazilian_portuguese: 'Falha ao atualizar o status ativo do projeto', tok_pisin: 'I no inap update project active status', - indonesian: 'Gagal mengupdate status aktif proyek' + indonesian: 'Gagal mengupdate status aktif proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเค•เฅเคฐเคฟเคฏ เคธเฅเคฅเคฟเคคเคฟ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, projectSettingsLoadError: { english: 'Error loading quest settings.', spanish: 'Error al cargar la configuraciรณn de quest.', brazilian_portuguese: 'Erro ao carregar as configuraรงรตes da quest.', tok_pisin: 'I no inap load quest settings.', - indonesian: 'Gagal memuat pengaturan quest.' + indonesian: 'Gagal memuat pengaturan quest.', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคฆเคพ เคคเฅเคฐเฅเคŸเคฟเฅค' }, projectSettings: { english: 'Project Settings', spanish: 'Configuraciรณn del Proyecto', brazilian_portuguese: 'Configuraรงรตes do Projeto', tok_pisin: 'Project Settings', - indonesian: 'Pengaturan Proyek' + indonesian: 'Pengaturan Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚' }, publicProjectDescription: { english: 'Anyone can view and contribute to this project', @@ -3618,7 +4282,8 @@ export const localizations = { brazilian_portuguese: 'Qualquer pessoa pode ver e contribuir para este projeto', tok_pisin: 'Olgeta man i ken lukim na contributim long dispela project', - indonesian: 'Siapa saja dapat melihat dan berkontribusi pada proyek ini' + indonesian: 'Siapa saja dapat melihat dan berkontribusi pada proyek ini', + nepali: 'เคœเฅ‹เคธเฅเค•เฅˆเคฒเฅ‡ เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคนเฅ‡เคฐเฅเคจ เคฐ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคธเค•เฅเค›เคจเฅ' }, visibleProjectDescription: { english: @@ -3630,7 +4295,9 @@ export const localizations = { tok_pisin: 'Dispela project i save long public list na olgeta user i ken painim.', indonesian: - 'Proyek ini muncul di daftar publik dan dapat ditemukan oleh semua pengguna.' + 'Proyek ini muncul di daftar publik dan dapat ditemukan oleh semua pengguna.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคพเคฐเฅเคตเคœเคจเคฟเค• เคธเฅ‚เคšเฅ€เคนเคฐเฅ‚เคฎเคพ เคฆเฅ‡เค–เคฟเคจเฅเค› เคฐ เคธเคฌเฅˆ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพเคนเคฐเฅ‚เคฒเฅ‡ เคซเฅ‡เคฒเคพ เคชเคพเคฐเฅเคจ เคธเค•เฅเค›เคจเฅเฅค' }, invisibleProjectDescription: { english: @@ -3642,7 +4309,8 @@ export const localizations = { tok_pisin: 'Dispela project i no save long project directory olsem search result.', indonesian: - 'Proyek ini tidak ditampilkan di direktori proyek atau hasil pencarian.' + 'Proyek ini tidak ditampilkan di direktori proyek atau hasil pencarian.', + nepali: 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‡เคฐเฅ‡เค•เฅเคŸเคฐเฅ€เคนเคฐเฅ‚ เคตเคพ เค–เฅ‹เคœ เคชเคฐเคฟเคฃเคพเคฎเคนเคฐเฅ‚เคฎเคพ เคฆเฅ‡เค–เคฟเคเคฆเฅˆเคจเฅค' }, activeProjectDescription: { english: 'The project is currently open for viewing and contributions.', @@ -3651,7 +4319,8 @@ export const localizations = { brazilian_portuguese: 'O projeto estรก atualmente aberto para visualizaรงรฃo e contribuiรงรตes.', tok_pisin: 'Dispela project i open nau long lukim na contributim.', - indonesian: 'Proyek saat ini terbuka untuk dilihat dan kontribusi.' + indonesian: 'Proyek saat ini terbuka untuk dilihat dan kontribusi.', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคนเคพเคฒ เคนเฅ‡เคฐเฅเคจ เคฐ เคฏเฅ‹เค—เคฆเคพเคจเค•เคพ เคฒเคพเค—เคฟ เค–เฅเคฒเคพ เค›เฅค' }, inactiveProjectDescription: { english: @@ -3661,154 +4330,177 @@ export const localizations = { brazilian_portuguese: 'Este projeto estรก atualmente inativo e nรฃo estรก aceitando contribuiรงรตes.', tok_pisin: 'Dispela project i no wok nau na i no acceptim contributim.', - indonesian: 'Proyek ini saat ini tidak aktif dan tidak menerima kontribusi.' + indonesian: + 'Proyek ini saat ini tidak aktif dan tidak menerima kontribusi.', + nepali: 'เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคนเคพเคฒ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เค› เคฐ เคฏเฅ‹เค—เคฆเคพเคจเคนเคฐเฅ‚ เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคฆเฅˆเคจเฅค' }, loadingOptions: { english: 'Loading options...', spanish: 'Cargando opciones...', brazilian_portuguese: 'Carregando opรงรตes...', tok_pisin: 'I loadim ol option...', - indonesian: 'Memuat opsi...' + indonesian: 'Memuat opsi...', + nepali: 'เคตเคฟเค•เคฒเฅเคชเคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ...' }, loadingTagCategories: { english: 'Loading tag categories...', spanish: 'Cargando categorรญas de etiquetas...', brazilian_portuguese: 'Carregando categorias de etiquetas...', tok_pisin: 'I loadim ol tag category...', - indonesian: 'Memuat kategori tag...' + indonesian: 'Memuat kategori tag...', + nepali: 'เคŸเฅเคฏเคพเค— เคถเฅเคฐเฅ‡เคฃเฅ€เคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ...' }, questSettings: { english: 'Quest Settings', spanish: 'Configuraciรณn de la Misiรณn', brazilian_portuguese: 'Configuraรงรตes da Missรฃo', tok_pisin: 'Quest Settings', - indonesian: 'Pengaturan Quest' + indonesian: 'Pengaturan Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚' }, questSettingsLoadError: { english: 'Error loading quest settings.', spanish: 'Error al cargar la configuraciรณn de quest.', brazilian_portuguese: 'Erro ao carregar as configuraรงรตes da quest.', tok_pisin: 'I no inap load quest settings.', - indonesian: 'Gagal memuat pengaturan quest.' + indonesian: 'Gagal memuat pengaturan quest.', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคฆเคพ เคคเฅเคฐเฅเคŸเคฟเฅค' }, visibleQuestDescription: { english: 'This quest is visible to users', spanish: 'Esta misiรณn es visible para los usuarios', brazilian_portuguese: 'Esta missรฃo รฉ visรญvel para os usuรกrios', tok_pisin: 'Dispela quest i save long ol user', - indonesian: 'Quest ini terlihat oleh pengguna' + indonesian: 'Quest ini terlihat oleh pengguna', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพเคนเคฐเฅ‚เคฒเคพเคˆ เคฆเฅ‡เค–เคฟเคจเฅเค›' }, invisibleQuestDescription: { english: 'This quest is hidden from users', spanish: 'Esta misiรณn estรก oculta para los usuarios', brazilian_portuguese: 'Esta missรฃo estรก oculta dos usuรกrios', tok_pisin: 'Dispela quest i hait long ol user', - indonesian: 'Quest ini disembunyikan dari pengguna' + indonesian: 'Quest ini disembunyikan dari pengguna', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพเคนเคฐเฅ‚เคฌเคพเคŸ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เค›' }, activeQuestDescription: { english: 'This quest is available for completion', spanish: 'Esta misiรณn estรก disponible para completar', brazilian_portuguese: 'Esta missรฃo estรก disponรญvel para conclusรฃo', tok_pisin: 'Dispela quest i redi long pinisim', - indonesian: 'Quest ini tersedia untuk diselesaikan' + indonesian: 'Quest ini tersedia untuk diselesaikan', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ เคชเฅ‚เคฐเคพ เค—เคฐเฅเคจเค•เฅ‹ เคฒเคพเค—เคฟ เค‰เคชเคฒเคฌเฅเคง เค›' }, inactiveQuestDescription: { english: 'This quest is temporarily disabled', spanish: 'Esta misiรณn estรก temporalmente deshabilitada', brazilian_portuguese: 'Esta missรฃo estรก temporariamente desabilitada', tok_pisin: 'Dispela quest i stop liklik taim', - indonesian: 'Quest ini sementara dinonaktifkan' + indonesian: 'Quest ini sementara dinonaktifkan', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ เค…เคธเฅเคฅเคพเคฏเฅ€ เคฐเฅ‚เคชเคฎเคพ เค…เคธเค•เฅเคทเคฎ เค›' }, questMadeInvisible: { english: 'The quest has been made invisible', spanish: 'La misiรณn se ha hecho invisible', brazilian_portuguese: 'A missรฃo foi tornada invisรญvel', tok_pisin: 'Quest i mekim hait nau', - indonesian: 'Quest telah dibuat tidak terlihat' + indonesian: 'Quest telah dibuat tidak terlihat', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เค…เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, questMadeVisible: { english: 'The quest has been made visible', spanish: 'La misiรณn se ha hecho visible', brazilian_portuguese: 'A missรฃo foi tornada visรญvel', tok_pisin: 'Quest i mekim save nau', - indonesian: 'Quest telah dibuat terlihat' + indonesian: 'Quest telah dibuat terlihat', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, questMadeInactive: { english: 'The quest has been made inactive', spanish: 'La misiรณn se ha hecho inactiva', brazilian_portuguese: 'A missรฃo foi tornada inativa', tok_pisin: 'Quest i mekim stop nau', - indonesian: 'Quest telah dibuat tidak aktif' + indonesian: 'Quest telah dibuat tidak aktif', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, questMadeActive: { english: 'The quest has been made active', spanish: 'La misiรณn se ha hecho activa', brazilian_portuguese: 'A missรฃo foi tornada ativa', tok_pisin: 'Quest i mekim active nau', - indonesian: 'Quest telah dibuat aktif' + indonesian: 'Quest telah dibuat aktif', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, failedToUpdateQuestSettings: { english: 'Failed to update quest settings', spanish: 'Error al actualizar la configuraciรณn de la misiรณn', brazilian_portuguese: 'Falha ao atualizar as configuraรงรตes da missรฃo', tok_pisin: 'I no inap update quest settings', - indonesian: 'Gagal mengupdate pengaturan quest' + indonesian: 'Gagal mengupdate pengaturan quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, loadingAudio: { english: 'Loading audio...', spanish: 'Cargando audio...', brazilian_portuguese: 'Carregando รกudio...', tok_pisin: 'I loadim audio...', - indonesian: 'Memuat audio...' + indonesian: 'Memuat audio...', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคฒเฅ‹เคก เค—เคฐเฅเคฆเฅˆ...' }, updateAvailable: { english: 'A new update is available!', spanish: 'ยกUna nueva actualizaciรณn estรก disponible!', brazilian_portuguese: 'Uma nova atualizaรงรฃo estรก disponรญvel!', tok_pisin: 'Nupela update i stap!', - indonesian: 'Pembaruan baru tersedia!' + indonesian: 'Pembaruan baru tersedia!', + nepali: 'เคจเคฏเคพเค เค…เคชเคกเฅ‡เคŸ เค‰เคชเคฒเคฌเฅเคง เค›!' }, updateNow: { english: 'Update Now', spanish: 'Actualizar Ahora', brazilian_portuguese: 'Atualizar Agora', tok_pisin: 'Update Nau', - indonesian: 'Perbarui Sekarang' + indonesian: 'Perbarui Sekarang', + nepali: 'เค…เคนเคฟเคฒเฅ‡ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, updateFailed: { english: 'Update failed', spanish: 'Actualizaciรณn fallida', brazilian_portuguese: 'Atualizaรงรฃo falhou', tok_pisin: 'Update i pundaun', - indonesian: 'Pembaruan gagal' + indonesian: 'Pembaruan gagal', + nepali: 'เค…เคชเคกเฅ‡เคŸ เค…เคธเคซเคฒ เคญเคฏเฅ‹' }, updateErrorTryAgain: { english: 'Please try again or dismiss', spanish: 'Por favor intente nuevamente o descarte', brazilian_portuguese: 'Por favor tente novamente ou descarte', tok_pisin: 'Traim gen o rausim', - indonesian: 'Silakan coba lagi atau abaikan' + indonesian: 'Silakan coba lagi atau abaikan', + nepali: 'เค•เฅƒเคชเคฏเคพ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคตเคพ เค–เคพเคฐเฅ‡เคœ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, retry: { english: 'Retry', spanish: 'Reintentar', brazilian_portuguese: 'Tentar novamente', tok_pisin: 'Traim gen', - indonesian: 'Coba lagi' + indonesian: 'Coba lagi', + nepali: 'เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, enterCommentOptional: { english: 'Enter your comment (optional)', spanish: 'Escribe tu comentario (opcional)', brazilian_portuguese: 'Escreva seu comentรกrio (opcional)', tok_pisin: 'Raitim comment bilong yu (yu ken o nogat)', - indonesian: 'Masukkan komentar Anda (opsional)' + indonesian: 'Masukkan komentar Anda (opsional)', + nepali: 'เค†เคซเฅเคจเฅ‹ เคŸเคฟเคชเฅเคชเคฃเฅ€ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ (เคตเฅˆเค•เคฒเฅเคชเคฟเค•)' }, auth_init_error_title: { english: 'Initialization Error', spanish: 'Error de Inicializaciรณn', brazilian_portuguese: 'Erro de Inicializaรงรฃo', tok_pisin: 'Initialization Error', - indonesian: 'Kesalahan Inisialisasi' + indonesian: 'Kesalahan Inisialisasi', + nepali: 'เคธเฅเคฐเฅเคตเคพเคค เคคเฅเคฐเฅเคŸเคฟ' }, auth_init_error_message: { english: @@ -3819,42 +4511,49 @@ export const localizations = { 'Erro ao inicializar o aplicativo. Por favor, tente sair e entrar novamente.', tok_pisin: 'I no inap start app. Plis traim logout na login gen.', indonesian: - 'Gagal menginisialisasi aplikasi. Silakan coba logout dan login kembali.' + 'Gagal menginisialisasi aplikasi. Silakan coba logout dan login kembali.', + nepali: + 'เคเคช เคธเฅเคฐเฅ เค—เคฐเฅเคจ เค…เคธเคซเคฒ เคญเคฏเฅ‹เฅค เค•เฅƒเคชเคฏเคพ เคฒเค—เค†เค‰เคŸ เค—เคฐเฅ‡เคฐ เคชเฅเคจ: เคฒเค— เค‡เคจ เค—เคฐเฅเคจเฅ‡ เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, auth_init_error_ok: { english: 'OK', spanish: 'OK', brazilian_portuguese: 'OK', tok_pisin: 'Orait', - indonesian: 'OK' + indonesian: 'OK', + nepali: 'เค เฅ€เค• เค›' }, projectDownloaded: { english: 'Project downloaded', spanish: 'Proyecto descargado', brazilian_portuguese: 'Projeto baixado', tok_pisin: 'Project i daun pinis', - indonesian: 'Proyek diunduh' + indonesian: 'Proyek diunduh', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคกเคพเค‰เคจเคฒเฅ‹เคก เคญเคฏเฅ‹' }, passwordMustBeAtLeast6Characters: { english: 'Password must be at least 6 characters', spanish: 'La contraseรฑa debe tener al menos 6 caracteres', brazilian_portuguese: 'A senha deve ter pelo menos 6 caracteres', tok_pisin: 'Password i mas gat 6 character o moa', - indonesian: 'Kata sandi harus minimal 6 karakter' + indonesian: 'Kata sandi harus minimal 6 karakter', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เค•เคฎเฅเคคเคฟเคฎเคพ เฅฌ เคตเคฐเฅเคฃเค•เฅ‹ เคนเฅเคจเฅเคชเคฐเฅเค›' }, passwordUpdateFailed: { english: 'Failed to update password', spanish: 'Error al actualizar la contraseรฑa', brazilian_portuguese: 'Falha ao atualizar a senha', tok_pisin: 'I no inap update password', - indonesian: 'Gagal mengupdate kata sandi' + indonesian: 'Gagal mengupdate kata sandi', + nepali: 'เคชเคพเคธเคตเคฐเฅเคก เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, clearCache: { english: 'Clear Cache', spanish: 'Limpiar cachรฉ', brazilian_portuguese: 'Limpar cache', tok_pisin: 'Klinim Cache', - indonesian: 'Hapus Cache' + indonesian: 'Hapus Cache', + nepali: 'เค•เฅเคฏเคพเคธ เค–เคพเคฒเฅ€ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, clearCacheConfirmation: { english: 'Are you sure you want to clear all cached data?', @@ -3862,14 +4561,16 @@ export const localizations = { brazilian_portuguese: 'Tem certeza que deseja limpar todos os dados em cache?', tok_pisin: 'Yu sure long klinim olgeta cache data?', - indonesian: 'Apakah Anda yakin ingin menghapus semua data cache?' + indonesian: 'Apakah Anda yakin ingin menghapus semua data cache?', + nepali: 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เคธเคฌเฅˆ เค•เฅเคฏเคพเคธ เคกเคพเคŸเคพ เค–เคพเคฒเฅ€ เค—เคฐเฅเคจ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค›?' }, cacheClearedSuccess: { english: 'Cache cleared successfully', spanish: 'Cachรฉ limpiada correctamente', brazilian_portuguese: 'Cache limpa com sucesso', tok_pisin: 'Cache i klin gut pinis', - indonesian: 'Cache berhasil dihapus' + indonesian: 'Cache berhasil dihapus', + nepali: 'เค•เฅเคฏเคพเคธ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เค–เคพเคฒเฅ€ เค—เคฐเคฟเคฏเฅ‹' }, exportRequiresInternet: { english: 'This feature requires an internet connection', @@ -3877,28 +4578,32 @@ export const localizations = { brazilian_portuguese: 'Esta funcionalidade requer uma conexรฃo com a internet', tok_pisin: 'Dispela feature i nidim internet connection', - indonesian: 'Fitur ini memerlukan koneksi internet' + indonesian: 'Fitur ini memerlukan koneksi internet', + nepali: 'เคฏเฅ‹ เคธเฅเคตเคฟเคงเคพเค•เฅ‹ เคฒเคพเค—เคฟ เค‡เคจเฅเคŸเคฐเคจเฅ‡เคŸ เคœเคกเคพเคจ เค†เคตเคถเฅเคฏเค• เค›' }, exportDataComingSoon: { english: 'Data export feature coming soon', spanish: 'La exportaciรณn de datos estรก prรณxima', brazilian_portuguese: 'A exportaรงรฃo de dados estรก prรณxima', tok_pisin: 'Data export feature i kam bihain', - indonesian: 'Fitur ekspor data segera hadir' + indonesian: 'Fitur ekspor data segera hadir', + nepali: 'เคกเคพเคŸเคพ เคจเคฟเคฐเฅเคฏเคพเคค เคธเฅเคตเคฟเคงเคพ เค›เคฟเคŸเฅเคŸเฅˆ เค†เค‰เคเคฆเฅˆเค›' }, info: { english: 'Info', spanish: 'Informaciรณn', brazilian_portuguese: 'Informaรงรฃo', tok_pisin: 'Info', - indonesian: 'Info' + indonesian: 'Info', + nepali: 'เคœเคพเคจเค•เคพเคฐเฅ€' }, enableNotifications: { english: 'Enable Notifications', spanish: 'Habilitar notificaciones', brazilian_portuguese: 'Habilitar notificaรงรตes', tok_pisin: 'Onim Notification', - indonesian: 'Aktifkan Notifikasi' + indonesian: 'Aktifkan Notifikasi', + nepali: 'เคธเฅ‚เคšเคจเคพเคนเคฐเฅ‚ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, notificationsDescription: { english: 'Receive notifications for app updates and important information', @@ -3908,21 +4613,24 @@ export const localizations = { 'Receber notificaรงรตes para atualizaรงรตes do aplicativo e informaรงรตes importantes', tok_pisin: 'Kisim notification long app update na important information', indonesian: - 'Terima notifikasi untuk pembaruan aplikasi dan informasi penting' + 'Terima notifikasi untuk pembaruan aplikasi dan informasi penting', + nepali: 'เคเคช เค…เคชเคกเฅ‡เคŸ เคฐ เคฎเคนเคคเฅเคคเฅเคตเคชเฅ‚เคฐเฅเคฃ เคœเคพเคจเค•เคพเคฐเฅ€เค•เฅ‹ เคฒเคพเค—เคฟ เคธเฅ‚เคšเคจเคพเคนเคฐเฅ‚ เคชเฅเคฐเคพเคชเฅเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, contentPreferences: { english: 'Content Preferences', spanish: 'Preferencias de contenido', brazilian_portuguese: 'Preferรชncias de conteรบdo', tok_pisin: 'Content Preferences', - indonesian: 'Preferensi Konten' + indonesian: 'Preferensi Konten', + nepali: 'เคธเคพเคฎเค—เฅเคฐเฅ€ เคชเฅเคฐเคพเคฅเคฎเคฟเค•เคคเคพเคนเคฐเฅ‚' }, showHiddenContent: { english: 'Show Hidden Content', spanish: 'Mostrar contenido oculto', brazilian_portuguese: 'Mostrar conteรบdo oculto', tok_pisin: 'Soim Hait Content', - indonesian: 'Tampilkan Konten Tersembunyi' + indonesian: 'Tampilkan Konten Tersembunyi', + nepali: 'เคฒเฅเค•เฅ‡เค•เฅ‹ เคธเคพเคฎเค—เฅเคฐเฅ€ เคฆเฅ‡เค–เคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, showHiddenContentDescription: { english: 'Allow displaying content that has been marked as invisible', @@ -3931,21 +4639,24 @@ export const localizations = { 'Permitir mostrar conteรบdo que foi marcado como invisรญvel', tok_pisin: 'Larim soim content we ol i makim hait', indonesian: - 'Izinkan menampilkan konten yang ditandai sebagai tidak terlihat' + 'Izinkan menampilkan konten yang ditandai sebagai tidak terlihat', + nepali: 'เค…เคฆเฅƒเคถเฅเคฏ เคญเคจเฅ€ เคšเคฟเคจเฅเคน เคฒเค—เคพเค‡เคเค•เฅ‹ เคธเคพเคฎเค—เฅเคฐเฅ€ เคชเฅเคฐเคฆเคฐเฅเคถเคจ เค—เคฐเฅเคจ เค…เคจเฅเคฎเคคเคฟ เคฆเคฟเคจเฅเคนเฅ‹เคธเฅ' }, dataAndStorage: { english: 'Data & Storage', spanish: 'Datos y almacenamiento', brazilian_portuguese: 'Dados e armazenamento', tok_pisin: 'Data na Storage', - indonesian: 'Data & Penyimpanan' + indonesian: 'Data & Penyimpanan', + nepali: 'เคกเคพเคŸเคพ เคฐ เคญเคฃเฅเคกเคพเคฐเคฃ' }, downloadOnWifiOnly: { english: 'Download on WiFi Only', spanish: 'Descargar solo en WiFi', brazilian_portuguese: 'Baixar apenas em WiFi', tok_pisin: 'Daunim long WiFi tasol', - indonesian: 'Unduh hanya di WiFi' + indonesian: 'Unduh hanya di WiFi', + nepali: 'WiFi เคฎเคพ เคฎเคพเคคเฅเคฐ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, downloadOnWifiOnlyDescription: { english: 'Only download content when connected to WiFi', @@ -3953,21 +4664,24 @@ export const localizations = { brazilian_portuguese: 'Baixar conteรบdo apenas quando estiver conectado ร  WiFi', tok_pisin: 'Daunim content taim yu joinim WiFi tasol', - indonesian: 'Hanya unduh konten saat terhubung ke WiFi' + indonesian: 'Hanya unduh konten saat terhubung ke WiFi', + nepali: 'WiFi เคฎเคพ เคœเคกเคพเคจ เคนเฅเคเคฆเคพ เคฎเคพเคคเฅเคฐ เคธเคพเคฎเค—เฅเคฐเฅ€ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, autoBackup: { english: 'Auto Backup', spanish: 'Copia de seguridad automรกtica', brazilian_portuguese: 'Backup automรกtico', tok_pisin: 'Auto Backup', - indonesian: 'Backup Otomatis' + indonesian: 'Backup Otomatis', + nepali: 'เคธเฅเคตเคค: เคฌเฅเคฏเคพเค•เค…เคช' }, autoBackupDescription: { english: 'Automatically backup your data to the cloud', spanish: 'Hacer una copia de seguridad automรกtica de tus datos en la nube', brazilian_portuguese: 'Fazer um backup automรกtico dos seus dados na nuvem', tok_pisin: 'Otomatik backup data bilong yu long cloud', - indonesian: 'Secara otomatis backup data Anda ke cloud' + indonesian: 'Secara otomatis backup data Anda ke cloud', + nepali: 'เค†เคซเฅเคจเฅ‹ เคกเคพเคŸเคพ เคธเฅเคตเคšเคพเคฒเคฟเคค เคฐเฅ‚เคชเคฎเคพ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคฌเฅเคฏเคพเค•เค…เคช เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, clearCacheDescription: { english: 'Clear all cached data to free up storage space', @@ -3976,49 +4690,56 @@ export const localizations = { brazilian_portuguese: 'Limpar todos os dados em cache para liberar espaรงo de armazenamento', tok_pisin: 'Klinim olgeta cache data long mekim moa storage space', - indonesian: 'Hapus semua data cache untuk mengosongkan ruang penyimpanan' + indonesian: 'Hapus semua data cache untuk mengosongkan ruang penyimpanan', + nepali: 'เคญเคฃเฅเคกเคพเคฐเคฃ เค เคพเค‰เค เค–เคพเคฒเฅ€ เค—เคฐเฅเคจ เคธเคฌเฅˆ เค•เฅเคฏเคพเคธ เคกเคพเคŸเคพ เค–เคพเคฒเฅ€ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, exportData: { english: 'Export Data', spanish: 'Exportar datos', brazilian_portuguese: 'Exportar dados', tok_pisin: 'Export Data', - indonesian: 'Ekspor Data' + indonesian: 'Ekspor Data', + nepali: 'เคกเคพเคŸเคพ เคจเคฟเคฐเฅเคฏเคพเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, exportDataDescription: { english: 'Export your data for backup or transfer', spanish: 'Exportar tus datos para respaldo o transferencia', brazilian_portuguese: 'Exportar seus dados para backup ou transferรชncia', tok_pisin: 'Export data bilong yu long backup o transfer', - indonesian: 'Ekspor data Anda untuk backup atau transfer' + indonesian: 'Ekspor data Anda untuk backup atau transfer', + nepali: 'เคฌเฅเคฏเคพเค•เค…เคช เคตเคพ เคธเฅเคฅเคพเคจเคพเคจเฅเคคเคฐเคฃเค•เฅ‹ เคฒเคพเค—เคฟ เค†เคซเฅเคจเฅ‹ เคกเคพเคŸเคพ เคจเคฟเคฐเฅเคฏเคพเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, support: { english: 'Support', spanish: 'Soporte', brazilian_portuguese: 'Suporte', tok_pisin: 'Support', - indonesian: 'Dukungan' + indonesian: 'Dukungan', + nepali: 'เคธเคนเคพเคฏเคคเคพ' }, helpCenter: { english: 'Help Center', spanish: 'Centro de ayuda', brazilian_portuguese: 'Centro de ajuda', tok_pisin: 'Help Center', - indonesian: 'Pusat Bantuan' + indonesian: 'Pusat Bantuan', + nepali: 'เคธเคนเคพเคฏเคคเคพ เค•เฅ‡เคจเฅเคฆเฅเคฐ' }, helpCenterComingSoon: { english: 'Help center feature coming soon', spanish: 'El centro de ayuda estรก prรณximo', brazilian_portuguese: 'O centro de ajuda estรก prรณximo', tok_pisin: 'Help center feature i kam bihain', - indonesian: 'Fitur pusat bantuan segera hadir' + indonesian: 'Fitur pusat bantuan segera hadir', + nepali: 'เคธเคนเคพเคฏเคคเคพ เค•เฅ‡เคจเฅเคฆเฅเคฐ เคธเฅเคตเคฟเคงเคพ เค›เคฟเคŸเฅเคŸเฅˆ เค†เค‰เคเคฆเฅˆเค›' }, contactSupport: { english: 'Contact Support', spanish: 'Contactar soporte', brazilian_portuguese: 'Contatar suporte', tok_pisin: 'Contact Support', - indonesian: 'Hubungi Dukungan' + indonesian: 'Hubungi Dukungan', + nepali: 'เคธเคนเคพเคฏเคคเคพเคธเคเค— เคธเคฎเฅเคชเคฐเฅเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, contactSupportComingSoon: { english: 'Contact support feature coming soon', @@ -4026,35 +4747,40 @@ export const localizations = { brazilian_portuguese: 'A funcionalidade de contato com o suporte estรก prรณxima', tok_pisin: 'Contact support feature i kam bihain', - indonesian: 'Fitur hubungi dukungan segera hadir' + indonesian: 'Fitur hubungi dukungan segera hadir', + nepali: 'เคธเคนเคพเคฏเคคเคพเคธเคเค— เคธเคฎเฅเคชเคฐเฅเค• เคธเฅเคตเคฟเคงเคพ เค›เคฟเคŸเฅเคŸเฅˆ เค†เค‰เคเคฆเฅˆเค›' }, termsAndConditions: { english: 'Terms & Conditions', spanish: 'Tรฉrminos y condiciones', brazilian_portuguese: 'Termos e condiรงรตes', tok_pisin: 'Terms na Conditions', - indonesian: 'Syarat & Ketentuan' + indonesian: 'Syarat & Ketentuan', + nepali: 'เคจเคฟเคฏเคฎ เคฐ เคธเคฐเฅเคคเคนเคฐเฅ‚' }, termsAndConditionsComingSoon: { english: 'Terms & Conditions feature coming soon', spanish: 'La funciรณn de tรฉrminos y condiciones estรก prรณxima', brazilian_portuguese: 'A funcionalidade de termos e condiรงรตes estรก prรณxima', tok_pisin: 'Terms na Conditions feature i kam bihain', - indonesian: 'Fitur Syarat & Ketentuan segera hadir' + indonesian: 'Fitur Syarat & Ketentuan segera hadir', + nepali: 'เคจเคฟเคฏเคฎ เคฐ เคธเคฐเฅเคคเคนเคฐเฅ‚ เคธเฅเคตเคฟเคงเคพ เค›เคฟเคŸเฅเคŸเฅˆ เค†เค‰เคเคฆเฅˆเค›' }, experimentalFeatures: { english: 'Experimental Features', spanish: 'Caracterรญsticas Experimentales', brazilian_portuguese: 'Recursos Experimentais', tok_pisin: 'Experimental Features', - indonesian: 'Fitur Eksperimental' + indonesian: 'Fitur Eksperimental', + nepali: 'เคชเฅเคฐเคฏเฅ‹เค—เคพเคคเฅเคฎเค• เคธเฅเคตเคฟเคงเคพเคนเคฐเฅ‚' }, aiSuggestions: { english: 'AI Suggestions', spanish: 'Sugerencias de IA', brazilian_portuguese: 'Sugestรตes de IA', tok_pisin: 'AI Suggestions', - indonesian: 'Saran AI' + indonesian: 'Saran AI', + nepali: 'เคเค†เคˆ เคธเฅเคเคพเคตเคนเคฐเฅ‚' }, aiSuggestionsDescription: { english: @@ -4066,14 +4792,17 @@ export const localizations = { tok_pisin: 'Onim AI translation suggestions i save helpim long ol translation klostu', indonesian: - 'Aktifkan saran terjemahan berbasis AI berdasarkan terjemahan terdekat' + 'Aktifkan saran terjemahan berbasis AI berdasarkan terjemahan terdekat', + nepali: + 'เคจเคœเคฟเค•เค•เคพ เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚เคฎเคพ เค†เคงเคพเคฐเคฟเคค เคเค†เคˆ-เคธเค‚เคšเคพเคฒเคฟเคค เค…เคจเฅเคตเคพเคฆ เคธเฅเคเคพเคตเคนเคฐเฅ‚ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, playAll: { english: 'Play All Assets', spanish: 'Reproducir Todos los Recursos', brazilian_portuguese: 'Reproduzir Todos os Recursos', tok_pisin: 'Playim Olgeta Assets', - indonesian: 'Putar Semua Aset' + indonesian: 'Putar Semua Aset', + nepali: 'เคธเคฌเฅˆ เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคชเฅเคฒเฅ‡ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, playAllDescription: { english: @@ -4085,21 +4814,25 @@ export const localizations = { tok_pisin: 'Onim play all assets feature long playim olgeta audio assets long wanpela taim', indonesian: - 'Aktifkan fitur putar semua aset untuk memutar semua aset audio secara berurutan' + 'Aktifkan fitur putar semua aset untuk memutar semua aset audio secara berurutan', + nepali: + 'เคธเคฌเฅˆ เค…เคกเคฟเคฏเฅ‹ เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เค•เฅเคฐเคฎเคถเคƒ เคชเฅเคฒเฅ‡ เค—เคฐเฅเคจ เคธเคฌเฅˆ เคเคธเฅ‡เคŸเคนเคฐเฅ‚ เคชเฅเคฒเฅ‡ เค—เคฐเฅเคจเฅ‡ เคธเฅเคตเคฟเคงเคพ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, advanced: { english: 'Advanced', spanish: 'Avanzado', brazilian_portuguese: 'Avanรงado', tok_pisin: 'Advanced', - indonesian: 'Lanjutan' + indonesian: 'Lanjutan', + nepali: 'เค‰เคจเฅเคจเคค' }, debugMode: { english: 'Debug Mode', spanish: 'Modo de depuraciรณn', brazilian_portuguese: 'Modo de depuraรงรฃo', tok_pisin: 'Debug Mode', - indonesian: 'Mode Debug' + indonesian: 'Mode Debug', + nepali: 'เคกเคฟเคฌเค— เคฎเฅ‹เคก' }, debugModeDescription: { english: 'Enable debug mode for development features', @@ -4107,7 +4840,8 @@ export const localizations = { brazilian_portuguese: 'Habilitar modo de depuraรงรฃo para funcionalidades de desenvolvimento', tok_pisin: 'Onim debug mode long development features', - indonesian: 'Aktifkan mode debug untuk fitur pengembangan' + indonesian: 'Aktifkan mode debug untuk fitur pengembangan', + nepali: 'เคตเคฟเค•เคพเคธ เคธเฅเคตเคฟเคงเคพเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เคกเคฟเคฌเค— เคฎเฅ‹เคก เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, settingsRequireInternet: { english: 'Some settings require an internet connection', @@ -4115,70 +4849,80 @@ export const localizations = { brazilian_portuguese: 'Algumas configuraรงรตes requerem uma conexรฃo com a internet', tok_pisin: 'Sampela settings i nidim internet connection', - indonesian: 'Beberapa pengaturan memerlukan koneksi internet' + indonesian: 'Beberapa pengaturan memerlukan koneksi internet', + nepali: 'เค•เฅ‡เคนเฅ€ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚เคฒเคพเคˆ เค‡เคจเฅเคŸเคฐเคจเฅ‡เคŸ เคœเคกเคพเคจ เค†เคตเคถเฅเคฏเค• เค›' }, internetConnectionRequired: { english: 'Internet connection required', spanish: 'Se requiere conexiรณn a internet', brazilian_portuguese: 'Conexรฃo com a internet necessรกria', tok_pisin: 'Internet connection i mas', - indonesian: 'Koneksi internet diperlukan' + indonesian: 'Koneksi internet diperlukan', + nepali: 'เค‡เคจเฅเคŸเคฐเคจเฅ‡เคŸ เคœเคกเคพเคจ เค†เคตเคถเฅเคฏเค• เค›' }, clear: { english: 'Clear', spanish: 'Limpiar', brazilian_portuguese: 'Limpar', tok_pisin: 'Klinim', - indonesian: 'Hapus' + indonesian: 'Hapus', + nepali: 'เค–เคพเคฒเฅ€ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, unnamedAsset: { english: 'Unnamed Asset', spanish: 'Actividad sin nombre', brazilian_portuguese: 'Atividade sem nome', tok_pisin: 'Asset i no gat nem', - indonesian: 'Asset Tanpa Nama' + indonesian: 'Asset Tanpa Nama', + nepali: 'เคจเคพเคฎ เคจเคญเคเค•เฅ‹ เคเคธเฅ‡เคŸ' }, noAssetSelected: { english: 'No Asset Selected', spanish: 'No hay actividades seleccionadas', brazilian_portuguese: 'Nenhuma atividade selecionada', tok_pisin: 'Yu no makim wanpela asset', - indonesian: 'Tidak Ada Asset yang Dipilih' + indonesian: 'Tidak Ada Asset yang Dipilih', + nepali: 'เค•เฅเคจเฅˆ เคเคธเฅ‡เคŸ เค›เคพเคจเคฟเคเค•เฅ‹ เค›เฅˆเคจ' }, assetNotAvailableOffline: { english: 'Asset not available offline', spanish: 'La actividad no estรก disponible sin conexiรณn', brazilian_portuguese: 'A atividade nรฃo estรก disponรญvel offline', tok_pisin: 'Asset i no stap taim i no gat internet', - indonesian: 'Asset tidak tersedia offline' + indonesian: 'Asset tidak tersedia offline', + nepali: 'เคเคธเฅ‡เคŸ เค…เคซเคฒเคพเค‡เคจ เค‰เคชเคฒเคฌเฅเคง เค›เฅˆเคจ' }, cloudError: { english: 'Cloud error: {error}', spanish: 'Error en la nube: {error}', brazilian_portuguese: 'Erro na nuvem: {error}', tok_pisin: 'Cloud error: {error}', - indonesian: 'Kesalahan cloud: {error}' + indonesian: 'Kesalahan cloud: {error}', + nepali: 'เค•เฅเคฒเคพเค‰เคก เคคเฅเคฐเฅเคŸเคฟ: {error}' }, assetNotFoundOnline: { english: 'Asset not found online', spanish: 'La actividad no se encontrรณ en lรญnea', brazilian_portuguese: 'A atividade nรฃo foi encontrada online', tok_pisin: 'Asset i no stap long internet', - indonesian: 'Asset tidak ditemukan online' + indonesian: 'Asset tidak ditemukan online', + nepali: 'เคเคธเฅ‡เคŸ เค…เคจเคฒเคพเค‡เคจ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, trySwitchingToCloudDataSource: { english: 'Try switching to Cloud data source above', spanish: 'Intenta cambiar a la fuente de datos en la nube', brazilian_portuguese: 'Tente mudar para a fonte de dados na nuvem', tok_pisin: 'Traim senisim long Cloud data source antap', - indonesian: 'Coba beralih ke sumber data Cloud di atas' + indonesian: 'Coba beralih ke sumber data Cloud di atas', + nepali: 'เคฎเคพเคฅเคฟ เค•เฅเคฒเคพเค‰เคก เคกเคพเคŸเคพ เคธเฅเคฐเฅ‹เคคเคฎเคพ เคธเฅเคตเคฟเคš เค—เคฐเฅเคจเฅ‡ เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, trySwitchingToOfflineDataSource: { english: 'Try switching to Offline data source above', spanish: 'Intenta cambiar a la fuente de datos sin conexiรณn', - brazilian_portuguese: 'Tente mudar para a fonte de dados offline', + brazilian_portuguese: 'Tente mudar para a fonte de datos offline', tok_pisin: 'Traim senisim long Offline data source antap', - indonesian: 'Coba beralih ke sumber data Offline di atas' + indonesian: 'Coba beralih ke sumber data Offline di atas', + nepali: 'เคฎเคพเคฅเคฟ เค…เคซเคฒเคพเค‡เคจ เคกเคพเคŸเคพ เคธเฅเคฐเฅ‹เคคเคฎเคพ เคธเฅเคตเคฟเคš เค—เคฐเฅเคจเฅ‡ เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, assetMayNotBeSynchronized: { english: 'This asset may not be synchronized or may not exist', @@ -4186,49 +4930,56 @@ export const localizations = { brazilian_portuguese: 'Esta atividade pode nรฃo estar sincronizada ou pode nรฃo existir', tok_pisin: 'Dispela asset i no sync o i no stap', - indonesian: 'Asset ini mungkin tidak tersinkronisasi atau tidak ada' + indonesian: 'Asset ini mungkin tidak tersinkronisasi atau tidak ada', + nepali: 'เคฏเฅ‹ เคเคธเฅ‡เคŸ เคธเคฟเค‚เค•เฅเคฐเฅ‹เคจเคพเค‡เคœ เคจเคญเคเค•เฅ‹ เคตเคพ เค…เคตเคธเฅเคฅเคฟเคค เคจเคนเฅเคจ เคธเค•เฅเค›' }, noContentAvailable: { english: 'No content available', spanish: 'No hay contenido disponible', brazilian_portuguese: 'Nenhum conteรบdo disponรญvel', tok_pisin: 'I no gat content', - indonesian: 'Tidak ada konten tersedia' + indonesian: 'Tidak ada konten tersedia', + nepali: 'เค•เฅเคจเฅˆ เคธเคพเคฎเค—เฅเคฐเฅ€ เค‰เคชเคฒเคฌเฅเคง เค›เฅˆเคจ' }, audioReady: { english: 'Audio ready', spanish: 'Audio listo', brazilian_portuguese: 'รudio pronto', tok_pisin: 'Audio i redi', - indonesian: 'Audio siap' + indonesian: 'Audio siap', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคคเคฏเคพเคฐ เค›' }, audioNotAvailable: { english: 'Audio not available', spanish: 'Audio no disponible', brazilian_portuguese: 'รudio nรฃo disponรญvel', tok_pisin: 'Audio i no stap', - indonesian: 'Audio tidak tersedia' + indonesian: 'Audio tidak tersedia', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เค‰เคชเคฒเคฌเฅเคง เค›เฅˆเคจ' }, imagesAvailable: { english: 'Images available', spanish: 'Imรกgenes disponibles', brazilian_portuguese: 'Imagens disponรญveis', tok_pisin: 'Ol piksa i stap', - indonesian: 'Gambar tersedia' + indonesian: 'Gambar tersedia', + nepali: 'เคคเคธเฅเคฌเคฟเคฐเคนเคฐเฅ‚ เค‰เคชเคฒเคฌเฅเคง เค›เคจเฅ' }, language: { english: 'Language', spanish: 'Idioma', brazilian_portuguese: 'Idioma', tok_pisin: 'Tokples', - indonesian: 'Bahasa' + indonesian: 'Bahasa', + nepali: 'เคญเคพเคทเคพ' }, template: { english: 'Template', spanish: 'Plantilla', brazilian_portuguese: 'Plantilla', tok_pisin: 'Template', - indonesian: 'Template' + indonesian: 'Template', + nepali: 'เคŸเฅ‡เคฎเฅเคชเฅเคฒเฅ‡เคŸ' }, // template options bible: { @@ -4236,210 +4987,240 @@ export const localizations = { spanish: 'Biblia', brazilian_portuguese: 'Bรญblia', tok_pisin: 'Bible', - indonesian: 'Alkitab' + indonesian: 'Alkitab', + nepali: 'เคฌเคพเค‡เคฌเคฒ' }, unstructured: { english: 'Unstructured', spanish: 'No estructurado', brazilian_portuguese: 'Nรฃo estruturado', tok_pisin: 'Unstructured', - indonesian: 'Tidak terstruktur' + indonesian: 'Tidak terstruktur', + nepali: 'เคธเค‚เคฐเคšเคจเคพ เคจเคญเคเค•เฅ‹' }, audioTracks: { english: 'Audio tracks', spanish: 'Pistas de audio', brazilian_portuguese: 'Pistas de รกudio', tok_pisin: 'Ol audio track', - indonesian: 'Trek audio' + indonesian: 'Trek audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคŸเฅเคฐเฅเคฏเคพเค•เคนเคฐเฅ‚' }, membersOnly: { english: 'Members Only', spanish: 'Solo para miembros', brazilian_portuguese: 'Sรณ para membros', tok_pisin: 'Member tasol', - indonesian: 'Khusus Anggota' + indonesian: 'Khusus Anggota', + nepali: 'เคธเคฆเคธเฅเคฏเคนเคฐเฅ‚เค•เฅ‹ เคฒเคพเค—เคฟ เคฎเคพเคคเฅเคฐ' }, cloud: { english: 'Cloud', spanish: 'Nube', brazilian_portuguese: 'Nuvem', tok_pisin: 'Cloud', - indonesian: 'Cloud' + indonesian: 'Cloud', + nepali: 'เค•เฅเคฒเคพเค‰เคก' }, syncing: { english: 'Syncing', spanish: 'Sincronizando', brazilian_portuguese: 'Sincronizando', tok_pisin: 'I sync', - indonesian: 'Sinkronisasi' + indonesian: 'Sinkronisasi', + nepali: 'เคธเคฟเค™เฅเค• เค—เคฐเฅเคฆเฅˆ' }, synced: { english: 'Synced', spanish: 'Sincronizado', brazilian_portuguese: 'Sincronizado', tok_pisin: 'Sync pinis', - indonesian: 'Tersinkronisasi' + indonesian: 'Tersinkronisasi', + nepali: 'เคธเคฟเค™เฅเค• เคญเคฏเฅ‹' }, questSyncedToCloud: { english: 'Quest is synced to cloud', spanish: 'La misiรณn estรก sincronizada en la nube', brazilian_portuguese: 'A missรฃo estรก sincronizada na nuvem', tok_pisin: 'Quest i sync pinis long cloud', - indonesian: 'Quest telah disinkronkan ke cloud' + indonesian: 'Quest telah disinkronkan ke cloud', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เค•เฅเคฒเคพเค‰เคกเคฎเคพ เคธเคฟเค™เฅเค• เคญเคเค•เฅ‹ เค›' }, failed: { english: 'Failed', spanish: 'Fallado', brazilian_portuguese: 'Falhado', tok_pisin: 'I pail', - indonesian: 'Gagal' + indonesian: 'Gagal', + nepali: 'เค…เคธเคซเคฒ เคญเคฏเฅ‹' }, state: { english: 'State', spanish: 'Estado', brazilian_portuguese: 'Estado', tok_pisin: 'State', - indonesian: 'Status' + indonesian: 'Status', + nepali: 'เค…เคตเคธเฅเคฅเคพ' }, noQuestSelected: { english: 'No Quest Selected', spanish: 'No hay proyecto seleccionado', brazilian_portuguese: 'Nenhum projeto selecionado', tok_pisin: 'Yu no makim wanpela quest', - indonesian: 'Tidak Ada Quest yang Dipilih' + indonesian: 'Tidak Ada Quest yang Dipilih', + nepali: 'เค•เฅเคจเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸ เค›เคพเคจเคฟเคเค•เฅ‹ เค›เฅˆเคจ' }, liveAttachmentStates: { english: 'Live Attachment States', spanish: 'Estados de adjuntos en vivo', brazilian_portuguese: 'Estados de anexos em tempo real', tok_pisin: 'Live Attachment States', - indonesian: 'Status Lampiran Langsung' + indonesian: 'Status Lampiran Langsung', + nepali: 'เคชเฅเคฐเคคเฅเคฏเค•เฅเคท เคธเค‚เคฒเค—เฅเคจเค• เค…เคตเคธเฅเคฅเคพเคนเคฐเฅ‚' }, searching: { english: 'Searching', spanish: 'Buscando', brazilian_portuguese: 'Buscando', tok_pisin: 'I painim', - indonesian: 'Mencari' + indonesian: 'Mencari', + nepali: 'เค–เฅ‹เคœเฅเคฆเฅˆ' }, translationSubmittedSuccessfully: { english: 'Translation submitted successfully', spanish: 'Traducciรณn enviada correctamente', brazilian_portuguese: 'Traduรงรฃo enviada com sucesso', tok_pisin: 'Translation i go gut pinis', - indonesian: 'Terjemahan berhasil dikirim' + indonesian: 'Terjemahan berhasil dikirim', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเฅ‡เคถ เค—เคฐเคฟเคฏเฅ‹' }, transcriptionSubmittedSuccessfully: { english: 'Transcription submitted successfully', spanish: 'Transcripciรณn enviada correctamente', brazilian_portuguese: 'Transcriรงรฃo enviada com sucesso', tok_pisin: 'Transcription i go gut pinis', - indonesian: 'Transkripsi berhasil dikirim' + indonesian: 'Transkripsi berhasil dikirim', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเฅ‡เคถ เค—เคฐเคฟเคฏเฅ‹' }, text: { english: 'Text', spanish: 'Texto', brazilian_portuguese: 'Texto', tok_pisin: 'Text', - indonesian: 'Teks' + indonesian: 'Teks', + nepali: 'เคชเคพเค ' }, audio: { english: 'Audio', spanish: 'Audio', brazilian_portuguese: 'รudio', tok_pisin: 'Audio', - indonesian: 'Audio' + indonesian: 'Audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹' }, targetLanguage: { english: 'Target Language', spanish: 'Idioma de destino', brazilian_portuguese: 'Idioma de destino', tok_pisin: 'Target Tokples', - indonesian: 'Bahasa Target' + indonesian: 'Bahasa Target', + nepali: 'เคฒเค•เฅเคทเคฟเคค เคญเคพเคทเคพ' }, sourceLanguage: { english: 'Source Language', spanish: 'Idioma de origen', brazilian_portuguese: 'Idioma de origem', tok_pisin: 'Source Tokples', - indonesian: 'Bahasa Sumber' + indonesian: 'Bahasa Sumber', + nepali: 'เคธเฅเคฐเฅ‹เคค เคญเคพเคทเคพ' }, your: { english: 'Your', spanish: 'Tu', brazilian_portuguese: 'Seu', tok_pisin: 'Bilong yu', - indonesian: 'Anda' + indonesian: 'Anda', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹' }, translation: { english: 'Translation', spanish: 'Traducciรณn', brazilian_portuguese: 'Traduรงรฃo', tok_pisin: 'Translation', - indonesian: 'Terjemahan' + indonesian: 'Terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆ' }, readyToSubmit: { english: 'Ready to submit', spanish: 'Listo para enviar', brazilian_portuguese: 'Pronto para enviar', tok_pisin: 'Redi long salim', - indonesian: 'Siap untuk dikirim' + indonesian: 'Siap untuk dikirim', + nepali: 'เคชเฅ‡เคถ เค—เคฐเฅเคจ เคคเคฏเคพเคฐ' }, online: { english: 'Online', spanish: 'En lรญnea', brazilian_portuguese: 'Online', tok_pisin: 'Online', - indonesian: 'Online' + indonesian: 'Online', + nepali: 'เค…เคจเคฒเคพเค‡เคจ' }, allProjects: { english: 'All Projects', spanish: 'Todos los proyectos', brazilian_portuguese: 'Todos os projetos', tok_pisin: 'Olgeta Project', - indonesian: 'Semua Proyek' + indonesian: 'Semua Proyek', + nepali: 'เคธเคฌเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚' }, searchProjects: { english: 'Search projects...', spanish: 'Buscar proyectos...', brazilian_portuguese: 'Buscar projetos...', tok_pisin: 'Painim ol project...', - indonesian: 'Cari proyek...' + indonesian: 'Cari proyek...', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚ เค–เฅ‹เคœเฅเคจเฅเคนเฅ‹เคธเฅ...' }, noProjectSelected: { english: 'No Project Selected', spanish: 'No hay proyecto seleccionado', brazilian_portuguese: 'Nenhum projeto selecionado', tok_pisin: 'Yu no makim wanpela project', - indonesian: 'Tidak Ada Proyek yang Dipilih' + indonesian: 'Tidak Ada Proyek yang Dipilih', + nepali: 'เค•เฅเคจเฅˆ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค›เคพเคจเคฟเคเค•เฅ‹ เค›เฅˆเคจ' }, noQuestsFound: { english: 'No quests found', spanish: 'No se encontraron misiones', brazilian_portuguese: 'Nenhuma missรฃo encontrada', tok_pisin: 'I no gat quest', - indonesian: 'Tidak ada quest ditemukan' + indonesian: 'Tidak ada quest ditemukan', + nepali: 'เค•เฅเคจเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, noQuestsAvailable: { english: 'No quests available', spanish: 'No hay misiones disponibles', brazilian_portuguese: 'Nenhuma missรฃo disponรญvel', tok_pisin: 'I no gat quest long usim', - indonesian: 'Tidak ada quest tersedia' + indonesian: 'Tidak ada quest tersedia', + nepali: 'เค•เฅเคจเฅˆ เค•เฅเคตเฅ‡เคธเฅเคŸ เค‰เคชเคฒเคฌเฅเคง เค›เฅˆเคจ' }, pleaseLogInToVote: { english: 'Please log in to vote', spanish: 'Por favor, inicia sesiรณn para votar', brazilian_portuguese: 'Por favor, faรงa login para votar', tok_pisin: 'Plis login pastaim long vote', - indonesian: 'Silakan login untuk memilih' + indonesian: 'Silakan login untuk memilih', + nepali: 'เค•เฅƒเคชเคฏเคพ เคฎเคคเคฆเคพเคจ เค—เคฐเฅเคจ เคฒเค— เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, pleaseLogInToTranscribe: { english: 'Please log in to transcribe audio', spanish: 'Por favor, inicia sesiรณn para transcribir audio', brazilian_portuguese: 'Por favor, faรงa login para transcrever รกudio', tok_pisin: 'Plis login pastaim long transcribe audio', - indonesian: 'Silakan login untuk mentranskripsi audio' + indonesian: 'Silakan login untuk mentranskripsi audio', + nepali: 'เค•เฅƒเคชเคฏเคพ เค…เคกเคฟเคฏเฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคพเค‡เคฌ เค—เคฐเฅเคจ เคฒเค— เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, transcriptionFailed: { english: 'Failed to transcribe audio. Please try again.', @@ -4447,98 +5228,112 @@ export const localizations = { brazilian_portuguese: 'Falha ao transcrever รกudio. Por favor, tente novamente.', tok_pisin: 'I no inap transcribe audio. Plis traim gen.', - indonesian: 'Gagal mentranskripsi audio. Silakan coba lagi.' + indonesian: 'Gagal mentranskripsi audio. Silakan coba lagi.', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคพเค‡เคฌ เค—เคฐเฅเคจ เค…เคธเคซเคฒเฅค เค•เฅƒเคชเคฏเคพ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, yourTranscriptionHasBeenSubmitted: { english: 'Your transcription has been submitted', spanish: 'Tu transcripciรณn ha sido enviada', brazilian_portuguese: 'Sua transcriรงรฃo foi enviada', tok_pisin: 'Transcription bilong yu i go pinis', - indonesian: 'Transkripsi Anda telah dikirim' + indonesian: 'Transkripsi Anda telah dikirim', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคถเคจ เคชเฅ‡เคถ เค—เคฐเคฟเคเค•เฅ‹ เค›' }, failedToCreateTranscription: { english: 'Failed to create transcription', spanish: 'Error al crear la transcripciรณn', brazilian_portuguese: 'Falha ao criar a transcriรงรฃo', tok_pisin: 'I no inap mekim transcription', - indonesian: 'Gagal membuat transkripsi' + indonesian: 'Gagal membuat transkripsi', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคถเคจ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, enterYourTranscription: { english: 'Enter your transcription', spanish: 'Escribe tu transcripciรณn', brazilian_portuguese: 'Digite sua transcriรงรฃo', tok_pisin: 'Raitim transcription bilong yu', - indonesian: 'Masukkan transkripsi Anda' + indonesian: 'Masukkan transkripsi Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคถเคจ เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, submitTranscription: { english: 'Submit Transcription', spanish: 'Enviar transcripciรณn', brazilian_portuguese: 'Enviar transcriรงรฃo', tok_pisin: 'Salim Transcription', - indonesian: 'Kirim Transkripsi' + indonesian: 'Kirim Transkripsi', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคถเคจ เคชเฅ‡เคถ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, good: { english: 'Good', spanish: 'Bueno', brazilian_portuguese: 'Bom', tok_pisin: 'Gut', - indonesian: 'Bagus' + indonesian: 'Bagus', + nepali: 'เคฐเคพเคฎเฅเคฐเฅ‹' }, needsWork: { english: 'Needs Work', spanish: 'Necesita trabajo', brazilian_portuguese: 'Precisa de trabalho', tok_pisin: 'I nidim wok moa', - indonesian: 'Perlu Perbaikan' + indonesian: 'Perlu Perbaikan', + nepali: 'เคธเฅเคงเคพเคฐ เคšเคพเคนเคฟเคจเฅเค›' }, pleaseLogInToVoteOnTranslations: { english: 'Please log in to vote on translations', spanish: 'Por favor, inicia sesiรณn para votar en traducciones', brazilian_portuguese: 'Por favor, faรงa login para votar em traduรงรตes', tok_pisin: 'Plis login pastaim long vote long ol translation', - indonesian: 'Silakan login untuk memilih terjemahan' + indonesian: 'Silakan login untuk memilih terjemahan', + nepali: 'เค•เฅƒเคชเคฏเคพ เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚เคฎเคพ เคฎเคคเคฆเคพเคจ เค—เคฐเฅเคจ เคฒเค— เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, translationNotFound: { english: 'Translation not found', spanish: 'Traducciรณn no encontrada', brazilian_portuguese: 'Traduรงรฃo nรฃo encontrada', tok_pisin: 'Translation i no stap', - indonesian: 'Terjemahan tidak ditemukan' + indonesian: 'Terjemahan tidak ditemukan', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคซเฅ‡เคฒเคพ เคชเคฐเฅ‡เคจ' }, noTranslationsYet: { english: 'No translations yet. Be the first to translate!', spanish: 'No hay traducciones aรบn. Sรฉ el primero en traducir!', brazilian_portuguese: 'Nenhuma traduรงรฃo ainda. Seja o primeiro a traduzir!', tok_pisin: 'I no gat translation yet. Yu ken namba wan long translate!', - indonesian: 'Belum ada terjemahan. Jadilah yang pertama menerjemahkan!' + indonesian: 'Belum ada terjemahan. Jadilah yang pertama menerjemahkan!', + nepali: 'เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เค…เคจเฅเคตเคพเคฆ เค›เฅˆเคจเฅค เคชเคนเคฟเคฒเฅ‹ เค…เคจเฅเคตเคพเคฆเค• เคฌเคจเฅเคจเฅเคนเฅ‹เคธเฅ!' }, viewProjectLimitedAccess: { english: 'View Project (Limited Access)', spanish: 'Ver proyecto (Acceso limitado)', brazilian_portuguese: 'Ver projeto (Acesso limitado)', tok_pisin: 'Lukim Project (Limited Access)', - indonesian: 'Lihat Proyek (Akses Terbatas)' + indonesian: 'Lihat Proyek (Akses Terbatas)', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ (เคธเฅ€เคฎเคฟเคค เคชเคนเฅเคเคš)' }, languages: { english: 'Languages', spanish: 'Idiomas', brazilian_portuguese: 'Idiomas', tok_pisin: 'Ol Tokples', - indonesian: 'Bahasa' + indonesian: 'Bahasa', + nepali: 'เคญเคพเคทเคพเคนเคฐเฅ‚' }, downloadRequired: { english: 'Download required', spanish: 'Descarga requerida', brazilian_portuguese: 'Download requerido', tok_pisin: 'Yu mas daunim', - indonesian: 'Unduhan diperlukan' + indonesian: 'Unduhan diperlukan', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เค†เคตเคถเฅเคฏเค• เค›' }, myProjects: { english: 'My Projects', spanish: 'Mis proyectos', brazilian_portuguese: 'Meus projetos', tok_pisin: 'Ol Project Bilong Mi', - indonesian: 'Proyek Saya' + indonesian: 'Proyek Saya', + nepali: 'เคฎเฅ‡เคฐเคพ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚' }, statusTranslationActive: { english: @@ -4549,7 +5344,9 @@ export const localizations = { 'Esta traduรงรฃo estรก atualmente ativa. Uma traduรงรฃo ativa tambรฉm รฉ visรญvel.', tok_pisin: 'Dispela translation i active nau. Active translation i save tu.', - indonesian: 'Terjemahan ini saat ini aktif. Terjemahan aktif juga terlihat.' + indonesian: + 'Terjemahan ini saat ini aktif. Terjemahan aktif juga terlihat.', + nepali: 'เคฏเฅ‹ เค…เคจเฅเคตเคพเคฆ เคนเคพเคฒ เคธเค•เฅเคฐเคฟเคฏ เค›เฅค เคธเค•เฅเคฐเคฟเคฏ เค…เคจเฅเคตเคพเคฆ เคชเคจเคฟ เคฆเฅƒเคถเฅเคฏเคฎเคพเคจ เค›เฅค' }, statusTranslationInactive: { english: @@ -4561,14 +5358,17 @@ export const localizations = { tok_pisin: 'Dispela translation i no active. Yu no ken mekim wanpela samting sapos yu no mekim active gen.', indonesian: - 'Terjemahan ini tidak aktif. Tidak ada tindakan yang dapat dilakukan kecuali diaktifkan kembali.' + 'Terjemahan ini tidak aktif. Tidak ada tindakan yang dapat dilakukan kecuali diaktifkan kembali.', + nepali: + 'เคฏเฅ‹ เค…เคจเฅเคตเคพเคฆ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เค›เฅค เคชเฅเคจ: เคธเค•เฅเคฐเคฟเคฏ เคจเค—เคฐเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เค•เคพเคฐเฅเคฏ เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, statusTranslationVisible: { english: 'This translation is visible to other users.', spanish: 'Esta traducciรณn es visible para otros usuarios.', brazilian_portuguese: 'Esta traduรงรฃo estรก visรญvel para outros usuรกrios.', tok_pisin: 'Dispela translation i save long ol narapela user.', - indonesian: 'Terjemahan ini terlihat oleh pengguna lain.' + indonesian: 'Terjemahan ini terlihat oleh pengguna lain.', + nepali: 'เคฏเฅ‹ เค…เคจเฅเคตเคพเคฆ เค…เคจเฅเคฏ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพเคนเคฐเฅ‚เคฒเคพเคˆ เคฆเฅ‡เค–เคฟเคจเฅเค›เฅค' }, statusTranslationInvisible: { english: @@ -4580,70 +5380,81 @@ export const localizations = { tok_pisin: 'Dispela translation i hait na bai i no soim long ol narapela user. Hait translation i no active tu.', indonesian: - 'Terjemahan ini disembunyikan dan tidak akan ditampilkan kepada pengguna lain. Terjemahan yang tidak terlihat juga tidak aktif.' + 'Terjemahan ini disembunyikan dan tidak akan ditampilkan kepada pengguna lain. Terjemahan yang tidak terlihat juga tidak aktif.', + nepali: + 'เคฏเฅ‹ เค…เคจเฅเคตเคพเคฆ เคฒเฅเค•เคพเค‡เคเค•เฅ‹ เค› เคฐ เค…เคจเฅเคฏ เคชเฅเคฐเคฏเฅ‹เค—เค•เคฐเฅเคคเคพเคนเคฐเฅ‚เคฒเคพเคˆ เคฆเฅ‡เค–เคพเค‡เคจเฅ‡ เค›เฅˆเคจเฅค เค…เคฆเฅƒเคถเฅเคฏ เค…เคจเฅเคตเคพเคฆ เคชเคจเคฟ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เค›เฅค' }, statusTranslationMadeVisible: { english: 'The translation has been made visible', spanish: 'La traducciรณn se ha hecho visible', brazilian_portuguese: 'A traduรงรฃo foi tornada visรญvel', tok_pisin: 'Translation i mekim save nau', - indonesian: 'Terjemahan telah dibuat terlihat' + indonesian: 'Terjemahan telah dibuat terlihat', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคฆเฅƒเคถเฅเคฏเคฎเคพเคจ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, statusTranslationMadeInvisible: { english: 'The translation has been made invisible', spanish: 'La traducciรณn se ha hecho invisible', brazilian_portuguese: 'A traduรงรฃo foi tornada invisรญvel', tok_pisin: 'Translation i mekim hait nau', - indonesian: 'Terjemahan telah dibuat tidak terlihat' + indonesian: 'Terjemahan telah dibuat tidak terlihat', + nepali: 'เค…เคจเฅเคตเคพเคฆ เค…เคฆเฅƒเคถเฅเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, statusTranslationMadeActive: { english: 'The translation has been made active', spanish: 'La traducciรณn se ha activado', brazilian_portuguese: 'A traduรงรฃo foi ativada', tok_pisin: 'Translation i mekim active nau', - indonesian: 'Terjemahan telah diaktifkan' + indonesian: 'Terjemahan telah diaktifkan', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคธเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, statusTranslationMadeInactive: { english: 'The translation has been made inactive', spanish: 'La traducciรณn ha sido desactivada', brazilian_portuguese: 'A traduรงรฃo foi desativada', tok_pisin: 'Translation i mekim stop nau', - indonesian: 'Terjemahan telah dinonaktifkan' + indonesian: 'Terjemahan telah dinonaktifkan', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เคฌเคจเคพเค‡เคเค•เฅ‹ เค›' }, statusTranslationUpdateFailed: { english: 'Failed to update translation settings', spanish: 'Error al actualizar la configuraciรณn de la traducciรณn', brazilian_portuguese: 'Falha ao atualizar as configuraรงรตes da traduรงรฃo', tok_pisin: 'I no inap update translation settings', - indonesian: 'Gagal mengupdate pengaturan terjemahan' + indonesian: 'Gagal mengupdate pengaturan terjemahan', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, translationSettingsLoadError: { english: 'Error loading translation settings.', spanish: 'Error al cargar la configuraciรณn de traducciรณn.', brazilian_portuguese: 'Erro ao carregar as configuraรงรตes de traduรงรฃo.', tok_pisin: 'I no inap load translation settings.', - indonesian: 'Gagal memuat pengaturan terjemahan.' + indonesian: 'Gagal memuat pengaturan terjemahan.', + nepali: 'เค…เคจเฅเคตเคพเคฆ เคธเฅ‡เคŸเคฟเค™เคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคฆเคพ เคคเฅเคฐเฅเคŸเคฟเฅค' }, contentText: { english: 'Content Text', spanish: 'Texto del Contenido', brazilian_portuguese: 'Texto do Conteรบdo', tok_pisin: 'Content Text', - indonesian: 'Teks Konten' + indonesian: 'Teks Konten', + nepali: 'เคธเคพเคฎเค—เฅเคฐเฅ€ เคชเคพเค ' }, enterContentText: { english: 'Enter content text...', spanish: 'Ingrese el texto del contenido...', brazilian_portuguese: 'Digite o texto do conteรบdo...', tok_pisin: 'Putim content text...', - indonesian: 'Masukkan teks konten...' + indonesian: 'Masukkan teks konten...', + nepali: 'เคธเคพเคฎเค—เฅเคฐเฅ€ เคชเคพเค  เคชเฅเคฐเคตเคฟเคทเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ...' }, saving: { english: 'Saving...', spanish: 'Guardando...', brazilian_portuguese: 'Salvando...', tok_pisin: 'Seivim...', - indonesian: 'Menyimpan...' + indonesian: 'Menyimpan...', + nepali: 'เคธเฅเคฐเค•เฅเคทเคฟเคค เค—เคฐเฅเคฆเฅˆ...' }, localAssetEditHint: { english: 'This asset is local only. Text can be edited until published.', @@ -4653,147 +5464,169 @@ export const localizations = { 'Este recurso รฉ apenas local. O texto pode ser editado atรฉ ser publicado.', tok_pisin: 'Dispela asset i local tasol. Yu ken senisim text inap yu publishim.', - indonesian: 'Aset ini hanya lokal. Teks dapat diedit hingga dipublikasikan.' + indonesian: + 'Aset ini hanya lokal. Teks dapat diedit hingga dipublikasikan.', + nepali: 'เคฏเฅ‹ เคเคธเฅ‡เคŸ เคธเฅเคฅเคพเคจเฅ€เคฏ เคฎเคพเคคเฅเคฐ เค›เฅค เคชเฅเคฐเค•เคพเคถเคฟเคค เคจเคญเคเคธเคฎเฅเคฎ เคชเคพเค  เคธเคฎเฅเคชเคพเคฆเคจ เค—เคฐเฅเคจ เคธเค•เคฟเคจเฅเค›เฅค' }, requests: { english: 'Requests', spanish: 'Solicitudes', brazilian_portuguese: 'Solicitaรงรตes', tok_pisin: 'Ol askim', - indonesian: 'Permintaan' + indonesian: 'Permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคงเคนเคฐเฅ‚' }, noPendingRequests: { english: 'No pending membership requests', spanish: 'No hay solicitudes de membresรญa pendientes', brazilian_portuguese: 'Sem solicitaรงรตes de adesรฃo pendentes', tok_pisin: 'I no gat askim i stap', - indonesian: 'Tidak ada permintaan keanggotaan tertunda' + indonesian: 'Tidak ada permintaan keanggotaan tertunda', + nepali: 'เค•เฅเคจเฅˆ เคฌเคพเคเค•เฅ€ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค›เฅˆเคจ' }, confirmApprove: { english: 'Approve Request', spanish: 'Aprobar Solicitud', brazilian_portuguese: 'Aprovar Solicitaรงรฃo', tok_pisin: 'Orait long askim', - indonesian: 'Setujui Permintaan' + indonesian: 'Setujui Permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคธเฅเคตเฅ€เค•เฅƒเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmApproveMessage: { english: 'Add {name} as a member of this project?', spanish: 'ยฟAgregar a {name} como miembro de este proyecto?', brazilian_portuguese: 'Adicionar {name} como membro deste projeto?', tok_pisin: 'Putim {name} i kamap memba bilong projek?', - indonesian: 'Tambahkan {name} sebagai anggota proyek ini?' + indonesian: 'Tambahkan {name} sebagai anggota proyek ini?', + nepali: '{name} เคฒเคพเคˆ เคฏเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเค•เฅ‹ เคธเคฆเคธเฅเคฏเค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคฅเคชเฅเคจเฅ‡?' }, requestApproved: { english: 'Request approved', spanish: 'Solicitud aprobada', brazilian_portuguese: 'Solicitaรงรฃo aprovada', tok_pisin: 'Askim i orait', - indonesian: 'Permintaan disetujui' + indonesian: 'Permintaan disetujui', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคธเฅเคตเฅ€เค•เฅƒเคค เคญเคฏเฅ‹' }, confirmDeny: { english: 'Deny Request', spanish: 'Rechazar Solicitud', brazilian_portuguese: 'Negar Solicitaรงรฃo', tok_pisin: 'Tambu askim', - indonesian: 'Tolak Permintaan' + indonesian: 'Tolak Permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, confirmDenyMessage: { english: 'Deny membership request from {name}?', spanish: 'ยฟRechazar solicitud de membresรญa de {name}?', brazilian_portuguese: 'Negar solicitaรงรฃo de adesรฃo de {name}?', tok_pisin: 'Tambu askim bilong {name}?', - indonesian: 'Tolak permintaan keanggotaan dari {name}?' + indonesian: 'Tolak permintaan keanggotaan dari {name}?', + nepali: '{name} เคฌเคพเคŸ เคธเคฆเคธเฅเคฏเคคเคพ เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจเฅ‡?' }, requestDenied: { english: 'Request denied', spanish: 'Solicitud rechazada', brazilian_portuguese: 'Solicitaรงรฃo negada', tok_pisin: 'Askim i tambu', - indonesian: 'Permintaan ditolak' + indonesian: 'Permintaan ditolak', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เฅƒเคค เคญเคฏเฅ‹' }, failedToApproveRequest: { english: 'Failed to approve request', spanish: 'Error al aprobar solicitud', brazilian_portuguese: 'Falha ao aprovar solicitaรงรฃo', tok_pisin: 'Askim i no inap orait', - indonesian: 'Gagal menyetujui permintaan' + indonesian: 'Gagal menyetujui permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เคธเฅเคตเฅ€เค•เฅƒเคค เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, failedToDenyRequest: { english: 'Failed to deny request', spanish: 'Error al rechazar solicitud', brazilian_portuguese: 'Falha ao negar solicitaรงรฃo', tok_pisin: 'Askim i no inap tambu', - indonesian: 'Gagal menolak permintaan' + indonesian: 'Gagal menolak permintaan', + nepali: 'เค…เคจเฅเคฐเฅ‹เคง เค…เคธเฅเคตเฅ€เค•เคพเคฐ เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, downloadQuestToView: { english: 'This quest must be downloaded before you can view it.', spanish: 'Este quest debe descargarse antes de poder verlo.', brazilian_portuguese: 'Esta quest deve ser baixada antes de visualizรก-la.', tok_pisin: 'Yu mas daunim dispela quest pastaim long lukim.', - indonesian: 'Quest ini harus diunduh sebelum Anda dapat melihatnya.' + indonesian: 'Quest ini harus diunduh sebelum Anda dapat melihatnya.', + nepali: 'เคฏเฅ‹ เค•เฅเคตเฅ‡เคธเฅเคŸ เคนเฅ‡เคฐเฅเคจเฅ เค…เค˜เคฟ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคชเคฐเฅเค›เฅค' }, downloadNow: { english: 'Download Now', spanish: 'Descargar Ahora', brazilian_portuguese: 'Baixar Agora', tok_pisin: 'Daunim nau', - indonesian: 'Unduh Sekarang' + indonesian: 'Unduh Sekarang', + nepali: 'เค…เคนเคฟเคฒเฅ‡ เคกเคพเค‰เคจเคฒเฅ‹เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, vadTitle: { english: 'Voice Activity', spanish: 'Actividad de Voz', brazilian_portuguese: 'Atividade de Voz', tok_pisin: 'Wok bilong vois', - indonesian: 'Aktivitas Suara' + indonesian: 'Aktivitas Suara', + nepali: 'เค†เคตเคพเคœ เค—เคคเคฟเคตเคฟเคงเคฟ' }, vadDescription: { english: 'Records automatically when you speak', spanish: 'Graba automรกticamente cuando hablas', brazilian_portuguese: 'Grava automaticamente quando vocรช fala', tok_pisin: 'Em i save record pastaim taim yu toktok', - indonesian: 'Merekam otomatis saat Anda berbicara' + indonesian: 'Merekam otomatis saat Anda berbicara', + nepali: 'เคคเคชเคพเคˆเค‚ เคฌเฅ‹เคฒเฅเคฆเคพ เคธเฅเคตเคšเคพเคฒเคฟเคค เคฐเฅ‚เคชเคฎเคพ เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเค›' }, vadCurrentLevel: { english: 'Current Level', spanish: 'Nivel Actual', brazilian_portuguese: 'Nรญvel Atual', tok_pisin: 'Level nau', - indonesian: 'Level Saat Ini' + indonesian: 'Level Saat Ini', + nepali: 'เคนเคพเคฒเค•เฅ‹ เคธเฅเคคเคฐ' }, vadRecordingNow: { english: 'Recording', spanish: 'Grabando', brazilian_portuguese: 'Gravando', tok_pisin: 'I save nau', - indonesian: 'Merekam' + indonesian: 'Merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคฆเฅˆ' }, vadWaiting: { english: 'Waiting', spanish: 'Esperando', brazilian_portuguese: 'Aguardando', tok_pisin: 'Wetim', - indonesian: 'Menunggu' + indonesian: 'Menunggu', + nepali: 'เคชเคฐเฅเค–เคเคฆเฅˆ' }, vadPaused: { english: 'Paused', spanish: 'Pausado', brazilian_portuguese: 'Pausado', tok_pisin: 'I stop liklik', - indonesian: 'Dijeda' + indonesian: 'Dijeda', + nepali: 'เคฐเฅ‹เค•เคฟเคเค•เฅ‹' }, vadThreshold: { english: 'Sensitivity', spanish: 'Sensibilidad', brazilian_portuguese: 'Sensibilidade', tok_pisin: 'Strong bilong harim', - indonesian: 'Sensitivitas' + indonesian: 'Sensitivitas', + nepali: 'เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒเคคเคพ' }, vadSilenceDuration: { english: 'Pause Length', spanish: 'Duraciรณn de Pausa', brazilian_portuguese: 'Duraรงรฃo da Pausa', tok_pisin: 'Taim bilong pas', - indonesian: 'Durasi Jeda' + indonesian: 'Durasi Jeda', + nepali: 'เคฐเฅ‹เค•เคพเค‡เค•เฅ‹ เคฒเคฎเฅเคฌเคพเค‡' }, vadSilenceDescription: { english: 'How much silence is needed to determine segment boundaries.', @@ -4803,14 +5636,16 @@ export const localizations = { 'Quanto silรชncio รฉ necessรกrio para determinar os limites do segmento.', tok_pisin: 'Hamas taim i no gat nois bilong katim toktok.', indonesian: - 'Berapa lama keheningan yang diperlukan untuk menentukan batas segmen.' + 'Berapa lama keheningan yang diperlukan untuk menentukan batas segmen.', + nepali: 'เค–เคฃเฅเคก เคธเฅ€เคฎเคพเคนเคฐเฅ‚ เคจเคฟเคฐเฅเคงเคพเคฐเคฃ เค—เคฐเฅเคจ เค•เคคเคฟ เคฎเฅŒเคจเคคเคพ เค†เคตเคถเฅเคฏเค• เค›เฅค' }, vadMinSegmentLength: { english: 'Minimum Segment Length', spanish: 'Longitud Mรญnima de Segmento', brazilian_portuguese: 'Comprimento Mรญnimo do Segmento', tok_pisin: 'Liklik Taim Inap Bilong Toktok', - indonesian: 'Panjang Segmen Minimum' + indonesian: 'Panjang Segmen Minimum', + nepali: 'เคจเฅเคฏเฅ‚เคจเคคเคฎ เค–เคฃเฅเคก เคฒเคฎเฅเคฌเคพเค‡' }, vadMinSegmentLengthDescription: { english: 'Discard segments below this duration (filter brief noises)', @@ -4819,140 +5654,161 @@ export const localizations = { brazilian_portuguese: 'Descartar segmentos abaixo desta duraรงรฃo (filtrar ruรญdos breves)', tok_pisin: 'Rausim sotpela rekoding (filta liklik pairap)', - indonesian: 'Buang segmen di bawah durasi ini (filter suara singkat)' + indonesian: 'Buang segmen di bawah durasi ini (filter suara singkat)', + nepali: + 'เคฏเฅ‹ เค…เคตเคงเคฟเคญเคจเฅเคฆเคพ เค•เคฎ เค–เคฃเฅเคกเคนเคฐเฅ‚ เคคเฅเคฏเคพเค—เฅเคจเฅเคนเฅ‹เคธเฅ (เค›เฅ‹เคŸเฅ‹ เค†เคตเคพเคœเคนเคฐเฅ‚ เคซเคฟเคฒเฅเคŸเคฐ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ)' }, vadNoFilter: { english: 'No filter', spanish: 'Sin filtro', brazilian_portuguese: 'Sem filtro', tok_pisin: 'No filta', - indonesian: 'Tanpa filter' + indonesian: 'Tanpa filter', + nepali: 'เค•เฅเคจเฅˆ เคซเคฟเคฒเฅเคŸเคฐ เค›เฅˆเคจ' }, vadLightFilter: { english: 'Light filter', spanish: 'Filtro ligero', brazilian_portuguese: 'Filtro leve', tok_pisin: 'Liklik filta', - indonesian: 'Filter ringan' + indonesian: 'Filter ringan', + nepali: 'เคนเคฒเฅเค•เคพ เคซเคฟเคฒเฅเคŸเคฐ' }, vadMediumFilter: { english: 'Medium filter', spanish: 'Filtro medio', brazilian_portuguese: 'Filtro mรฉdio', tok_pisin: 'Namel filta', - indonesian: 'Filter sedang' + indonesian: 'Filter sedang', + nepali: 'เคฎเคงเฅเคฏเคฎ เคซเคฟเคฒเฅเคŸเคฐ' }, vadStrongFilter: { english: 'Strong filter', spanish: 'Filtro fuerte', brazilian_portuguese: 'Filtro forte', tok_pisin: 'Strongpela filta', - indonesian: 'Filter kuat' + indonesian: 'Filter kuat', + nepali: 'เคฌเคฒเคฟเคฏเฅ‹ เคซเคฟเคฒเฅเคŸเคฐ' }, vadSensitive: { english: 'Sensitive', spanish: 'Sensible', brazilian_portuguese: 'Sensรญvel', tok_pisin: 'I harim gut', - indonesian: 'Sensitif' + indonesian: 'Sensitif', + nepali: 'เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒ' }, vadNormal: { english: 'Normal', spanish: 'Normal', brazilian_portuguese: 'Normal', tok_pisin: 'Nambawan', - indonesian: 'Normal' + indonesian: 'Normal', + nepali: 'เคธเคพเคฎเคพเคจเฅเคฏ' }, vadLoud: { english: 'Loud', spanish: 'Alto', brazilian_portuguese: 'Alto', tok_pisin: 'Bikpela nois', - indonesian: 'Keras' + indonesian: 'Keras', + nepali: 'เคšเคฐเฅเค•เฅ‹' }, vadVerySensitive: { english: 'Very Sensitive', spanish: 'Muy Sensible', brazilian_portuguese: 'Muito Sensรญvel', tok_pisin: 'I harim tumas', - indonesian: 'Sangat Sensitif' + indonesian: 'Sangat Sensitif', + nepali: 'เค…เคคเฅเคฏเคจเฅเคค เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒ' }, vadLoudOnly: { english: 'Loud Only', spanish: 'Solo Alto', brazilian_portuguese: 'Apenas Alto', tok_pisin: 'Bikpela nois tasol', - indonesian: 'Keras Saja' + indonesian: 'Keras Saja', + nepali: 'เคšเคฐเฅเค•เฅ‹ เคฎเคพเคคเฅเคฐ' }, vadVeryLoud: { english: 'Very Loud', spanish: 'Muy Alto', brazilian_portuguese: 'Muito Alto', tok_pisin: 'Bikpela nois tumas', - indonesian: 'Sangat Keras' + indonesian: 'Sangat Keras', + nepali: 'เค…เคคเฅเคฏเคจเฅเคค เคšเคฐเฅเค•เฅ‹' }, vadQuickSegments: { english: 'Quick', spanish: 'Rรกpido', brazilian_portuguese: 'Rรกpido', tok_pisin: 'Kwik', - indonesian: 'Cepat' + indonesian: 'Cepat', + nepali: 'เค›เคฟเคŸเฅ‹' }, vadBalanced: { english: 'Balanced', spanish: 'Equilibrado', brazilian_portuguese: 'Equilibrado', tok_pisin: 'Naispela', - indonesian: 'Seimbang' + indonesian: 'Seimbang', + nepali: 'เคธเคจเฅเคคเฅเคฒเคฟเคค' }, vadCompleteThoughts: { english: 'Complete', spanish: 'Completo', brazilian_portuguese: 'Completo', tok_pisin: 'Olgeta', - indonesian: 'Lengkap' + indonesian: 'Lengkap', + nepali: 'เคชเฅ‚เคฐเฅเคฃ' }, vadDisplayMode: { english: 'Display Mode', spanish: 'Modo de Visualizaciรณn', brazilian_portuguese: 'Modo de Exibiรงรฃo', tok_pisin: 'Kaim bilong lukim', - indonesian: 'Mode Tampilan' + indonesian: 'Mode Tampilan', + nepali: 'เคชเฅเคฐเคฆเคฐเฅเคถเคจ เคฎเฅ‹เคก' }, vadFullScreen: { english: 'Full Screen', spanish: 'Pantalla Completa', brazilian_portuguese: 'Tela Cheia', tok_pisin: 'Fulap skrin', - indonesian: 'Layar Penuh' + indonesian: 'Layar Penuh', + nepali: 'เคชเฅ‚เคฐเฅเคฃ เคธเฅเค•เฅเคฐเคฟเคจ' }, vadFooter: { english: 'Footer', spanish: 'Pie de Pรกgina', brazilian_portuguese: 'Rodapรฉ', tok_pisin: 'Asdaun', - indonesian: 'Footer' + indonesian: 'Footer', + nepali: 'เคซเฅเคŸเคฐ' }, vadDisplayDescription: { english: 'Choose how the waveform appears when recording', spanish: 'Elige cรณmo aparece la forma de onda al grabar', brazilian_portuguese: 'Escolha como a forma de onda aparece ao gravar', tok_pisin: 'Makim olsem wanem wevpom i kamap taim yu save record', - indonesian: 'Pilih bagaimana bentuk gelombang muncul saat merekam' + indonesian: 'Pilih bagaimana bentuk gelombang muncul saat merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เค—เคฐเฅเคฆเคพ เคคเคฐเค‚เค— เค•เคธเคฐเฅ€ เคฆเฅ‡เค–เคพ เคชเคฐเฅเค› เค›เคพเคจเฅเคจเฅเคนเฅ‹เคธเฅ' }, vadStop: { english: 'Stop Recording', spanish: 'Detener Grabaciรณn', brazilian_portuguese: 'Parar Gravaรงรฃo', tok_pisin: 'Stopim rekod', - indonesian: 'Berhenti Merekam' + indonesian: 'Berhenti Merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคฐเฅ‹เค•เฅเคจเฅเคนเฅ‹เคธเฅ' }, vadHelpTitle: { english: 'How It Works', spanish: 'Cรณmo Funciona', brazilian_portuguese: 'Como Funciona', tok_pisin: 'Olsem wanem em i wok', - indonesian: 'Cara Kerja' + indonesian: 'Cara Kerja', + nepali: 'เคฏเฅ‹ เค•เคธเคฐเฅ€ เค•เคพเคฎ เค—เคฐเฅเค›' }, vadHelpAutomatic: { english: @@ -4964,7 +5820,9 @@ export const localizations = { tok_pisin: 'Taim masin i harim nois, em bai stat long rekodim. Bihain long taim i no gat nois, em bai sevim. Yu ken rekodim planti taim olsem wanwan taim rekod i op.', indonesian: - 'Saat suara terdeteksi, segmen akan mulai merekam secara otomatis. Setelah keheningan, segmen akan disimpan. Anda dapat merekam beberapa segmen seperti ini secara berurutan saat perekaman diaktifkan.' + 'Saat suara terdeteksi, segmen akan mulai merekam secara otomatis. Setelah keheningan, segmen akan disimpan. Anda dapat merekam beberapa segmen seperti ini secara berurutan saat perekaman diaktifkan.', + nepali: + 'เคœเคฌ เค†เคตเคพเคœ เคชเคคเฅเคคเคพ เคฒเคพเค—เฅเค› เคเค• เค–เคฃเฅเคก เคธเฅเคตเคšเคพเคฒเคฟเคค เคฐเฅ‚เคชเคฎเคพ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคธเฅเคฐเฅ เคนเฅเคจเฅ‡เค›เฅค เค•เฅ‡เคนเฅ€ เคฎเฅŒเคจเคคเคพ เคชเค›เคฟ เค–เคฃเฅเคก เคธเฅ‡เคญ เคนเฅเคจเฅ‡เค›เฅค เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคธเค•เฅเคฐเคฟเคฏ เคนเฅเคเคฆเคพ เคคเคชเคพเคˆเค‚ เคฏเคธเคฐเฅ€ เค•เฅเคฐเคฎเคถเคƒ เคงเฅ‡เคฐเฅˆ เค–เคฃเฅเคกเคนเคฐเฅ‚ เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, vadHelpSensitivity: { english: @@ -4976,7 +5834,9 @@ export const localizations = { tok_pisin: 'Sensitiv i makim hamas nois i nidim bilong stat na pinis. Liklik sensitiv i harim smol toktok, tasol em i ken harim tu ol narapela nois.', indonesian: - 'Sensitivitas mengatur ambang batas untuk menentukan kapan klip dimulai dan berakhir. Sensitivitas rendah menangkap suara pelan, tetapi juga suara lain yang mungkin.' + 'Sensitivitas mengatur ambang batas untuk menentukan kapan klip dimulai dan berakhir. Sensitivitas rendah menangkap suara pelan, tetapi juga suara lain yang mungkin.', + nepali: + 'เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒเคคเคพเคฒเฅ‡ เค•เฅเคฒเคฟเคช เค•เคนเคฟเคฒเฅ‡ เคธเฅเคฐเฅ เคฐ เคธเคฎเคพเคชเฅเคค เคนเฅเคจเฅเค› เคจเคฟเคฐเฅเคงเคพเคฐเคฃ เค—เคฐเฅเคจ เคธเฅ€เคฎเคพ เคธเฅ‡เคŸ เค—เคฐเฅเค›เฅค เค•เคฎ เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒเคคเคพเคฒเฅ‡ เคถเคพเคจเฅเคค เคฌเฅ‹เคฒเฅ€ เคธเคฎเคพเคคเฅเค›, เคคเคฐ เค…เคจเฅเคฏ เคธเคฎเฅเคญเคพเคตเคฟเคค เค†เคตเคพเคœเคนเคฐเฅ‚ เคชเคจเคฟเฅค' }, vadHelpPause: { english: @@ -4988,7 +5848,9 @@ export const localizations = { tok_pisin: 'Sotpela taim bilong pas bai katim rekod bilong yu long planti hap long ol liklik taim yu pas.', indonesian: - 'Durasi jeda yang lebih pendek akan memecah rekaman Anda menjadi lebih banyak segmen pada jeda yang lebih kecil.' + 'Durasi jeda yang lebih pendek akan memecah rekaman Anda menjadi lebih banyak segmen pada jeda yang lebih kecil.', + nepali: + 'เค›เฅ‹เคŸเฅ‹ เคฐเฅ‹เค•เคพเค‡เค•เฅ‹ เคฒเคฎเฅเคฌเคพเค‡เคฒเฅ‡ เคคเคชเคพเคˆเค‚เค•เฅ‹ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™เคฒเคพเคˆ เคธเคพเคจเคพ เคฐเฅ‹เค•เคพเค‡เคนเคฐเฅ‚เคฎเคพ เคงเฅ‡เคฐเฅˆ เค–เคฃเฅเคกเคนเคฐเฅ‚เคฎเคพ เคตเคฟเคญเคพเคœเคจ เค—เคฐเฅเคจเฅ‡เค›เฅค' }, vadHelpMinSegment: { english: @@ -5000,21 +5862,25 @@ export const localizations = { tok_pisin: 'Liklik Taim Inap i banisim ol sotpela rekod aninit long taim yu makim, olsem kus o doa i paitim.', indonesian: - 'Panjang Segmen Minimum mencegah penyimpanan segmen yang sangat pendek di bawah durasi yang ditetapkan, seperti batuk atau bunyi pintu.' + 'Panjang Segmen Minimum mencegah penyimpanan segmen yang sangat pendek di bawah durasi yang ditetapkan, seperti batuk atau bunyi pintu.', + nepali: + 'เคจเฅเคฏเฅ‚เคจเคคเคฎ เค–เคฃเฅเคก เคฒเคฎเฅเคฌเคพเค‡เคฒเฅ‡ เคธเฅ‡เคŸ เค—เคฐเคฟเคเค•เฅ‹ เค…เคตเคงเคฟเคญเคจเฅเคฆเคพ เค•เคฎ เคงเฅ‡เคฐเฅˆ เค›เฅ‹เคŸเฅ‹ เค–เคฃเฅเคกเคนเคฐเฅ‚ เคธเฅ‡เคญ เค—เคฐเฅเคจเคฌเคพเคŸ เคฐเฅ‹เค•เฅเค›, เคœเคธเฅเคคเฅˆ เค–เฅ‹เค•เฅ€ เคตเคพ เคขเฅ‹เค•เคพ เค เฅ‹เค•เฅเคจเฅ‡ เค†เคตเคพเคœเฅค' }, vadAutoCalibrate: { english: 'Auto-Calibrate', spanish: 'Auto-Calibrar', brazilian_portuguese: 'Auto-Calibrar', tok_pisin: 'Olsem wanem yet', - indonesian: 'Auto-Kalibrasi' + indonesian: 'Auto-Kalibrasi', + nepali: 'เคธเฅเคตเคค: เค•เฅเคฏเคพเคฒเคฟเคฌเฅเคฐเฅ‡เคŸ' }, vadCalibrating: { english: 'Calibrating...', spanish: 'Calibrando...', brazilian_portuguese: 'Calibrando...', tok_pisin: 'Wokim nau...', - indonesian: 'Mengkalibrasi...' + indonesian: 'Mengkalibrasi...', + nepali: 'เค•เฅเคฏเคพเคฒเคฟเคฌเฅเคฐเฅ‡เคŸ เค—เคฐเฅเคฆเฅˆ...' }, vadCalibrationFailed: { english: 'Calibration failed. Please try again in a quieter environment.', @@ -5024,7 +5890,9 @@ export const localizations = { 'Calibraรงรฃo falhou. Por favor, tente novamente em um ambiente mais silencioso.', tok_pisin: 'Em i no wok. Traim gen long ples i no gat tumas nois.', indonesian: - 'Kalibrasi gagal. Silakan coba lagi di lingkungan yang lebih tenang.' + 'Kalibrasi gagal. Silakan coba lagi di lingkungan yang lebih tenang.', + nepali: + 'เค•เฅเคฏเคพเคฒเคฟเคฌเฅเคฐเฅ‡เคธเคจ เค…เคธเคซเคฒ เคญเคฏเฅ‹เฅค เค•เฅƒเคชเคฏเคพ เคถเคพเคจเฅเคค เคตเคพเคคเคพเคตเคฐเคฃเคฎเคพ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, vadCalibrateHint: { english: @@ -5036,14 +5904,17 @@ export const localizations = { tok_pisin: 'Taim masin i wokim kalibresen, yu mas stap isi o larim ol nois tasol we yu laik i stap aninit long mak.', indonesian: - 'Selama kalibrasi otomatis tetaplah diam atau hanya izinkan suara yang ingin Anda agar berada di bawah ambang sensitivitas.' + 'Selama kalibrasi otomatis tetaplah diam atau hanya izinkan suara yang ingin Anda agar berada di bawah ambang sensitivitas.', + nepali: + 'เคธเฅเคตเคค: เค•เฅเคฏเคพเคฒเคฟเคฌเฅเคฐเฅ‡เคธเคจเค•เฅ‹ เคธเคฎเคฏเคฎเคพ เคฎเฅŒเคจ เคฐเคนเคจเฅเคนเฅ‹เคธเฅ เคตเคพ เค•เฅ‡เคตเคฒ เคคเฅ€ เค†เคตเคพเคœเคนเคฐเฅ‚ เคฎเคพเคคเฅเคฐ เค…เคจเฅเคฎเคคเคฟ เคฆเคฟเคจเฅเคนเฅ‹เคธเฅ เคœเฅเคจ เคคเคชเคพเคˆเค‚ เคธเค‚เคตเฅ‡เคฆเคจเคถเฅ€เคฒเคคเคพ เคธเฅ€เคฎเคพเคญเคจเฅเคฆเคพ เคคเคฒ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›เฅค' }, appUpgradeRequired: { english: 'App Upgrade Required', spanish: 'Actualizaciรณn de App Requerida', brazilian_portuguese: 'Atualizaรงรฃo do App Necessรกria', tok_pisin: 'Yu mas upgreidim app', - indonesian: 'Pembaruan Aplikasi Diperlukan' + indonesian: 'Pembaruan Aplikasi Diperlukan', + nepali: 'เคเคช เค…เคชเค—เฅเคฐเฅ‡เคก เค†เคตเคถเฅเคฏเค• เค›' }, appUpgradeServerAhead: { english: @@ -5055,7 +5926,9 @@ export const localizations = { tok_pisin: 'Yu mas kisim nupela version bilong app long usim ol nupela samting. Plis upgreidim long go het.', indonesian: - 'Versi baru aplikasi diperlukan untuk mengakses fitur terbaru. Silakan perbarui untuk melanjutkan.' + 'Versi baru aplikasi diperlukan untuk mengakses fitur terbaru. Silakan perbarui untuk melanjutkan.', + nepali: + 'เคจเคตเฅ€เคจเคคเคฎ เคธเฅเคตเคฟเคงเคพเคนเคฐเฅ‚ เคชเคนเฅเคเคš เค—เคฐเฅเคจ เคเคชเค•เฅ‹ เคจเคฏเคพเค เคธเค‚เคธเฅเค•เคฐเคฃ เค†เคตเคถเฅเคฏเค• เค›เฅค เค•เฅƒเคชเคฏเคพ เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจ เค…เคชเคกเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, appUpgradeServerBehind: { english: @@ -5067,56 +5940,65 @@ export const localizations = { tok_pisin: 'Version bilong app bilong yu i nupela moa long server. Plis contactim support o wetim server i upgreidim.', indonesian: - 'Versi aplikasi Anda lebih baru dari server. Silakan hubungi dukungan atau tunggu server diperbarui.' + 'Versi aplikasi Anda lebih baru dari server. Silakan hubungi dukungan atau tunggu server diperbarui.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคเคช เคธเค‚เคธเฅเค•เคฐเคฃ เคธเคฐเฅเคญเคฐเคญเคจเฅเคฆเคพ เคจเคฏเคพเค เค›เฅค เค•เฅƒเคชเคฏเคพ เคธเคฎเคฐเฅเคฅเคจเคฒเคพเคˆ เคธเคฎเฅเคชเคฐเฅเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ เคตเคพ เคธเคฐเฅเคญเคฐ เค…เคชเคกเฅ‡เคŸ เคนเฅเคจเฅ‡ เคชเฅเคฐเคคเฅ€เค•เฅเคทเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, upgradeToVersion: { english: 'Please upgrade to version {version}', spanish: 'Por favor actualice a la versiรณn {version}', brazilian_portuguese: 'Por favor atualize para a versรฃo {version}', tok_pisin: 'Plis upgreidim long version {version}', - indonesian: 'Silakan perbarui ke versi {version}' + indonesian: 'Silakan perbarui ke versi {version}', + nepali: 'เค•เฅƒเคชเคฏเคพ เคธเค‚เคธเฅเค•เคฐเคฃ {version} เคฎเคพ เค…เคชเค—เฅเคฐเฅ‡เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, currentVersion: { english: 'Current Version', spanish: 'Versiรณn Actual', brazilian_portuguese: 'Versรฃo Atual', tok_pisin: 'Version nau', - indonesian: 'Versi Saat Ini' + indonesian: 'Versi Saat Ini', + nepali: 'เคนเคพเคฒเค•เฅ‹ เคธเค‚เคธเฅเค•เคฐเคฃ' }, requiredVersion: { english: 'Required Version', spanish: 'Versiรณn Requerida', brazilian_portuguese: 'Versรฃo Necessรกria', tok_pisin: 'Version yu mas gat', - indonesian: 'Versi yang Diperlukan' + indonesian: 'Versi yang Diperlukan', + nepali: 'เค†เคตเคถเฅเคฏเค• เคธเค‚เคธเฅเค•เคฐเคฃ' }, upgradeApp: { english: 'Upgrade App', spanish: 'Actualizar App', brazilian_portuguese: 'Atualizar App', tok_pisin: 'Upgreidim App', - indonesian: 'Perbarui Aplikasi' + indonesian: 'Perbarui Aplikasi', + nepali: 'เคเคช เค…เคชเค—เฅเคฐเฅ‡เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, checkingSchemaVersion: { english: 'Checking schema compatibility...', spanish: 'Verificando compatibilidad del esquema...', brazilian_portuguese: 'Verificando compatibilidade do esquema...', tok_pisin: 'Checkim schema compatibility...', - indonesian: 'Memeriksa kompatibilitas skema...' + indonesian: 'Memeriksa kompatibilitas skema...', + nepali: 'เคธเฅเค•เคฟเคฎเคพ เค…เคจเฅเค•เฅ‚เคฒเคคเคพ เคœเคพเคเคš เค—เคฐเฅเคฆเฅˆ...' }, scanningCorruptedAttachments: { english: 'Scanning for corrupted attachments...', spanish: 'Buscando archivos adjuntos corruptos...', brazilian_portuguese: 'Procurando anexos corrompidos...', tok_pisin: 'Lukluk long ol bagarap fayl...', - indonesian: 'Memindai lampiran yang rusak...' + indonesian: 'Memindai lampiran yang rusak...', + nepali: 'เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเฅเค•เฅเคฏเคพเคจ เค—เคฐเฅเคฆเฅˆ...' }, noCorruptedAttachments: { english: 'No Corrupted Attachments', spanish: 'No hay archivos adjuntos corruptos', brazilian_portuguese: 'Sem Anexos Corrompidos', tok_pisin: 'I no gat bagarap fayl', - indonesian: 'Tidak Ada Lampiran Rusak' + indonesian: 'Tidak Ada Lampiran Rusak', + nepali: 'เค•เฅเคจเฅˆ เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เค›เฅˆเคจ' }, attachmentDatabaseHealthy: { english: @@ -5127,14 +6009,16 @@ export const localizations = { 'Seu banco de dados de anexos estรก saudรกvel. Todos os registros estรฃo vรกlidos.', tok_pisin: 'Database bilong ol fayl bilong yu i gutpela. Olgeta rekod i orait.', - indonesian: 'Database lampiran Anda sehat. Semua catatan lampiran valid.' + indonesian: 'Database lampiran Anda sehat. Semua catatan lampiran valid.', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคกเคพเคŸเคพเคฌเฅ‡เคธ เคธเฅเคตเคธเฅเคฅ เค›เฅค เคธเคฌเฅˆ เคธเค‚เคฒเค—เฅเคจเค• เคฐเฅ‡เค•เคฐเฅเคกเคนเคฐเฅ‚ เคฎเคพเคจเฅเคฏ เค›เคจเฅเฅค' }, corruptedAttachments: { english: 'Corrupted Attachments', spanish: 'Archivos Adjuntos Corruptos', brazilian_portuguese: 'Anexos Corrompidos', tok_pisin: 'Ol Bagarap Fayl', - indonesian: 'Lampiran Rusak' + indonesian: 'Lampiran Rusak', + nepali: 'เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚' }, foundCorruptedAttachments: { english: @@ -5146,7 +6030,9 @@ export const localizations = { tok_pisin: 'Mi lukim {count} bagarap fayl wantaim blob URL long database. Ol dispela i mekim sync nogut na yu mas klinim.', indonesian: - 'Ditemukan {count} lampiran rusak dengan URL blob di database. Ini menyebabkan kesalahan sinkronisasi dan harus dibersihkan.' + 'Ditemukan {count} lampiran rusak dengan URL blob di database. Ini menyebabkan kesalahan sinkronisasi dan harus dibersihkan.', + nepali: + 'เคกเคพเคŸเคพเคฌเฅ‡เคธเคฎเคพ blob URL เคญเคเค•เฅ‹ {count} เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคซเฅ‡เคฒเคพ เคชเคพเคฐเคฟเคฏเฅ‹เฅค เคฏเคธเคฒเฅ‡ เคธเคฟเค‚เค• เคคเฅเคฐเฅเคŸเคฟเคนเคฐเฅ‚ เคจเคฟเคฎเฅเคคเฅเคฏเคพเค‡เคฐเคนเฅ‡เค•เฅ‹ เค› เคฐ เคธเคซเคพ เค—เคฐเฅเคจเฅเคชเคฐเฅเค›เฅค' }, foundCorruptedAttachmentsPlural: { english: @@ -5158,70 +6044,81 @@ export const localizations = { tok_pisin: 'Mi lukim {count} bagarap fayl wantaim blob URL long database. Ol dispela i mekim sync nogut na yu mas klinim.', indonesian: - 'Ditemukan {count} lampiran rusak dengan URL blob di database. Ini menyebabkan kesalahan sinkronisasi dan harus dibersihkan.' + 'Ditemukan {count} lampiran rusak dengan URL blob di database. Ini menyebabkan kesalahan sinkronisasi dan harus dibersihkan.', + nepali: + 'เคกเคพเคŸเคพเคฌเฅ‡เคธเคฎเคพ blob URL เคญเคเค•เคพ {count} เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคซเฅ‡เคฒเคพ เคชเคพเคฐเคฟเคฏเฅ‹เฅค เคฏเคธเคฒเฅ‡ เคธเคฟเค‚เค• เคคเฅเคฐเฅเคŸเคฟเคนเคฐเฅ‚ เคจเคฟเคฎเฅเคคเฅเคฏเคพเค‡เคฐเคนเฅ‡เค•เฅ‹ เค› เคฐ เคธเคซเคพ เค—เคฐเฅเคจเฅเคชเคฐเฅเค›เฅค' }, cleanAll: { english: 'Clean All ({count})', spanish: 'Limpiar Todo ({count})', brazilian_portuguese: 'Limpar Tudo ({count})', tok_pisin: 'Klinim Olgeta ({count})', - indonesian: 'Bersihkan Semua ({count})' + indonesian: 'Bersihkan Semua ({count})', + nepali: 'เคธเคฌเฅˆ เคธเคซเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ ({count})' }, cleaning: { english: 'Cleaning...', spanish: 'Limpiando...', brazilian_portuguese: 'Limpando...', tok_pisin: 'Mi klinim nau...', - indonesian: 'Membersihkan...' + indonesian: 'Membersihkan...', + nepali: 'เคธเคซเคพ เค—เคฐเฅเคฆเฅˆ...' }, size: { english: 'Size', spanish: 'Tamaรฑo', brazilian_portuguese: 'Tamanho', tok_pisin: 'Saiz', - indonesian: 'Ukuran' + indonesian: 'Ukuran', + nepali: 'เค†เค•เคพเคฐ' }, attachmentId: { english: 'Attachment ID', spanish: 'ID del Archivo Adjunto', brazilian_portuguese: 'ID do Anexo', tok_pisin: 'ID bilong Fayl', - indonesian: 'ID Lampiran' + indonesian: 'ID Lampiran', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค• ID' }, localUri: { english: 'Local URI', spanish: 'URI Local', brazilian_portuguese: 'URI Local', tok_pisin: 'Local URI', - indonesian: 'URI Lokal' + indonesian: 'URI Lokal', + nepali: 'เคธเฅเคฅเคพเคจเฅ€เคฏ URI' }, associatedAssets: { english: 'Associated Assets ({count})', spanish: 'Activos Asociados ({count})', brazilian_portuguese: 'Ativos Associados ({count})', tok_pisin: 'Ol Asset i go wantaim ({count})', - indonesian: 'Aset Terkait ({count})' + indonesian: 'Aset Terkait ({count})', + nepali: 'เคธเคฎเฅเคฌเคฆเฅเคง เคเคธเฅ‡เคŸเคนเคฐเฅ‚ ({count})' }, contentLinks: { english: 'Content Links ({count})', spanish: 'Enlaces de Contenido ({count})', brazilian_portuguese: 'Links de Conteรบdo ({count})', tok_pisin: 'Ol Link bilong Content ({count})', - indonesian: 'Tautan Konten ({count})' + indonesian: 'Tautan Konten ({count})', + nepali: 'เคธเคพเคฎเค—เฅเคฐเฅ€ เคฒเคฟเค‚เค•เคนเคฐเฅ‚ ({count})' }, cleanThis: { english: 'Clean This', spanish: 'Limpiar Esto', brazilian_portuguese: 'Limpar Isto', tok_pisin: 'Klinim Dispela', - indonesian: 'Bersihkan Ini' + indonesian: 'Bersihkan Ini', + nepali: 'เคฏเฅ‹ เคธเคซเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, cleanCorruptedAttachment: { english: 'Clean Corrupted Attachment', spanish: 'Limpiar Archivo Adjunto Corrupto', brazilian_portuguese: 'Limpar Anexo Corrompido', tok_pisin: 'Klinim Bagarap Fayl', - indonesian: 'Bersihkan Lampiran Rusak' + indonesian: 'Bersihkan Lampiran Rusak', + nepali: 'เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคธเคซเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, cleanCorruptedAttachmentConfirm: { english: @@ -5233,35 +6130,41 @@ export const localizations = { tok_pisin: 'Dispela bai rausim ol rekod bilong bagarap fayl na ol referens bilong en long database. Yu no inap tanim bek dispela.', indonesian: - 'Ini akan menghapus catatan lampiran rusak dan referensinya dari database. Tindakan ini tidak dapat dibatalkan.' + 'Ini akan menghapus catatan lampiran rusak dan referensinya dari database. Tindakan ini tidak dapat dibatalkan.', + nepali: + 'เคฏเคธเคฒเฅ‡ เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคฐเฅ‡เค•เคฐเฅเคก เคฐ เคฏเคธเค•เฅ‹ เคธเคจเฅเคฆเคฐเฅเคญเคนเคฐเฅ‚ เคกเคพเคŸเคพเคฌเฅ‡เคธเคฌเคพเคŸ เคนเคŸเคพเค‰เคจเฅ‡เค›เฅค เคฏเฅ‹ เค•เคพเคฐเฅเคฏ เคชเฅ‚เคฐเฅเคตเคตเคค เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, clean: { english: 'Clean', spanish: 'Limpiar', brazilian_portuguese: 'Limpar', tok_pisin: 'Klinim', - indonesian: 'Bersihkan' + indonesian: 'Bersihkan', + nepali: 'เคธเคซเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, corruptedAttachmentCleanedSuccess: { english: 'Corrupted attachment cleaned successfully.', spanish: 'Archivo adjunto corrupto limpiado exitosamente.', brazilian_portuguese: 'Anexo corrompido limpo com sucesso.', tok_pisin: 'Bagarap fayl i klinim gut pinis.', - indonesian: 'Lampiran rusak berhasil dibersihkan.' + indonesian: 'Lampiran rusak berhasil dibersihkan.', + nepali: 'เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเคซเคพ เค—เคฐเคฟเคฏเฅ‹เฅค' }, failedToCleanAttachment: { english: 'Failed to clean attachment: {error}', spanish: 'Error al limpiar el archivo adjunto: {error}', brazilian_portuguese: 'Falha ao limpar anexo: {error}', tok_pisin: 'I no inap klinim fayl: {error}', - indonesian: 'Gagal membersihkan lampiran: {error}' + indonesian: 'Gagal membersihkan lampiran: {error}', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค• เคธเคซเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ: {error}' }, cleanAllCorruptedAttachments: { english: 'Clean All Corrupted Attachments', spanish: 'Limpiar Todos los Archivos Adjuntos Corruptos', brazilian_portuguese: 'Limpar Todos os Anexos Corrompidos', tok_pisin: 'Klinim Olgeta Bagarap Fayl', - indonesian: 'Bersihkan Semua Lampiran Rusak' + indonesian: 'Bersihkan Semua Lampiran Rusak', + nepali: 'เคธเคฌเฅˆ เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเคซเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, cleanAllConfirm: { english: @@ -5273,7 +6176,9 @@ export const localizations = { tok_pisin: 'Dispela bai klinim {count} bagarap fayl. Yu no inap tanim bek dispela.', indonesian: - 'Ini akan membersihkan {count} lampiran rusak. Tindakan ini tidak dapat dibatalkan.' + 'Ini akan membersihkan {count} lampiran rusak. Tindakan ini tidak dapat dibatalkan.', + nepali: + 'เคฏเคธเคฒเฅ‡ {count} เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคธเคซเคพ เค—เคฐเฅเคจเฅ‡เค›เฅค เคฏเฅ‹ เค•เคพเคฐเฅเคฏ เคชเฅ‚เคฐเฅเคตเคตเคค เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, cleanAllConfirmPlural: { english: @@ -5285,14 +6190,17 @@ export const localizations = { tok_pisin: 'Dispela bai klinim {count} bagarap fayl. Yu no inap tanim bek dispela.', indonesian: - 'Ini akan membersihkan {count} lampiran rusak. Tindakan ini tidak dapat dibatalkan.' + 'Ini akan membersihkan {count} lampiran rusak. Tindakan ini tidak dapat dibatalkan.', + nepali: + 'เคฏเคธเคฒเฅ‡ {count} เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเคซเคพ เค—เคฐเฅเคจเฅ‡เค›เฅค เคฏเฅ‹ เค•เคพเคฐเฅเคฏ เคชเฅ‚เคฐเฅเคตเคตเคค เค—เคฐเฅเคจ เคธเค•เคฟเคเคฆเฅˆเคจเฅค' }, partialSuccess: { english: 'Partial Success', spanish: 'ร‰xito Parcial', brazilian_portuguese: 'Sucesso Parcial', tok_pisin: 'Sampela i Orait', - indonesian: 'Berhasil Sebagian' + indonesian: 'Berhasil Sebagian', + nepali: 'เค†เค‚เคถเคฟเค• เคธเคซเคฒเคคเคพ' }, cleanedAttachmentsWithErrors: { english: @@ -5303,7 +6211,8 @@ export const localizations = { 'Limpou {cleaned} anexo. Ocorreu {errorCount} erro:\n\n{errors}', tok_pisin: 'Klinim {cleaned} fayl. {errorCount} rong i kamap:\n\n{errors}', indonesian: - 'Membersihkan {cleaned} lampiran. {errorCount} kesalahan terjadi:\n\n{errors}' + 'Membersihkan {cleaned} lampiran. {errorCount} kesalahan terjadi:\n\n{errors}', + nepali: '{cleaned} เคธเค‚เคฒเค—เฅเคจเค• เคธเคซเคพ เค—เคฐเคฟเคฏเฅ‹เฅค {errorCount} เคคเฅเคฐเฅเคŸเคฟ เคญเคฏเฅ‹:\n\n{errors}' }, cleanedAttachmentsWithErrorsPlural: { english: @@ -5314,28 +6223,33 @@ export const localizations = { 'Limpou {cleaned} anexos. Ocorreram {errorCount} erros:\n\n{errors}', tok_pisin: 'Klinim {cleaned} fayl. {errorCount} rong i kamap:\n\n{errors}', indonesian: - 'Membersihkan {cleaned} lampiran. {errorCount} kesalahan terjadi:\n\n{errors}' + 'Membersihkan {cleaned} lampiran. {errorCount} kesalahan terjadi:\n\n{errors}', + nepali: + '{cleaned} เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเคซเคพ เค—เคฐเคฟเคฏเฅ‹เฅค {errorCount} เคคเฅเคฐเฅเคŸเคฟเคนเคฐเฅ‚ เคญเคฏเฅ‹:\n\n{errors}' }, successfullyCleanedAttachments: { english: 'Successfully cleaned {cleaned} corrupted attachment.', spanish: 'Se limpiรณ exitosamente {cleaned} archivo adjunto corrupto.', brazilian_portuguese: 'Limpou com sucesso {cleaned} anexo corrompido.', tok_pisin: 'Klinim gut {cleaned} bagarap fayl.', - indonesian: 'Berhasil membersihkan {cleaned} lampiran rusak.' + indonesian: 'Berhasil membersihkan {cleaned} lampiran rusak.', + nepali: '{cleaned} เคฌเคฟเค—เฅเคฐเคฟเคเค•เฅ‹ เคธเค‚เคฒเค—เฅเคจเค• เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเคซเคพ เค—เคฐเคฟเคฏเฅ‹เฅค' }, successfullyCleanedAttachmentsPlural: { english: 'Successfully cleaned {cleaned} corrupted attachments.', spanish: 'Se limpiaron exitosamente {cleaned} archivos adjuntos corruptos.', brazilian_portuguese: 'Limpou com sucesso {cleaned} anexos corrompidos.', tok_pisin: 'Klinim gut {cleaned} bagarap fayl.', - indonesian: 'Berhasil membersihkan {cleaned} lampiran rusak.' + indonesian: 'Berhasil membersihkan {cleaned} lampiran rusak.', + nepali: '{cleaned} เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคธเคซเคพ เค—เคฐเคฟเคฏเฅ‹เฅค' }, failedToCleanAttachments: { english: 'Failed to clean attachments: {error}', spanish: 'Error al limpiar los archivos adjuntos: {error}', brazilian_portuguese: 'Falha ao limpar anexos: {error}', tok_pisin: 'I no inap klinim ol fayl: {error}', - indonesian: 'Gagal membersihkan lampiran: {error}' + indonesian: 'Gagal membersihkan lampiran: {error}', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคธเคซเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ: {error}' }, failedToLoadCorruptedAttachments: { english: 'Failed to load corrupted attachments. Please try again.', @@ -5344,70 +6258,80 @@ export const localizations = { brazilian_portuguese: 'Falha ao carregar anexos corrompidos. Por favor, tente novamente.', tok_pisin: 'I no inap loadim ol bagarap fayl. Plis traim gen.', - indonesian: 'Gagal memuat lampiran rusak. Silakan coba lagi.' + indonesian: 'Gagal memuat lampiran rusak. Silakan coba lagi.', + nepali: 'เคฌเคฟเค—เฅเคฐเคฟเคเค•เคพ เคธเค‚เคฒเค—เฅเคจเค•เคนเคฐเฅ‚ เคฒเฅ‹เคก เค—เคฐเฅเคจ เค…เคธเคซเคฒเฅค เค•เฅƒเคชเคฏเคพ เคชเฅเคจ: เคชเฅเคฐเคฏเคพเคธ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, unnamed: { english: 'Unnamed', spanish: 'Sin nombre', brazilian_portuguese: 'Sem nome', tok_pisin: 'I no gat nem', - indonesian: 'Tanpa nama' + indonesian: 'Tanpa nama', + nepali: 'เคจเคพเคฎ เคจเคญเคเค•เฅ‹' }, backToProjects: { english: 'Back to Projects', spanish: 'Volver a Proyectos', brazilian_portuguese: 'Voltar aos Projetos', tok_pisin: 'Go bek long ol Projek', - indonesian: 'Kembali ke Proyek' + indonesian: 'Kembali ke Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚เคฎเคพ เคซเคฐเฅเค•เคจเฅเคนเฅ‹เคธเฅ' }, downloaded: { english: 'Downloaded', spanish: 'Descargado', brazilian_portuguese: 'Baixado', tok_pisin: 'Downloaded', - indonesian: 'Diunduh' + indonesian: 'Diunduh', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เคญเคฏเฅ‹' }, freeUpSpace: { english: 'Free Up Space', spanish: 'Liberar Espacio', brazilian_portuguese: 'Liberar Espaรงo', tok_pisin: 'Free Up Space', - indonesian: 'Bebaskan Ruang' + indonesian: 'Bebaskan Ruang', + nepali: 'เค เคพเค‰เค เค–เคพเคฒเฅ€ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, storageUsed: { english: 'Storage Used', spanish: 'Espacio Usado', brazilian_portuguese: 'Espaรงo Usado', tok_pisin: 'Storage Used', - indonesian: 'Penyimpanan yang Digunakan' + indonesian: 'Penyimpanan yang Digunakan', + nepali: 'เคชเฅเคฐเคฏเฅ‹เค— เคญเคเค•เฅ‹ เคญเคฃเฅเคกเคพเคฐเคฃ' }, notDownloaded: { english: 'Not Downloaded', spanish: 'No Descargado', brazilian_portuguese: 'Nรฃo Baixado', tok_pisin: 'Not Downloaded', - indonesian: 'Tidak Diunduh' + indonesian: 'Tidak Diunduh', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เคญเคเค•เฅ‹ เค›เฅˆเคจ' }, missingCloudData: { english: 'Missing Cloud Data', spanish: 'Falta Datos en la Nube', brazilian_portuguese: 'Dados na Nuvem Faltando', tok_pisin: 'No gat ol data long cloud', - indonesian: 'Data Cloud Hilang' + indonesian: 'Data Cloud Hilang', + nepali: 'เค•เฅเคฒเคพเค‰เคก เคกเคพเคŸเคพ เคนเคฐเคพเค‡เคฐเคนเฅ‡เค•เฅ‹ เค›' }, deleteAccount: { english: 'Delete Account', spanish: 'Eliminar Cuenta', brazilian_portuguese: 'Excluir Conta', tok_pisin: 'Rausim Account', - indonesian: 'Hapus Akun' + indonesian: 'Hapus Akun', + nepali: 'เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, accountDeletionTitle: { english: 'Delete Your Account', spanish: 'Eliminar Tu Cuenta', brazilian_portuguese: 'Excluir Sua Conta', tok_pisin: 'Rausim Account Bilong Yu', - indonesian: 'Hapus Akun Anda' + indonesian: 'Hapus Akun Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‰เคจเฅเคนเฅ‹เคธเฅ' }, accountDeletionWarning: { english: @@ -5419,7 +6343,9 @@ export const localizations = { tok_pisin: 'Bihain long rausim account bilong yu, yu no inap mekim registration o login taim yu no gat internet. Yu mas gat internet long mekim nupela account o login.', indonesian: - 'Setelah menghapus akun Anda, Anda tidak akan dapat mendaftar atau masuk saat offline. Anda harus online untuk membuat akun baru atau masuk.' + 'Setelah menghapus akun Anda, Anda tidak akan dapat mendaftar atau masuk saat offline. Anda harus online untuk membuat akun baru atau masuk.', + nepali: + 'เค†เคซเฅเคจเฅ‹ เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเคเคชเค›เคฟ, เคคเคชเคพเคˆเค‚ เค…เคซเคฒเคพเค‡เคจ เคนเฅเคเคฆเคพ เคฆเคฐเฅเคคเคพ เคตเคพ เคฒเค— เค‡เคจ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅ‡ เค›เฅˆเคจเฅค เคจเคฏเคพเค เค–เคพเคคเคพ เคฌเคจเคพเค‰เคจ เคตเคพ เคฒเค— เค‡เคจ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค' }, accountDeletionPIIWarning: { english: @@ -5431,7 +6357,9 @@ export const localizations = { tok_pisin: 'Account bilong yu bai stop wok (soft delete). Ol data bilong yu bai stap, tasol yu no inap go long app inap yu restore account. Yu inap restore account long eni taim, tasol yu mas gat internet long mekim.', indonesian: - 'Akun Anda akan dinonaktifkan (penghapusan lunak). Semua data Anda akan dilestarikan, tetapi Anda tidak akan dapat mengakses aplikasi hingga Anda memulihkan akun Anda. Anda dapat memulihkan akun Anda kapan saja, tetapi Anda harus online untuk melakukannya.' + 'Akun Anda akan dinonaktifkan (penghapusan lunak). Semua data Anda akan dilestarikan, tetapi Anda tidak akan dapat mengakses aplikasi hingga Anda memulihkan akun Anda. Anda dapat memulihkan akun Anda kapan saja, tetapi Anda harus online untuk melakukannya.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคจเคฟเคทเฅเค•เฅเคฐเคฟเคฏ เค—เคฐเคฟเคจเฅ‡เค› (เคธเคซเฅเคŸ เคกเคฟเคฒเคฟเคŸ)เฅค เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฌเฅˆ เคกเคพเคŸเคพ เคธเฅเคฐเค•เฅเคทเคฟเคค เคฐเคนเคจเฅ‡เค›, เคคเคฐ เคคเคชเคพเคˆเค‚เคฒเฅ‡ เค†เคซเฅเคจเฅ‹ เค–เคพเคคเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เคจเค—เคฐเฅ‡เคธเคฎเฅเคฎ เคเคช เคชเคนเฅเคเคš เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅ‡ เค›เฅˆเคจเฅค เคคเคชเคพเคˆเค‚ เคœเฅเคจเคธเฅเค•เฅˆ เคฌเฅ‡เคฒเคพ เค†เคซเฅเคจเฅ‹ เค–เคพเคคเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›, เคคเคฐ เคคเฅเคฏเคธเค•เคพ เคฒเคพเค—เคฟ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค' }, accountDeletionContributionsInfo: { english: @@ -5443,7 +6371,9 @@ export const localizations = { tok_pisin: 'Olgeta samting yu bin helpim (project, quest, asset, translation, vote) bai i stap na bai i stap olsem long term yu bin oreti long en taim yu joinim. Account bilong yu inap restore long eni taim, na ol data bilong yu bai kamap bek.', indonesian: - 'Semua kontribusi Anda (proyek, quest, aset, terjemahan, suara) akan dilestarikan dan akan tetap publik sesuai dengan syarat yang telah Anda setujui saat bergabung. Akun Anda dapat dipulihkan kapan saja, dan semua data Anda akan dapat diakses lagi.' + 'Semua kontribusi Anda (proyek, quest, aset, terjemahan, suara) akan dilestarikan dan akan tetap publik sesuai dengan syarat yang telah Anda setujui saat bergabung. Akun Anda dapat dipulihkan kapan saja, dan semua data Anda akan dapat diakses lagi.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เคพ เคธเคฌเฅˆ เคฏเฅ‹เค—เคฆเคพเคจเคนเคฐเฅ‚ (เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคนเคฐเฅ‚, เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚, เคเคธเฅ‡เคŸเคนเคฐเฅ‚, เค…เคจเฅเคตเคพเคฆเคนเคฐเฅ‚, เคฎเคคเคนเคฐเฅ‚) เคธเฅเคฐเค•เฅเคทเคฟเคค เคฐเคนเคจเฅ‡เค›เคจเฅ เคฐ เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคเคฆเคพ เคธเคนเคฎเคค เคญเคเค•เคพ เคธเคฐเฅเคคเคนเคฐเฅ‚ เค…เคจเฅเคธเคพเคฐ เคธเคพเคฐเฅเคตเคœเคจเคฟเค• เคฐเคนเคจเฅ‡เค›เคจเฅเฅค เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคœเฅเคจเคธเฅเค•เฅˆ เคฌเฅ‡เคฒเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคธเค•เคฟเคจเฅเค›, เคฐ เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฌเฅˆ เคกเคพเคŸเคพ เคซเฅ‡เคฐเคฟ เคชเคนเฅเคเคšเคฏเฅ‹เค—เฅเคฏ เคนเฅเคจเฅ‡เค›เฅค' }, accountDeletionConfirm: { english: @@ -5455,7 +6385,9 @@ export const localizations = { tok_pisin: 'Yu tru long rausim account bilong yu? Yu inap restore long bihain, tasol yu mas gat internet long mekim.', indonesian: - 'Apakah Anda benar-benar yakin ingin menghapus akun Anda? Anda dapat memulihkannya nanti, tetapi Anda harus online untuk melakukannya.' + 'Apakah Anda benar-benar yakin ingin menghapus akun Anda? Anda dapat memulihkannya nanti, tetapi Anda harus online untuk melakukannya.', + nepali: + 'เค•เฅ‡ เคคเคชเคพเคˆเค‚ เค†เคซเฅเคจเฅ‹ เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‰เคจ เคจเคฟเคถเฅเคšเคฟเคค เคนเฅเคจเฅเคนเฅเคจเฅเค›? เคคเคชเคพเคˆเค‚ เคฏเคธเคฒเคพเคˆ เคชเค›เคฟ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›, เคคเคฐ เคคเฅเคฏเคธเค•เคพ เคฒเคพเค—เคฟ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค' }, accountDeletionConfirmMessage: { english: @@ -5467,21 +6399,25 @@ export const localizations = { tok_pisin: 'Account bilong yu bai raus (soft delete). Yu inap restore long bihain long login screen, tasol yu mas gat internet long restore.', indonesian: - 'Akun Anda akan dihapus (penghapusan lunak). Anda dapat memulihkannya nanti dari layar login, tetapi Anda harus online untuk memulihkannya.' + 'Akun Anda akan dihapus (penghapusan lunak). Anda dapat memulihkannya nanti dari layar login, tetapi Anda harus online untuk memulihkannya.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‡เคจเฅ‡เค› (เคธเคซเฅเคŸ เคกเคฟเคฒเคฟเคŸ)เฅค เคคเคชเคพเคˆเค‚ เคฏเคธเคฒเคพเคˆ เคชเค›เคฟ เคฒเค—เค‡เคจ เคธเฅเค•เฅเคฐเคฟเคจเคฌเคพเคŸ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›, เคคเคฐ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค' }, accountDeletionStep1Title: { english: 'Step 1: Understand the Consequences', spanish: 'Paso 1: Entender las Consecuencias', brazilian_portuguese: 'Etapa 1: Entender as Consequรชncias', tok_pisin: 'Step 1: Save ol Samting Bai Kamap', - indonesian: 'Langkah 1: Pahami Konsekuensinya' + indonesian: 'Langkah 1: Pahami Konsekuensinya', + nepali: 'เคšเคฐเคฃ เฅง: เคชเคฐเคฟเคฃเคพเคฎเคนเคฐเฅ‚ เคฌเฅเคเฅเคจเฅเคนเฅ‹เคธเฅ' }, accountDeletionStep2Title: { english: 'Step 2: Final Confirmation', spanish: 'Paso 2: Confirmaciรณn Final', brazilian_portuguese: 'Etapa 2: Confirmaรงรฃo Final', tok_pisin: 'Step 2: Final Confirm', - indonesian: 'Langkah 2: Konfirmasi Akhir' + indonesian: 'Langkah 2: Konfirmasi Akhir', + nepali: 'เคšเคฐเคฃ เฅจ: เค…เคจเฅเคคเคฟเคฎ เคชเฅเคทเฅเคŸเคฟ' }, accountDeletionSuccess: { english: @@ -5493,21 +6429,25 @@ export const localizations = { tok_pisin: 'Account bilong yu i raus pinis (soft delete). Yu inap restore long bihain, tasol yu mas gat internet long mekim. Yu bai sign out nau.', indonesian: - 'Akun Anda telah berhasil dihapus (penghapusan lunak). Anda dapat memulihkannya nanti, tetapi Anda harus online untuk melakukannya. Anda akan keluar sekarang.' + 'Akun Anda telah berhasil dihapus (penghapusan lunak). Anda dapat memulihkannya nanti, tetapi Anda harus online untuk melakukannya. Anda akan keluar sekarang.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคฎเฅ‡เคŸเคพเค‡เคฏเฅ‹ (เคธเคซเฅเคŸ เคกเคฟเคฒเคฟเคŸ)เฅค เคคเคชเคพเคˆเค‚ เคฏเคธเคฒเคพเคˆ เคชเค›เคฟ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›, เคคเคฐ เคคเฅเคฏเคธเค•เคพ เคฒเคพเค—เคฟ เคคเคชเคพเคˆเค‚ เค…เคจเคฒเคพเค‡เคจ เคนเฅเคจเฅเคชเคฐเฅเค›เฅค เคคเคชเคพเคˆเค‚ เค…เคฌ เคธเคพเค‡เคจ เค†เค‰เคŸ เคนเฅเคจเฅเคนเฅเคจเฅ‡เค›เฅค' }, accountDeletionError: { english: 'Failed to delete account: {error}', spanish: 'Error al eliminar la cuenta: {error}', brazilian_portuguese: 'Falha ao excluir conta: {error}', tok_pisin: 'I no inap rausim account: {error}', - indonesian: 'Gagal menghapus akun: {error}' + indonesian: 'Gagal menghapus akun: {error}', + nepali: 'เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‰เคจ เค…เคธเคซเคฒ: {error}' }, accountDeletedTitle: { english: 'Account Deleted', spanish: 'Cuenta Eliminada', brazilian_portuguese: 'Conta Excluรญda', tok_pisin: 'Account i Raus', - indonesian: 'Akun Dihapus' + indonesian: 'Akun Dihapus', + nepali: 'เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‡เคฏเฅ‹' }, accountDeletedMessage: { english: @@ -5519,21 +6459,25 @@ export const localizations = { tok_pisin: 'Account bilong yu i raus pinis. Yu inap restore long kamap bek ol data bilong yu, o yu inap logout na go bek long login.', indonesian: - 'Akun Anda telah dihapus. Anda dapat memulihkannya untuk mendapatkan kembali akses ke semua data Anda, atau Anda dapat keluar dan kembali ke layar login.' + 'Akun Anda telah dihapus. Anda dapat memulihkannya untuk mendapatkan kembali akses ke semua data Anda, atau Anda dapat keluar dan kembali ke layar login.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคฎเฅ‡เคŸเคพเค‡เคเค•เฅ‹ เค›เฅค เคคเคชเคพเคˆเค‚ เค†เคซเฅเคจเฅ‹ เคธเคฌเฅˆ เคกเคพเคŸเคพเคฎเคพ เคชเคนเฅเคเคš เคชเฅเคจ: เคชเฅเคฐเคพเคชเฅเคค เค—เคฐเฅเคจ เคฏเคธเคฒเคพเคˆ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›, เคตเคพ เคคเคชเคพเคˆเค‚ เคฒเค—เค†เค‰เคŸ เค—เคฐเฅ‡เคฐ เคฒเค—เค‡เคจ เคธเฅเค•เฅเคฐเคฟเคจเคฎเคพ เคซเคฐเฅเค•เคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, restoreAccount: { english: 'Restore Account', spanish: 'Restaurar Cuenta', brazilian_portuguese: 'Restaurar Conta', tok_pisin: 'Restore Account', - indonesian: 'Pulihkan Akun' + indonesian: 'Pulihkan Akun', + nepali: 'เค–เคพเคคเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, restoreAccountConfirmTitle: { english: 'Restore Account?', spanish: 'ยฟRestaurar Cuenta?', brazilian_portuguese: 'Restaurar Conta?', tok_pisin: 'Restore Account?', - indonesian: 'Pulihkan Akun?' + indonesian: 'Pulihkan Akun?', + nepali: 'เค–เคพเคคเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจเฅ‡?' }, restoreAccountConfirmMessage: { english: @@ -5545,7 +6489,9 @@ export const localizations = { tok_pisin: 'Account bilong yu bai restore olgeta. Ol data bilong yu bai kamap bek, na yu inap wokim ol samting olsem bipo.', indonesian: - 'Akun Anda akan dipulihkan sepenuhnya. Semua data Anda akan dapat diakses lagi, dan Anda dapat melanjutkan menggunakan aplikasi secara normal.' + 'Akun Anda akan dipulihkan sepenuhnya. Semua data Anda akan dapat diakses lagi, dan Anda dapat melanjutkan menggunakan aplikasi secara normal.', + nepali: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคชเฅ‚เคฐเฅเคฃ เคฐเฅ‚เคชเคฎเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเคฟเคจเฅ‡เค›เฅค เคคเคชเคพเคˆเค‚เค•เฅ‹ เคธเคฌเฅˆ เคกเคพเคŸเคพ เคซเฅ‡เคฐเคฟ เคชเคนเฅเคเคšเคฏเฅ‹เค—เฅเคฏ เคนเฅเคจเฅ‡เค›, เคฐ เคคเคชเคพเคˆเค‚ เคธเคพเคฎเคพเคจเฅเคฏ เคฐเฅ‚เคชเคฎเคพ เคเคช เคชเฅเคฐเคฏเฅ‹เค— เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, accountRestoreSuccess: { english: 'Your account has been successfully restored. Welcome back!', @@ -5553,21 +6499,24 @@ export const localizations = { brazilian_portuguese: 'Sua conta foi restaurada com sucesso. Bem-vindo de volta!', tok_pisin: 'Account bilong yu i restore pinis. Welkam bek!', - indonesian: 'Akun Anda telah berhasil dipulihkan. Selamat datang kembali!' + indonesian: 'Akun Anda telah berhasil dipulihkan. Selamat datang kembali!', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเคฟเคฏเฅ‹เฅค เคซเฅ‡เคฐเคฟ เคธเฅเคตเคพเค—เคค เค›!' }, accountRestoreError: { english: 'Failed to restore account: {error}', spanish: 'Error al restaurar la cuenta: {error}', brazilian_portuguese: 'Falha ao restaurar conta: {error}', tok_pisin: 'I no inap restore account: {error}', - indonesian: 'Gagal memulihkan akun: {error}' + indonesian: 'Gagal memulihkan akun: {error}', + nepali: 'เค–เคพเคคเคพ เคชเฅเคจเคฐเฅเคธเฅเคฅเคพเคชเคจเคพ เค—เคฐเฅเคจ เค…เคธเคซเคฒ: {error}' }, signInRequired: { english: 'Sign In Required', spanish: 'Inicio de Sesiรณn Requerido', brazilian_portuguese: 'Login Necessรกrio', tok_pisin: 'Mas I Mas Sign In', - indonesian: 'Masuk Diperlukan' + indonesian: 'Masuk Diperlukan', + nepali: 'เคธเคพเค‡เคจ เค‡เคจ เค†เคตเคถเฅเคฏเค• เค›' }, blockContentLoginMessage: { english: @@ -5579,77 +6528,89 @@ export const localizations = { tok_pisin: 'Mipela save long ol samting yu laik block long account bilong yu. Plis register long ol samting i ken hide stret.', indonesian: - 'Kami menyimpan informasi tentang apa yang akan diblokir di akun Anda. Silakan daftar untuk memastikan konten yang diblokir dapat disembunyikan dengan benar.' + 'Kami menyimpan informasi tentang apa yang akan diblokir di akun Anda. Silakan daftar untuk memastikan konten yang diblokir dapat disembunyikan dengan benar.', + nepali: + 'เคนเคพเคฎเฅ€ เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพเคฎเคพ เค•เฅ‡ เคฌเฅเคฒเค• เค—เคฐเฅเคจเฅ‡ เคฌเคพเคฐเฅ‡ เคœเคพเคจเค•เคพเคฐเฅ€ เคญเคฃเฅเคกเคพเคฐเคฃ เค—เคฐเฅเค›เฅŒเค‚เฅค เค•เฅƒเคชเคฏเคพ เคฌเฅเคฒเค• เค—เคฐเคฟเคเค•เฅ‹ เคธเคพเคฎเค—เฅเคฐเฅ€ เคฐเคพเคฎเฅเคฐเคฐเฅ€ เคฒเฅเค•เคพเค‰เคจ เคธเค•เคฟเคจเฅ‡ เคธเฅเคจเคฟเคถเฅเคšเคฟเคค เค—เคฐเฅเคจ เคฆเคฐเฅเคคเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, connected: { english: 'Connected', spanish: 'Conectado', brazilian_portuguese: 'Conectado', tok_pisin: 'i connect pinis', - indonesian: 'Terhubung' + indonesian: 'Terhubung', + nepali: 'เคœเคกเคพเคจ เคญเคฏเฅ‹' }, downloadStatus: { english: 'Download Status', spanish: 'Estado de Descarga', brazilian_portuguese: 'Status de Download', tok_pisin: 'Download Status', - indonesian: 'Status Unduhan' + indonesian: 'Status Unduhan', + nepali: 'เคกเคพเค‰เคจเคฒเฅ‹เคก เคธเฅเคฅเคฟเคคเคฟ' }, powersyncStatus: { english: 'PowerSync Status', spanish: 'Estado de PowerSync', brazilian_portuguese: 'Status do PowerSync', tok_pisin: 'PowerSync Status', - indonesian: 'Status PowerSync' + indonesian: 'Status PowerSync', + nepali: 'PowerSync เคธเฅเคฅเคฟเคคเคฟ' }, networkStatus: { english: 'Network Status', spanish: 'Estado de Red', brazilian_portuguese: 'Status da Rede', tok_pisin: 'Network Status', - indonesian: 'Status Jaringan' + indonesian: 'Status Jaringan', + nepali: 'เคจเฅ‡เคŸเคตเคฐเฅเค• เคธเฅเคฅเคฟเคคเคฟ' }, attachmentDownloadProgress: { english: 'Attachment Download Progress', spanish: 'Progreso de Descarga de Archivos', brazilian_portuguese: 'Progresso de Download de Anexos', tok_pisin: 'Attachment Download Progress', - indonesian: 'Kemajuan Unduhan Lampiran' + indonesian: 'Kemajuan Unduhan Lampiran', + nepali: 'เคธเค‚เคฒเค—เฅเคจเค• เคกเคพเค‰เคจเคฒเฅ‹เคก เคชเฅเคฐเค—เคคเคฟ' }, overallProgress: { english: 'Overall Progress', spanish: 'Progreso General', brazilian_portuguese: 'Progresso Geral', tok_pisin: 'Overall Progress', - indonesian: 'Kemajuan Keseluruhan' + indonesian: 'Kemajuan Keseluruhan', + nepali: 'เคธเคฎเค—เฅเคฐ เคชเฅเคฐเค—เคคเคฟ' }, currentDownload: { english: 'Current Download', spanish: 'Descarga Actual', brazilian_portuguese: 'Download Atual', tok_pisin: 'Current Download', - indonesian: 'Unduhan Saat Ini' + indonesian: 'Unduhan Saat Ini', + nepali: 'เคนเคพเคฒเค•เฅ‹ เคกเคพเค‰เคจเคฒเฅ‹เคก' }, currentUpload: { english: 'Current Upload', spanish: 'Carga Actual', brazilian_portuguese: 'Upload Atual', tok_pisin: 'Current Upload', - indonesian: 'Unggahan Saat Ini' + indonesian: 'Unggahan Saat Ini', + nepali: 'เคนเคพเคฒเค•เฅ‹ เค…เคชเคฒเฅ‹เคก' }, queueStatus: { english: 'Queue Status', spanish: 'Estado de Cola', brazilian_portuguese: 'Status da Fila', tok_pisin: 'Queue Status', - indonesian: 'Status Antrian' + indonesian: 'Status Antrian', + nepali: 'เคฒเคพเคฎ เคธเฅเคฅเคฟเคคเคฟ' }, allSynced: { english: 'All files synced', spanish: 'Todos los archivos sincronizados', brazilian_portuguese: 'Todos os arquivos sincronizados', tok_pisin: 'Olgeta file i sync pinis', - indonesian: 'Semua file disinkronkan' + indonesian: 'Semua file disinkronkan', + nepali: 'เคธเคฌเฅˆ เคซเคพเค‡เคฒเคนเคฐเฅ‚ เคธเคฟเค™เฅเค• เคญเคฏเฅ‹' }, signInToViewDownloadStatus: { english: 'Please sign in to view download status and sync information.', @@ -5659,56 +6620,64 @@ export const localizations = { 'Por favor, faรงa login para ver o status de download e informaรงรตes de sincronizaรงรฃo.', tok_pisin: 'Plis sign in long lukim download status na sync info.', indonesian: - 'Silakan masuk untuk melihat status unduhan dan informasi sinkronisasi.' + 'Silakan masuk untuk melihat status unduhan dan informasi sinkronisasi.', + nepali: 'เค•เฅƒเคชเคฏเคพ เคกเคพเค‰เคจเคฒเฅ‹เคก เคธเฅเคฅเคฟเคคเคฟ เคฐ เคธเคฟเค‚เค• เคœเคพเคจเค•เคพเคฐเฅ€ เคนเฅ‡เคฐเฅเคจ เคธเคพเค‡เคจ เค‡เคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, unsynced: { english: 'Unsynced', spanish: 'No sincronizado', brazilian_portuguese: 'Nรฃo sincronizado', tok_pisin: 'i no sync yet', - indonesian: 'Tidak disinkronkan' + indonesian: 'Tidak disinkronkan', + nepali: 'เคธเคฟเค™เฅเค• เคญเคเค•เฅ‹ เค›เฅˆเคจ' }, onboardingCreateProjectTitle: { english: 'Record a Bible, or any other content', spanish: 'Graba una Biblia o cualquier otro contenido', brazilian_portuguese: 'Grave uma Bรญblia ou qualquer outro conteรบdo', tok_pisin: 'Rekodim Baibel o ol narapela samting', - indonesian: 'Rekam Alkitab atau konten lainnya' + indonesian: 'Rekam Alkitab atau konten lainnya', + nepali: 'เคฌเคพเค‡เคฌเคฒ เคตเคพ เค…เคจเฅเคฏ เค•เฅเคจเฅˆ เคชเคจเคฟ เคธเคพเคฎเค—เฅเคฐเฅ€ เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingCreateProjectSubtitle: { english: 'Start by creating your first project', spanish: 'Comienza creando tu primer proyecto', brazilian_portuguese: 'Comece criando seu primeiro projeto', tok_pisin: 'Stat long mekim nupela projek', - indonesian: 'Mulai dengan membuat proyek pertama Anda' + indonesian: 'Mulai dengan membuat proyek pertama Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคชเคนเคฟเคฒเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅ‡เคฐ เคธเฅเคฐเฅ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingCreateProjectExample: { english: 'Stories', spanish: 'Historias', brazilian_portuguese: 'Histรณrias', tok_pisin: 'Stori', - indonesian: 'Cerita' + indonesian: 'Cerita', + nepali: 'เค•เคฅเคพเคนเคฐเฅ‚' }, onboardingCreateProjectDescription: { english: 'Example project name', spanish: 'Nombre de proyecto de ejemplo', brazilian_portuguese: 'Nome do projeto de exemplo', tok_pisin: 'Nem bilong projek olsem', - indonesian: 'Nama proyek contoh' + indonesian: 'Nama proyek contoh', + nepali: 'เค‰เคฆเคพเคนเคฐเคฃ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคจเคพเคฎ' }, onboardingCreateProject: { english: 'Create Project', spanish: 'Crear Proyecto', brazilian_portuguese: 'Criar Projeto', tok_pisin: 'Mekim Projek', - indonesian: 'Buat Proyek' + indonesian: 'Buat Proyek', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingCreateQuestTitle: { english: 'Organize your content', spanish: 'Organiza tu contenido', brazilian_portuguese: 'Organize seu conteรบdo', tok_pisin: 'Oganaisim samting bilong yu', - indonesian: 'Organisir konten Anda' + indonesian: 'Organisir konten Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคธเคพเคฎเค—เฅเคฐเฅ€ เคตเฅเคฏเคตเคธเฅเคฅเคฟเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingCreateQuestSubtitle: { english: 'Add quests to break down your project into manageable pieces', @@ -5717,35 +6686,41 @@ export const localizations = { 'Adicione missรตes para dividir seu projeto em partes gerenciรกveis', tok_pisin: 'Putim kwest long brukim projek i go long liklik hap', indonesian: - 'Tambahkan quest untuk membagi proyek Anda menjadi bagian yang dapat dikelola' + 'Tambahkan quest untuk membagi proyek Anda menjadi bagian yang dapat dikelola', + nepali: + 'เค†เคซเฅเคจเฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฒเคพเคˆ เคตเฅเคฏเคตเคธเฅเคฅเคพเคชเคจ เคฏเฅ‹เค—เฅเคฏ เคŸเฅเค•เฅเคฐเคพเคนเคฐเฅ‚เคฎเคพ เคตเคฟเคญเคพเคœเคจ เค—เคฐเฅเคจ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚ เคฅเคชเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingQuestExample1: { english: 'Story 1', spanish: 'Historia 1', brazilian_portuguese: 'Histรณria 1', tok_pisin: 'Stori 1', - indonesian: 'Cerita 1' + indonesian: 'Cerita 1', + nepali: 'เค•เคฅเคพ เฅง' }, onboardingQuestExample2: { english: 'Story 2', spanish: 'Historia 2', brazilian_portuguese: 'Histรณria 2', tok_pisin: 'Stori 2', - indonesian: 'Cerita 2' + indonesian: 'Cerita 2', + nepali: 'เค•เคฅเคพ เฅจ' }, onboardingCreateQuest: { english: 'Create Quest', spanish: 'Crear Misiรณn', brazilian_portuguese: 'Criar Missรฃo', tok_pisin: 'Mekim Kwest', - indonesian: 'Buat Quest' + indonesian: 'Buat Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingRecordAudioTitle: { english: 'Start recording', spanish: 'Comienza a grabar', brazilian_portuguese: 'Comece a gravar', tok_pisin: 'Stat long rekodim', - indonesian: 'Mulai merekam' + indonesian: 'Mulai merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคธเฅเคฐเฅ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingRecordAudioSubtitle: { english: 'Hold the button to record, or slide to record anytime you talk', @@ -5756,35 +6731,41 @@ export const localizations = { tok_pisin: 'Holim button long rekodim, o slipim long rekodim taim yu toktok', indonesian: - 'Tahan tombol untuk merekam, atau geser untuk merekam kapan saja Anda berbicara' + 'Tahan tombol untuk merekam, atau geser untuk merekam kapan saja Anda berbicara', + nepali: + 'เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจ เคฌเคŸเคจ เคฅเคฟเคšเฅเคจเฅเคนเฅ‹เคธเฅ, เคตเคพ เคคเคชเคพเคˆเค‚ เคฌเฅ‹เคฒเฅเคฆเคพ เคœเฅเคจเคธเฅเค•เฅˆ เคฌเฅ‡เคฒเคพ เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจ เคธเฅเคฒเคพเค‡เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingRecordMethod1: { english: 'Hold button to record', spanish: 'Mantรฉn presionado para grabar', brazilian_portuguese: 'Mantenha pressionado para gravar', tok_pisin: 'Holim button long rekodim', - indonesian: 'Tahan tombol untuk merekam' + indonesian: 'Tahan tombol untuk merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจ เคฌเคŸเคจ เคฅเคฟเคšเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingRecordMethod2: { english: 'Slide to record anytime you talk', spanish: 'Desliza para grabar cuando hables', brazilian_portuguese: 'Deslize para gravar quando falar', tok_pisin: 'Slipim long rekodim taim yu toktok', - indonesian: 'Geser untuk merekam kapan saja Anda berbicara' + indonesian: 'Geser untuk merekam kapan saja Anda berbicara', + nepali: 'เคฌเฅ‹เคฒเฅเคฆเคพ เคœเฅเคจเคธเฅเค•เฅˆ เคฌเฅ‡เคฒเคพ เคฐเฅ‡เค•เคฐเฅเคก เค—เคฐเฅเคจ เคธเฅเคฒเคพเค‡เคก เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingStartRecording: { english: 'Start Recording', spanish: 'Comenzar Grabaciรณn', brazilian_portuguese: 'Iniciar Gravaรงรฃo', tok_pisin: 'Stat Rekodim', - indonesian: 'Mulai Merekam' + indonesian: 'Mulai Merekam', + nepali: 'เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™ เคธเฅเคฐเฅ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingInviteTitle: { english: 'Work together', spanish: 'Trabaja en equipo', brazilian_portuguese: 'Trabalhe juntos', tok_pisin: 'Wok wantaim', - indonesian: 'Bekerja bersama' + indonesian: 'Bekerja bersama', + nepali: 'เคธเคเค—เฅˆ เค•เคพเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingInviteSubtitle: { english: @@ -5796,84 +6777,97 @@ export const localizations = { tok_pisin: 'Singim ol narapela long wok wantaim. Bai ol kisim notis na lukim projek bilong yu long list bilong ol', indonesian: - 'Undang orang lain untuk berkolaborasi. Mereka akan menerima notifikasi dan melihat proyek Anda di daftar mereka' + 'Undang orang lain untuk berkolaborasi. Mereka akan menerima notifikasi dan melihat proyek Anda di daftar mereka', + nepali: + 'เค…เคฐเฅ‚เคฒเคพเคˆ เคธเคนเคฏเฅ‹เค— เค—เคฐเฅเคจ เคจเคฟเคฎเฅเคคเฅ‹ เคฆเคฟเคจเฅเคนเฅ‹เคธเฅเฅค เค‰เคจเฅ€เคนเคฐเฅ‚เคฒเฅ‡ เคธเฅ‚เคšเคจเคพ เคชเฅเคฐเคพเคชเฅเคค เค—เคฐเฅเคจเฅ‡เค›เคจเฅ เคฐ เค‰เคจเฅ€เคนเคฐเฅ‚เค•เฅ‹ เคธเฅ‚เคšเฅ€เคฎเคพ เคคเคชเคพเคˆเค‚เค•เฅ‹ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เคฆเฅ‡เค–เฅเคจเฅ‡เค›เคจเฅ' }, onboardingInviteBenefit1: { english: 'They receive a notification', spanish: 'Reciben una notificaciรณn', brazilian_portuguese: 'Eles recebem uma notificaรงรฃo', tok_pisin: 'Ol kisim notis', - indonesian: 'Mereka menerima notifikasi' + indonesian: 'Mereka menerima notifikasi', + nepali: 'เค‰เคจเฅ€เคนเคฐเฅ‚เคฒเฅ‡ เคธเฅ‚เคšเคจเคพ เคชเฅเคฐเคพเคชเฅเคค เค—เคฐเฅเค›เคจเฅ' }, onboardingInviteBenefit2: { english: 'Project appears in their list', spanish: 'El proyecto aparece en su lista', brazilian_portuguese: 'O projeto aparece em sua lista', tok_pisin: 'Projek i kamap long list bilong ol', - indonesian: 'Proyek muncul di daftar mereka' + indonesian: 'Proyek muncul di daftar mereka', + nepali: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค‰เคจเฅ€เคนเคฐเฅ‚เค•เฅ‹ เคธเฅ‚เคšเฅ€เคฎเคพ เคฆเฅ‡เค–เคฟเคจเฅเค›' }, onboardingInviteCollaborators: { english: 'Invite Collaborators', spanish: 'Invitar Colaboradores', brazilian_portuguese: 'Convidar Colaboradores', tok_pisin: 'Singim Ol Wokman', - indonesian: 'Undang Kolaborator' + indonesian: 'Undang Kolaborator', + nepali: 'เคธเคนเค•เคฐเฅเคฎเฅ€เคนเคฐเฅ‚เคฒเคพเคˆ เคจเคฟเคฎเฅเคคเฅ‹ เคฆเคฟเคจเฅเคนเฅ‹เคธเฅ' }, onboardingContinue: { english: 'Continue', spanish: 'Continuar', brazilian_portuguese: 'Continuar', tok_pisin: 'Gohet', - indonesian: 'Lanjutkan' + indonesian: 'Lanjutkan', + nepali: 'เคœเคพเคฐเฅ€ เคฐเคพเค–เฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingBible: { english: 'Bible', spanish: 'Biblia', brazilian_portuguese: 'Bรญblia', tok_pisin: 'Baibel', - indonesian: 'Alkitab' + indonesian: 'Alkitab', + nepali: 'เคฌเคพเค‡เคฌเคฒ' }, onboardingOther: { english: 'Other', spanish: 'Otro', brazilian_portuguese: 'Outro', tok_pisin: 'Narapela', - indonesian: 'Lainnya' + indonesian: 'Lainnya', + nepali: 'เค…เคจเฅเคฏ' }, onboardingBibleSelectBookTitle: { english: 'Select a Book', spanish: 'Selecciona un Libro', brazilian_portuguese: 'Selecione um Livro', tok_pisin: 'Pilim Buk', - indonesian: 'Pilih Buku' + indonesian: 'Pilih Buku', + nepali: 'เคเค‰เคŸเคพ เคชเฅเคธเฅเคคเค• เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingBibleSelectBookSubtitle: { english: 'Choose which book of the Bible to translate', spanish: 'Elige quรฉ libro de la Biblia traducir', brazilian_portuguese: 'Escolha qual livro da Bรญblia traduzir', tok_pisin: 'Pilim wanpela buk bilong Baibel long tanim', - indonesian: 'Pilih buku Alkitab mana yang akan diterjemahkan' + indonesian: 'Pilih buku Alkitab mana yang akan diterjemahkan', + nepali: 'เคฌเคพเค‡เคฌเคฒเค•เฅ‹ เค•เฅเคจ เคชเฅเคธเฅเคคเค• เค…เคจเฅเคตเคพเคฆ เค—เคฐเฅเคจเฅ‡ เค›เคพเคจเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingBibleBookExample1: { english: 'Genesis', spanish: 'Gรฉnesis', brazilian_portuguese: 'Gรชnesis', tok_pisin: 'Jenesis', - indonesian: 'Kejadian' + indonesian: 'Kejadian', + nepali: 'เค‰เคคเฅเคชเคคเฅเคคเคฟ' }, onboardingBibleBookExample2: { english: 'Matthew', spanish: 'Mateo', brazilian_portuguese: 'Mateus', tok_pisin: 'Matyu', - indonesian: 'Matius' + indonesian: 'Matius', + nepali: 'เคฎเคคเฅเคคเฅ€' }, onboardingBibleCreateChapterTitle: { english: 'Create Chapter Quests', spanish: 'Crear Quests de Capรญtulos', brazilian_portuguese: 'Criar Quests de Capรญtulos', tok_pisin: 'Mekim Ol Kwest bilong Kapitol', - indonesian: 'Buat Quest Bab' + indonesian: 'Buat Quest Bab', + nepali: 'เค…เคงเฅเคฏเคพเคฏ เค•เฅเคตเฅ‡เคธเฅเคŸเคนเคฐเฅ‚ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingBibleCreateChapterSubtitle: { english: 'Each chapter becomes a quest you can work on', @@ -5882,28 +6876,32 @@ export const localizations = { brazilian_portuguese: 'Cada capรญtulo se torna uma quest em que vocรช pode trabalhar', tok_pisin: 'Olgeta kapitol i kamap wanpela kwest yu ken wok long en', - indonesian: 'Setiap bab menjadi quest yang dapat Anda kerjakan' + indonesian: 'Setiap bab menjadi quest yang dapat Anda kerjakan', + nepali: 'เคชเฅเคฐเคคเฅเคฏเฅ‡เค• เค…เคงเฅเคฏเคพเคฏ เคเค‰เคŸเคพ เค•เฅเคตเฅ‡เคธเฅเคŸ เคฌเคจเฅเค› เคœเคธเคฎเคพ เคคเคชเคพเคˆเค‚ เค•เคพเคฎ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›' }, onboardingBibleChapterExample1: { english: 'Chapter 1', spanish: 'Capรญtulo 1', brazilian_portuguese: 'Capรญtulo 1', tok_pisin: 'Kapitol 1', - indonesian: 'Bab 1' + indonesian: 'Bab 1', + nepali: 'เค…เคงเฅเคฏเคพเคฏ เฅง' }, onboardingBibleChapterExample2: { english: 'Chapter 2', spanish: 'Capรญtulo 2', brazilian_portuguese: 'Capรญtulo 2', tok_pisin: 'Kapitol 2', - indonesian: 'Bab 2' + indonesian: 'Bab 2', + nepali: 'เค…เคงเฅเคฏเคพเคฏ เฅจ' }, onboardingVisionTitle: { english: 'Every language. Every culture.', spanish: 'Cada idioma. Cada cultura.', brazilian_portuguese: 'Cada idioma. Cada cultura.', tok_pisin: 'Olgeta tokples. Olgeta kalsa.', - indonesian: 'Setiap bahasa. Setiap budaya.' + indonesian: 'Setiap bahasa. Setiap budaya.', + nepali: 'เคชเฅเคฐเคคเฅเคฏเฅ‡เค• เคญเคพเคทเคพเฅค เคชเฅเคฐเคคเฅเคฏเฅ‡เค• เคธเค‚เคธเฅเค•เฅƒเคคเคฟเฅค' }, onboardingVisionSubtitle: { english: @@ -5915,14 +6913,17 @@ export const localizations = { tok_pisin: 'Kisim ol text na audio bilong tokples kwiktaim. Stat long lokal, sync taim yu gat internet. Wok wantaim, tanim tokples, stretim.', indonesian: - 'Kumpulkan data bahasa teks dan audio dengan cepat. Lokal pertama, sinkronkan saat terhubung. Berkolaborasi, terjemahkan, validasi.' + 'Kumpulkan data bahasa teks dan audio dengan cepat. Lokal pertama, sinkronkan saat terhubung. Berkolaborasi, terjemahkan, validasi.', + nepali: + 'เคชเคพเค  เคฐ เค…เคกเคฟเคฏเฅ‹ เคญเคพเคทเคพ เคกเคพเคŸเคพ เค›เคฟเคŸเฅเคŸเฅˆ เคธเค™เฅเค•เคฒเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค เคธเฅเคฅเคพเคจเฅ€เคฏ-เคชเฅเคฐเคฅเคฎ, เคœเคกเคพเคจ เคนเฅเคเคฆเคพ เคธเคฟเค‚เค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค เคธเคนเคฏเฅ‹เค— เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ, เค…เคจเฅเคตเคพเคฆ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ, เคชเฅเคฐเคฎเคพเคฃเคฟเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค' }, onboardingVisionStatement1: { english: "Every language having access to the world's knowledge.", spanish: 'Cada idioma con acceso al conocimiento del mundo.', brazilian_portuguese: 'Cada idioma tendo acesso ao conhecimento do mundo.', tok_pisin: 'Olgeta tokples i gat akses long save bilong wol.', - indonesian: 'Setiap bahasa memiliki akses ke pengetahuan dunia.' + indonesian: 'Setiap bahasa memiliki akses ke pengetahuan dunia.', + nepali: 'เคชเฅเคฐเคคเฅเคฏเฅ‡เค• เคญเคพเคทเคพเคฒเฅ‡ เคตเคฟเคถเฅเคตเค•เฅ‹ เคœเฅเคžเคพเคจเคฎเคพ เคชเคนเฅเคเคš เคชเคพเค‰เคจเฅ‡เฅค' }, onboardingVisionStatement2: { english: 'Every culture sharing its meaning with the world.', @@ -5930,7 +6931,8 @@ export const localizations = { brazilian_portuguese: 'Cada cultura compartilhando seu significado com o mundo.', tok_pisin: 'Olgeta kalsa i salim save bilong en i go long wol.', - indonesian: 'Setiap budaya berbagi maknanya dengan dunia.' + indonesian: 'Setiap budaya berbagi maknanya dengan dunia.', + nepali: 'เคชเฅเคฐเคคเฅเคฏเฅ‡เค• เคธเค‚เคธเฅเค•เฅƒเคคเคฟเคฒเฅ‡ เค†เคซเฅเคจเฅ‹ เค…เคฐเฅเคฅ เคตเคฟเคถเฅเคตเคธเคเค— เคธเคพเคเคพ เค—เคฐเฅเคจเฅ‡เฅค' }, onboardingVisionCC0: { english: 'CC0/public domain data ensures no party can stop this vision.', @@ -5941,21 +6943,25 @@ export const localizations = { tok_pisin: 'CC0/pablik domain data i mekim olsem wanpela man o grup i no inap stopim dispela visen.', indonesian: - 'Data CC0/domain publik memastikan tidak ada pihak yang dapat menghentikan visi ini.' + 'Data CC0/domain publik memastikan tidak ada pihak yang dapat menghentikan visi ini.', + nepali: + 'CC0/เคธเคพเคฐเฅเคตเคœเคจเคฟเค• เคกเฅ‹เคฎเฅ‡เคจ เคกเคพเคŸเคพเคฒเฅ‡ เค•เฅเคจเฅˆ เคชเคจเคฟ เคชเค•เฅเคทเคฒเฅ‡ เคฏเฅ‹ เคฆเฅƒเคทเฅเคŸเคฟเค•เฅ‹เคฃเคฒเคพเคˆ เคฐเฅ‹เค•เฅเคจ เคจเคธเค•เฅเคจเฅ‡ เคธเฅเคจเคฟเคถเฅเคšเคฟเคค เค—เคฐเฅเค›เฅค' }, onboardingOurVision: { english: 'Our Vision', spanish: 'Nuestra Visiรณn', brazilian_portuguese: 'Nossa Visรฃo', tok_pisin: 'Visen Bilong Mipela', - indonesian: 'Visi Kami' + indonesian: 'Visi Kami', + nepali: 'เคนเคพเคฎเฅเคฐเฅ‹ เคฆเฅƒเคทเฅเคŸเคฟ' }, onboardingSelectLanguageTitle: { english: 'Choose Your Language', spanish: 'Elige Tu Idioma', brazilian_portuguese: 'Escolha Seu Idioma', tok_pisin: 'Pilim Tokples Bilong Yu', - indonesian: 'Pilih Bahasa Anda' + indonesian: 'Pilih Bahasa Anda', + nepali: 'เค†เคซเฅเคจเฅ‹ เคญเคพเคทเคพ เค›เคพเคจเฅเคจเฅเคนเฅ‹เคธเฅ' }, onboardingSelectLanguageSubtitle: { english: "Select the language you'd like to use for the app interface", @@ -5964,14 +6970,16 @@ export const localizations = { brazilian_portuguese: 'Selecione o idioma que deseja usar para a interface do aplicativo', tok_pisin: 'Pilim tokples yu laikim long yusim long app', - indonesian: 'Pilih bahasa yang ingin Anda gunakan untuk antarmuka aplikasi' + indonesian: 'Pilih bahasa yang ingin Anda gunakan untuk antarmuka aplikasi', + nepali: 'เคเคช เค‡เคจเฅเคŸเคฐเคซเฅ‡เคธเค•เฅ‹ เคฒเคพเค—เคฟ เคคเคชเคพเคˆเค‚ เคชเฅเคฐเคฏเฅ‹เค— เค—เคฐเฅเคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅ‡ เคญเคพเคทเคพ เคšเคฏเคจ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, exportProgress: { english: 'Export Progress', spanish: 'Progreso de Exportaciรณn', brazilian_portuguese: 'Progresso de Exportaรงรฃo', tok_pisin: 'Export Progress', - indonesian: 'Progres Exportasi' + indonesian: 'Progres Exportasi', + nepali: 'เคจเคฟเคฐเฅเคฏเคพเคค เคชเฅเคฐเค—เคคเคฟ' }, exporting: { english: 'Exporting chapter... This may take a few moments.', @@ -5979,42 +6987,48 @@ export const localizations = { brazilian_portuguese: 'Exportando capรญtulo... Isso pode levar alguns momentos.', tok_pisin: 'Exporting chapter... This may take a few moments.', - indonesian: 'Mengekspor bab... Ini mungkin memakan beberapa saat.' + indonesian: 'Mengekspor bab... Ini mungkin memakan beberapa saat.', + nepali: 'เค…เคงเฅเคฏเคพเคฏ เคจเคฟเคฐเฅเคฏเคพเคค เค—เคฐเฅเคฆเฅˆ... เคฏเคธเคฒเฅ‡ เค•เฅ‡เคนเฅ€ เค•เฅเคทเคฃ เคฒเคฟเคจ เคธเค•เฅเค›เฅค' }, exportReady: { english: 'Export is ready!', spanish: 'Exportaciรณn lista!', brazilian_portuguese: 'Exportaรงรฃo pronta!', tok_pisin: 'Export is ready!', - indonesian: 'Ekspor siap!' + indonesian: 'Ekspor siap!', + nepali: 'เคจเคฟเคฐเฅเคฏเคพเคค เคคเคฏเคพเคฐ เค›!' }, share: { english: 'Share', spanish: 'Compartir', brazilian_portuguese: 'Compartilhar', tok_pisin: 'Share', - indonesian: 'Bagikan' + indonesian: 'Bagikan', + nepali: 'เคธเคพเคเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, exportFailed: { english: 'Export failed', spanish: 'Exportaciรณn fallida', brazilian_portuguese: 'Exportaรงรฃo falhou', tok_pisin: 'Export failed', - indonesian: 'Ekspor gagal' + indonesian: 'Ekspor gagal', + nepali: 'เคจเคฟเคฐเฅเคฏเคพเคค เค…เคธเคซเคฒ เคญเคฏเฅ‹' }, close: { english: 'Close', spanish: 'Cerrar', brazilian_portuguese: 'Fechar', tok_pisin: 'Close', - indonesian: 'Tutup' + indonesian: 'Tutup', + nepali: 'เคฌเคจเฅเคฆ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, exportForDistribution: { english: 'Export for Distribution', spanish: 'Exportar para distribuciรณn', brazilian_portuguese: 'Exportar para distribuiรงรฃo', tok_pisin: 'Export for Distribution', - indonesian: 'Ekspor untuk Distribusi' + indonesian: 'Ekspor untuk Distribusi', + nepali: 'เคตเคฟเคคเคฐเคฃเค•เฅ‹ เคฒเคพเค—เคฟ เคจเคฟเคฐเฅเคฏเคพเคค' }, exportForDistributionDescription: { english: 'This export is intended for public distribution and sharing.', @@ -6024,14 +7038,16 @@ export const localizations = { 'Esta exportaรงรฃo รฉ destinada ร  distribuiรงรฃo e compartilhamento pรบblicos.', tok_pisin: 'Dispela export bilong wok long putim igo aut long olgeta na kisim sindaun wantaim ol arapela.', - indonesian: 'Ekspor ini dimaksudkan untuk distribusi dan pembagian publik.' + indonesian: 'Ekspor ini dimaksudkan untuk distribusi dan pembagian publik.', + nepali: 'เคฏเฅ‹ เคจเคฟเคฐเฅเคฏเคพเคค เคธเคพเคฐเฅเคตเคœเคจเคฟเค• เคตเคฟเคคเคฐเคฃ เคฐ เคธเคพเคเฅ‡เคฆเคพเคฐเฅ€เค•เฅ‹ เคฒเคพเค—เคฟ เคนเฅ‹เฅค' }, exportForFeedback: { english: 'Export for Feedback', spanish: 'Exportar para feedback', brazilian_portuguese: 'Exportar para feedback', tok_pisin: 'Export for Feedback', - indonesian: 'Ekspor untuk Feedback' + indonesian: 'Ekspor untuk Feedback', + nepali: 'เคชเฅเคฐเคคเคฟเค•เฅเคฐเคฟเคฏเคพเค•เฅ‹ เคฒเคพเค—เคฟ เคจเคฟเคฐเฅเคฏเคพเคค' }, exportForFeedbackDescription: { english: 'This export is intended for feedback and sharing.', @@ -6039,21 +7055,24 @@ export const localizations = { brazilian_portuguese: 'Esta exportaรงรฃo รฉ destinada a feedback e compartilhado.', tok_pisin: 'Dispela export bilong wok long feedback o share.', - indonesian: 'Ekspor ini dimaksudkan untuk feedback dan pembagian.' + indonesian: 'Ekspor ini dimaksudkan untuk feedback dan pembagian.', + nepali: 'เคฏเฅ‹ เคจเคฟเคฐเฅเคฏเคพเคค เคชเฅเคฐเคคเคฟเค•เฅเคฐเคฟเคฏเคพ เคฐ เคธเคพเคเฅ‡เคฆเคพเคฐเฅ€เค•เฅ‹ เคฒเคพเค—เคฟ เคนเฅ‹เฅค' }, selectExportType: { english: 'Select Export Type', spanish: 'Seleccionar tipo de exportaciรณn', brazilian_portuguese: 'Selecionar tipo de exportaรงรฃo', tok_pisin: 'Makim kain export', - indonesian: 'Pilih Jenis Ekspor' + indonesian: 'Pilih Jenis Ekspor', + nepali: 'เคจเคฟเคฐเฅเคฏเคพเคค เคชเฅเคฐเค•เคพเคฐ เค›เคพเคจเฅเคจเฅเคนเฅ‹เคธเฅ' }, shareLocally: { english: 'Share File', spanish: 'Compartir archivo', brazilian_portuguese: 'Compartilhar arquivo', tok_pisin: 'Shareim file', - indonesian: 'Bagikan file' + indonesian: 'Bagikan file', + nepali: 'เคซเคพเค‡เคฒ เคธเคพเคเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, shareLocallyDescription: { english: 'Create a local audio file to save or share', @@ -6061,14 +7080,16 @@ export const localizations = { brazilian_portuguese: 'Criar um arquivo de รกudio local para salvar ou compartilhar', tok_pisin: 'Mekim lokal audio fail long save o shareim', - indonesian: 'Buat file audio lokal untuk disimpan atau dibagikan' + indonesian: 'Buat file audio lokal untuk disimpan atau dibagikan', + nepali: 'เคธเฅเคฐเค•เฅเคทเคฟเคค เคตเคพ เคธเคพเคเคพ เค—เคฐเฅเคจ เคธเฅเคฅเคพเคจเฅ€เคฏ เค…เคกเคฟเคฏเฅ‹ เคซเคพเค‡เคฒ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, questExport: { english: 'Quest Export', spanish: 'Exportaciรณn de Quest', brazilian_portuguese: 'Exportaรงรฃo de Quest', tok_pisin: 'Quest Export', - indonesian: 'Ekspor Quest' + indonesian: 'Ekspor Quest', + nepali: 'เค•เฅเคตเฅ‡เคธเฅเคŸ เคจเคฟเคฐเฅเคฏเคพเคค' }, questExportDescription: { english: @@ -6080,21 +7101,25 @@ export const localizations = { tok_pisin: 'Exportim ol bible chapter olsem audio fail long shareim na distributim', indonesian: - 'Ekspor pasal-pasal alkitab sebagai file audio untuk dibagikan dan didistribusikan' + 'Ekspor pasal-pasal alkitab sebagai file audio untuk dibagikan dan didistribusikan', + nepali: + 'เคธเคพเคเฅ‡เคฆเคพเคฐเฅ€ เคฐ เคตเคฟเคคเคฐเคฃเค•เฅ‹ เคฒเคพเค—เคฟ เคฌเคพเค‡เคฌเคฒ เค…เคงเฅเคฏเคพเคฏเคนเคฐเฅ‚เคฒเคพเคˆ เค…เคกเคฟเคฏเฅ‹ เคซเคพเค‡เคฒเคนเคฐเฅ‚เค•เฅ‹ เคฐเฅ‚เคชเคฎเคพ เคจเคฟเคฐเฅเคฏเคพเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, transcription: { english: 'Transcription', spanish: 'Transcripciรณn', brazilian_portuguese: 'Transcriรงรฃo', tok_pisin: 'Transcription', - indonesian: 'Transkripsi' + indonesian: 'Transkripsi', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเฅเค•เฅเคฐเคฟเคชเฅเคธเคจ' }, transcriptions: { english: 'Transcriptions', spanish: 'Transcripciones', brazilian_portuguese: 'Transcriรงรตes', tok_pisin: 'Ol Transcription', - indonesian: 'Transkripsi' + indonesian: 'Transkripsi', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเฅเค•เฅเคฐเคฟเคชเฅเคธเคจเคนเคฐเฅ‚' }, noTranscriptionsYet: { english: 'No transcriptions yet. Be the first to transcribe!', @@ -6102,7 +7127,9 @@ export const localizations = { brazilian_portuguese: 'Nenhuma transcriรงรฃo ainda. Seja o primeiro a transcrever!', tok_pisin: 'I no gat transcription yet. Yu ken namba wan long transcribe!', - indonesian: 'Belum ada transkripsi. Jadilah yang pertama mentranskripsi!' + indonesian: 'Belum ada transkripsi. Jadilah yang pertama mentranskripsi!', + nepali: + 'เค…เคนเคฟเคฒเฅ‡เคธเคฎเฅเคฎ เค•เฅเคจเฅˆ เคŸเฅเคฐเคพเคจเฅเคธเฅเค•เฅเคฐเคฟเคชเฅเคธเคจ เค›เฅˆเคจเฅค เคชเคนเคฟเคฒเฅ‹ เคŸเฅเคฐเคพเคจเฅเคธเฅเค•เฅเคฐเคพเค‡เคฌเคฐ เคฌเคจเฅเคจเฅเคนเฅ‹เคธเฅ!' }, transcriptionDescription: { english: 'Enable automatic transcription of audio recordings', @@ -6110,28 +7137,32 @@ export const localizations = { brazilian_portuguese: 'Habilitar transcriรงรฃo automรกtica de gravaรงรตes de รกudio', tok_pisin: 'Enablem automatic transcription bilong audio recordings', - indonesian: 'Aktifkan transkripsi otomatis rekaman audio' + indonesian: 'Aktifkan transkripsi otomatis rekaman audio', + nepali: 'เค…เคกเคฟเคฏเฅ‹ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™เคนเคฐเฅ‚เค•เฅ‹ เคธเฅเคตเคšเคพเคฒเคฟเคค เคŸเฅเคฐเคพเคจเฅเคธเค•เฅเคฐเคฟเคชเฅเคธเคจ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, transcriptionComplete: { english: 'Transcription Complete', spanish: 'Transcripciรณn completada', brazilian_portuguese: 'Transcriรงรฃo concluรญda', tok_pisin: 'Transcription i pinis', - indonesian: 'Transkripsi selesai' + indonesian: 'Transkripsi selesai', + nepali: 'เคŸเฅเคฐเคพเคจเฅเคธเฅเค•เฅเคฐเคฟเคชเฅเคธเคจ เคชเฅ‚เคฐเคพ เคญเคฏเฅ‹' }, copyFeedbackLink: { english: 'Copy Feedback Link', spanish: 'Copiar enlace de feedback', brazilian_portuguese: 'Copiar link de feedback', tok_pisin: 'Kopim feedback link', - indonesian: 'Salin Tautan Umpan Balik' + indonesian: 'Salin Tautan Umpan Balik', + nepali: 'เคชเฅเคฐเคคเคฟเค•เฅเคฐเคฟเคฏเคพ เคฒเคฟเค‚เค• เค•เคชเฅ€ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, copyFeedbackLinkDescription: { english: 'Copy a link to share for feedback', spanish: 'Copiar un enlace para compartir para feedback', brazilian_portuguese: 'Copiar um link para compartilhar para feedback', tok_pisin: 'Kopim link long shareim bilong feedback', - indonesian: 'Salin tautan untuk dibagikan untuk umpan balik' + indonesian: 'Salin tautan untuk dibagikan untuk umpan balik', + nepali: 'เคชเฅเคฐเคคเคฟเค•เฅเคฐเคฟเคฏเคพเค•เฅ‹ เคฒเคพเค—เคฟ เคธเคพเคเคพ เค—เคฐเฅเคจ เคฒเคฟเค‚เค• เค•เคชเฅ€ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, feedbackLinkNote: { english: @@ -6143,21 +7174,47 @@ export const localizations = { tok_pisin: 'Notis: Mipela planim long mekim link long LangQuest website we ol export inap lukim na tok long en long bihain.', indonesian: - 'Catatan: Kami berencana untuk mengimplementasikan tautan ke situs web LangQuest di mana ekspor dapat dilihat dan dikomentari di masa depan.' + 'Catatan: Kami berencana untuk mengimplementasikan tautan ke situs web LangQuest di mana ekspor dapat dilihat dan dikomentari di masa depan.', + nepali: + 'เคจเฅ‹เคŸ: เคนเคพเคฎเฅ€ เคญเคตเคฟเคทเฅเคฏเคฎเคพ LangQuest เคตเฅ‡เคฌเคธเคพเค‡เคŸเคฎเคพ เคฒเคฟเค‚เค• เคฒเคพเค—เฅ‚ เค—เคฐเฅเคจเฅ‡ เคฏเฅ‹เคœเคจเคพ เคฌเคจเคพเค‡เคฐเคนเฅ‡เค•เคพ เค›เฅŒเค‚ เคœเคนเคพเค เคจเคฟเคฐเฅเคฏเคพเคคเคนเคฐเฅ‚ เคนเฅ‡เคฐเฅเคจ เคฐ เคŸเคฟเคชเฅเคชเคฃเฅ€ เค—เคฐเฅเคจ เคธเค•เคฟเคจเฅเค›เฅค' }, linkCopied: { english: 'Link copied to clipboard!', spanish: 'ยกEnlace copiado al portapapeles!', brazilian_portuguese: 'Link copiado para a รกrea de transferรชncia!', tok_pisin: 'Link kopim igo long clipboard!', - indonesian: 'Tautan disalin ke clipboard!' + indonesian: 'Tautan disalin ke clipboard!', + nepali: 'เคฒเคฟเค‚เค• เค•เฅเคฒเคฟเคชเคฌเฅ‹เคฐเฅเคกเคฎเคพ เค•เคชเฅ€ เคญเคฏเฅ‹!' }, verseMarkers: { english: 'Verse Labels', spanish: 'Etiquetas de Versรญculos', brazilian_portuguese: 'Etiquetas de Versรญculos', tok_pisin: 'Verse Labels', - indonesian: 'Label Versi' + indonesian: 'Label Versi', + nepali: 'เคชเคฆ เคฒเฅ‡เคฌเคฒเคนเคฐเฅ‚' + }, + enableVerseLabelsQuestion: { + english: 'Enable Verse Labels?', + spanish: 'ยฟHabilitar etiquetas de versรญculos?', + brazilian_portuguese: 'Habilitar etiquetas de versรญculos?', + tok_pisin: 'Enablem verse labels?', + indonesian: 'Aktifkan label versi?', + nepali: 'เคชเคฆ เคฒเฅ‡เคฌเคฒเคนเคฐเฅ‚ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅ‡?' + }, + enableVerseLabelsDescription: { + english: + 'This experimental feature helps organize Bible resources using verse labels. You can enable / disable it anytime at the Settings menu.', + spanish: + 'Esta funciรณn experimental ayuda a organizar los recursos de la Biblia usando etiquetas de versรญculos. Puedes habilitarla / deshabilitarla en cualquier momento desde el menรบ de Configuraciรณn.', + brazilian_portuguese: + 'Este recurso experimental ajuda a organizar os recursos da Bรญblia usando etiquetas de versรญculos. Vocรช pode ativรก-lo / desativรก-lo a qualquer momento no menu de Configuraรงรตes.', + tok_pisin: + 'Dispela experimental feature i helpim long organaisim Bible resources wantaim verse labels. Yu ken enablem / disableim long Settings menu long anytime.', + indonesian: + 'Fitur eksperimental ini membantu mengorganisir sumber daya Alkitab menggunakan label versi. Anda dapat mengaktifkan / menonaktifkannya kapan saja di menu Pengaturan.', + nepali: + 'เคฏเฅ‹ เคชเฅเคฐเคฏเฅ‹เค—เคพเคคเฅเคฎเค• เคธเฅเคตเคฟเคงเคพเคฒเฅ‡ เคชเคฆ เคฒเฅ‡เคฌเคฒเคนเคฐเฅ‚ เคชเฅเคฐเคฏเฅ‹เค— เค—เคฐเฅ‡เคฐ เคฌเคพเค‡เคฌเคฒ เคธเฅเคฐเฅ‹เคคเคนเคฐเฅ‚ เคตเฅเคฏเคตเคธเฅเคฅเคฟเคค เค—เคฐเฅเคจ เคฎเคฆเฅเคฆเคค เค—เคฐเฅเค›เฅค เคคเคชเคพเคˆเค‚ เคฏเคธเคฒเคพเคˆ เคธเฅ‡เคŸเคฟเค™เฅเคธ เคฎเฅ‡เคจเฅเคฎเคพ เคœเฅเคจเคธเฅเค•เฅˆ เคธเคฎเคฏ เคธเค•เฅเคทเคฎ / เค…เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจ เคธเค•เฅเคจเฅเคนเฅเคจเฅเค›เฅค' }, verseMarkersDescription: { english: 'Enable verse labels to help organize Bible resources', @@ -6167,7 +7224,9 @@ export const localizations = { 'Habilitar etiquetas de versรญculos para ajudar a organizar recursos da Bรญblia', tok_pisin: 'Enable verse labels to help organize Bible resources', indonesian: - 'Aktifkan label versi untuk membantu mengorganisir sumber daya Alkitab' + 'Aktifkan label versi untuk membantu mengorganisir sumber daya Alkitab', + nepali: + 'เคฌเคพเค‡เคฌเคฒ เคธเฅเคฐเฅ‹เคคเคนเคฐเฅ‚ เคตเฅเคฏเคตเคธเฅเคฅเคฟเคค เค—เคฐเฅเคจ เคฎเคฆเฅเคฆเคค เค—เคฐเฅเคจ เคชเคฆ เคฒเฅ‡เคฌเคฒเคนเคฐเฅ‚ เคธเค•เฅเคทเคฎ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, // Languoid Link Suggestion strings languoidLinkSuggestionTitle: { @@ -6175,14 +7234,16 @@ export const localizations = { spanish: 'ยฟVincular tu idioma?', brazilian_portuguese: 'ยฟVincular seu idioma?', tok_pisin: 'Joinim tok ples bilong yu?', - indonesian: 'Apakah Anda ingin menghubungkan bahasa Anda?' + indonesian: 'Apakah Anda ingin menghubungkan bahasa Anda?', + nepali: 'เค†เคซเฅเคจเฅ‹ เคญเคพเคทเคพ เคฒเคฟเค‚เค• เค—เคฐเฅเคจเฅเคนเฅเคจเฅเค›?' }, languoidLinkSuggestionDrawerTitle: { english: 'Link to existing language', spanish: 'Vincular a un idioma existente', brazilian_portuguese: 'Vincular a um idioma existente', tok_pisin: 'Joinim wanpela tok ples', - indonesian: 'Hubungkan ke bahasa yang ada' + indonesian: 'Hubungkan ke bahasa yang ada', + nepali: 'เค…เคตเคธเฅเคฅเคฟเคค เคญเคพเคทเคพเคฎเคพ เคฒเคฟเค‚เค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, languoidLinkSuggestionDescription: { english: @@ -6194,98 +7255,113 @@ export const localizations = { tok_pisin: 'Mipela painim tok ples i stap pinis we inap wankain long tok ples yu bin mekim. Yu laik joinim wanpela tok ples i stap pinis?', indonesian: - 'Kami menemukan bahasa yang ada yang mungkin cocok dengan yang Anda buat. Apakah Anda ingin menghubungkan ke bahasa yang ada?' + 'Kami menemukan bahasa yang ada yang mungkin cocok dengan yang Anda buat. Apakah Anda ingin menghubungkan ke bahasa yang ada?', + nepali: + 'เคนเคพเคฎเฅ€เคฒเฅ‡ เคคเคชเคพเคˆเค‚เคฒเฅ‡ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจเฅเคญเคเค•เฅ‹ เคธเคเค— เคฎเฅ‡เคฒ เค–เคพเคจ เคธเค•เฅเคจเฅ‡ เค…เคตเคธเฅเคฅเคฟเคค เคญเคพเคทเคพเคนเคฐเฅ‚ เคซเฅ‡เคฒเคพ เคชเคพเคฐเฅเคฏเฅŒเค‚เฅค เค•เฅ‡ เคคเคชเคพเคˆเค‚ เค…เคตเคธเฅเคฅเคฟเคค เคญเคพเคทเคพเคฎเคพ เคฒเคฟเค‚เค• เค—เคฐเฅเคจ เคšเคพเคนเคจเฅเคนเฅเคจเฅเค›?' }, yourLanguage: { english: 'Your language', spanish: 'Tu idioma', brazilian_portuguese: 'Seu idioma', tok_pisin: 'Tok ples bilong yu', - indonesian: 'Bahasa Anda' + indonesian: 'Bahasa Anda', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคญเคพเคทเคพ' }, seeLanguageSuggestions: { english: 'See language suggestions', spanish: 'Ver sugerencias de idioma', brazilian_portuguese: 'Ver sugestรตes de idioma', tok_pisin: 'Lukim ol tok ples bilong en', - indonesian: 'Lihat sugesti bahasa' + indonesian: 'Lihat sugesti bahasa', + nepali: 'เคญเคพเคทเคพ เคธเฅเคเคพเคตเคนเคฐเฅ‚ เคนเฅ‡เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, keepMyLanguage: { english: 'Keep my language', spanish: 'Mantener mi idioma', brazilian_portuguese: 'Manter meu idioma', tok_pisin: 'Holim tok ples bilong mi', - indonesian: 'Simpan bahasa saya' + indonesian: 'Simpan bahasa saya', + nepali: 'เคฎเฅ‡เคฐเฅ‹ เคญเคพเคทเคพ เคฐเคพเค–เฅเคจเฅเคนเฅ‹เคธเฅ' }, chooseThisLanguage: { english: 'Choose this language', spanish: 'Elegir este idioma', brazilian_portuguese: 'Escolher este idioma', tok_pisin: 'Pilim dispela tok ples', - indonesian: 'Pilih bahasa ini' + indonesian: 'Pilih bahasa ini', + nepali: 'เคฏเฅ‹ เคญเคพเคทเคพ เค›เคพเคจเฅเคจเฅเคนเฅ‹เคธเฅ' }, exactMatch: { english: 'Exact match', spanish: 'Coincidencia exacta', brazilian_portuguese: 'Correspondรชncia exata', tok_pisin: 'Sem tru', - indonesian: 'Kecocokan persis' + indonesian: 'Kecocokan persis', + nepali: 'เค เฅ€เค• เคฎเคฟเคฒเฅเคฏเฅ‹' }, partialMatch: { english: 'Partial match', spanish: 'Coincidencia parcial', brazilian_portuguese: 'Correspondรชncia parcial', tok_pisin: 'Luk olsem', - indonesian: 'Kecocokan sebagian' + indonesian: 'Kecocokan sebagian', + nepali: 'เค†เค‚เคถเคฟเค• เคฎเคฟเคฒเฅเคฏเฅ‹' }, matchedByName: { english: 'Matched by name', spanish: 'Coincide por nombre', brazilian_portuguese: 'Correspondido por nome', tok_pisin: 'Painim long nem', - indonesian: 'Cocok berdasarkan nama' + indonesian: 'Cocok berdasarkan nama', + nepali: 'เคจเคพเคฎเคฆเฅเคตเคพเคฐเคพ เคฎเฅ‡เคฒ เค–เคพเคฏเฅ‹' }, matchedByAlias: { english: 'Matched by alias', spanish: 'Coincide por alias', brazilian_portuguese: 'Correspondido por alias', tok_pisin: 'Painim long narapela nem', - indonesian: 'Cocok berdasarkan alias' + indonesian: 'Cocok berdasarkan alias', + nepali: 'เค‰เคชเคจเคพเคฎเคฆเฅเคตเคพเคฐเคพ เคฎเฅ‡เคฒ เค–เคพเคฏเฅ‹' }, matchedByIsoCode: { english: 'Matched by ISO code', spanish: 'Coincide por cรณdigo ISO', brazilian_portuguese: 'Correspondido por cรณdigo ISO', tok_pisin: 'Painim long ISO kod', - indonesian: 'Cocok berdasarkan kode ISO' + indonesian: 'Cocok berdasarkan kode ISO', + nepali: 'ISO เค•เฅ‹เคกเคฆเฅเคตเคพเคฐเคพ เคฎเฅ‡เคฒ เค–เคพเคฏเฅ‹' }, languageLinkSuccess: { english: 'Language linked successfully', spanish: 'Idioma vinculado con รฉxito', brazilian_portuguese: 'Idioma vinculado com sucesso', tok_pisin: 'Tok ples joinim gut', - indonesian: 'Bahasa berhasil dihubungkan' + indonesian: 'Bahasa berhasil dihubungkan', + nepali: 'เคญเคพเคทเคพ เคธเคซเคฒเคคเคพเคชเฅ‚เคฐเฅเคตเค• เคฒเคฟเค‚เค• เคญเคฏเฅ‹' }, languageLinkError: { english: 'Failed to link language', spanish: 'Error al vincular idioma', brazilian_portuguese: 'Falha ao vincular idioma', tok_pisin: 'No inap joinim tok ples', - indonesian: 'Gagal menghubungkan bahasa' + indonesian: 'Gagal menghubungkan bahasa', + nepali: 'เคญเคพเคทเคพ เคฒเคฟเค‚เค• เค—เคฐเฅเคจ เค…เคธเคซเคฒ' }, keepLanguageSuccess: { english: 'Your custom language has been kept', spanish: 'Tu idioma personalizado ha sido conservado', brazilian_portuguese: 'Seu idioma personalizado foi mantido', tok_pisin: 'Tok ples bilong yu i stap yet', - indonesian: 'Bahasa kustom Anda telah disimpan' + indonesian: 'Bahasa kustom Anda telah disimpan', + nepali: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค†เคซเฅเคจเฅˆ เคญเคพเคทเคพ เคฐเคพเค–เคฟเคเค•เฅ‹ เค›' }, enableLanguoidLinkSuggestions: { english: 'Language link suggestions', spanish: 'Sugerencias de vinculaciรณn de idioma', brazilian_portuguese: 'Sugestรตes de vinculaรงรฃo de idioma', tok_pisin: 'Ol tok ples bilong joinim', - indonesian: 'Saran tautan bahasa' + indonesian: 'Saran tautan bahasa', + nepali: 'เคญเคพเคทเคพ เคฒเคฟเค‚เค• เคธเฅเคเคพเคตเคนเคฐเฅ‚' }, enableLanguoidLinkSuggestionsDescription: { english: @@ -6297,7 +7373,9 @@ export const localizations = { tok_pisin: 'Kisim ol tok ples bilong joinim tok ples bilong yu wantaim ol tok ples i stap pinis long database', indonesian: - 'Dapatkan saran untuk menghubungkan bahasa kustom Anda dengan yang ada di database' + 'Dapatkan saran untuk menghubungkan bahasa kustom Anda dengan yang ada di database', + nepali: + 'เค†เคซเฅเคจเฅ‹ เค†เคซเฅเคจเฅˆ-เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเคฟเคเค•เคพ เคญเคพเคทเคพเคนเคฐเฅ‚เคฒเคพเคˆ เคกเคพเคŸเคพเคฌเฅ‡เคธเคฎเคพ เค…เคตเคธเฅเคฅเคฟเคค เคญเคพเคทเคพเคนเคฐเฅ‚เคธเคเค— เคฒเคฟเค‚เค• เค—เคฐเฅเคจ เคธเฅเคเคพเคตเคนเคฐเฅ‚ เคชเฅเคฐเคพเคชเฅเคค เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' } } as const; diff --git a/supabase/functions/send-email/_templates/confirm-email.tsx b/supabase/functions/send-email/_templates/confirm-email.tsx index f0d1c44ef..54a20a7e3 100644 --- a/supabase/functions/send-email/_templates/confirm-email.tsx +++ b/supabase/functions/send-email/_templates/confirm-email.tsx @@ -76,6 +76,15 @@ export const ConfirmEmail = ({ button: 'Strongim LangQuest Akaun', orCopy: 'Or copyim pasteim link yu long yu browser:', expiry: 'Link yu no expireim long 24 hours.' + }, + ne: { + preview: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ LangQuest เค–เคพเคคเคพ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', + title: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', + description: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจ เคฐ เคฆเคฐเฅเคคเคพ เคชเฅ‚เคฐเคพ เค—เคฐเฅเคจ เคฏเฅ‹ เคฒเคฟเค‚เค• เค…เคจเฅเคธเคฐเคฃ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ:', + button: 'เค–เคพเคคเคพ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', + orCopy: 'เคตเคพ เคฏเฅ‹ เคฒเคฟเค‚เค• เคคเคชเคพเคˆเค‚เค•เฅ‹ เคฌเฅเคฐเคพเค‰เคœเคฐเคฎเคพ เค•เคชเคฟ เคฐ เคชเฅ‡เคธเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ:', + expiry: 'เคฏเฅ‹ เคฒเคฟเค‚เค• เฅจเฅช เค˜เคฃเฅเคŸเคพเคฎเคพ เคธเคฎเคพเคชเฅเคค เคนเฅเคจเฅ‡เค›เฅค' } }; diff --git a/supabase/functions/send-email/_templates/invite-email.tsx b/supabase/functions/send-email/_templates/invite-email.tsx index 0fb6f9f2d..f98b37e3b 100644 --- a/supabase/functions/send-email/_templates/invite-email.tsx +++ b/supabase/functions/send-email/_templates/invite-email.tsx @@ -103,6 +103,19 @@ export const InviteEmail = ({ button: 'Joinim LangQuest', orCopy: 'Or copyim pasteim link yu long yu browser:', expiry: 'Link yu no expireim long 7 days.' + }, + ne: { + preview: `เคคเคชเคพเคˆเค‚เคฒเคพเคˆ ${projectName} เคฎเคพ LangQuest เคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเคฟเคเค•เฅ‹ เค›`, + title: 'เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸ เค†เคฎเคจเฅเคคเฅเคฐเคฃ', + greeting: 'เคจเคฎเคธเฅเค•เคพเคฐ!', + description: `${inviterName} เคฒเฅ‡ เคคเคชเคพเคˆเค‚เคฒเคพเคˆ LangQuest เคฎเคพ "${projectName}" เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเฅเคจเฅเคญเคเค•เฅ‹ เค›, เคเค• เคธเคนเคฏเฅ‹เค—เฅ€ เคญเคพเคทเคพ เคธเคฟเค•เคพเค‡ เคชเฅเคฒเฅ‡เคŸเคซเคฐเฅเคฎเฅค`, + whatIsLangQuest: + 'LangQuest เคฒเฅ‡ เคธเคฎเฅเคฆเคพเคฏเคนเคฐเฅ‚เคฒเคพเคˆ เคญเคพเคทเคพ เคธเคฟเค•เคพเค‡ เคธเฅเคฐเฅ‹เคคเคนเคฐเฅ‚ เคธเคฟเคฐเฅเคœเคจเคพ เคฐ เคธเคพเคเฅ‡เคฆเคพเคฐเฅ€ เค—เคฐเฅเคจ เคฎเคฆเฅเคฆเคค เค—เคฐเฅเคฆเค›เฅค เค…เคจเฅเคตเคพเคฆ, เค…เคกเคฟเคฏเฅ‹ เคฐเฅ‡เค•เคฐเฅเคกเคฟเค™เคนเคฐเฅ‚ เคฏเฅ‹เค—เคฆเคพเคจ เค—เคฐเฅเคจ เคฐ เคตเคฟเคถเฅเคตเคญเคฐ เคญเคพเคทเคพเคนเคฐเฅ‚ เคธเค‚เคฐเค•เฅเคทเคฃ เค—เคฐเฅเคจ เคฎเคฆเฅเคฆเคค เค—เคฐเฅเคจ เคนเคพเคฎเฅ€เคธเคเค— เคœเฅ‹เคกเคฟเคจเฅเคนเฅ‹เคธเฅเฅค', + instruction: + 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เค–เคพเคคเคพ เคธเคฟเคฐเฅเคœเคจเคพ เค—เคฐเฅเคจ เคฐ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เคคเคฒเค•เฅ‹ เคฌเคŸเคจเคฎเคพ เค•เฅเคฒเคฟเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ:', + button: 'LangQuest เคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจเฅเคนเฅ‹เคธเฅ', + orCopy: 'เคตเคพ เคฏเฅ‹ เคฒเคฟเค‚เค• เคคเคชเคพเคˆเค‚เค•เฅ‹ เคฌเฅเคฐเคพเค‰เคœเคฐเคฎเคพ เค•เคชเคฟ เคฐ เคชเฅ‡เคธเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ:', + expiry: 'เคฏเฅ‹ เค†เคฎเคจเฅเคคเฅเคฐเคฃ เคฒเคฟเค‚เค• เฅญ เคฆเคฟเคจเคฎเคพ เคธเคฎเคพเคชเฅเคค เคนเฅเคจเฅ‡เค›เฅค' } }; diff --git a/supabase/functions/send-email/_templates/reset-password.tsx b/supabase/functions/send-email/_templates/reset-password.tsx index de08104d7..793160be5 100644 --- a/supabase/functions/send-email/_templates/reset-password.tsx +++ b/supabase/functions/send-email/_templates/reset-password.tsx @@ -90,6 +90,17 @@ export const ResetPassword = ({ button: 'Resetim Password', orCopy: 'Or copyim pasteim link yu long yu browser:', expiry: 'Link yu no expireim long 24 hours.' + }, + ne: { + preview: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ LangQuest เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', + title: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', + greeting: 'เคจเคฎเคธเฅเค•เคพเคฐ,', + description: + 'เค•เคธเฅˆเคฒเฅ‡ เคคเคชเคพเคˆเค‚เค•เฅ‹ LangQuest เค–เคพเคคเคพเค•เฅ‹ เคฒเคพเค—เคฟ เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค…เคจเฅเคฐเฅ‹เคง เค—เคฐเฅ‡เค•เฅ‹ เค›เฅค เคฏเคฆเคฟ เคฏเฅ‹ เคคเคชเคพเคˆเค‚ เคนเฅ‹เค‡เคจ เคญเคจเฅ‡, เค•เฅƒเคชเคฏเคพ เคฏเฅ‹ เค‡เคฎเฅ‡เคฒ เคฌเฅ‡เคตเคพเคธเฅเคคเคพ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅเฅค', + instruction: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจ เคคเคฒเค•เฅ‹ เคฌเคŸเคจเคฎเคพ เค•เฅเคฒเคฟเค• เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ:', + button: 'เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ', + orCopy: 'เคตเคพ เคฏเฅ‹ เคฒเคฟเค‚เค• เคคเคชเคพเคˆเค‚เค•เฅ‹ เคฌเฅเคฐเคพเค‰เคœเคฐเคฎเคพ เค•เคชเคฟ เคฐ เคชเฅ‡เคธเฅเคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ:', + expiry: 'เคฏเฅ‹ เคฒเคฟเค‚เค• เฅจเฅช เค˜เคฃเฅเคŸเคพเคฎเคพ เคธเคฎเคพเคชเฅเคค เคนเฅเคจเฅ‡เค›เฅค' } }; diff --git a/supabase/functions/send-email/index.ts b/supabase/functions/send-email/index.ts index d2c38a868..21bfdd510 100644 --- a/supabase/functions/send-email/index.ts +++ b/supabase/functions/send-email/index.ts @@ -22,7 +22,8 @@ const signupEmailSubjects = { fr: 'Confirmez votre compte LangQuest', 'pt-BR': 'Confirme sua conta LangQuest', 'id-ID': 'Konfirmasi Akun LangQuest Anda', - 'tpi-PG': 'Strongim LangQuest Akaun bilong yu' + 'tpi-PG': 'Strongim LangQuest Akaun bilong yu', + ne: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ LangQuest เค–เคพเคคเคพ เคชเฅเคทเฅเคŸเคฟ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }; // Email subject translations const emailSubjects = { @@ -34,7 +35,8 @@ const emailSubjects = { fr: 'Rรฉinitialisez votre mot de passe LangQuest', 'pt-BR': 'Redefina sua senha do LangQuest', 'id-ID': 'Atur Ulang Kata Sandi LangQuest Anda', - 'tpi-PG': 'Resetim LangQuest Password bilong yu' + 'tpi-PG': 'Resetim LangQuest Password bilong yu', + ne: 'เคคเคชเคพเคˆเค‚เค•เฅ‹ LangQuest เคชเคพเคธเคตเคฐเฅเคก เคฐเคฟเคธเฅ‡เคŸ เค—เคฐเฅเคจเฅเคนเฅ‹เคธเฅ' }, invite: { en: "You've been invited to join a project on LangQuest", @@ -42,7 +44,8 @@ const emailSubjects = { fr: 'Vous avez รฉtรฉ invitรฉ ร  rejoindre un projet sur LangQuest', 'pt-BR': 'Vocรช foi convidado para participar de um projeto no LangQuest', 'id-ID': 'Anda telah diundang untuk bergabung dalam proyek di LangQuest', - 'tpi-PG': 'Yu telah strongim langquest bilong yu' + 'tpi-PG': 'Yu telah strongim langquest bilong yu', + ne: 'เคคเคชเคพเคˆเค‚เคฒเคพเคˆ LangQuest เคฎเคพ เคเค‰เคŸเคพ เคชเฅเคฐเฅ‹เคœเฅ‡เค•เฅเคŸเคฎเคพ เคธเคพเคฎเฅ‡เคฒ เคนเฅเคจ เค†เคฎเคจเฅเคคเฅเคฐเคฟเคค เค—เคฐเคฟเคเค•เฅ‹ เค›' } }; const emailTypeEndpoint = { @@ -68,7 +71,8 @@ function mapLanguoidNameToLocale( 'brazilian portuguese': 'pt-BR', 'tok pisin': 'tpi-PG', 'standard indonesian': 'id-ID', - indonesian: 'id-ID' // Also handle just "Indonesian" + indonesian: 'id-ID', // Also handle just "Indonesian" + nepali: 'ne' }; return mapping[normalized] ?? 'en'; diff --git a/supabase/migrations/20260127120000_add_nepali_ui_ready.sql b/supabase/migrations/20260127120000_add_nepali_ui_ready.sql new file mode 100644 index 000000000..ee499c17a --- /dev/null +++ b/supabase/migrations/20260127120000_add_nepali_ui_ready.sql @@ -0,0 +1,32 @@ +-- Enable Nepali as a UI-ready language +-- The languoid record already exists in production with id: 7a735df4-4f4e-4a03-b60e-eb7911152cf4 +-- ISO 639-3: npi (Nepali language, not the macrolanguage nep) +-- +-- In local dev, the languoid is created by seeds (which run AFTER migrations), +-- so we use conditional statements that only execute if the languoid exists. + +UPDATE public.languoid +SET ui_ready = true, + last_updated = NOW() +WHERE id = '7a735df4-4f4e-4a03-b60e-eb7911152cf4'; + +-- Add Nepali endonymic alias (เคจเฅ‡เคชเคพเคฒเฅ€) - only if the languoid exists (production) +-- Both subject and label reference Nepali since it's an endonym (self-name) +-- In local dev, seeds handle creating this alias +INSERT INTO public.languoid_alias ( + subject_languoid_id, + label_languoid_id, + name, + alias_type, + source_names +) +SELECT + '7a735df4-4f4e-4a03-b60e-eb7911152cf4', + '7a735df4-4f4e-4a03-b60e-eb7911152cf4', + 'เคจเฅ‡เคชเคพเคฒเฅ€', + 'endonym'::public.alias_type, + ARRAY['lexvo'] +WHERE EXISTS ( + SELECT 1 FROM public.languoid WHERE id = '7a735df4-4f4e-4a03-b60e-eb7911152cf4' +) +ON CONFLICT (subject_languoid_id, label_languoid_id, alias_type, name) DO NOTHING; diff --git a/supabase/seeds/public.sql b/supabase/seeds/public.sql index 7c9730945..345a6ff6f 100644 --- a/supabase/seeds/public.sql +++ b/supabase/seeds/public.sql @@ -71,7 +71,11 @@ INSERT INTO "public"."languoid" ("id", "parent_id", "name", "level", "ui_ready", ('9e3f8bd9-c2e5-4f5a-b98d-123456789012', NULL, 'Mixteco de Penasco', 'language', false, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), ('4a8b7c6d-5e4f-3a2b-1c9d-987654321098', NULL, 'Zapoteco de Santiago', 'language', false, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), ('2f1e3d4c-5b6a-7890-1234-567890abcdef', NULL, 'Popoluca', 'language', false, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), - ('ceae62bf-d109-4eb9-95e3-3fd0d2ba0ab2', NULL, 'Universal', 'language', false, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL); + ('ceae62bf-d109-4eb9-95e3-3fd0d2ba0ab2', NULL, 'Universal', 'language', false, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('7a735df4-4f4e-4a03-b60e-eb7911152cf4', NULL, 'Nepali', 'language', true, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d', NULL, 'Brazilian Portuguese', 'language', true, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('b2c3d4e5-f6a7-4b5c-9d0e-1f2a3b4c5d6e', NULL, 'Tok Pisin', 'language', true, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('c3d4e5f6-a7b8-4c5d-0e1f-2a3b4c5d6e7f', NULL, 'Indonesian', 'language', true, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL); -- @@ -79,11 +83,15 @@ INSERT INTO "public"."languoid" ("id", "parent_id", "name", "level", "ui_ready", -- INSERT INTO "public"."languoid_alias" ("id", "subject_languoid_id", "label_languoid_id", "name", "alias_type", "source_names", "active", "download_profiles", "created_at", "last_updated", "creator_id") VALUES - ('a1a1a1a1-0001-4000-8000-000000000001', 'bd6027e5-b122-43b9-ba0a-4f5d5a25f1dd', 'bd6027e5-b122-43b9-ba0a-4f5d5a25f1dd', 'English', 'endonym', ARRAY['glottolog'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), - ('a1a1a1a1-0002-4000-8000-000000000002', '7c37870b-7d52-4589-934f-576f03781263', '7c37870b-7d52-4589-934f-576f03781263', 'Espaรฑol', 'endonym', ARRAY['glottolog'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), - ('a1a1a1a1-0003-4000-8000-000000000003', '9e3f8bd9-c2e5-4f5a-b98d-123456789012', '9e3f8bd9-c2e5-4f5a-b98d-123456789012', 'Tu''un Savi', 'endonym', ARRAY['glottolog'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), - ('a1a1a1a1-0004-4000-8000-000000000004', '4a8b7c6d-5e4f-3a2b-1c9d-987654321098', '4a8b7c6d-5e4f-3a2b-1c9d-987654321098', 'Diidxazรก', 'endonym', ARRAY['glottolog'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), - ('a1a1a1a1-0005-4000-8000-000000000005', '2f1e3d4c-5b6a-7890-1234-567890abcdef', '2f1e3d4c-5b6a-7890-1234-567890abcdef', 'Nuntajษจฬƒyi', 'endonym', ARRAY['glottolog'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL); + ('a1a1a1a1-0001-4000-8000-000000000001', 'bd6027e5-b122-43b9-ba0a-4f5d5a25f1dd', 'bd6027e5-b122-43b9-ba0a-4f5d5a25f1dd', 'English', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0002-4000-8000-000000000002', '7c37870b-7d52-4589-934f-576f03781263', '7c37870b-7d52-4589-934f-576f03781263', 'Espaรฑol', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0003-4000-8000-000000000003', '9e3f8bd9-c2e5-4f5a-b98d-123456789012', '9e3f8bd9-c2e5-4f5a-b98d-123456789012', 'Tu''un Savi', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0004-4000-8000-000000000004', '4a8b7c6d-5e4f-3a2b-1c9d-987654321098', '4a8b7c6d-5e4f-3a2b-1c9d-987654321098', 'Diidxazรก', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0005-4000-8000-000000000005', '2f1e3d4c-5b6a-7890-1234-567890abcdef', '2f1e3d4c-5b6a-7890-1234-567890abcdef', 'Nuntajษจฬƒyi', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0006-4000-8000-000000000006', '7a735df4-4f4e-4a03-b60e-eb7911152cf4', '7a735df4-4f4e-4a03-b60e-eb7911152cf4', 'เคจเฅ‡เคชเคพเคฒเฅ€', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0007-4000-8000-000000000007', 'a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d', 'a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d', 'Portuguรชs Brasileiro', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0008-4000-8000-000000000008', 'b2c3d4e5-f6a7-4b5c-9d0e-1f2a3b4c5d6e', 'b2c3d4e5-f6a7-4b5c-9d0e-1f2a3b4c5d6e', 'Tok Pisin', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('a1a1a1a1-0009-4000-8000-000000000009', 'c3d4e5f6-a7b8-4c5d-0e1f-2a3b4c5d6e7f', 'c3d4e5f6-a7b8-4c5d-0e1f-2a3b4c5d6e7f', 'Bahasa Indonesia', 'endonym', ARRAY['lexvo'], true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL); -- @@ -95,7 +103,11 @@ INSERT INTO "public"."languoid_source" ("id", "name", "version", "languoid_id", ('b2b2b2b2-0002-4000-8000-000000000002', 'iso639-3', NULL, '7c37870b-7d52-4589-934f-576f03781263', 'spa', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), ('b2b2b2b2-0003-4000-8000-000000000003', 'iso639-3', NULL, '9e3f8bd9-c2e5-4f5a-b98d-123456789012', 'mil', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), ('b2b2b2b2-0004-4000-8000-000000000004', 'iso639-3', NULL, '4a8b7c6d-5e4f-3a2b-1c9d-987654321098', 'zas', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), - ('b2b2b2b2-0005-4000-8000-000000000005', 'iso639-3', NULL, '2f1e3d4c-5b6a-7890-1234-567890abcdef', 'poi', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL); + ('b2b2b2b2-0005-4000-8000-000000000005', 'iso639-3', NULL, '2f1e3d4c-5b6a-7890-1234-567890abcdef', 'poi', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('b2b2b2b2-0006-4000-8000-000000000006', 'iso639-3', NULL, '7a735df4-4f4e-4a03-b60e-eb7911152cf4', 'npi', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('b2b2b2b2-0007-4000-8000-000000000007', 'iso639-3', NULL, 'a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d', 'por', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('b2b2b2b2-0008-4000-8000-000000000008', 'iso639-3', NULL, 'b2c3d4e5-f6a7-4b5c-9d0e-1f2a3b4c5d6e', 'tpi', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL), + ('b2b2b2b2-0009-4000-8000-000000000009', 'iso639-3', NULL, 'c3d4e5f6-a7b8-4c5d-0e1f-2a3b4c5d6e7f', 'ind', NULL, true, NULL, '2024-01-01 00:00:00+00', '2024-01-01 00:00:00+00', NULL); -- diff --git a/views/SettingsView.tsx b/views/SettingsView.tsx index 28254718c..2b1af755b 100644 --- a/views/SettingsView.tsx +++ b/views/SettingsView.tsx @@ -282,10 +282,8 @@ export default function SettingsView() { }, { id: 'questExport', - title: t('questExport') || 'Quest Export', - description: - t('questExportDescription') || - 'Export bible chapters as audio files for sharing and distribution', + title: t('questExport'), + description: t('questExportDescription'), type: 'toggle', value: enableQuestExport, onPress: () => handleQuestExportToggle(!enableQuestExport), @@ -293,20 +291,16 @@ export default function SettingsView() { }, { id: 'verseMarkers', - title: t('verseMarkers') || 'Verse Labels', - description: - t('verseMarkersDescription') || - 'Enable verse labels to help organize Bible resources', + title: t('verseMarkers'), + description: t('verseMarkersDescription'), type: 'toggle', value: enableVerseMarkers, onPress: () => handleVerseMarkersToggle(!enableVerseMarkers) }, { id: 'transcription', - title: t('transcription') || 'Transcription', - description: - t('transcriptionDescription') || - 'Enable automatic transcription of audio recordings', + title: t('transcription'), + description: t('transcriptionDescription'), type: 'toggle', value: enableTranscription, onPress: () => handleTranscriptionToggle(!enableTranscription) diff --git a/views/new/BibleBookList.tsx b/views/new/BibleBookList.tsx index 1c0ee238e..874bb091b 100644 --- a/views/new/BibleBookList.tsx +++ b/views/new/BibleBookList.tsx @@ -4,6 +4,8 @@ import { Icon } from '@/components/ui/icon'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { BIBLE_BOOKS } from '@/constants/bibleStructure'; +import { useBibleBookNameGetter } from '@/hooks/useBibleBookName'; +import { useLocalization } from '@/hooks/useLocalization'; import { useLocalStore } from '@/store/localStore'; import { BOOK_ICON_MAP } from '@/utils/BOOK_GRAPHICS'; import { cn, useThemeColor } from '@/utils/styleUtils'; @@ -27,6 +29,8 @@ export function BibleBookList({ canCreateNew = false, onCloudLoadingChange }: BibleBookListProps) { + const { t } = useLocalization(); + const getBookName = useBibleBookNameGetter(); const primaryColor = useThemeColor('primary'); const secondaryColor = useThemeColor('chart-2'); @@ -79,6 +83,7 @@ export function BibleBookList({ const iconSource = BOOK_ICON_MAP[book.id]; const bookExists = existingBookIds?.has(book.id); const isDisabled = !bookExists && !canCreateNew; + const { abbrev } = getBookName(book.id); if (!bookExists && isDisabled) { return; @@ -106,11 +111,8 @@ export function BibleBookList({ resizeMode="contain" /> - - {book.id} + + {abbrev} {book.chapters} @@ -138,8 +140,8 @@ export function BibleBookList({ diff --git a/views/new/NextGenProjectsView.tsx b/views/new/NextGenProjectsView.tsx index 13b85eb5d..c6551f751 100644 --- a/views/new/NextGenProjectsView.tsx +++ b/views/new/NextGenProjectsView.tsx @@ -903,10 +903,10 @@ export default function NextGenProjectsView() { {searchQuery - ? 'No projects found' + ? t('noProjectsFound') : activeTab === 'my' - ? 'No projects yet' - : 'No projects available'} + ? t('noProjectsYet') + : t('noProjectsAvailable')} {activeTab === 'my' && !searchQuery && ( {assets.length > 0 && ( + + )} + {!isPublished && currentUser && ( + + )} + + + {/* Right side: Publish/Export buttons (isolated) */} + + {/* OLD handlePlayAllAssets button - commented out (replaced by Library icon button) */} + {/* {assets.length > 0 && ( - )} + )} */} {isPublished ? ( - // Only show cloud-check icon if user is creator, member, or owner + // Show cloud badge and export button if user is creator, member, or owner canSeePublishedBadge ? ( <> - {!isPublished && ( - - )} {currentQuestId && currentProjectId && ( )} - + ) )} @@ -3402,7 +3720,7 @@ export default function BibleAssetsView() { //variant="destructive" // size="lg" className="ml-14 w-full flex-row items-center justify-around gap-2 rounded-lg bg-primary p-2 px-4" - onPress={() => setShowRecording(true)} + onPress={() => void handleGoToRecording()} > diff --git a/views/new/NextGenAssetsView.tsx b/views/new/NextGenAssetsView.tsx index 4a3eaa26e..4990c432d 100644 --- a/views/new/NextGenAssetsView.tsx +++ b/views/new/NextGenAssetsView.tsx @@ -29,17 +29,18 @@ import { useLocalStore } from '@/store/localStore'; import { SHOW_DEV_ELEMENTS } from '@/utils/featureFlags'; import RNAlert from '@blazejkustra/react-native-alert'; import { LegendList } from '@legendapp/list'; +import { Audio } from 'expo-av'; import { ArrowBigDownDashIcon, CheckCheck, CloudUpload, FlagIcon, InfoIcon, + ListVideo, LockIcon, MicIcon, PauseIcon, PencilIcon, - PlayIcon, RefreshCwIcon, SearchIcon, SettingsIcon, @@ -81,6 +82,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { eq } from 'drizzle-orm'; import { AssetListItem } from './AssetListItem'; import RecordingViewSimplified from './recording/components/RecordingViewSimplified'; +import { useSelectionMode } from './recording/hooks/useSelectionMode'; type Asset = typeof asset.$inferSelect; type AssetQuestLink = Asset & { @@ -101,6 +103,11 @@ export default function NextGenAssetsView() { const audioContext = useAudio(); const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); + + // Selection mode for visual highlight (playAll start point) + const { selectedAssetIds, enterSelection, cancelSelection } = + useSelectionMode(); + const [debouncedSearchQuery, searchQuery, setSearchQuery] = useDebouncedState( '', 300 @@ -118,14 +125,27 @@ export default function NextGenAssetsView() { const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState< string | null >(null); - const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId - const assetOrderRef = React.useRef([]); // Ordered list of asset IDs - const uriOrderRef = React.useRef([]); // Ordered list of URIs matching assetOrderRef - const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms + // const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId + // const assetOrderRef = React.useRef([]); // Ordered list of asset IDs + // const uriOrderRef = React.useRef([]); // Ordered list of URIs matching assetOrderRef + // const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms + + // New PlayAll state (starts from selected asset) + const [isPlayAllRunning, setIsPlayAllRunning] = React.useState(false); + const isPlayAllRunningRef = React.useRef(false); + const currentPlayAllSoundRef = React.useRef(null); const timeoutIdsRef = React.useRef>>( new Set() ); + const currentPlayingAssetIdRef = React.useRef(null); + + // Ref to hold latest audioContext for cleanup (avoids stale closure) + const audioContextRef = React.useRef(audioContext); + React.useEffect(() => { + audioContextRef.current = audioContext; + }, [audioContext]); + // Animation for refresh button const spinValue = useSharedValue(0); @@ -184,6 +204,23 @@ export default function NextGenAssetsView() { return questData?.[0]; }, [currentQuestData, queriedQuestData]); + // Check if quest is published (source is 'synced') - computed early for use in callbacks + const isPublished = selectedQuest?.source === 'synced'; + + // Handle single selection (for playAll start point) - always single selection + const handleToggleSelect = React.useCallback( + (assetId: string) => { + // Single selection: if already selected, deselect. Otherwise, select only this one. + if (selectedAssetIds.has(assetId)) { + cancelSelection(); + } else { + // Select only this one (enterSelection clears previous and sets new) + enterSelection(assetId); + } + }, + [selectedAssetIds, cancelSelection, enterSelection] + ); + // Query project data to get privacy status if not passed const { data: queriedProjectData } = useHybridData({ dataType: 'project-privacy-assets', @@ -245,7 +282,6 @@ export default function NextGenAssetsView() { const currentStatus = useStatusContext(); currentStatus.layerStatus(LayerType.QUEST, currentQuestId || ''); const showInvisibleContent = useLocalStore((s) => s.showHiddenContent); - const enablePlayAll = useLocalStore((s) => s.enablePlayAll); const { data, @@ -331,16 +367,16 @@ export default function NextGenAssetsView() { item: AssetQuestLink & { source?: HybridDataSource }; isPublished: boolean; }) => { - const isPlaying = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && - currentlyPlayingAssetId === item.id; + // Check if this asset is currently playing in PlayAll mode + const isPlaying = isPlayAllRunning && currentlyPlayingAssetId === item.id; // Debug logging for highlighting if (isPlaying && __DEV__) { console.log(`๐ŸŽจ Rendering highlighted asset: ${item.id.slice(0, 8)}`); } + const isSelected = selectedAssetIds.has(item.id); + return ( <> ); }, - // Use stable memo key instead of Map reference to prevent hook dependency issues - // Always has exactly 2 dependencies (string, string) - never changes size [ currentQuestId, safeAttachmentStates, - audioContext.isPlaying, - audioContext.currentAudioId, + isPlayAllRunning, currentlyPlayingAssetId, - handleAssetUpdate + handleAssetUpdate, + selectedAssetIds, + handleToggleSelect ] ); @@ -409,7 +446,7 @@ export default function NextGenAssetsView() { ); // Special audio ID for "play all" mode - const PLAY_ALL_AUDIO_ID = 'play-all-assets'; + // const PLAY_ALL_AUDIO_ID = 'play-all-assets'; // Fetch audio URIs for an asset (similar to RecordingViewSimplified) // Includes fallback logic for local-only files when server records are removed @@ -688,168 +725,261 @@ export default function NextGenAssetsView() { ); // Track currently playing asset based on audio position - React.useEffect(() => { - if ( - !audioContext.isPlaying || - audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID - ) { - setCurrentlyPlayingAssetId(null); - return; - } - - // Calculate which asset is playing based on cumulative position - const checkCurrentAsset = () => { - const uris = uriOrderRef.current; - const durations = segmentDurationsRef.current; - - if (uris.length === 0) return; + // React.useEffect(() => { + // if ( + // !audioContext.isPlaying || + // audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID + // ) { + // setCurrentlyPlayingAssetId(null); + // return; + // } + + // // Calculate which asset is playing based on cumulative position + // const checkCurrentAsset = () => { + // const uris = uriOrderRef.current; + // const durations = segmentDurationsRef.current; + + // if (uris.length === 0) return; + + // const position = audioContext.position; // Position in milliseconds + + // // If we don't have durations yet, use simple percentage-based approach + // if (durations.length === 0 || durations.every((d) => d === 0)) { + // const duration = audioContext.duration; + // if (duration === 0) { + // console.log( + // `โธ๏ธ No duration available yet (position: ${position}ms, duration: ${duration}ms)` + // ); + // return; + // } + + // // Fallback: use percentage-based calculation + // const positionPercent = position / duration; + // const uriIndex = Math.min( + // Math.floor(positionPercent * uris.length), + // uris.length - 1 + // ); + + // const currentUri = uris[uriIndex]; + // if (currentUri) { + // const assetId = assetUriMapRef.current.get(currentUri); + // if (assetId) { + // if (assetId !== currentlyPlayingAssetId) { + // console.log( + // `๐ŸŽต [Fallback] Highlighting asset ${assetId.slice(0, 8)} (segment ${uriIndex + 1}/${uris.length}, ${Math.round(positionPercent * 100)}%)` + // ); + // setCurrentlyPlayingAssetId(assetId); + // } + // } else { + // console.warn(`โš ๏ธ No asset ID found for URI at index ${uriIndex}`); + // } + // } + // return; + // } + + // // Calculate which segment we're in based on cumulative durations + // let cumulativeDuration = 0; + // for (let i = 0; i < uris.length; i++) { + // const segmentDuration = durations[i] || 0; + // const segmentStart = cumulativeDuration; + // cumulativeDuration += segmentDuration; + + // // If position is within this segment's range + // // Use <= for the last segment to catch it even if position is slightly off + // if ( + // (position >= segmentStart && position <= cumulativeDuration) || + // (i === uris.length - 1 && position >= segmentStart) + // ) { + // const currentUri = uris[i]; + // if (currentUri) { + // const assetId = assetUriMapRef.current.get(currentUri); + // if (assetId) { + // if (assetId !== currentlyPlayingAssetId) { + // console.log( + // `๐ŸŽต Highlighting asset ${assetId.slice(0, 8)} (segment ${i + 1}/${uris.length}, position: ${Math.round(position)}ms in range [${Math.round(segmentStart)}-${Math.round(cumulativeDuration)}]ms)` + // ); + // setCurrentlyPlayingAssetId(assetId); + // } + // } else { + // console.warn(`โš ๏ธ No asset ID found for URI at index ${i}`); + // } + // } + // break; + // } + // } + // }; + + // // Check immediately and then periodically while playing + // checkCurrentAsset(); + // const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms + // return () => clearInterval(interval); + // }, [ + // audioContext.isPlaying, + // audioContext.currentAudioId, + // audioContext.position, + // audioContext.duration, + // currentlyPlayingAssetId + // ]); + + // Handle play all - plays all assets sequentially starting from selected asset + const handlePlayAll = React.useCallback(async () => { + currentPlayingAssetIdRef.current = null; + try { + // Check if already playing - toggle to stop + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + try { + await currentPlayAllSoundRef.current.stopAsync(); + await currentPlayAllSoundRef.current.unloadAsync(); + currentPlayAllSoundRef.current = null; + } catch (error) { + console.error('Error stopping sound:', error); + } + } + currentPlayingAssetIdRef.current = null; + setCurrentlyPlayingAssetId(null); + console.log('โธ๏ธ Stopped play all'); + return; + } - const position = audioContext.position; // Position in milliseconds + // Determine which assets to process based on selection state + let assetsToProcess: AssetQuestLink[]; - // If we don't have durations yet, use simple percentage-based approach - if (durations.length === 0 || durations.every((d) => d === 0)) { - const duration = audioContext.duration; - if (duration === 0) { + // If there's a selected asset, start from it + if (selectedAssetIds.size > 0) { + const firstSelectedIndex = assets.findIndex((a) => + selectedAssetIds.has(a.id) + ); + if (firstSelectedIndex >= 0) { + assetsToProcess = assets.slice(firstSelectedIndex); console.log( - `โธ๏ธ No duration available yet (position: ${position}ms, duration: ${duration}ms)` + `๐ŸŽต Starting from first selected asset at index ${firstSelectedIndex}` ); - return; + } else { + assetsToProcess = assets; } + } else { + // No selection, play all + assetsToProcess = assets; + } - // Fallback: use percentage-based calculation - const positionPercent = position / duration; - const uriIndex = Math.min( - Math.floor(positionPercent * uris.length), - uris.length - 1 - ); - - const currentUri = uris[uriIndex]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - if (assetId !== currentlyPlayingAssetId) { - console.log( - `๐ŸŽต [Fallback] Highlighting asset ${assetId.slice(0, 8)} (segment ${uriIndex + 1}/${uris.length}, ${Math.round(positionPercent * 100)}%)` - ); - setCurrentlyPlayingAssetId(assetId); - } - } else { - console.warn(`โš ๏ธ No asset ID found for URI at index ${uriIndex}`); - } - } + if (assetsToProcess.length === 0) { + console.warn('โš ๏ธ No assets to play'); return; } - // Calculate which segment we're in based on cumulative durations - let cumulativeDuration = 0; - for (let i = 0; i < uris.length; i++) { - const segmentDuration = durations[i] || 0; - const segmentStart = cumulativeDuration; - cumulativeDuration += segmentDuration; - - // If position is within this segment's range - // Use <= for the last segment to catch it even if position is slightly off - if ( - (position >= segmentStart && position <= cumulativeDuration) || - (i === uris.length - 1 && position >= segmentStart) - ) { - const currentUri = uris[i]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - if (assetId !== currentlyPlayingAssetId) { - console.log( - `๐ŸŽต Highlighting asset ${assetId.slice(0, 8)} (segment ${i + 1}/${uris.length}, position: ${Math.round(position)}ms in range [${Math.round(segmentStart)}-${Math.round(cumulativeDuration)}]ms)` - ); - setCurrentlyPlayingAssetId(assetId); - } - } else { - console.warn(`โš ๏ธ No asset ID found for URI at index ${i}`); - } - } - break; - } - } - }; + console.log( + `๐ŸŽต Starting play all from ${assetsToProcess.length} assets...` + ); - // Check immediately and then periodically while playing - checkCurrentAsset(); - const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms - return () => clearInterval(interval); - }, [ - audioContext.isPlaying, - audioContext.currentAudioId, - audioContext.position, - audioContext.duration, - currentlyPlayingAssetId - ]); - - // Handle play all assets - const handlePlayAllAssets = React.useCallback(async () => { - try { - const isPlayingAll = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + // Mark as running + isPlayAllRunningRef.current = true; + setIsPlayAllRunning(true); - if (isPlayingAll) { - await audioContext.stopCurrentSound(); - setCurrentlyPlayingAssetId(null); - assetUriMapRef.current.clear(); - assetOrderRef.current = []; - uriOrderRef.current = []; - segmentDurationsRef.current = []; - } else { - if (assets.length === 0) { - console.warn('โš ๏ธ No assets to play'); + // Build playlist: Array<{assetId, uris}> + const playlist: { assetId: string; uris: string[] }[] = []; + + for (const asset of assetsToProcess) { + // Check if cancelled + if (!isPlayAllRunningRef.current) { + console.log('โธ๏ธ Play all cancelled during playlist build'); return; } - // Collect all URIs from all assets in order, tracking which asset each URI belongs to - const allUris: string[] = []; - assetUriMapRef.current.clear(); - assetOrderRef.current = []; - uriOrderRef.current = []; - segmentDurationsRef.current = []; - - for (const asset of assets) { - const uris = await getAssetAudioUris(asset.id); - if (uris.length > 0) { - assetOrderRef.current.push(asset.id); - for (const uri of uris) { - allUris.push(uri); - uriOrderRef.current.push(uri); - // Map each URI to its asset ID - assetUriMapRef.current.set(uri, asset.id); - } - } + // Get URIs for this asset + const uris = await getAssetAudioUris(asset.id); + if (uris.length > 0) { + playlist.push({ assetId: asset.id, uris }); } + } - if (allUris.length === 0) { - console.error('โŒ No audio URIs found for any assets'); + if (playlist.length === 0) { + console.error('โŒ No audio URIs found for any assets'); + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + return; + } + + console.log( + `โ–ถ๏ธ Playing ${playlist.reduce((sum, p) => sum + p.uris.length, 0)} audio segments from ${playlist.length} assets` + ); + + // Play each asset sequentially with direct linking + for (let i = 0; i < playlist.length; i++) { + // Check if cancelled + if (!isPlayAllRunningRef.current) { + console.log('โธ๏ธ Play all cancelled'); + currentPlayingAssetIdRef.current = null; + setCurrentlyPlayingAssetId(null); return; } + const item = playlist[i]!; + + // HIGHLIGHT THIS ASSET - direct link! + currentPlayingAssetIdRef.current = item.assetId; + setCurrentlyPlayingAssetId(item.assetId); console.log( - `โ–ถ๏ธ Playing ${allUris.length} audio segments from ${assets.length} assets` + `โ–ถ๏ธ [${i + 1}/${playlist.length}] Playing asset ${item.assetId.slice(0, 8)} (assetId: ${currentPlayingAssetIdRef.current}, ${item.uris.length} segments)` ); - // Set the first asset as currently playing - // Note: Duration preloading is handled by AudioContext.playSoundSequence - if (assetOrderRef.current.length > 0) { - setCurrentlyPlayingAssetId(assetOrderRef.current[0] || null); - } + // Play all URIs for this asset sequentially + for (const uri of item.uris) { + // Check if cancelled + if (!isPlayAllRunningRef.current) { + currentPlayingAssetIdRef.current = null; + setCurrentlyPlayingAssetId(null); + return; + } - await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); + // Play this URI and wait for it to finish + await new Promise((resolve) => { + // Create and play the sound + Audio.Sound.createAsync({ uri }, { shouldPlay: true }) + .then(({ sound }) => { + // Store reference for immediate cancellation + currentPlayAllSoundRef.current = sound; + + // Set up listener for when sound finishes + sound.setOnPlaybackStatusUpdate((status) => { + if (!status.isLoaded) return; + + if (status.didJustFinish) { + currentPlayAllSoundRef.current = null; + void sound.unloadAsync().then(() => { + resolve(); + }); + } + }); + }) + .catch((error) => { + console.error('Failed to play audio:', error); + currentPlayAllSoundRef.current = null; + resolve(); // Continue to next even on error + }); + }); + } } + + // Finished playing all + console.log('โœ… Finished playing all assets'); + currentPlayingAssetIdRef.current = null; + setCurrentlyPlayingAssetId(null); + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + currentPlayAllSoundRef.current = null; } catch (error) { - console.error('โŒ Failed to play all assets:', error); + console.error('โŒ Error playing all assets:', error); setCurrentlyPlayingAssetId(null); - assetUriMapRef.current.clear(); - assetOrderRef.current = []; - uriOrderRef.current = []; - segmentDurationsRef.current = []; + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + currentPlayAllSoundRef.current = null; } - }, [audioContext, getAssetAudioUris, assets]); + }, [assets, getAssetAudioUris, selectedAssetIds]); // Handle publish button press with useMutation const { mutate: publishQuest, isPending: isPublishing } = useMutation({ @@ -1014,42 +1144,79 @@ export default function NextGenAssetsView() { } }; + // Handle going to recording - stops any playing audio first + const handleGoToRecording = React.useCallback(async () => { + // Stop PlayAll if running + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + try { + await currentPlayAllSoundRef.current.stopAsync(); + await currentPlayAllSoundRef.current.unloadAsync(); + currentPlayAllSoundRef.current = null; + } catch (error) { + console.error('Error stopping sound:', error); + } + } + + currentPlayingAssetIdRef.current = null; + setCurrentlyPlayingAssetId(null); + } + + // Stop any other audio from audioContext + if (audioContext.isPlaying) { + await audioContext.stopCurrentSound(); + } + + // Now show recording + setShowRecording(true); + }, [audioContext]); + // Cleanup effect: Clear all refs and stop audio when component unmounts // This prevents memory leaks when navigating away from the assets view React.useEffect(() => { // Capture refs in variables to avoid stale closure warnings - const assetUriMap = assetUriMapRef.current; - const assetOrder = assetOrderRef.current; - const uriOrder = uriOrderRef.current; - const segmentDurations = segmentDurationsRef.current; const timeoutIds = timeoutIdsRef.current; - // Store reference to audioContext methods - access current value in cleanup - const audioContextRef = audioContext; return () => { - // Stop audio playback if playing (check current state, not captured state) - if (audioContextRef.isPlaying) { - void audioContextRef.stopCurrentSound(); + // Stop audio playback if playing (access via ref for latest state) + if (audioContextRef.current.isPlaying) { + void audioContextRef.current.stopCurrentSound(); } - // Clear all refs to free memory - assetUriMap.clear(); - assetOrder.length = 0; - uriOrder.length = 0; - segmentDurations.length = 0; + // Stop PlayAll if running + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + void currentPlayAllSoundRef.current + .stopAsync() + .then(() => { + void currentPlayAllSoundRef.current?.unloadAsync(); + currentPlayAllSoundRef.current = null; + }) + .catch(() => { + // Ignore errors during cleanup + currentPlayAllSoundRef.current = null; + }); + } + } // Clear all pending timeouts timeoutIds.forEach((id) => clearTimeout(id)); timeoutIds.clear(); // Reset state + currentPlayingAssetIdRef.current = null; setCurrentlyPlayingAssetId(null); + setIsPlayAllRunning(false); console.log('๐Ÿงน Cleaned up NextGenAssetsView on unmount'); }; - // Empty dependency array - this effect should only run on mount/unmount - // We access audioContext directly in cleanup to get the latest state - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!currentQuestId) { @@ -1075,9 +1242,6 @@ export default function NextGenAssetsView() { ); } - // Check if quest is published (source is 'synced') - const isPublished = selectedQuest?.source === 'synced'; - // Get project name for PrivateAccessGate // Note: queriedProjectData doesn't include name, so we only use currentProjectData const projectName = currentProjectData?.name || ''; @@ -1111,23 +1275,21 @@ export default function NextGenAssetsView() { - {assets.length > 0 && enablePlayAll && ( - + {assets.length > 0 && ( + <> + + )} @@ -1232,7 +1394,7 @@ export default function NextGenAssetsView() { variant="outline" size="icon" className="border-[1.5px] border-primary" - onPress={() => setShowRecording(true)} + onPress={() => void handleGoToRecording()} > @@ -1298,12 +1460,12 @@ export default function NextGenAssetsView() { item.id} - extraData={currentlyPlayingAssetId} + extraData={[currentlyPlayingAssetId, selectedAssetIds]} + recycleItems renderItem={({ item }) => renderItem({ item, isPublished })} onEndReached={onEndReached} onEndReachedThreshold={0.5} estimatedItemSize={120} - recycleItems contentContainerStyle={{ gap: 8, paddingBottom: !isPublished ? 100 : 24 @@ -1365,7 +1527,7 @@ export default function NextGenAssetsView() { variant="destructive" size="lg" className="w-full" - onPress={() => setShowRecording(true)} + onPress={() => void handleGoToRecording()} > state.setVadSilenceDuration ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const vadMinSegmentLength = useLocalStore( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return (state) => state.vadMinSegmentLength ); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const setVadMinSegmentLength = useLocalStore( - // eslint-disable-next-line @typescript-eslint/no-unsafe-return (state) => state.setVadMinSegmentLength ); const vadDisplayMode = useLocalStore((state) => state.vadDisplayMode); const setVadDisplayMode = useLocalStore((state) => state.setVadDisplayMode); - const enablePlayAll = useLocalStore((state) => state.enablePlayAll); const [showVADSettings, setShowVADSettings] = React.useState(false); const [autoCalibrateOnOpen, setAutoCalibrateOnOpen] = React.useState(false); @@ -292,14 +288,12 @@ const BibleRecordingView = ({ const [currentlyPlayingAssetId, setCurrentlyPlayingAssetId] = React.useState< string | null >(null); - const assetUriMapRef = React.useRef>(new Map()); // URI -> assetId - const segmentDurationsRef = React.useRef([]); // Duration of each URI segment in ms - // Track segment ranges for each asset (start position, end position, duration) - const assetSegmentRangesRef = React.useRef< - Map - >(new Map()); - // Track last scrolled asset to avoid scrolling to the same asset multiple times - const lastScrolledAssetIdRef = React.useRef(null); + // Track if PlayAll is running (for button icon state) + const [isPlayAllRunning, setIsPlayAllRunning] = React.useState(false); + // Ref to track if handlePlayAll is running (for cancellation) + const isPlayAllRunningRef = React.useRef(false); + // Ref to track current playing sound for immediate cancellation + const currentPlayAllSoundRef = React.useRef(null); // Track setTimeout IDs for cleanup const timeoutIdsRef = React.useRef>>( @@ -309,38 +303,11 @@ const BibleRecordingView = ({ // Track AbortController for batch loading cleanup const batchLoadingControllerRef = React.useRef(null); - // Create SharedValues for each asset's progress (0-100 percentage) - // We need to create them at the top level, so we'll create a pool and map them - // Store the mapping in a ref that gets updated when assets change - const assetProgressSharedMapRef = React.useRef< - Map>> - >(new Map()); - - // Create SharedValues for assets (max 100 assets supported) - // We create a pool and reuse them - must create at top level (hooks rule) - const progressPool0 = useSharedValue(0); - const progressPool1 = useSharedValue(0); - const progressPool2 = useSharedValue(0); - const progressPool3 = useSharedValue(0); - const progressPool4 = useSharedValue(0); - const progressPool5 = useSharedValue(0); - const progressPool6 = useSharedValue(0); - const progressPool7 = useSharedValue(0); - const progressPool8 = useSharedValue(0); - const progressPool9 = useSharedValue(0); - // Create more if needed (extend this pattern or use a different approach) - const progressPool = React.useRef([ - progressPool0, - progressPool1, - progressPool2, - progressPool3, - progressPool4, - progressPool5, - progressPool6, - progressPool7, - progressPool8, - progressPool9 - ]).current; + // Ref to hold latest audioContext for cleanup (avoids stale closure) + const audioContextCurrentRef = React.useRef(audioContext); + React.useEffect(() => { + audioContextCurrentRef.current = audioContext; + }, [audioContext]); // Insertion wheel state const [insertionIndex, setInsertionIndex] = React.useState(0); @@ -359,20 +326,15 @@ const BibleRecordingView = ({ // Persist initial props in refs - these should NOT change during the recording session // even when invalidateQueries causes re-renders. We capture them once on mount. - const persistedNextVerseRef = React.useRef(nextVerse); - const persistedLimitVerseRef = React.useRef(limitVerse); - const persistedVerseRef = React.useRef(_verse); - - // Log initial values on mount - React.useEffect(() => { - console.log( - `๐Ÿ“Œ Persisted initial props | nextVerse: ${nextVerse} | limitVerse: ${limitVerse} | _verse: ${_verse?.from}-${_verse?.to}` - ); - persistedNextVerseRef.current = nextVerse; - persistedLimitVerseRef.current = limitVerse; - persistedVerseRef.current = _verse; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only run on mount - these values are persisted for the session + // Using useState with initializer function ensures this only runs once + const [persistedProps] = React.useState(() => ({ + nextVerse, + limitVerse, + verse: _verse + })); + const persistedNextVerseRef = React.useRef(persistedProps.nextVerse); + const persistedLimitVerseRef = React.useRef(persistedProps.limitVerse); + const persistedVerseRef = React.useRef(persistedProps.verse); // Debounced insertion index to prevent button flickering when scrolling fast const [debouncedIsAtEnd, setDebouncedIsAtEnd] = React.useState(false); @@ -774,31 +736,6 @@ const BibleRecordingView = ({ } }, [verseToAdd, isVADLocked, addVersePill]); - // Map assets to SharedValues from the pool (after assets is declared) - const assetIdsKey = React.useMemo( - () => assets.map((a) => a.id).join(','), - [assets] - ); - React.useEffect(() => { - if (assets.length === 0) { - assetProgressSharedMapRef.current.clear(); - return; - } - - const map = assetProgressSharedMapRef.current; - map.clear(); - - // Assign SharedValues from pool to assets - for (let i = 0; i < Math.min(assets.length, progressPool.length); i++) { - const asset = assets[i]; - if (asset) { - // Reset the SharedValue - progressPool[i]!.value = 0; - map.set(asset.id, progressPool[i]!); - } - } - }, [assetIdsKey, assets, progressPool]); - // Stable item list that only updates when content actually changes // We intentionally use assetContentKey instead of allItems to prevent re-renders // when items array reference changes but content is identical @@ -1188,9 +1125,6 @@ const BibleRecordingView = ({ [] ); - // Special audio ID for "play all" mode - const PLAY_ALL_AUDIO_ID = 'play-all-assets'; - // Handle asset playback const handlePlayAsset = React.useCallback( async (assetId: string) => { @@ -1225,317 +1159,143 @@ const BibleRecordingView = ({ [audioContext, getAssetAudioUris] ); - // Track currently playing asset based on audio position during play-all - React.useEffect(() => { - if ( - !audioContext.isPlaying || - audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID - ) { - setCurrentlyPlayingAssetId(null); - return; - } - - // Calculate which asset is playing based on cumulative position - // Also update progress for each asset based on its segment range - const checkCurrentAsset = () => { - const uris = Array.from(assetUriMapRef.current.keys()); - const durations = segmentDurationsRef.current; - const ranges = assetSegmentRangesRef.current; - - if (uris.length === 0) return; - - const position = audioContext.position; // Position in milliseconds - - // Update progress for each asset based on its segment range - const progressMap = assetProgressSharedMapRef.current; - for (const [assetId, range] of ranges.entries()) { - const progressShared = progressMap.get(assetId); - if (!progressShared) { - debugLog( - `โš ๏ธ No progress SharedValue found for asset ${assetId.slice(0, 8)}` - ); - continue; - } - - if (position < range.startMs) { - // Before this asset's segments - no progress - progressShared.value = 0; - } else if (position >= range.endMs) { - // After this asset's segments - fully complete - progressShared.value = 100; - } else { - // Within this asset's segments - calculate progress - const assetPosition = position - range.startMs; - const progressPercent = (assetPosition / range.durationMs) * 100; - const clampedProgress = Math.min(100, Math.max(0, progressPercent)); - progressShared.value = clampedProgress; - debugLog( - `๐Ÿ“Š Asset ${assetId.slice(0, 8)} progress: ${Math.round(clampedProgress)}% (position: ${Math.round(position)}ms, range: [${Math.round(range.startMs)}-${Math.round(range.endMs)}]ms)` - ); - } - } - - // Find which asset is currently playing - let newPlayingAssetId: string | null = null; - - // If we don't have durations yet, use simple percentage-based approach - if (durations.length === 0 || durations.every((d) => d === 0)) { - const duration = audioContext.duration; - if (duration === 0) return; - - // Fallback: use percentage-based calculation - const positionPercent = position / duration; - const uriIndex = Math.min( - Math.floor(positionPercent * uris.length), - uris.length - 1 - ); + // Handle play all assets - optimized version with direct control + const handlePlayAll = React.useCallback(async () => { + try { + // Check if already playing - toggle to stop + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); - const currentUri = uris[uriIndex]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - newPlayingAssetId = assetId; - } - } - } else { - // Calculate which segment we're in based on cumulative durations - let cumulativeDuration = 0; - for (let i = 0; i < uris.length; i++) { - const segmentDuration = durations[i] || 0; - const segmentStart = cumulativeDuration; - cumulativeDuration += segmentDuration; - - // If position is within this segment's range - if ( - (position >= segmentStart && position <= cumulativeDuration) || - (i === uris.length - 1 && position >= segmentStart) - ) { - const currentUri = uris[i]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - newPlayingAssetId = assetId; - } - } - break; + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + try { + await currentPlayAllSoundRef.current.stopAsync(); + await currentPlayAllSoundRef.current.unloadAsync(); + currentPlayAllSoundRef.current = null; + } catch (error) { + console.error('Error stopping sound:', error); } } - } - // Update currently playing asset ID and scroll to it - if (newPlayingAssetId) { - setCurrentlyPlayingAssetId((prev) => { - if (newPlayingAssetId !== prev) { - debugLog( - `๐ŸŽต Highlighting asset ${newPlayingAssetId.slice(0, 8)} (was: ${prev?.slice(0, 8) ?? 'none'})` - ); - - // Scroll to the currently playing asset (only if it changed) - if ( - wheelRef.current && - newPlayingAssetId !== lastScrolledAssetIdRef.current - ) { - // Find the index of the asset in the assets array - const assetIndex = assets.findIndex( - (a) => a.id === newPlayingAssetId - ); - if (assetIndex >= 0) { - debugLog( - `๐Ÿ“œ Scrolling to asset at index ${assetIndex} (asset ${newPlayingAssetId.slice(0, 8)})` - ); - // Scroll the item to the top of the wheel - // scrollItemToTop adds 1 internally, so subtract 1 to get correct position - wheelRef.current.scrollItemToTop(assetIndex - 1, true); - lastScrolledAssetIdRef.current = newPlayingAssetId; - } else { - debugLog( - `โš ๏ธ Could not find asset ${newPlayingAssetId.slice(0, 8)} in assets array` - ); - } - } - - return newPlayingAssetId; - } - return prev; - }); + setCurrentlyPlayingAssetId(null); + debugLog('โธ๏ธ Stopped play all'); + return; } - }; - // Check immediately and then periodically while playing - checkCurrentAsset(); - const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms - return () => clearInterval(interval); - // Note: We intentionally read audioContext.position and audioContext.duration inside the callback - // rather than including them as dependencies, because they change frequently (every ~200ms) - // and we don't want to re-run the effect that often. The interval handles the updates. - // assetProgressSharedMap is a ref, so we access it directly in the callback. - // assets is included to find the asset index for scrolling. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [audioContext.isPlaying, audioContext.currentAudioId, assets]); - - // Handle play all assets - const handlePlayAllAssets = React.useCallback(async () => { - try { - const isPlayingAll = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + if (itemsForWheel.length === 0) { + console.warn('โš ๏ธ No items to play'); + return; + } - if (isPlayingAll) { - debugLog('โธ๏ธ Stopping play all'); - await audioContext.stopCurrentSound(); - setCurrentlyPlayingAssetId(null); - assetUriMapRef.current.clear(); - segmentDurationsRef.current = []; - assetSegmentRangesRef.current.clear(); - lastScrolledAssetIdRef.current = null; - // Reset all asset progress - for (const progressShared of assetProgressSharedMapRef.current.values()) { - progressShared.value = 0; - } - } else { - debugLog('โ–ถ๏ธ Playing all assets'); - if (assets.length === 0) { - console.warn('โš ๏ธ No assets to play'); + // Mark as running + isPlayAllRunningRef.current = true; + setIsPlayAllRunning(true); + + debugLog(`๐ŸŽต Starting play all from wheel position ${insertionIndex}`); + + let assetsPlayed = 0; + + // Iterate directly through itemsForWheel starting from insertionIndex + for ( + let wheelIndex = insertionIndex; + wheelIndex < itemsForWheel.length; + wheelIndex++ + ) { + // Check if cancelled + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!isPlayAllRunningRef.current) { + debugLog('โธ๏ธ Play all cancelled'); + setCurrentlyPlayingAssetId(null); return; } - // Collect all URIs from all assets in order, tracking which asset each URI belongs to - const allUris: string[] = []; - assetUriMapRef.current.clear(); - segmentDurationsRef.current = []; - - for (const asset of assets) { - const uris = await getAssetAudioUris(asset.id); - for (const uri of uris) { - allUris.push(uri); - // Map each URI to its asset ID - assetUriMapRef.current.set(uri, asset.id); - } - } + const item = itemsForWheel[wheelIndex]; - if (allUris.length === 0) { - console.error('โŒ No audio URIs found for any assets'); - return; + // Skip if no item or if it's a pill + if (!item || isPill(item)) { + debugLog(`โญ๏ธ Position ${wheelIndex}: skipping pill`); + continue; } - debugLog( - `โ–ถ๏ธ Playing ${allUris.length} audio segments from ${assets.length} assets` - ); + // It's an asset - play it + const asset = item; - // Preload durations for accurate highlighting and calculate asset segment ranges - try { - const durations: number[] = []; - for (const uri of allUris) { - try { - const { sound } = await Audio.Sound.createAsync({ uri }); - const status = await sound.getStatusAsync(); - await sound.unloadAsync(); - durations.push( - status.isLoaded ? (status.durationMillis ?? 0) : 0 - ); - } catch (error) { - debugLog( - `Failed to get duration for ${uri.slice(0, 30)}:`, - error - ); - durations.push(0); - } - } - segmentDurationsRef.current = durations; + // Get URIs for this asset + const uris = await getAssetAudioUris(asset.id); + if (uris.length === 0) { debugLog( - `๐Ÿ“Š Loaded durations for ${durations.length} segments:`, - durations.map((d) => Math.round(d / 1000)).join('s, ') + 's' + `โš ๏ธ Position ${wheelIndex}: no URIs for asset ${asset.name}` ); + continue; + } - // Calculate segment ranges for each asset - assetSegmentRangesRef.current.clear(); - let cumulativeStart = 0; - for (const asset of assets) { - const assetUris = allUris.filter( - (uri) => assetUriMapRef.current.get(uri) === asset.id - ); - if (assetUris.length === 0) continue; - - // Find the indices of this asset's URIs in the allUris array - const assetUriIndices: number[] = []; - for (let i = 0; i < allUris.length; i++) { - const uri = allUris[i]; - if (uri && assetUriMapRef.current.get(uri) === asset.id) { - assetUriIndices.push(i); - } - } - - // Calculate total duration for this asset's segments - const assetDuration = assetUriIndices.reduce( - (sum, idx) => sum + (durations[idx] || 0), - 0 - ); - - const startMs = cumulativeStart; - const endMs = cumulativeStart + assetDuration; - - assetSegmentRangesRef.current.set(asset.id, { - startMs, - endMs, - durationMs: assetDuration - }); + // HIGHLIGHT THIS ASSET + setCurrentlyPlayingAssetId(asset.id); - // Reset progress for this asset - const progressShared = assetProgressSharedMapRef.current.get( - asset.id - ); - if (progressShared) { - progressShared.value = 0; - debugLog(`๐Ÿ”„ Reset progress for asset ${asset.id.slice(0, 8)}`); - } else { - debugLog( - `โš ๏ธ No progress SharedValue found for asset ${asset.id.slice(0, 8)} when setting up ranges` - ); - } + // Scroll to this position in the wheel (wheelIndex is the direct position) + if (wheelRef.current) { + wheelRef.current.scrollItemToTop(wheelIndex - 1, true); + } - debugLog( - `๐Ÿ“Š Asset ${asset.id.slice(0, 8)} segments: ${assetUriIndices.length} segments, ${Math.round(assetDuration / 1000)}s total, range [${Math.round(startMs)}-${Math.round(endMs)}]ms` - ); + assetsPlayed++; + debugLog( + `โ–ถ๏ธ Position ${wheelIndex}: Playing asset ${asset.name} (${uris.length} segments)` + ); - cumulativeStart = endMs; + // Play all URIs for this asset sequentially + for (const uri of uris) { + // Check if cancelled + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!isPlayAllRunningRef.current) { + setCurrentlyPlayingAssetId(null); + return; } - } catch (error) { - debugLog('Failed to preload durations:', error); - // Continue anyway - will use percentage-based fallback - } - // Set the first asset as currently playing and scroll to it - if (assets.length > 0 && assets[0]) { - const firstAssetId = assets[0].id; - setCurrentlyPlayingAssetId(firstAssetId); - lastScrolledAssetIdRef.current = null; // Reset to allow immediate scroll + // Play this URI and wait for it to finish + await new Promise((resolve) => { + Audio.Sound.createAsync({ uri }, { shouldPlay: true }) + .then(({ sound }) => { + currentPlayAllSoundRef.current = sound; - // Scroll to first asset immediately - if (wheelRef.current) { - debugLog( - `๐Ÿ“œ Scrolling to first asset at index 0 (asset ${firstAssetId.slice(0, 8)})` - ); - // scrollItemToTop adds 1 internally, so subtract 1 to get correct position (0 -> -1 -> 0) - wheelRef.current.scrollItemToTop(-1, true); - lastScrolledAssetIdRef.current = firstAssetId; - } + sound.setOnPlaybackStatusUpdate((status) => { + if (!status.isLoaded) return; + + if (status.didJustFinish) { + currentPlayAllSoundRef.current = null; + void sound.unloadAsync().then(() => { + resolve(); + }); + } + }); + }) + .catch((error) => { + console.error('Failed to play audio:', error); + currentPlayAllSoundRef.current = null; + resolve(); + }); + }); } + } - await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); + if (assetsPlayed === 0) { + console.warn('โš ๏ธ No assets found to play from current position'); } + + // Done playing all + debugLog('โœ… Finished playing all assets'); + setCurrentlyPlayingAssetId(null); + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + currentPlayAllSoundRef.current = null; } catch (error) { console.error('โŒ Failed to play all assets:', error); setCurrentlyPlayingAssetId(null); - assetUriMapRef.current.clear(); - segmentDurationsRef.current = []; - assetSegmentRangesRef.current.clear(); - lastScrolledAssetIdRef.current = null; - // Reset all asset progress - for (const progressShared of assetProgressSharedMapRef.current.values()) { - progressShared.value = 0; - } + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + currentPlayAllSoundRef.current = null; } - }, [audioContext, getAssetAudioUris, assets]); + }, [getAssetAudioUris, insertionIndex, itemsForWheel]); // ============================================================================ // RECORDING HANDLERS @@ -2572,28 +2332,36 @@ const BibleRecordingView = ({ // This prevents memory leaks when navigating away from the recording view React.useEffect(() => { // Capture refs in variables to avoid stale closure warnings - const assetUriMap = assetUriMapRef.current; - const segmentDurations = segmentDurationsRef.current; - const assetSegmentRanges = assetSegmentRangesRef.current; - const assetProgressSharedMap = assetProgressSharedMapRef.current; const pendingAssetNames = pendingAssetNamesRef.current; const loadedAssetIds = loadedAssetIdsRef.current; const timeoutIds = timeoutIdsRef.current; - // Store reference to audioContext - access current value in cleanup - const audioContextRef = audioContext; return () => { - // Stop audio playback if playing (check current state, not captured state) - if (audioContextRef.isPlaying) { - void audioContextRef.stopCurrentSound(); + // Stop audio playback if playing (access via ref for latest state) + if (audioContextCurrentRef.current.isPlaying) { + void audioContextCurrentRef.current.stopCurrentSound(); + } + + // Stop PlayAll if running + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + void currentPlayAllSoundRef.current + .stopAsync() + .then(() => { + void currentPlayAllSoundRef.current?.unloadAsync(); + currentPlayAllSoundRef.current = null; + }) + .catch(() => { + // Ignore errors during cleanup + currentPlayAllSoundRef.current = null; + }); + } } // Clear all refs to free memory - assetUriMap.clear(); - segmentDurations.length = 0; - assetSegmentRanges.clear(); - assetProgressSharedMap.clear(); - lastScrolledAssetIdRef.current = null; pendingAssetNames.clear(); loadedAssetIds.clear(); @@ -2611,12 +2379,10 @@ const BibleRecordingView = ({ setAssetSegmentCounts(new Map()); setAssetDurations(new Map()); setCurrentlyPlayingAssetId(null); + setIsPlayAllRunning(false); - debugLog('๐Ÿงน Cleaned up RecordingViewSimplified on unmount'); + debugLog('๐Ÿงน Cleaned up BibleRecordingView on unmount'); }; - // Empty dependency array - this effect should only run on mount/unmount - // We access audioContext directly in cleanup to get the latest state - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ============================================================================ @@ -2719,9 +2485,7 @@ const BibleRecordingView = ({ const isThisAssetPlayingIndividually = audioContext.isPlaying && audioContext.currentAudioId === item.id; const isThisAssetPlayingInPlayAll = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && - currentlyPlayingAssetId === item.id; + isPlayAllRunning && currentlyPlayingAssetId === item.id; const isThisAssetPlaying = isThisAssetPlayingIndividually || isThisAssetPlayingInPlayAll; const isSelected = selectedAssetIds.has(item.id); @@ -2737,13 +2501,6 @@ const BibleRecordingView = ({ // Duration from lazy-loaded metadata const duration = item.duration; - // Get custom progress for play-all mode - const customProgress = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID - ? assetProgressSharedMapRef.current.get(item.id) - : undefined; - // Get stable callbacks from Map (avoids creating new functions) const callbacks = assetCallbacksMap.get(item.id); @@ -2764,7 +2521,6 @@ const BibleRecordingView = ({ duration={duration} canMergeDown={canMergeDown} segmentCount={item.segmentCount} - customProgress={customProgress} onPress={callbacks.onPress} onLongPress={callbacks.onLongPress} onPlay={callbacks.onPlay} @@ -2778,6 +2534,7 @@ const BibleRecordingView = ({ formatVerseRange, audioContext.isPlaying, audioContext.currentAudioId, + isPlayAllRunning, currentlyPlayingAssetId, selectedAssetIds, isSelectionMode, @@ -2910,27 +2667,23 @@ const BibleRecordingView = ({ */} - - {assets.length} {t('assets').toLowerCase()} - - {assets.length > 0 && enablePlayAll && ( + {assets.length > 0 && ( )} + + {assets.length} {t('assets').toLowerCase()} + state.vadDisplayMode); const setVadDisplayMode = useLocalStore((state) => state.setVadDisplayMode); - const enablePlayAll = useLocalStore((state) => state.enablePlayAll); + const [showVADSettings, setShowVADSettings] = React.useState(false); const [autoCalibrateOnOpen, setAutoCalibrateOnOpen] = React.useState(false); @@ -169,6 +169,17 @@ const RecordingViewSimplified = ({ // Track last scrolled asset to avoid scrolling to the same asset multiple times const lastScrolledAssetIdRef = React.useRef(null); + // New PlayAll state (starts from insertionIndex) + const [isPlayAllRunning, setIsPlayAllRunning] = React.useState(false); + const isPlayAllRunningRef = React.useRef(false); + const currentPlayAllSoundRef = React.useRef(null); + + // Ref to hold latest audioContext for cleanup (avoids stale closure) + const audioContextCurrentRef = React.useRef(audioContext); + React.useEffect(() => { + audioContextCurrentRef.current = audioContext; + }, [audioContext]); + // Track setTimeout IDs for cleanup const timeoutIdsRef = React.useRef>>( new Set() @@ -177,38 +188,8 @@ const RecordingViewSimplified = ({ // Track AbortController for batch loading cleanup const batchLoadingControllerRef = React.useRef(null); - // Create SharedValues for each asset's progress (0-100 percentage) - // We need to create them at the top level, so we'll create a pool and map them - // Store the mapping in a ref that gets updated when assets change - const assetProgressSharedMapRef = React.useRef< - Map>> - >(new Map()); - - // Create SharedValues for assets (max 100 assets supported) - // We create a pool and reuse them - must create at top level (hooks rule) - const progressPool0 = useSharedValue(0); - const progressPool1 = useSharedValue(0); - const progressPool2 = useSharedValue(0); - const progressPool3 = useSharedValue(0); - const progressPool4 = useSharedValue(0); - const progressPool5 = useSharedValue(0); - const progressPool6 = useSharedValue(0); - const progressPool7 = useSharedValue(0); - const progressPool8 = useSharedValue(0); - const progressPool9 = useSharedValue(0); - // Create more if needed (extend this pattern or use a different approach) - const progressPool = React.useRef([ - progressPool0, - progressPool1, - progressPool2, - progressPool3, - progressPool4, - progressPool5, - progressPool6, - progressPool7, - progressPool8, - progressPool9 - ]).current; + // Single SharedValue for play-all progress (only 1 asset plays at a time) + const playAllProgress = useSharedValue(0); // Insertion wheel state const [insertionIndex, setInsertionIndex] = React.useState(0); @@ -354,31 +335,6 @@ const RecordingViewSimplified = ({ return result; }, [rawAssets, assetSegmentCounts, assetDurations]); - // Map assets to SharedValues from the pool (after assets is declared) - const assetIdsKey = React.useMemo( - () => assets.map((a) => a.id).join(','), - [assets] - ); - React.useEffect(() => { - if (assets.length === 0) { - assetProgressSharedMapRef.current.clear(); - return; - } - - const map = assetProgressSharedMapRef.current; - map.clear(); - - // Assign SharedValues from pool to assets - for (let i = 0; i < Math.min(assets.length, progressPool.length); i++) { - const asset = assets[i]; - if (asset) { - // Reset the SharedValue - progressPool[i]!.value = 0; - map.set(asset.id, progressPool[i]!); - } - } - }, [assetIdsKey, assets, progressPool]); - // Stable asset list that only updates when content actually changes // We intentionally use assetContentKey instead of assets to prevent re-renders // when assets array reference changes but content is identical @@ -722,9 +678,6 @@ const RecordingViewSimplified = ({ [] ); - // Special audio ID for "play all" mode - const PLAY_ALL_AUDIO_ID = 'play-all-assets'; - // Handle asset playback const handlePlayAsset = React.useCallback( async (assetId: string) => { @@ -759,317 +712,174 @@ const RecordingViewSimplified = ({ [audioContext, getAssetAudioUris] ); - // Track currently playing asset based on audio position during play-all - React.useEffect(() => { - if ( - !audioContext.isPlaying || - audioContext.currentAudioId !== PLAY_ALL_AUDIO_ID - ) { - setCurrentlyPlayingAssetId(null); - return; - } + // Handle play all - plays all assets sequentially starting from insertionIndex + const handlePlayAll = React.useCallback(async () => { + try { + // Check if already playing - toggle to stop + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); - // Calculate which asset is playing based on cumulative position - // Also update progress for each asset based on its segment range - const checkCurrentAsset = () => { - const uris = Array.from(assetUriMapRef.current.keys()); - const durations = segmentDurationsRef.current; - const ranges = assetSegmentRangesRef.current; + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + try { + await currentPlayAllSoundRef.current.stopAsync(); + await currentPlayAllSoundRef.current.unloadAsync(); + currentPlayAllSoundRef.current = null; + } catch (error) { + console.error('Error stopping sound:', error); + } + } - if (uris.length === 0) return; + // Reset progress + playAllProgress.value = 0; + setCurrentlyPlayingAssetId(null); + debugLog('โธ๏ธ Stopped play all'); + return; + } - const position = audioContext.position; // Position in milliseconds + if (assets.length === 0) { + console.warn('โš ๏ธ No assets to play'); + return; + } - // Update progress for each asset based on its segment range - const progressMap = assetProgressSharedMapRef.current; - for (const [assetId, range] of ranges.entries()) { - const progressShared = progressMap.get(assetId); - if (!progressShared) { - debugLog( - `โš ๏ธ No progress SharedValue found for asset ${assetId.slice(0, 8)}` - ); - continue; - } + // Determine which assets to process starting from insertionIndex + const startIndex = Math.min(insertionIndex, assets.length - 1); + const assetsToProcess = assets.slice(startIndex); - if (position < range.startMs) { - // Before this asset's segments - no progress - progressShared.value = 0; - } else if (position >= range.endMs) { - // After this asset's segments - fully complete - progressShared.value = 100; - } else { - // Within this asset's segments - calculate progress - const assetPosition = position - range.startMs; - const progressPercent = (assetPosition / range.durationMs) * 100; - const clampedProgress = Math.min(100, Math.max(0, progressPercent)); - progressShared.value = clampedProgress; - debugLog( - `๐Ÿ“Š Asset ${assetId.slice(0, 8)} progress: ${Math.round(clampedProgress)}% (position: ${Math.round(position)}ms, range: [${Math.round(range.startMs)}-${Math.round(range.endMs)}]ms)` - ); - } + if (assetsToProcess.length === 0) { + console.warn('โš ๏ธ No assets to play from insertion index'); + return; } - // Find which asset is currently playing - let newPlayingAssetId: string | null = null; + debugLog( + `๐ŸŽต Starting play all from insertion index ${startIndex} (${assetsToProcess.length} assets)...` + ); - // If we don't have durations yet, use simple percentage-based approach - if (durations.length === 0 || durations.every((d) => d === 0)) { - const duration = audioContext.duration; - if (duration === 0) return; + // Mark as running + isPlayAllRunningRef.current = true; + setIsPlayAllRunning(true); - // Fallback: use percentage-based calculation - const positionPercent = position / duration; - const uriIndex = Math.min( - Math.floor(positionPercent * uris.length), - uris.length - 1 - ); + // Build playlist: Array<{assetId, uris}> + const playlist: { assetId: string; uris: string[] }[] = []; - const currentUri = uris[uriIndex]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - newPlayingAssetId = assetId; - } + for (const asset of assetsToProcess) { + // Check if cancelled + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!isPlayAllRunningRef.current) { + debugLog('โธ๏ธ Play all cancelled during playlist build'); + return; } - } else { - // Calculate which segment we're in based on cumulative durations - let cumulativeDuration = 0; - for (let i = 0; i < uris.length; i++) { - const segmentDuration = durations[i] || 0; - const segmentStart = cumulativeDuration; - cumulativeDuration += segmentDuration; - - // If position is within this segment's range - if ( - (position >= segmentStart && position <= cumulativeDuration) || - (i === uris.length - 1 && position >= segmentStart) - ) { - const currentUri = uris[i]; - if (currentUri) { - const assetId = assetUriMapRef.current.get(currentUri); - if (assetId) { - newPlayingAssetId = assetId; - } - } - break; - } + + // Get URIs for this asset + const uris = await getAssetAudioUris(asset.id); + if (uris.length > 0) { + playlist.push({ assetId: asset.id, uris }); } } - // Update currently playing asset ID and scroll to it - if (newPlayingAssetId) { - setCurrentlyPlayingAssetId((prev) => { - if (newPlayingAssetId !== prev) { - debugLog( - `๐ŸŽต Highlighting asset ${newPlayingAssetId.slice(0, 8)} (was: ${prev?.slice(0, 8) ?? 'none'})` - ); - - // Scroll to the currently playing asset (only if it changed) - if ( - wheelRef.current && - newPlayingAssetId !== lastScrolledAssetIdRef.current - ) { - // Find the index of the asset in the assets array - const assetIndex = assets.findIndex( - (a) => a.id === newPlayingAssetId - ); - if (assetIndex >= 0) { - debugLog( - `๐Ÿ“œ Scrolling to asset at index ${assetIndex} (asset ${newPlayingAssetId.slice(0, 8)})` - ); - // Scroll the item to the top of the wheel - // scrollItemToTop adds 1 internally, so subtract 1 to get correct position - wheelRef.current.scrollItemToTop(assetIndex - 1, true); - lastScrolledAssetIdRef.current = newPlayingAssetId; - } else { - debugLog( - `โš ๏ธ Could not find asset ${newPlayingAssetId.slice(0, 8)} in assets array` - ); - } - } - - return newPlayingAssetId; - } - return prev; - }); + if (playlist.length === 0) { + console.error('โŒ No audio URIs found for any assets'); + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + return; } - }; - // Check immediately and then periodically while playing - checkCurrentAsset(); - const interval = setInterval(checkCurrentAsset, 200); // Check every 200ms - return () => clearInterval(interval); - // Note: We intentionally read audioContext.position and audioContext.duration inside the callback - // rather than including them as dependencies, because they change frequently (every ~200ms) - // and we don't want to re-run the effect that often. The interval handles the updates. - // assetProgressSharedMap is a ref, so we access it directly in the callback. - // assets is included to find the asset index for scrolling. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [audioContext.isPlaying, audioContext.currentAudioId, assets]); - - // Handle play all assets - const handlePlayAllAssets = React.useCallback(async () => { - try { - const isPlayingAll = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID; + debugLog( + `โ–ถ๏ธ Playing ${playlist.reduce((sum, p) => sum + p.uris.length, 0)} audio segments from ${playlist.length} assets` + ); - if (isPlayingAll) { - debugLog('โธ๏ธ Stopping play all'); - await audioContext.stopCurrentSound(); - setCurrentlyPlayingAssetId(null); - assetUriMapRef.current.clear(); - segmentDurationsRef.current = []; - assetSegmentRangesRef.current.clear(); - lastScrolledAssetIdRef.current = null; - // Reset all asset progress - for (const progressShared of assetProgressSharedMapRef.current.values()) { - progressShared.value = 0; - } - } else { - debugLog('โ–ถ๏ธ Playing all assets'); - if (assets.length === 0) { - console.warn('โš ๏ธ No assets to play'); + // Play each asset sequentially + for (let i = 0; i < playlist.length; i++) { + // Check if cancelled + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!isPlayAllRunningRef.current) { + debugLog('โธ๏ธ Play all cancelled'); + setCurrentlyPlayingAssetId(null); return; } - // Collect all URIs from all assets in order, tracking which asset each URI belongs to - const allUris: string[] = []; - assetUriMapRef.current.clear(); - segmentDurationsRef.current = []; - - for (const asset of assets) { - const uris = await getAssetAudioUris(asset.id); - for (const uri of uris) { - allUris.push(uri); - // Map each URI to its asset ID - assetUriMapRef.current.set(uri, asset.id); - } - } + const item = playlist[i]!; + const actualAssetIndex = startIndex + i; - if (allUris.length === 0) { - console.error('โŒ No audio URIs found for any assets'); - return; + // HIGHLIGHT THIS ASSET + setCurrentlyPlayingAssetId(item.assetId); + + // Give React a chance to process the state update + await Promise.resolve(); + + // Scroll to this asset in the wheel + // scrollItemToTop adds 1 internally, so subtract 1 to get correct position + if (wheelRef.current) { + wheelRef.current.scrollItemToTop(actualAssetIndex - 1, true); } debugLog( - `โ–ถ๏ธ Playing ${allUris.length} audio segments from ${assets.length} assets` + `โ–ถ๏ธ [${i + 1}/${playlist.length}] Playing asset at index ${actualAssetIndex} (${item.assetId.slice(0, 8)}, ${item.uris.length} segments)` ); - // Preload durations for accurate highlighting and calculate asset segment ranges - try { - const durations: number[] = []; - for (const uri of allUris) { - try { - const { sound } = await Audio.Sound.createAsync({ uri }); - const status = await sound.getStatusAsync(); - await sound.unloadAsync(); - durations.push( - status.isLoaded ? (status.durationMillis ?? 0) : 0 - ); - } catch (error) { - debugLog( - `Failed to get duration for ${uri.slice(0, 30)}:`, - error - ); - durations.push(0); - } - } - segmentDurationsRef.current = durations; - debugLog( - `๐Ÿ“Š Loaded durations for ${durations.length} segments:`, - durations.map((d) => Math.round(d / 1000)).join('s, ') + 's' - ); - - // Calculate segment ranges for each asset - assetSegmentRangesRef.current.clear(); - let cumulativeStart = 0; - for (const asset of assets) { - const assetUris = allUris.filter( - (uri) => assetUriMapRef.current.get(uri) === asset.id - ); - if (assetUris.length === 0) continue; - - // Find the indices of this asset's URIs in the allUris array - const assetUriIndices: number[] = []; - for (let i = 0; i < allUris.length; i++) { - const uri = allUris[i]; - if (uri && assetUriMapRef.current.get(uri) === asset.id) { - assetUriIndices.push(i); - } - } - - // Calculate total duration for this asset's segments - const assetDuration = assetUriIndices.reduce( - (sum, idx) => sum + (durations[idx] || 0), - 0 - ); - - const startMs = cumulativeStart; - const endMs = cumulativeStart + assetDuration; - - assetSegmentRangesRef.current.set(asset.id, { - startMs, - endMs, - durationMs: assetDuration - }); + // Reset progress for new asset + playAllProgress.value = 0; - // Reset progress for this asset - const progressShared = assetProgressSharedMapRef.current.get( - asset.id - ); - if (progressShared) { - progressShared.value = 0; - debugLog(`๐Ÿ”„ Reset progress for asset ${asset.id.slice(0, 8)}`); - } else { - debugLog( - `โš ๏ธ No progress SharedValue found for asset ${asset.id.slice(0, 8)} when setting up ranges` - ); - } + // Play all URIs for this asset sequentially + for (const uri of item.uris) { + // Check if cancelled + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!isPlayAllRunningRef.current) { + setCurrentlyPlayingAssetId(null); + return; + } - debugLog( - `๐Ÿ“Š Asset ${asset.id.slice(0, 8)} segments: ${assetUriIndices.length} segments, ${Math.round(assetDuration / 1000)}s total, range [${Math.round(startMs)}-${Math.round(endMs)}]ms` - ); + // Play this URI and wait for it to finish + await new Promise((resolve) => { + Audio.Sound.createAsync({ uri }, { shouldPlay: true }) + .then(({ sound }) => { + currentPlayAllSoundRef.current = sound; - cumulativeStart = endMs; - } - } catch (error) { - debugLog('Failed to preload durations:', error); - // Continue anyway - will use percentage-based fallback - } + sound.setOnPlaybackStatusUpdate((status) => { + if (!status.isLoaded) return; - // Set the first asset as currently playing and scroll to it - if (assets.length > 0 && assets[0]) { - const firstAssetId = assets[0].id; - setCurrentlyPlayingAssetId(firstAssetId); - lastScrolledAssetIdRef.current = null; // Reset to allow immediate scroll + // Update progress for current asset + if (status.durationMillis) { + playAllProgress.value = + (status.positionMillis / status.durationMillis) * 100; + } - // Scroll to first asset immediately - if (wheelRef.current) { - debugLog( - `๐Ÿ“œ Scrolling to first asset at index 0 (asset ${firstAssetId.slice(0, 8)})` - ); - // scrollItemToTop adds 1 internally, so subtract 1 to get correct position (0 -> -1 -> 0) - wheelRef.current.scrollItemToTop(-1, true); - lastScrolledAssetIdRef.current = firstAssetId; - } + if (status.didJustFinish) { + // Mark as complete + playAllProgress.value = 100; + currentPlayAllSoundRef.current = null; + void sound.unloadAsync().then(() => { + resolve(); + }); + } + }); + }) + .catch((error) => { + console.error('Failed to play audio:', error); + currentPlayAllSoundRef.current = null; + resolve(); + }); + }); } - - await audioContext.playSoundSequence(allUris, PLAY_ALL_AUDIO_ID); } + + // Finished playing all - reset progress + debugLog('โœ… Finished playing all assets'); + playAllProgress.value = 0; + setCurrentlyPlayingAssetId(null); + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + currentPlayAllSoundRef.current = null; } catch (error) { - console.error('โŒ Failed to play all assets:', error); + console.error('โŒ Error playing all assets:', error); + playAllProgress.value = 0; setCurrentlyPlayingAssetId(null); - assetUriMapRef.current.clear(); - segmentDurationsRef.current = []; - assetSegmentRangesRef.current.clear(); - lastScrolledAssetIdRef.current = null; - // Reset all asset progress - for (const progressShared of assetProgressSharedMapRef.current.values()) { - progressShared.value = 0; - } + isPlayAllRunningRef.current = false; + setIsPlayAllRunning(false); + currentPlayAllSoundRef.current = null; } - }, [audioContext, getAssetAudioUris, assets]); + }, [assets, getAssetAudioUris, insertionIndex, playAllProgress]); // ============================================================================ // RECORDING HANDLERS @@ -1948,24 +1758,39 @@ const RecordingViewSimplified = ({ const assetUriMap = assetUriMapRef.current; const segmentDurations = segmentDurationsRef.current; const assetSegmentRanges = assetSegmentRangesRef.current; - const assetProgressSharedMap = assetProgressSharedMapRef.current; const pendingAssetNames = pendingAssetNamesRef.current; const loadedAssetIds = loadedAssetIdsRef.current; const timeoutIds = timeoutIdsRef.current; - // Store reference to audioContext - access current value in cleanup - const audioContextRef = audioContext; return () => { - // Stop audio playback if playing (check current state, not captured state) - if (audioContextRef.isPlaying) { - void audioContextRef.stopCurrentSound(); + // Stop audio playback if playing (access via ref for latest state) + if (audioContextCurrentRef.current.isPlaying) { + void audioContextCurrentRef.current.stopCurrentSound(); + } + + // Stop PlayAll if running + if (isPlayAllRunningRef.current) { + isPlayAllRunningRef.current = false; + + // Stop current sound immediately + if (currentPlayAllSoundRef.current) { + void currentPlayAllSoundRef.current + .stopAsync() + .then(() => { + void currentPlayAllSoundRef.current?.unloadAsync(); + currentPlayAllSoundRef.current = null; + }) + .catch(() => { + // Ignore errors during cleanup + currentPlayAllSoundRef.current = null; + }); + } } // Clear all refs to free memory assetUriMap.clear(); segmentDurations.length = 0; assetSegmentRanges.clear(); - assetProgressSharedMap.clear(); lastScrolledAssetIdRef.current = null; pendingAssetNames.clear(); loadedAssetIds.clear(); @@ -1984,12 +1809,10 @@ const RecordingViewSimplified = ({ setAssetSegmentCounts(new Map()); setAssetDurations(new Map()); setCurrentlyPlayingAssetId(null); + setIsPlayAllRunning(false); debugLog('๐Ÿงน Cleaned up RecordingViewSimplified on unmount'); }; - // Empty dependency array - this effect should only run on mount/unmount - // We access audioContext directly in cleanup to get the latest state - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ============================================================================ @@ -2024,9 +1847,7 @@ const RecordingViewSimplified = ({ const isThisAssetPlayingIndividually = audioContext.isPlaying && audioContext.currentAudioId === item.id; const isThisAssetPlayingInPlayAll = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID && - currentlyPlayingAssetId === item.id; + isPlayAllRunning && currentlyPlayingAssetId === item.id; const isThisAssetPlaying = isThisAssetPlayingIndividually || isThisAssetPlayingInPlayAll; const isSelected = selectedAssetIds.has(item.id); @@ -2036,12 +1857,10 @@ const RecordingViewSimplified = ({ // Duration from lazy-loaded metadata const duration = item.duration; - // Get custom progress for play-all mode - const customProgress = - audioContext.isPlaying && - audioContext.currentAudioId === PLAY_ALL_AUDIO_ID - ? assetProgressSharedMapRef.current.get(item.id) - : undefined; + // Get custom progress for play-all mode (only for the currently playing asset) + const customProgress = isThisAssetPlayingInPlayAll + ? playAllProgress + : undefined; return ( - {assets.length > 0 && enablePlayAll && ( - + {assets.length > 0 && ( + <> + + )} From 26778d4c97c2a12012367ee23c2613fc07126235 Mon Sep 17 00:00:00 2001 From: CalJosKos <120157396+CalJosKos@users.noreply.github.com> Date: Thu, 29 Jan 2026 10:17:58 -0800 Subject: [PATCH 5/5] bump (#709) This version will include inviting members after project creation, nepali localization, verse labeling, and play all assets from any asset --- app.config.ts | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app.config.ts b/app.config.ts index 957fbd993..a133d554b 100644 --- a/app.config.ts +++ b/app.config.ts @@ -53,7 +53,7 @@ export default ({ config }: ConfigContext): ExpoConfig => owner: 'eten-genesis', name: getAppName(appVariant), slug: 'langquest', - version: '2.0.10', + version: '2.0.11', orientation: 'portrait', icon: iconLight, scheme: getScheme(appVariant), diff --git a/package.json b/package.json index c4b2f78d1..83170639a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "langquest", "main": "expo-router/entry", - "version": "2.0.10", + "version": "2.0.11", "scripts": { "start": "expo start --dev-client", "start:prod": "expo start --dev-client --no-dev --minify",