wip(i18n): plural forms through Intl.PluralRules

Unfinished, continued in the next session: t() resolves key.one/.few/...
by the language's own plural rule, the parity check knows which forms a
language needs, 16 singular/plural key pairs are merged in every locale
and every count === 1 branch in the client uses the plural form. Still
open: the forms each locale needs for the remaining count strings, the
two new labels in every locale, and 35 tests that expect the old English
wording.
This commit is contained in:
Maurice
2026-10-07 22:57:51 +02:00
parent 588e483205
commit de128cc4ae
271 changed files with 931 additions and 651 deletions
+1 -1
View File
@@ -241,7 +241,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
{isClosed && <PollChip icon={<Lock size={9} strokeWidth={2.4} />}>{t('collab.polls.closed')}</PollChip>}
{remaining && !isClosed && <PollChip tone="warning" icon={<Clock size={9} strokeWidth={2.4} />}>{remaining}</PollChip>}
{poll.multiple_choice && <PollChip>{t('collab.polls.multiChoice')}</PollChip>}
<PollChip>{t(total === 1 ? 'collab.polls.vote' : 'collab.polls.votes', { n: total })}</PollChip>
<PollChip>{t('collab.polls.votes', { n: total })}</PollChip>
</div>
</div>
{canEdit && (
@@ -56,7 +56,7 @@ export function FilesView(S: FileManagerState) {
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
{t('files.count', { count: filteredFiles.length })}
</span>
</div>
@@ -60,13 +60,7 @@ export default function JourneyDayDawarich({
<DawarichIcon size={18} className="shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold text-content">
{/* Two keys picked by the count, the shape this codebase uses for a number
that changes its noun: there is no plural engine in the i18n layer, and one
key with {count} in it reads as "1 stays". */}
{t(
suggestions.length === 1 ? 'dawarich.journey.dayStays.one' : 'dawarich.journey.dayStays.other',
{ count: suggestions.length },
)}
{t('dawarich.journey.dayStays', { count: suggestions.length })}
</span>
{span && <span className="block text-[11px] text-content-faint tabular-nums">{span}</span>}
</span>
@@ -297,7 +297,7 @@ describe('GalleryView', () => {
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
expect(bodies[0]).toEqual({ provider: 'immich', asset_ids: ['asset-1'], media_types: ['image'] })
expect(toastSpy).toHaveBeenCalledWith('1 photos added', 'success', undefined)
expect(toastSpy).toHaveBeenCalledWith('1 photo added', 'success', undefined)
expect(screen.queryByRole('heading', { name: 'Immich' })).not.toBeInTheDocument()
})
@@ -288,7 +288,7 @@ describe('AirTrailImportModal', () => {
await user.click(await screen.findByRole('button', { name: /Import 1/i }));
await waitFor(() => expect(pushUndo).toHaveBeenCalled());
expect(toasts).toContainEqual(['success', '1 flight(s) imported']);
expect(toasts).toContainEqual(['success', '1 flight imported']);
expect(toasts).toContainEqual(['warning', '1 already in this trip, skipped']);
expect(pushUndo.mock.calls[0][0]).toBe('Import from AirTrail');
@@ -247,7 +247,7 @@ describe('AirTrailConnectionSection', () => {
await screen.findByDisplayValue('https://air.example.com');
await user.click(screen.getByRole('button', { name: /Test connection/ }));
await screen.findByText('Connected — 12 flight(s) found');
await screen.findByText('Connected. 12 flights found');
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(body).toEqual({ url: 'https://air.example.com', allowInsecureTls: false });
});
@@ -263,7 +263,7 @@ describe('AirTrailConnectionSection', () => {
await screen.findByDisplayValue('https://air.example.com');
await user.click(screen.getByRole('button', { name: /Test connection/ }));
expect(await screen.findByText('Connected — 0 flight(s) found')).toBeInTheDocument();
expect(await screen.findByText('Connected. 0 flights found')).toBeInTheDocument();
});
it('FE-COMP-AIRTRAIL-015: a refused test shows the returned error and keeps the badge off', async () => {
@@ -319,7 +319,7 @@ describe('OfflineTab', () => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await act(async () => { release(); });
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
expect(await screen.findByText('Stored 4 trips on this device')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
});
+1 -1
View File
@@ -70,7 +70,7 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
// enter the shared store used by the day map.
await onToursChanged?.(result.tours.map(tour => tour.place_id))
await loadTrip(tripId)
toast.success(t(result.tours.length === 1 ? 'tours.import.successOne' : 'tours.import.success', { count: result.tours.length }))
toast.success(t('tours.import.success', { count: result.tours.length }))
if (result.caution) toast.info(t('tours.import.caution'))
} else {
toast.warning(t('places.importAllSkipped'))
@@ -240,7 +240,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Users size={13} className="text-content-faint" />
<span className="text-content-secondary" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>
{t('members.access')} ({realMembers.length} {realMembers.length === 1 ? t('members.person') : t('members.persons')})
{t('members.access')} ({realMembers.length} {t('members.persons', { count: realMembers.length })})
</span>
</div>
@@ -41,7 +41,7 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
const labelId = useId()
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
const [ask, setAsk] = useState<Ask | null>(null)
const persons = `${m.realMembers.length} ${m.realMembers.length === 1 ? t('members.person') : t('members.persons')}`
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
return (
<>
+7 -2
View File
@@ -12,6 +12,7 @@ import {
} from '@trek/shared'
import type { TranslationStrings } from '@trek/shared/i18n'
import { importChunk } from '../utils/chunkReload'
import { resolveTemplate } from './resolveTemplate'
export { SUPPORTED_LANGUAGES }
@@ -134,8 +135,12 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
}, [language])
const value = useMemo((): TranslationContextValue => {
const intlLanguage = getIntlLanguage(language)
const template = (key: string, params?: Record<string, string | number>): string =>
resolveTemplate(strings, intlLanguage, key, params)
function t(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? en[key] ?? key) as string
let val = template(key, params)
if (params) {
Object.entries(params).forEach(([k, v]) => {
// Function replacement: a value carrying `$&` or `$1` (a trip named
@@ -147,7 +152,7 @@ export function TranslationProvider({ children }: TranslationProviderProps) {
}
function tHtml(key: string, params?: Record<string, string | number>): string {
let val: string = (strings[key] ?? en[key] ?? key) as string
let val = template(key, params)
if (params) {
Object.entries(params).forEach(([k, v]) => {
// Escape BEFORE substitution so a user-controlled value with `<` or
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import type { TranslationStrings } from '@trek/shared/i18n'
import { resolveTemplate } from './resolveTemplate'
const ru = {
'x.places': '{count} места',
'x.places.one': '{count} место',
'x.places.few': '{count} места',
'x.places.many': '{count} мест',
} as unknown as TranslationStrings
describe('resolveTemplate', () => {
it('picks the form the language selects, not count === 1', () => {
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 1 })).toBe('{count} место')
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 21 })).toBe('{count} место')
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 3 })).toBe('{count} места')
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 5 })).toBe('{count} мест')
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 11 })).toBe('{count} мест')
})
it('a form the locale does not spell out falls back to its general form, never to English', () => {
const ja = { 'x.places': '{count}件の場所' } as unknown as TranslationStrings
expect(resolveTemplate(ja, 'ja', 'x.places', { count: 1 })).toBe('{count}件の場所')
const ruWithoutMany = { 'x.places': '{count} места', 'x.places.one': '{count} место' } as unknown as TranslationStrings
expect(resolveTemplate(ruWithoutMany, 'ru', 'x.places', { count: 5 })).toBe('{count} места')
})
it('a key the locale lacks resolves in English with English rules', () => {
// 'places.count' and its .one form ship in en.
const empty = {} as TranslationStrings
expect(resolveTemplate(empty, 'ru', 'places.count', { count: 1 })).toBe('{count} place')
expect(resolveTemplate(empty, 'ru', 'places.count', { count: 2 })).toBe('{count} places')
})
it('reads n when there is no count, and ignores a count that is not a number', () => {
expect(resolveTemplate(ru, 'ru', 'x.places', { n: 5 })).toBe('{count} мест')
expect(resolveTemplate(ru, 'ru', 'x.places', { count: 'five' })).toBe('{count} места')
expect(resolveTemplate(ru, 'ru', 'x.places', { count: Number.NaN })).toBe('{count} места')
})
it('a group with only category keys still resolves, and an unknown key is the key', () => {
const groupOnly = { 'x.stays.one': 'one stay', 'x.stays.other': '{count} stays' } as unknown as TranslationStrings
expect(resolveTemplate(groupOnly, 'en', 'x.stays', { count: 1 })).toBe('one stay')
expect(resolveTemplate(groupOnly, 'en', 'x.stays', { count: 4 })).toBe('{count} stays')
expect(resolveTemplate(groupOnly, 'en', 'x.nothing', { count: 4 })).toBe('x.nothing')
expect(resolveTemplate(groupOnly, 'en', 'x.nothing')).toBe('x.nothing')
})
})
+28
View File
@@ -0,0 +1,28 @@
import en from '@trek/shared/i18n/en'
import { resolvePluralKey } from '@trek/shared'
import type { TranslationStrings } from '@trek/shared/i18n'
/**
* The template a key reads as. A numeric `count` (or `n`) picks the plural
* form the language's own rule selects (see `@trek/shared` plural helpers):
* `places.count.one`, `.few`, ... and the key itself as the general form.
* The active locale is asked with its rule first; English, with English's,
* only when the locale has none of the key's forms.
*/
export function resolveTemplate(
strings: TranslationStrings,
intlLanguage: string,
key: string,
params?: Record<string, string | number>,
): string {
const raw = params?.count ?? params?.n
const count = typeof raw === 'number' && Number.isFinite(raw) ? raw : undefined
if (count !== undefined) {
const own = resolvePluralKey(strings, key, count, intlLanguage)
if (own) return strings[own] as string
const fallback = resolvePluralKey(en, key, count, 'en')
if (fallback) return en[fallback] as string
return key
}
return (strings[key] ?? en[key] ?? key) as string
}
@@ -403,9 +403,9 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
</div>
)}
<div className={`flex gap-[6px] ${ongoing ? '' : 'mt-[9px]'}`}>
<SpotlightPill icon={<CalendarDays size={11} strokeWidth={2.2} />} label={days === 1 ? t('dashboard.mobile.spotlightDayOne', { count: days }) : t('dashboard.mobile.spotlightDaysMany', { count: days })} />
<SpotlightPill icon={<MapPin size={11} strokeWidth={2.2} />} label={places === 1 ? t('dashboard.hero.destinationOne', { count: places }) : t('dashboard.hero.destinationMany', { count: places })} />
<SpotlightPill icon={<Users size={11} strokeWidth={2.2} />} label={people === 1 ? t('dashboard.hero.travelerOne', { count: people }) : t('dashboard.hero.travelerMany', { count: people })} />
<SpotlightPill icon={<CalendarDays size={11} strokeWidth={2.2} />} label={t('dashboard.mobile.spotlightDays', { count: days })} />
<SpotlightPill icon={<MapPin size={11} strokeWidth={2.2} />} label={t('dashboard.hero.destinations', { count: places })} />
<SpotlightPill icon={<Users size={11} strokeWidth={2.2} />} label={t('dashboard.hero.travelers', { count: people })} />
</div>
</div>
</div>
@@ -564,7 +564,7 @@ function MTripListCard({ trip, locale, t, badge, pluginBadges, actions, onOpen }
<div className="flex text-center">
<ListStat value={trip.day_count ?? 0} label={t('dashboard.days')} />
<ListStat value={trip.place_count ?? 0} label={t('dashboard.places')} />
<ListStat value={trip.shared_count ?? 0} label={trip.shared_count === 1 ? t('dashboard.card.buddyOne') : t('dashboard.members')} />
<ListStat value={trip.shared_count ?? 0} label={t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })} />
</div>
<MTripBadges items={pluginBadges} />
</div>
@@ -331,7 +331,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
<div className="mb-1 mt-[14px] flex items-center gap-[10px]">
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
<span className="whitespace-nowrap font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
{t('places.count', { count: filtered.length })}
</span>
<span className="h-px flex-1 bg-[color:var(--m-rowbr)]" />
</div>
@@ -187,14 +187,8 @@ function StatusBand({ corridor, t }: { corridor: MRtCorridorController; t: Trans
// The phone's own wording, because the desk's ends in "narrow the corridor" and the
// corridor width is a control this screen deliberately does not have. What it does
// have is the category chips, and fewer of them really is what makes a box fit.
// Two keys picked by the count: the i18n layer has no plural engine, so a single key
// with {count} in a sentence that names the noun once reads as "1 stretches".
if (corridor.truncatedAreas > 0) {
notes.push(
t(corridor.truncatedAreas === 1 ? 'mobileTrip.rtTruncated.one' : 'mobileTrip.rtTruncated.other', {
count: corridor.truncatedAreas,
}),
)
notes.push(t('mobileTrip.rtTruncated', { count: corridor.truncatedAreas }))
}
if (corridor.offline) return <Note tone="warn" icon={<WifiOff size={13} strokeWidth={2} />}>{t('mobileTrip.rtSearchOffline')}</Note>
@@ -281,7 +281,7 @@ function PollCardRow({ poll, canEdit, currentUserId, t, onVote, onClosePoll, onD
</span>
)}
<span className="font-geist text-[0.625rem] text-m-faint">
{t(total === 1 ? 'collab.polls.vote' : 'collab.polls.votes', { n: total })}
{t('collab.polls.votes', { n: total })}
</span>
</div>
</div>
+2 -2
View File
@@ -798,7 +798,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
<div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
{t('atlas.yearsInRow', { count: streak })}
</span>
</div>
)}
@@ -807,7 +807,7 @@ function SidebarContent({ data, stats, regionsVisited, countryRegions, onOpenPla
<div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
{t('atlas.tripsIn', { count: tripsThisYear })} {thisYear}
</span>
</div>
)}
+3 -3
View File
@@ -428,9 +428,9 @@ describe('AtlasPage wiring', () => {
expect(screen.getAllByText('9')).toHaveLength(2);
expect(screen.getAllByText('40')).toHaveLength(2);
expect(screen.getByText('atlas.europe')).toBeInTheDocument();
// Singular labels are picked when streak / tripsThisYear are 1.
expect(screen.getByText('atlas.yearInRow')).toBeInTheDocument();
expect(screen.getByText(/atlas\.tripIn/)).toBeInTheDocument();
// The labels get the counts, so the language's own rule picks the form.
expect(screen.getByText('atlas.yearsInRow:1')).toBeInTheDocument();
expect(screen.getByText(/atlas\.tripsIn:1/)).toBeInTheDocument();
// The last-trip tile used to sit in this row; the row is stats only now.
expect(screen.queryByText('Paris Trip')).not.toBeInTheDocument();
+5 -5
View File
@@ -367,11 +367,11 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
const daysLeft = Math.max(0, Math.round((endMid.getTime() - todayMid.getTime()) / MS_PER_DAY))
countdownTop = t('dashboard.status.ongoing')
countdownNumber = String(daysLeft)
countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : daysLeft === 1 ? t('dashboard.hero.dayLeft') : t('dashboard.hero.daysLeft')
countdownLabel = daysLeft === 0 ? t('dashboard.hero.lastDay') : t('dashboard.hero.daysLeft', { count: daysLeft })
} else if (until !== null && until >= 0) {
countdownTop = t('dashboard.hero.startsIn')
countdownNumber = String(until)
countdownLabel = until === 1 ? t('dashboard.hero.dayUnitOne') : t('dashboard.hero.dayUnitMany')
countdownLabel = t('dashboard.hero.dayUnit', { count: until })
}
const members = bundle?.members || []
@@ -398,7 +398,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
{members.length > 4 && <div className="buddy-more">+{members.length - 4}</div>}
{members.length === 0 && <div className="buddy-avatar" style={{ background: buddyColor(0) }}>{initials(trip.owner_username)}</div>}
</div>
<div className="date-month">{buddyCount === 1 ? t('dashboard.hero.travelerOne', { count: buddyCount }) : t('dashboard.hero.travelerMany', { count: buddyCount })}</div>
<div className="date-month">{t('dashboard.hero.travelers', { count: buddyCount })}</div>
</div>
<div className="pass-cell dates-combined">
@@ -433,7 +433,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch
{places.length === 0 && <div className="place-more"><MapPin size={15} /></div>}
{places.length > 3 && <div className="place-more">+{places.length - 3}</div>}
</div>
<div className="date-month">{placeCount === 1 ? t('dashboard.hero.destinationOne', { count: placeCount }) : t('dashboard.hero.destinationMany', { count: placeCount })}</div>
<div className="date-month">{t('dashboard.hero.destinations', { count: placeCount })}</div>
</div>
</>
)
@@ -666,7 +666,7 @@ function TripCard({ trip, locale, badges, matchedPlaces, onOpen, onEdit, onCopy,
<div className="trip-meta" style={{ gridTemplateColumns: 'repeat(3, 1fr)' }}>
<div><span className="n mono">{trip.day_count ?? 0}</span><span className="k">{t('dashboard.days')}</span></div>
<div><span className="n mono">{trip.place_count ?? 0}</span><span className="k">{t('dashboard.places')}</span></div>
<div><span className="n mono">{trip.shared_count ?? 0}</span><span className="k">{trip.shared_count === 1 ? t('dashboard.card.buddyOne') : t('dashboard.members')}</span></div>
<div><span className="n mono">{trip.shared_count ?? 0}</span><span className="k">{t('dashboard.card.buddies', { count: trip.shared_count ?? 0 })}</span></div>
</div>
<TripCardBadges items={badges ?? []} />
</div>
+1 -1
View File
@@ -37,7 +37,7 @@ function InAppNotificationsPageDesktop(): React.ReactElement {
)}
</h1>
<p className="text-sm mt-0.5 text-content-muted">
{total} {total === 1 ? 'notification' : 'notifications'}
{total} {t('notifications.countLabel', { count: total })}
</p>
</div>
+1 -6
View File
@@ -112,12 +112,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
: validation?.scopeSelectable && selectedScopes.length === 0
? t('oauth.authorize.selectScope')
: validation?.scopeSelectable
? t(
selectedScopes.length !== 1
? 'oauth.authorize.approveManyScopes'
: 'oauth.authorize.approveOneScope',
{ count: selectedScopes.length },
)
? t('oauth.authorize.approveScopes', { count: selectedScopes.length })
: t('oauth.authorize.approveAccess')}
</button>
<button type="button"
@@ -227,7 +227,7 @@ describe('AdminUserModals', () => {
fireEvent.click(screen.getByRole('button', { name: /reset passkeys/i }));
confirmPasskeyReset();
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkey(s)'));
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkeys'));
expect(deletedFor).toBe('2');
});
+3 -3
View File
@@ -625,8 +625,8 @@ export function useAtlas() {
<div style="display:flex;flex-direction:column;gap:8px;min-width:160px">
<div style="font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;padding-bottom:6px;border-bottom:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'}">${name}${planned ? ` <span style="font-size:9px;font-weight:700;opacity:0.55;letter-spacing:0.06em">· ${t('atlas.planned')}</span>` : ''}</div>
<div style="display:flex;gap:14px">
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.tripCount === 1 ? t('atlas.tripSingular') : t('atlas.tripPlural')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${c.placeCount === 1 ? t('atlas.placeVisited') : t('atlas.placesVisited')}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.tripCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${t('atlas.tripsLabel', { count: c.tripCount })}</span></div>
<div><span style="font-size:16px;font-weight:800">${c.placeCount}</span> <span style="font-size:10px;opacity:0.5;text-transform:uppercase;letter-spacing:0.05em">${t('atlas.placesVisited', { count: c.placeCount })}</span></div>
</div>
<div style="display:flex;gap:2px;border-top:1px solid ${dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'};padding-top:8px">
${datesHtml}
@@ -895,7 +895,7 @@ export function useAtlas() {
tt.style.left = e.originalEvent.clientX + 12 + 'px'
tt.style.top = e.originalEvent.clientY - 10 + 'px'
tt.innerHTML = visited
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${count === 1 ? 'place' : 'places'}</div>`
? `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div><div style="margin-top:5px;font-size:11px"><b>${count}</b> ${t('atlas.regionPlacesLabel', { count })}</div>`
: `<div style="font-weight:600;margin-bottom:3px">${regionName}</div><div style="opacity:0.5;font-size:10px">${countryName}</div>`
}
})
+16 -2
View File
@@ -36,7 +36,21 @@ const NON_EN_LOCALES: Record<string, TranslationStrings> = {
ar, br, cs, pl, ja, ko, uk, gr,
}
const enKeys = new Set(Object.keys(en))
// A count-bearing string's category forms (`places.count.one`, `.few`, ...)
// differ by language on purpose: Russian needs `.few` and `.many`, Japanese
// none. They are left out of the key comparison here; the shared parity CLI
// checks each locale has exactly the forms its plural rule needs.
const VARIANTS = ['zero', 'one', 'two', 'few', 'many']
const allEnKeys = new Set(Object.keys(en))
const pluralGroups = new Set(
[...allEnKeys]
.filter((k) => VARIANTS.includes(k.slice(k.lastIndexOf('.') + 1)))
.map((k) => k.slice(0, k.lastIndexOf('.')))
.filter((base) => allEnKeys.has(base) || allEnKeys.has(`${base}.other`)),
)
const isVariant = (k: string) =>
VARIANTS.includes(k.slice(k.lastIndexOf('.') + 1)) && pluralGroups.has(k.slice(0, k.lastIndexOf('.')))
const enKeys = new Set([...allEnKeys].filter((k) => !isVariant(k)))
describe('i18n locale key parity', () => {
it('covers every non-en locale', () => {
@@ -46,7 +60,7 @@ describe('i18n locale key parity', () => {
for (const [locale, strings] of Object.entries(NON_EN_LOCALES)) {
it(`${locale} has the exact same key set as en`, () => {
const localeKeys = new Set(Object.keys(strings))
const localeKeys = new Set(Object.keys(strings).filter((k) => !isVariant(k)))
const missing = [...enKeys].filter((k) => !localeKeys.has(k))
const extra = [...localeKeys].filter((k) => !enKeys.has(k))
@@ -730,7 +730,7 @@ describe('MAdminPluginsPanel — operator-supplied egress hosts', () => {
mockPanel([plugin({ operatorEgress: true, egressHostCount: 2 })]);
render(<MAdminPluginsPanel />);
expect(await screen.findByRole('button', { name: '2 allowed host(s)' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: '2 allowed hosts' })).toBeInTheDocument();
});
it('FE-MOB-PLUGP-042: the sheet adds and removes hosts through the API', async () => {
@@ -747,7 +747,7 @@ describe('MAdminPluginsPanel — operator-supplied egress hosts', () => {
);
render(<MAdminPluginsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '1 allowed host(s)' }));
fireEvent.click(await screen.findByRole('button', { name: '1 allowed host' }));
expect(await screen.findByText('gotify.mydomain.test')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: ' ntfy.mydomain.test ' } });
@@ -1511,7 +1511,7 @@ describe('MAdminPluginsPanel — edge paths', () => {
await escapeUntilGone('No errors logged.');
// Allowed hosts → Escape, from the row chip.
fireEvent.click(screen.getByRole('button', { name: '1 allowed host(s)' }));
fireEvent.click(screen.getByRole('button', { name: '1 allowed host' }));
await screen.findByText('No hosts added yet.');
await escapeUntilGone('No hosts added yet.');
});
@@ -213,7 +213,7 @@ describe('MAdminSheets', () => {
await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]);
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkey(s)'));
await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkeys'));
expect(hitId).toBe('2');
});
@@ -104,8 +104,8 @@ describe('MDashboard', () => {
expect(screen.getByText('dashboard.status.ongoing')).toBeInTheDocument();
expect(screen.getByText('dashboard.mobile.spotlightDayOf')).toBeInTheDocument();
// Stat pills reuse the desktop hero keys (no mobile-only duplicates).
expect(screen.getByText('dashboard.hero.destinationMany')).toBeInTheDocument();
expect(screen.getByText('dashboard.hero.travelerMany')).toBeInTheDocument();
expect(screen.getByText('dashboard.hero.destinations')).toBeInTheDocument();
expect(screen.getByText('dashboard.hero.travelers')).toBeInTheDocument();
expect(screen.getByText('Lisbon')).toBeInTheDocument();
});
@@ -263,7 +263,7 @@ describe('MDashboard', () => {
expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('30')).toBeInTheDocument();
expect(screen.getByText('dashboard.card.buddyOne')).toBeInTheDocument();
expect(screen.getByText('dashboard.card.buddies')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'dashboard.archive' }));
expect(handleArchive).toHaveBeenCalledWith(4);
@@ -368,7 +368,7 @@ describe('MDashboard', () => {
render(<MDashboard />);
expect(screen.getByText('dashboard.hero.badgeRecent')).toBeInTheDocument();
expect(screen.getByText('dashboard.mobile.spotlightDayOne')).toBeInTheDocument();
expect(screen.getByText('dashboard.mobile.spotlightDays')).toBeInTheDocument();
});
it('FE-MOB-DASH-024: the spotlight opens on Enter as well as on click', () => {
@@ -188,7 +188,7 @@ describe('MAirTrailConnectionSection', () => {
renderSection();
await user.click(await screen.findByRole('button', { name: /Test connection/ }));
await screen.findByText('Connected — 12 flight(s) found');
await screen.findByText('Connected. 12 flights found');
expect(screen.getByText('Connected')).toBeInTheDocument();
});
@@ -270,7 +270,7 @@ describe('MSettingsOffline', () => {
expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled();
await act(async () => { release(); });
expect(await screen.findByText('Stored 4 trip(s) on this device')).toBeInTheDocument();
expect(await screen.findByText('Stored 4 trips on this device')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled();
});
@@ -111,7 +111,7 @@ describe('MPlacesBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: 'places.planned' }))
expect(useTripStore.getState().placesFilter).toBe('planned')
expect(screen.getByText('places.countSingular')).toBeInTheDocument()
expect(screen.getByText('places.count:1')).toBeInTheDocument()
expect(screen.getByText('Louvre')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'places.all' }))
@@ -121,7 +121,7 @@ describe('MPlacesBrowser', () => {
it('FE-MOB-PBROW-006: the tracks chip only appears with a track and filters to it', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'places.filterTracks' }))
expect(screen.getByText('places.countSingular')).toBeInTheDocument()
expect(screen.getByText('places.count:1')).toBeInTheDocument()
expect(screen.getByText('Seine Track')).toBeInTheDocument()
expect(screen.queryByText('Louvre')).not.toBeInTheDocument()
})
+84 -4
View File
@@ -12,6 +12,13 @@
// returns exit-code 1 when any drift is present (intended for CI). Without
// `--strict` the script exits 0 and prints, so it can also run as a non-blocking
// audit during translation work.
// 3. Plural forms: a count-bearing string (`key` or `key.other`, plus at
// least one of `key.zero|one|two|few|many` in en) is a plural group. Its
// category keys are not compared with en's, since Russian needs `.few`
// and `.many` and Japanese needs none. Instead every locale must spell
// out each category whole counts reach in its language, and may not
// carry one its language never selects. The rules come from
// `Intl.PluralRules`, through the same helpers the client resolves with.
//
// Limitations: we only parse *top-level* string keys (those declared as the
// first column of the file, matching the regex below). Nested objects, function
@@ -21,6 +28,8 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getIntlLanguage } from '../src/i18n/languages.ts';
import { allPluralCategories, integerPluralCategories } from '../src/i18n/plural.ts';
const here = dirname(fileURLToPath(import.meta.url));
const i18nRoot = join(here, '..', 'src', 'i18n');
@@ -52,6 +61,50 @@ function extractKeys(locale, file) {
return keys;
}
const VARIANT_CATEGORIES = ['zero', 'one', 'two', 'few', 'many'];
/** The plural groups en defines in one domain file: their base keys. */
function pluralGroups(enKeys) {
const groups = new Set();
for (const key of enKeys) {
const dot = key.lastIndexOf('.');
if (dot < 0 || !VARIANT_CATEGORIES.includes(key.slice(dot + 1))) continue;
const base = key.slice(0, dot);
if (enKeys.has(base) || enKeys.has(`${base}.other`)) groups.add(base);
}
return groups;
}
/** `{ base, category }` when `key` is a category form of one of `groups`. */
function asVariant(key, groups) {
const dot = key.lastIndexOf('.');
if (dot < 0) return null;
const category = key.slice(dot + 1);
const base = key.slice(0, dot);
return VARIANT_CATEGORIES.includes(category) && groups.has(base) ? { base, category } : null;
}
function withoutVariants(keys, groups) {
return new Set([...keys].filter((k) => !asVariant(k, groups)));
}
/** What one locale owes the plural groups of one file. */
function checkPluralForms(locale, keys, groups) {
const intl = getIntlLanguage(locale);
const required = integerPluralCategories(intl).filter((c) => c !== 'other');
const allowed = new Set(allPluralCategories(intl));
const missing = [];
const invalid = [];
for (const base of groups) {
for (const c of required) if (!keys.has(`${base}.${c}`)) missing.push(`${base}.${c}`);
}
for (const key of keys) {
const v = asVariant(key, groups);
if (v && !allowed.has(v.category)) invalid.push(key);
}
return { missing, invalid };
}
function diffSets(reference, candidate) {
const missing = [];
const extra = [];
@@ -69,7 +122,14 @@ function checkParity() {
const enKeysByDomain = new Map();
for (const f of enFiles) enKeysByDomain.set(f, extractKeys('en', f));
const report = { fileDrift: [], keyDrift: [] };
const report = { fileDrift: [], keyDrift: [], pluralDrift: [] };
const groupsByDomain = new Map();
for (const f of enFiles) {
const groups = pluralGroups(enKeysByDomain.get(f));
groupsByDomain.set(f, groups);
const { missing, invalid } = checkPluralForms('en', enKeysByDomain.get(f), groups);
if (missing.length || invalid.length) report.pluralDrift.push({ locale: 'en', file: f, missing, invalid });
}
for (const locale of locales) {
if (locale === 'en') continue;
@@ -87,10 +147,18 @@ function checkParity() {
for (const file of enFiles) {
if (!localeFiles.includes(file)) continue;
const localeKeys = extractKeys(locale, file);
const { missing, extra } = diffSets(enKeysByDomain.get(file), localeKeys);
const groups = groupsByDomain.get(file);
const { missing, extra } = diffSets(
withoutVariants(enKeysByDomain.get(file), groups),
withoutVariants(localeKeys, groups),
);
if (missing.length || extra.length) {
report.keyDrift.push({ locale, file, missing, extra });
}
const plural = checkPluralForms(locale, localeKeys, groups);
if (plural.missing.length || plural.invalid.length) {
report.pluralDrift.push({ locale, file, ...plural });
}
}
}
@@ -122,6 +190,18 @@ function formatReport(report) {
}
}
if (report.pluralDrift.length === 0) {
lines.push('Plural forms: OK');
} else {
lines.push(`Plural forms: ${report.pluralDrift.length} domain file(s) with missing or impossible forms`);
for (const { locale, file, missing, invalid } of report.pluralDrift) {
const parts = [];
if (missing.length) parts.push(`missing ${missing.join(', ')}`);
if (invalid.length) parts.push(`never selected ${invalid.join(', ')}`);
lines.push(` ${locale}/${file}: ${parts.join('; ')}`);
}
}
return lines.join('\n');
}
@@ -135,11 +215,11 @@ if (isCli) {
const strict = process.argv.includes('--strict');
const filesOnly = process.argv.includes('--files-only');
const report = checkParity();
process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [] } : report) + '\n');
process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [], pluralDrift: [] } : report) + '\n');
if (strict) {
const hasFileDrift = report.fileDrift.length > 0;
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0;
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0 || report.pluralDrift.length > 0;
if (hasFileDrift || hasKeyDrift) process.exit(1);
}
}
+5 -4
View File
@@ -43,10 +43,10 @@ const atlas: TranslationStrings = {
'atlas.daysLeft': 'يوم متبقٍ',
'atlas.streak': 'سلسلة',
'atlas.years': 'سنوات',
'atlas.yearInRow': 'سنة متتالية',
'atlas.yearsInRow': 'سنوات متتالية',
'atlas.tripIn': 'رحلة في',
'atlas.yearsInRow.one': 'سنة متتالية',
'atlas.tripsIn': 'رحلات في',
'atlas.tripsIn.one': 'رحلة في',
'atlas.since': 'منذ',
'atlas.europe': 'أوروبا',
'atlas.asia': 'آسيا',
@@ -57,10 +57,11 @@ const atlas: TranslationStrings = {
'atlas.other': 'أخرى',
'atlas.firstVisit': 'أول رحلة',
'atlas.lastVisitLabel': 'آخر رحلة',
'atlas.tripSingular': 'رحلة',
'atlas.tripPlural': 'رحلات',
'atlas.placeVisited': 'مكان تمت زيارته',
'atlas.tripsLabel': 'رحلات',
'atlas.tripsLabel.one': 'رحلة',
'atlas.placesVisited': 'أماكن تمت زيارتها',
'atlas.placesVisited.one': 'مكان تمت زيارته',
'atlas.planned': 'مُخطط',
'atlas.showPlanned': 'إظهار الدول المخطط زيارتها',
'atlas.plannedFor': 'مخطط لـ',
+1 -1
View File
@@ -88,7 +88,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'إغلاق',
'collab.polls.closed': 'مغلق',
'collab.polls.votes': '{n} أصوات',
'collab.polls.vote': '{n} صوت',
'collab.polls.votes.one': '{n} صوت',
'collab.polls.multipleChoice': 'اختيار متعدد',
'collab.polls.multiChoice': 'اختيار متعدد',
'collab.polls.deadline': 'الموعد النهائي',
+11 -10
View File
@@ -34,6 +34,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'الأيام',
'dashboard.places': 'الأماكن',
'dashboard.members': 'ال חברים',
'dashboard.card.buddies': 'ال חברים',
'dashboard.card.buddies.one': 'رفيق',
'dashboard.archive': 'أرشفة',
'dashboard.copyTrip': 'نسخ',
'dashboard.copySuffix': 'نسخة',
@@ -125,14 +127,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'مؤخرًا',
'dashboard.hero.tripDates': 'تواريخ الرحلة',
'dashboard.hero.noDates': 'لا توجد تواريخ',
'dashboard.hero.travelerOne': '{count} مسافر',
'dashboard.hero.travelerMany': '{count} مسافرين',
'dashboard.hero.destinationOne': '{count} وجهة',
'dashboard.hero.destinationMany': '{count} وجهات',
'dashboard.hero.dayUnitOne': 'يوم',
'dashboard.hero.dayUnitMany': 'أيام',
'dashboard.hero.dayLeft': 'بقي يوم',
'dashboard.hero.travelers': '{count} مسافرين',
'dashboard.hero.travelers.one': '{count} مسافر',
'dashboard.hero.destinations': '{count} وجهات',
'dashboard.hero.destinations.one': '{count} وجهة',
'dashboard.hero.dayUnit': 'أيام',
'dashboard.hero.dayUnit.one': 'يوم',
'dashboard.hero.daysLeft': 'الأيام المتبقية',
'dashboard.hero.daysLeft.one': 'بقي يوم',
'dashboard.hero.lastDay': 'اليوم الأخير',
'dashboard.hero.untilStart': 'حتى البداية',
'dashboard.hero.startsIn': 'تبدأ الرحلة بعد',
@@ -147,7 +149,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× حول خط الاستواء',
'dashboard.card.idea': 'فكرة',
'dashboard.card.buddyOne': 'رفيق',
'dashboard.fx.from': 'من',
'dashboard.fx.to': 'إلى',
'dashboard.fx.unavailable': 'السعر غير متاح',
@@ -194,8 +195,8 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.inDays': 'خلال {count} يوم',
'dashboard.mobile.inMonths': 'خلال {count} شهر',
'dashboard.mobile.spotlightDayOf': 'اليوم {day} من {total}',
'dashboard.mobile.spotlightDayOne': '{count} يوم',
'dashboard.mobile.spotlightDaysMany': '{count} أيام',
'dashboard.mobile.spotlightDays': '{count} أيام',
'dashboard.mobile.spotlightDays.one': '{count} يوم',
'dashboard.mobile.completed': 'مكتملة',
'dashboard.mobile.currencyConverter': 'محوّل العملات',
'dashboard.aria.toggleView': 'تبديل العرض',
+1 -1
View File
@@ -14,7 +14,7 @@ const files: TranslationStrings = {
'files.openError': 'تعذر فتح الملف',
'files.downloadPdf': 'تنزيل PDF',
'files.count': '{count} ملفات',
'files.countSingular': 'ملف واحد',
'files.count.one': 'ملف واحد',
'files.uploaded': 'تم رفع {count}',
'files.uploadError': 'فشل الرفع',
'files.uploadErrorType': 'نوع الملف هذا غير مدعوم',
+1 -1
View File
@@ -7,8 +7,8 @@ const members: TranslationStrings = {
'members.invite': 'دعوة',
'members.allHaveAccess': 'جميع المستخدمين لديهم صلاحية الوصول بالفعل.',
'members.access': 'الصلاحية',
'members.person': 'شخص',
'members.persons': 'أشخاص',
'members.persons.one': 'شخص',
'members.you': 'أنت',
'members.owner': 'المالك',
'members.leaveTrip': 'مغادرة الرحلة',
+2 -2
View File
@@ -84,8 +84,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
'oauth.authorize.trustNote': 'Only grant access to applications you trust. Your data stays on your server.', // en-fallback
'oauth.authorize.selectScope': 'Select at least one scope', // en-fallback
'oauth.authorize.approveOneScope': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveManyScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes.one': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveAccess': 'Approve Access', // en-fallback
'oauth.authorize.deny': 'Deny', // en-fallback
'oauth.authorize.choosePermissions': 'Choose which permissions to grant', // en-fallback
+1 -1
View File
@@ -70,7 +70,7 @@ const places: TranslationStrings = {
'places.categoriesSelected': 'فئات',
'places.clearFilter': 'مسح الفلتر',
'places.count': '{count} أماكن',
'places.countSingular': 'مكان واحد',
'places.count.one': 'مكان واحد',
'places.allPlanned': 'تم تخطيط جميع الأماكن',
'places.noneFound': 'لم يتم العثور على أماكن',
'places.editPlace': 'تعديل المكان',
+1 -1
View File
@@ -20,7 +20,7 @@ const tours: TranslationStrings = {
'tours.caution.tooltip': 'بيانات الارتفاع أو المقاييس غير مكتملة؛ تم الاستيراد مع تنبيه للحذر',
'tours.import.caution': 'تم الاستيراد مع تنبيه للحذر؛ بيانات الارتفاع أو المقاييس غير مكتملة',
'tours.import.success': 'تم استيراد {count} من الجولات من GPX',
'tours.import.successOne': 'تم استيراد جولة واحدة من GPX',
'tours.import.success.one': 'تم استيراد جولة واحدة من GPX',
'tours.import.noTrack': 'لم يُعثر على أثر مسجّل أو مسار في ملف GPX هذا',
'tours.import.error': 'تعذّر استيراد ملف GPX',
'tours.delete.confirmBody':
+5 -4
View File
@@ -58,10 +58,10 @@ const atlas: TranslationStrings = {
'atlas.daysLeft': 'gün qalıb',
'atlas.streak': 'Ardıcıllıq',
'atlas.years': 'il',
'atlas.yearInRow': 'ardıcıl il',
'atlas.yearsInRow': 'ardıcıl il',
'atlas.tripIn': 'səyahət ·',
'atlas.yearsInRow.one': 'ardıcıl il',
'atlas.tripsIn': 'səyahət ·',
'atlas.tripsIn.one': 'səyahət ·',
'atlas.since': 'etibarən',
'atlas.europe': 'Avropa',
@@ -74,10 +74,11 @@ const atlas: TranslationStrings = {
'atlas.firstVisit': 'İlk səyahət',
'atlas.lastVisitLabel': 'Son səyahət',
'atlas.tripSingular': 'Səyahət',
'atlas.tripPlural': 'Səyahətlər',
'atlas.placeVisited': 'Ziyarət edilmiş məkan',
'atlas.tripsLabel': 'Səyahətlər',
'atlas.tripsLabel.one': 'Səyahət',
'atlas.placesVisited': 'Ziyarət edilmiş məkanlar',
'atlas.placesVisited.one': 'Ziyarət edilmiş məkan',
'atlas.planned': 'Planlaşdırılıb',
'atlas.showPlanned': 'Planlaşdırılan ölkələri göstər',
+1 -1
View File
@@ -100,7 +100,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'Bağla',
'collab.polls.closed': 'Bağlanıb',
'collab.polls.votes': '{n} səs',
'collab.polls.vote': '{n} səs',
'collab.polls.votes.one': '{n} səs',
'collab.polls.multipleChoice': 'Çoxseçimli',
'collab.polls.multiChoice': 'Çoxseçimli',
'collab.polls.deadline': 'Son tarix',
+11 -10
View File
@@ -34,6 +34,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'Günlər',
'dashboard.places': 'Məkanlar',
'dashboard.members': 'Yoldaşlar',
'dashboard.card.buddies': 'Yoldaşlar',
'dashboard.card.buddies.one': 'Yoldaş',
'dashboard.archive': 'Arxivləşdir',
'dashboard.copyTrip': 'Nüsxəsini yarat',
'dashboard.copySuffix': 'nüsxə',
@@ -159,8 +161,8 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.inDays': '{count} gün sonra',
'dashboard.mobile.inMonths': '{count} ay sonra',
'dashboard.mobile.spotlightDayOf': 'Gün {day}/{total}',
'dashboard.mobile.spotlightDayOne': '{count} gün',
'dashboard.mobile.spotlightDaysMany': '{count} gün',
'dashboard.mobile.spotlightDays': '{count} gün',
'dashboard.mobile.spotlightDays.one': '{count} gün',
'dashboard.mobile.completed': 'Tamamlanıb',
'dashboard.mobile.currencyConverter': 'Valyuta çeviricisi',
'dashboard.filter.planned': 'Planlaşdırılıb',
@@ -171,14 +173,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'SON',
'dashboard.hero.tripDates': 'Səyahət tarixləri',
'dashboard.hero.noDates': 'Tarixlər təyin edilməyib',
'dashboard.hero.travelerOne': '{count} səyahətçi',
'dashboard.hero.travelerMany': '{count} səyahətçi',
'dashboard.hero.destinationOne': '{count} istiqamət',
'dashboard.hero.destinationMany': '{count} istiqamət',
'dashboard.hero.dayUnitOne': 'gün',
'dashboard.hero.dayUnitMany': 'gün',
'dashboard.hero.dayLeft': 'Qalan gün',
'dashboard.hero.travelers': '{count} səyahətçi',
'dashboard.hero.travelers.one': '{count} səyahətçi',
'dashboard.hero.destinations': '{count} istiqamət',
'dashboard.hero.destinations.one': '{count} istiqamət',
'dashboard.hero.dayUnit': 'gün',
'dashboard.hero.dayUnit.one': 'gün',
'dashboard.hero.daysLeft': 'Qalan günlər',
'dashboard.hero.daysLeft.one': 'Qalan gün',
'dashboard.hero.lastDay': 'Son gün',
'dashboard.hero.untilStart': 'Başlanmasına qalan',
'dashboard.hero.startsIn': 'Səyahətin başlanmasına',
@@ -193,7 +195,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ ekvator ətrafında {count} dövrə',
'dashboard.card.idea': 'İdeya',
'dashboard.card.buddyOne': 'Yoldaş',
'dashboard.fx.from': 'Mənbə',
'dashboard.fx.to': 'Hədəf',
'dashboard.fx.unavailable': 'Məzənnə mövcud deyil',
+1 -1
View File
@@ -17,7 +17,7 @@ const files: TranslationStrings = {
'files.openError': 'Faylı açmaq mümkün olmadı',
'files.downloadPdf': 'PDF-i endir',
'files.count': '{count} fayl',
'files.countSingular': '1 fayl',
'files.count.one': '{count} fayl',
'files.uploaded': '{count} fayl yükləndi',
'files.uploadError': 'Yükləmə uğursuz oldu',
'files.uploadErrorType': 'Bu fayl növü dəstəklənmir',
+1 -1
View File
@@ -9,8 +9,8 @@ const members: TranslationStrings = {
'Bütün istifadəçilərin artıq giriş icazəsi var.',
'members.access': 'Giriş icazəsi',
'members.person': 'nəfər',
'members.persons': 'nəfər',
'members.persons.one': 'nəfər',
'members.you': 'siz',
'members.owner': 'Sahib',
+2 -2
View File
@@ -115,8 +115,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.trustNote':
'Yalnız etibar etdiyiniz tətbiqlərə giriş icazəsi verin. Məlumatlarınız serverinizdə qalır.',
'oauth.authorize.selectScope': 'Ən azı bir icazə sahəsi seçin',
'oauth.authorize.approveOneScope': 'Təsdiqlə ({count} icazə sahəsi)',
'oauth.authorize.approveManyScopes': 'Təsdiqlə ({count} icazə sahəsi)',
'oauth.authorize.approveScopes': 'Təsdiqlə ({count} icazə sahəsi)',
'oauth.authorize.approveScopes.one': 'Təsdiqlə ({count} icazə sahəsi)',
'oauth.authorize.approveAccess': 'Girişə icazə ver',
'oauth.authorize.deny': 'Rədd et',
'oauth.authorize.choosePermissions': 'Veriləcək icazələri seçin',
+1 -1
View File
@@ -79,7 +79,7 @@ const places: TranslationStrings = {
'places.categoriesSelected': 'kateqoriya',
'places.clearFilter': 'Filtri təmizlə',
'places.count': '{count} məkan',
'places.countSingular': '1 məkan',
'places.count.one': '{count} məkan',
'places.allPlanned': 'Bütün məkanlar planlaşdırılıb',
'places.noneFound': 'Heç bir məkan tapılmadı',
+1 -1
View File
@@ -20,7 +20,7 @@ const tours: TranslationStrings = {
'tours.caution.tooltip': 'Yüksəklik və göstəricilər natamamdır — ehtiyatla idxal edilib',
'tours.import.caution': 'Ehtiyatla idxal edilib — yüksəklik və göstəricilər natamamdır',
'tours.import.success': 'GPX-dən {count} tur idxal edildi',
'tours.import.successOne': 'GPX-dən 1 tur idxal edildi',
'tours.import.success.one': 'GPX-dən {count} tur idxal edildi',
'tours.import.noTrack': 'Bu GPX faylında iz və ya marşrut tapılmadı',
'tours.import.error': 'GPX faylını idxal etmək mümkün olmadı',
'tours.delete.confirmBody':
+5 -4
View File
@@ -43,10 +43,10 @@ const atlas: TranslationStrings = {
'atlas.daysLeft': 'dias restantes',
'atlas.streak': 'Sequência',
'atlas.years': 'anos',
'atlas.yearInRow': 'ano seguido',
'atlas.yearsInRow': 'anos seguidos',
'atlas.tripIn': 'viagem em',
'atlas.yearsInRow.one': 'ano seguido',
'atlas.tripsIn': 'viagens em',
'atlas.tripsIn.one': 'viagem em',
'atlas.since': 'desde',
'atlas.europe': 'Europa',
'atlas.asia': 'Ásia',
@@ -57,10 +57,11 @@ const atlas: TranslationStrings = {
'atlas.other': 'Outro',
'atlas.firstVisit': 'Primeira viagem',
'atlas.lastVisitLabel': 'Última viagem',
'atlas.tripSingular': 'Viagem',
'atlas.tripPlural': 'Viagens',
'atlas.placeVisited': 'Lugar visitado',
'atlas.tripsLabel': 'Viagens',
'atlas.tripsLabel.one': 'Viagem',
'atlas.placesVisited': 'Lugares visitados',
'atlas.placesVisited.one': 'Lugar visitado',
'atlas.planned': 'Planejado',
'atlas.showPlanned': 'Mostrar países planejados',
'atlas.plannedFor': 'Planejado para',
+1 -1
View File
@@ -89,7 +89,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'Encerrar',
'collab.polls.closed': 'Encerrada',
'collab.polls.votes': '{n} votos',
'collab.polls.vote': '{n} voto',
'collab.polls.votes.one': '{n} voto',
'collab.polls.multipleChoice': 'Múltipla escolha',
'collab.polls.multiChoice': 'Múltipla escolha',
'collab.polls.deadline': 'Prazo',
+11 -10
View File
@@ -34,6 +34,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'Dias',
'dashboard.places': 'Lugares',
'dashboard.members': 'Parceiros de viagem',
'dashboard.card.buddies': 'Parceiros de viagem',
'dashboard.card.buddies.one': 'Parceiro',
'dashboard.archive': 'Arquivar',
'dashboard.copyTrip': 'Copiar',
'dashboard.copySuffix': 'cópia',
@@ -143,8 +145,8 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.inDays': 'Em {count} dias',
'dashboard.mobile.inMonths': 'Em {count} meses',
'dashboard.mobile.spotlightDayOf': 'Dia {day} de {total}',
'dashboard.mobile.spotlightDayOne': '{count} dia',
'dashboard.mobile.spotlightDaysMany': '{count} dias',
'dashboard.mobile.spotlightDays': '{count} dias',
'dashboard.mobile.spotlightDays.one': '{count} dia',
'dashboard.mobile.completed': 'Concluído',
'dashboard.mobile.currencyConverter': 'Conversor de moedas',
'dashboard.filter.planned': 'Planejadas',
@@ -155,14 +157,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'RECENTE',
'dashboard.hero.tripDates': 'Datas da viagem',
'dashboard.hero.noDates': 'Sem datas definidas',
'dashboard.hero.travelerOne': '{count} viajante',
'dashboard.hero.travelerMany': '{count} viajantes',
'dashboard.hero.destinationOne': '{count} destino',
'dashboard.hero.destinationMany': '{count} destinos',
'dashboard.hero.dayUnitOne': 'dia',
'dashboard.hero.dayUnitMany': 'dias',
'dashboard.hero.dayLeft': 'Dia restante',
'dashboard.hero.travelers': '{count} viajantes',
'dashboard.hero.travelers.one': '{count} viajante',
'dashboard.hero.destinations': '{count} destinos',
'dashboard.hero.destinations.one': '{count} destino',
'dashboard.hero.dayUnit': 'dias',
'dashboard.hero.dayUnit.one': 'dia',
'dashboard.hero.daysLeft': 'Dias restantes',
'dashboard.hero.daysLeft.one': 'Dia restante',
'dashboard.hero.lastDay': 'Último dia',
'dashboard.hero.untilStart': 'Até o início',
'dashboard.hero.startsIn': 'A viagem começa em',
@@ -177,7 +179,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× ao redor do equador',
'dashboard.card.idea': 'Ideia',
'dashboard.card.buddyOne': 'Parceiro',
'dashboard.fx.from': 'De',
'dashboard.fx.to': 'Para',
'dashboard.fx.unavailable': 'Taxa indisponível',
+1 -1
View File
@@ -14,7 +14,7 @@ const files: TranslationStrings = {
'files.openError': 'Não foi possível abrir o arquivo',
'files.downloadPdf': 'Baixar PDF',
'files.count': '{count} arquivos',
'files.countSingular': '1 arquivo',
'files.count.one': '{count} arquivo',
'files.uploaded': '{count} enviado(s)',
'files.uploadError': 'Falha no envio',
'files.uploadErrorType': 'Esse tipo de arquivo não é suportado',
+1 -1
View File
@@ -7,8 +7,8 @@ const members: TranslationStrings = {
'members.invite': 'Convidar',
'members.allHaveAccess': 'Todos os usuários já têm acesso.',
'members.access': 'Acesso',
'members.person': 'pessoa',
'members.persons': 'pessoas',
'members.persons.one': 'pessoa',
'members.you': 'você',
'members.owner': 'Proprietário',
'members.leaveTrip': 'Sair da viagem',
+2 -2
View File
@@ -86,8 +86,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
'oauth.authorize.trustNote': 'Only grant access to applications you trust. Your data stays on your server.', // en-fallback
'oauth.authorize.selectScope': 'Select at least one scope', // en-fallback
'oauth.authorize.approveOneScope': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveManyScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes.one': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveAccess': 'Approve Access', // en-fallback
'oauth.authorize.deny': 'Deny', // en-fallback
'oauth.authorize.choosePermissions': 'Choose which permissions to grant', // en-fallback
+1 -1
View File
@@ -70,7 +70,7 @@ const places: TranslationStrings = {
'places.categoriesSelected': 'categorias',
'places.clearFilter': 'Limpar filtro',
'places.count': '{count} lugares',
'places.countSingular': '1 lugar',
'places.count.one': '{count} lugar',
'places.allPlanned': 'Todos os lugares estão planejados',
'places.noneFound': 'Nenhum lugar encontrado',
'places.editPlace': 'Editar lugar',
+1 -1
View File
@@ -20,7 +20,7 @@ const tours: TranslationStrings = {
'tours.caution.tooltip': 'Elevação ou métricas incompletas: importado com ressalvas',
'tours.import.caution': 'Importado com ressalvas: elevação ou métricas incompletas',
'tours.import.success': '{count} passeios importados de GPX',
'tours.import.successOne': '1 passeio importado de GPX',
'tours.import.success.one': '{count} passeio importado de GPX',
'tours.import.noTrack': 'Nenhum traçado ou rota encontrado nesse arquivo GPX',
'tours.import.error': 'Não foi possível importar o arquivo GPX',
'tours.delete.confirmBody':
+5 -4
View File
@@ -19,10 +19,10 @@ const atlas: TranslationStrings = {
'atlas.streak': 'Ratxa',
'atlas.year': 'any',
'atlas.years': 'anys',
'atlas.yearInRow': 'any seguit',
'atlas.yearsInRow': 'anys seguits',
'atlas.tripIn': 'viatge a',
'atlas.yearsInRow.one': 'any seguit',
'atlas.tripsIn': 'viatges a',
'atlas.tripsIn.one': 'viatge a',
'atlas.since': 'des de',
'atlas.europe': 'Europa',
'atlas.asia': 'Àsia',
@@ -33,10 +33,11 @@ const atlas: TranslationStrings = {
'atlas.other': 'Altres',
'atlas.firstVisit': 'Primer viatge',
'atlas.lastVisitLabel': 'Darrer viatge',
'atlas.tripSingular': 'Viatge',
'atlas.tripPlural': 'Viatges',
'atlas.placeVisited': 'Lloc visitat',
'atlas.tripsLabel': 'Viatges',
'atlas.tripsLabel.one': 'Viatge',
'atlas.placesVisited': 'Llocs visitats',
'atlas.placesVisited.one': 'Lloc visitat',
'atlas.statsTab': 'Estadístiques',
'atlas.bucketTab': 'Llista de desitjos',
'atlas.addBucket': 'Afegeix a la llista de desitjos',
+1 -1
View File
@@ -81,7 +81,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'Tanca',
'collab.polls.closed': 'Tancada',
'collab.polls.votes': '{n} vots',
'collab.polls.vote': '{n} vot',
'collab.polls.votes.one': '{n} vot',
'collab.polls.multipleChoice': 'Selecció múltiple',
'collab.polls.multiChoice': 'Selecció múltiple',
'collab.polls.deadline': 'Data límit',
+11 -10
View File
@@ -31,6 +31,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'Dies',
'dashboard.places': 'Llocs',
'dashboard.members': 'Companys de viatge',
'dashboard.card.buddies': 'Companys de viatge',
'dashboard.card.buddies.one': 'Company',
'dashboard.archive': 'Arxiva',
'dashboard.copyTrip': 'Copia',
'dashboard.copySuffix': 'còpia',
@@ -113,14 +115,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'RECENT',
'dashboard.hero.tripDates': 'Dates del viatge',
'dashboard.hero.noDates': 'Sense dates',
'dashboard.hero.travelerOne': '{count} viatger',
'dashboard.hero.travelerMany': '{count} viatgers',
'dashboard.hero.destinationOne': '{count} destinació',
'dashboard.hero.destinationMany': '{count} destinacions',
'dashboard.hero.dayUnitOne': 'dia',
'dashboard.hero.dayUnitMany': 'dies',
'dashboard.hero.dayLeft': 'Dia restant',
'dashboard.hero.travelers': '{count} viatgers',
'dashboard.hero.travelers.one': '{count} viatger',
'dashboard.hero.destinations': '{count} destinacions',
'dashboard.hero.destinations.one': '{count} destinació',
'dashboard.hero.dayUnit': 'dies',
'dashboard.hero.dayUnit.one': 'dia',
'dashboard.hero.daysLeft': 'Dies restants',
'dashboard.hero.daysLeft.one': 'Dia restant',
'dashboard.hero.lastDay': 'Últim dia',
'dashboard.hero.untilStart': "Fins a l'inici",
'dashboard.hero.startsIn': "Comença d'aquí a",
@@ -135,7 +137,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': "≈ {count}× al voltant de l'equador",
'dashboard.card.idea': 'Idea',
'dashboard.card.buddyOne': 'Company',
'dashboard.fx.from': 'De',
'dashboard.fx.to': 'A',
'dashboard.fx.unavailable': 'Tipus de canvi no disponible',
@@ -208,7 +209,7 @@ const dashboard: TranslationStrings = {
'Un sol canal de calendari per a tots els teus viatges actius, sincronitzat automàticament. Exclou els viatges arxivats i els que van acabar fa més de 90 dies.',
'dashboard.mobile.addCoverImage': 'Afegeix una imatge de portada',
'dashboard.mobile.spotlightDayOf': 'Dia {day} de {total}',
'dashboard.mobile.spotlightDayOne': '{count} dia',
'dashboard.mobile.spotlightDaysMany': '{count} dies',
'dashboard.mobile.spotlightDays': '{count} dies',
'dashboard.mobile.spotlightDays.one': '{count} dia',
};
export default dashboard;
+1 -1
View File
@@ -8,7 +8,7 @@ const files: TranslationStrings = {
'files.openError': "No s'ha pogut obrir el fitxer",
'files.downloadPdf': 'Baixa el PDF',
'files.count': '{count} fitxers',
'files.countSingular': '1 fitxer',
'files.count.one': '{count} fitxer',
'files.uploaded': '{count} fitxers pujats',
'files.uploadError': 'La pujada ha fallat',
'files.dropzone': 'Arrossega els fitxers aquí',
+1 -1
View File
@@ -7,8 +7,8 @@ const members: TranslationStrings = {
'members.invite': 'Convida',
'members.allHaveAccess': 'Tots els usuaris ja hi tenen accés.',
'members.access': 'Accés',
'members.person': 'persona',
'members.persons': 'persones',
'members.persons.one': 'persona',
'members.you': 'tu',
'members.owner': 'Propietari',
'members.leaveTrip': 'Abandona el viatge',
+2 -2
View File
@@ -87,8 +87,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.trustNote':
'Només atorga accés a aplicacions de confiança. Les teves dades es mantenen al teu servidor.',
'oauth.authorize.selectScope': 'Selecciona almenys un àmbit',
'oauth.authorize.approveOneScope': 'Aprova ({count} àmbit)',
'oauth.authorize.approveManyScopes': 'Aprova ({count} àmbits)',
'oauth.authorize.approveScopes': 'Aprova ({count} àmbits)',
'oauth.authorize.approveScopes.one': 'Aprova ({count} àmbit)',
'oauth.authorize.approveAccess': "Aprova l'accés",
'oauth.authorize.deny': 'Denega',
'oauth.authorize.choosePermissions': 'Tria quins permisos atorgar',
+1 -1
View File
@@ -69,7 +69,7 @@ const places: TranslationStrings = {
'places.categoriesSelected': 'categories',
'places.clearFilter': 'Esborra el filtre',
'places.count': '{count} llocs',
'places.countSingular': '1 lloc',
'places.count.one': '{count} lloc',
'places.allPlanned': 'Tots els llocs estan planificats',
'places.noneFound': "No s'han trobat llocs",
'places.editPlace': 'Edita el lloc',
+1 -1
View File
@@ -20,7 +20,7 @@ const tours: TranslationStrings = {
'tours.caution.tooltip': 'Altitud o mètriques incompletes: importada amb reserves',
'tours.import.caution': 'Importada amb reserves: altitud o mètriques incompletes',
'tours.import.success': '{count} excursions importades de GPX',
'tours.import.successOne': '1 excursió importada de GPX',
'tours.import.success.one': '{count} excursió importada de GPX',
'tours.import.noTrack': "No s'ha trobat cap traça ni ruta en aquest fitxer GPX",
'tours.import.error': "No s'ha pogut importar el fitxer GPX",
'tours.delete.confirmBody':
+5 -4
View File
@@ -41,10 +41,10 @@ const atlas: TranslationStrings = {
'atlas.streak': 'Série',
'atlas.year': 'rok',
'atlas.years': 'roky/let',
'atlas.yearInRow': 'rok v řadě',
'atlas.yearsInRow': 'let v řadě',
'atlas.tripIn': 'cesta v roce',
'atlas.yearsInRow.one': 'rok v řadě',
'atlas.tripsIn': 'cest v roce',
'atlas.tripsIn.one': 'cesta v roce',
'atlas.since': 'od',
'atlas.europe': 'Evropa',
'atlas.asia': 'Asie',
@@ -55,10 +55,11 @@ const atlas: TranslationStrings = {
'atlas.other': 'Ostatní',
'atlas.firstVisit': 'První cesta',
'atlas.lastVisitLabel': 'Poslední cesta',
'atlas.tripSingular': 'Cesta',
'atlas.tripPlural': 'Cesty',
'atlas.placeVisited': 'Navštívené místo',
'atlas.tripsLabel': 'Cesty',
'atlas.tripsLabel.one': 'Cesta',
'atlas.placesVisited': 'Navštívená místa',
'atlas.placesVisited.one': 'Navštívené místo',
'atlas.searchCountry': 'Hledat zemi...',
'atlas.searchPlaces': 'Místa',
'atlas.planned': 'Plánováno',
+1 -1
View File
@@ -89,7 +89,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'Uzavřít',
'collab.polls.closed': 'Uzavřeno',
'collab.polls.votes': '{n} hlasů',
'collab.polls.vote': '{n} hlas',
'collab.polls.votes.one': '{n} hlas',
'collab.polls.multipleChoice': 'Více možností',
'collab.polls.multiChoice': 'Více možností',
'collab.polls.deadline': 'Termín',
+11 -10
View File
@@ -34,6 +34,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'Dní',
'dashboard.places': 'Míst',
'dashboard.members': 'Cestovní parťáci',
'dashboard.card.buddies': 'Cestovní parťáci',
'dashboard.card.buddies.one': 'Parťák',
'dashboard.archive': 'Archivovat',
'dashboard.copyTrip': 'Kopírovat',
'dashboard.copySuffix': 'kopie',
@@ -143,8 +145,8 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.inDays': 'Za {count} dní',
'dashboard.mobile.inMonths': 'Za {count} měsíců',
'dashboard.mobile.spotlightDayOf': 'Den {day} z {total}',
'dashboard.mobile.spotlightDayOne': '{count} den',
'dashboard.mobile.spotlightDaysMany': '{count} dní',
'dashboard.mobile.spotlightDays': '{count} dní',
'dashboard.mobile.spotlightDays.one': '{count} den',
'dashboard.mobile.completed': 'Dokončeno',
'dashboard.mobile.currencyConverter': 'Převodník měn',
'dashboard.filter.planned': 'Plánované',
@@ -155,14 +157,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'NEDÁVNO',
'dashboard.hero.tripDates': 'Termíny cesty',
'dashboard.hero.noDates': 'Bez termínu',
'dashboard.hero.travelerOne': '{count} cestovatel',
'dashboard.hero.travelerMany': '{count} cestovatelů',
'dashboard.hero.destinationOne': '{count} cíl',
'dashboard.hero.destinationMany': '{count} cílů',
'dashboard.hero.dayUnitOne': 'den',
'dashboard.hero.dayUnitMany': 'dní',
'dashboard.hero.dayLeft': 'Zbývá den',
'dashboard.hero.travelers': '{count} cestovatelů',
'dashboard.hero.travelers.one': '{count} cestovatel',
'dashboard.hero.destinations': '{count} cílů',
'dashboard.hero.destinations.one': '{count} cíl',
'dashboard.hero.dayUnit': 'dní',
'dashboard.hero.dayUnit.one': 'den',
'dashboard.hero.daysLeft': 'Zbývá dní',
'dashboard.hero.daysLeft.one': 'Zbývá den',
'dashboard.hero.lastDay': 'Poslední den',
'dashboard.hero.untilStart': 'Do začátku',
'dashboard.hero.startsIn': 'Začíná za',
@@ -177,7 +179,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× kolem rovníku',
'dashboard.card.idea': 'Nápad',
'dashboard.card.buddyOne': 'Parťák',
'dashboard.fx.from': 'Z',
'dashboard.fx.to': 'Na',
'dashboard.fx.unavailable': 'Kurz není dostupný',
+1 -1
View File
@@ -14,7 +14,7 @@ const files: TranslationStrings = {
'files.openError': 'Soubor nelze otevřít',
'files.downloadPdf': 'Stáhnout PDF',
'files.count': '{count} souborů',
'files.countSingular': '1 soubor',
'files.count.one': '{count} soubor',
'files.uploaded': '{count} nahráno',
'files.uploadError': 'Nahrávání se nezdařilo',
'files.uploadErrorType': 'Tento typ souboru není podporován',
+1 -1
View File
@@ -7,8 +7,8 @@ const members: TranslationStrings = {
'members.invite': 'Pozvat',
'members.allHaveAccess': 'Všichni uživatelé již mají přístup.',
'members.access': 'Přístup',
'members.person': 'osoba',
'members.persons': 'osob',
'members.persons.one': 'osoba',
'members.you': 'vy',
'members.owner': 'Vlastník',
'members.leaveTrip': 'Opustit cestu',
+2 -2
View File
@@ -84,8 +84,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
'oauth.authorize.trustNote': 'Only grant access to applications you trust. Your data stays on your server.', // en-fallback
'oauth.authorize.selectScope': 'Select at least one scope', // en-fallback
'oauth.authorize.approveOneScope': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveManyScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes.one': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveAccess': 'Approve Access', // en-fallback
'oauth.authorize.deny': 'Deny', // en-fallback
'oauth.authorize.choosePermissions': 'Choose which permissions to grant', // en-fallback
+1 -1
View File
@@ -70,7 +70,7 @@ const places: TranslationStrings = {
'places.categoriesSelected': 'kategorií',
'places.clearFilter': 'Vymazat filtr',
'places.count': '{count} míst',
'places.countSingular': '1 místo',
'places.count.one': '{count} místo',
'places.allPlanned': 'Všechna místa jsou naplánována',
'places.noneFound': 'Žádná místa nebyla nalezena',
'places.editPlace': 'Upravit místo',
+1 -1
View File
@@ -20,7 +20,7 @@ const tours: TranslationStrings = {
'tours.caution.tooltip': 'Neúplné výškové údaje nebo metriky – importováno s výhradou',
'tours.import.caution': 'Importováno s výhradou – neúplné výškové údaje nebo metriky',
'tours.import.success': 'Počet výletů importovaných z GPX: {count}',
'tours.import.successOne': 'Z GPX byl importován 1 výlet',
'tours.import.success.one': 'Z GPX byl importován {count} výlet',
'tours.import.noTrack': 'V tomto souboru GPX nebyla nalezena stopa ani trasa',
'tours.import.error': 'Soubor GPX se nepodařilo importovat',
'tours.delete.confirmBody':
+5 -4
View File
@@ -43,10 +43,10 @@ const atlas: TranslationStrings = {
'atlas.daysLeft': 'Tage',
'atlas.streak': 'Serie',
'atlas.years': 'Jahre',
'atlas.yearInRow': 'Jahr in Folge',
'atlas.yearsInRow': 'Jahre in Folge',
'atlas.tripIn': 'Reise in',
'atlas.yearsInRow.one': 'Jahr in Folge',
'atlas.tripsIn': 'Reisen in',
'atlas.tripsIn.one': 'Reise in',
'atlas.since': 'seit',
'atlas.europe': 'Europa',
'atlas.asia': 'Asien',
@@ -57,10 +57,11 @@ const atlas: TranslationStrings = {
'atlas.other': 'Andere',
'atlas.firstVisit': 'Erste Reise',
'atlas.lastVisitLabel': 'Letzte Reise',
'atlas.tripSingular': 'Reise',
'atlas.tripPlural': 'Reisen',
'atlas.placeVisited': 'Ort besucht',
'atlas.tripsLabel': 'Reisen',
'atlas.tripsLabel.one': 'Reise',
'atlas.placesVisited': 'Orte besucht',
'atlas.placesVisited.one': 'Ort besucht',
'atlas.planned': 'Geplant',
'atlas.showPlanned': 'Geplante Länder anzeigen',
'atlas.plannedFor': 'Geplant für',
+1 -1
View File
@@ -89,7 +89,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'Schließen',
'collab.polls.closed': 'Geschlossen',
'collab.polls.votes': '{n} Stimmen',
'collab.polls.vote': '{n} Stimme',
'collab.polls.votes.one': '{n} Stimme',
'collab.polls.multipleChoice': 'Mehrfachauswahl',
'collab.polls.multiChoice': 'Mehrfachauswahl',
'collab.polls.deadline': 'Frist',
+11 -10
View File
@@ -35,6 +35,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'Tage',
'dashboard.places': 'Orte',
'dashboard.members': 'Reise-Buddies',
'dashboard.card.buddies': 'Reise-Buddies',
'dashboard.card.buddies.one': 'Begleiter',
'dashboard.archive': 'Archivieren',
'dashboard.copyTrip': 'Kopieren',
'dashboard.copySuffix': 'Kopie',
@@ -146,8 +148,8 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.tomorrow': 'Morgen',
'dashboard.mobile.inDays': 'In {count} Tagen',
'dashboard.mobile.spotlightDayOf': 'Tag {day} von {total}',
'dashboard.mobile.spotlightDayOne': '{count} Tag',
'dashboard.mobile.spotlightDaysMany': '{count} Tage',
'dashboard.mobile.spotlightDays': '{count} Tage',
'dashboard.mobile.spotlightDays.one': '{count} Tag',
'dashboard.mobile.inMonths': 'In {count} Monaten',
'dashboard.mobile.completed': 'Abgeschlossen',
'dashboard.mobile.currencyConverter': 'Währungsrechner',
@@ -159,14 +161,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'KÜRZLICH',
'dashboard.hero.tripDates': 'Reisedaten',
'dashboard.hero.noDates': 'Freie Planung',
'dashboard.hero.travelerOne': '{count} Reisender',
'dashboard.hero.travelerMany': '{count} Reisende',
'dashboard.hero.destinationOne': '{count} Ziel',
'dashboard.hero.destinationMany': '{count} Ziele',
'dashboard.hero.dayUnitOne': 'Tag',
'dashboard.hero.dayUnitMany': 'Tage',
'dashboard.hero.dayLeft': 'Tag übrig',
'dashboard.hero.travelers': '{count} Reisende',
'dashboard.hero.travelers.one': '{count} Reisender',
'dashboard.hero.destinations': '{count} Ziele',
'dashboard.hero.destinations.one': '{count} Ziel',
'dashboard.hero.dayUnit': 'Tage',
'dashboard.hero.dayUnit.one': 'Tag',
'dashboard.hero.daysLeft': 'Tage übrig',
'dashboard.hero.daysLeft.one': 'Tag übrig',
'dashboard.hero.lastDay': 'Letzter Tag',
'dashboard.hero.untilStart': 'Bis Start',
'dashboard.hero.startsIn': 'Reise beginnt in',
@@ -181,7 +183,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× um den Äquator',
'dashboard.card.idea': 'Idee',
'dashboard.card.buddyOne': 'Begleiter',
'dashboard.fx.from': 'Von',
'dashboard.fx.to': 'Nach',
'dashboard.fx.unavailable': 'Kurs nicht verfügbar',
+1 -1
View File
@@ -14,7 +14,7 @@ const files: TranslationStrings = {
'files.openError': 'Datei konnte nicht geöffnet werden',
'files.downloadPdf': 'PDF herunterladen',
'files.count': '{count} Dateien',
'files.countSingular': '1 Datei',
'files.count.one': '{count} Datei',
'files.uploaded': '{count} hochgeladen',
'files.uploadError': 'Fehler beim Hochladen',
'files.uploadErrorType': 'Dieser Dateityp wird nicht unterstützt',
+1 -1
View File
@@ -7,8 +7,8 @@ const members: TranslationStrings = {
'members.invite': 'Einladen',
'members.allHaveAccess': 'Alle Benutzer haben bereits Zugriff.',
'members.access': 'Zugriff',
'members.person': 'Person',
'members.persons': 'Personen',
'members.persons.one': 'Person',
'members.you': 'du',
'members.owner': 'Eigentümer',
'members.leaveTrip': 'Reise verlassen',
+2 -2
View File
@@ -88,8 +88,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.', // en-fallback
'oauth.authorize.trustNote': 'Only grant access to applications you trust. Your data stays on your server.', // en-fallback
'oauth.authorize.selectScope': 'Select at least one scope', // en-fallback
'oauth.authorize.approveOneScope': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveManyScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes': 'Approve ({count} scopes)', // en-fallback
'oauth.authorize.approveScopes.one': 'Approve ({count} scope)', // en-fallback
'oauth.authorize.approveAccess': 'Approve Access', // en-fallback
'oauth.authorize.deny': 'Deny', // en-fallback
'oauth.authorize.choosePermissions': 'Choose which permissions to grant', // en-fallback
+1 -1
View File
@@ -70,7 +70,7 @@ const places: TranslationStrings = {
'places.categoriesSelected': 'Kategorien',
'places.clearFilter': 'Filter zurücksetzen',
'places.count': '{count} Orte',
'places.countSingular': '1 Ort',
'places.count.one': '{count} Ort',
'places.allPlanned': 'Alle Orte sind eingeplant',
'places.noneFound': 'Keine Orte gefunden',
'places.editPlace': 'Ort bearbeiten',
+1 -1
View File
@@ -20,7 +20,7 @@ const tours: TranslationStrings = {
'tours.caution.tooltip': 'Höhendaten oder Streckenwerte unvollständig – mit Vorsicht importiert',
'tours.import.caution': 'Mit Vorsicht importiert – Höhendaten oder Streckenwerte unvollständig',
'tours.import.success': '{count} Touren aus GPX importiert',
'tours.import.successOne': '1 Tour aus GPX importiert',
'tours.import.success.one': '{count} Tour aus GPX importiert',
'tours.import.noTrack': 'Keine Strecke oder Route in dieser GPX-Datei gefunden',
'tours.import.error': 'GPX-Datei konnte nicht importiert werden',
'tours.delete.confirmBody':
+5 -2
View File
@@ -336,7 +336,8 @@ const admin: TranslationStrings = {
'This plugin does not use operator-supplied hosts. Its allowed hosts are fixed in its manifest.',
'admin.plugins.allowedHosts.restartNote': 'Saving restarts the plugin so it picks up the new list.',
'admin.plugins.allowedHosts.add': 'Add allowed host',
'admin.plugins.allowedHosts.count': '{n} allowed host(s)',
'admin.plugins.allowedHosts.count': '{n} allowed hosts',
'admin.plugins.allowedHosts.count.one': '{n} allowed host',
'admin.plugins.operatorEgressPill': '+ hosts you add',
'admin.plugins.operatorEgressHint':
'This plugin talks to a service only you can name. After installing, add the hosts it may reach under ⋯ → Allowed hosts. It can reach no others.',
@@ -552,6 +553,7 @@ const admin: TranslationStrings = {
'admin.plugins.sortUpdates': 'Updates first',
'admin.plugins.sortDownloads': 'Most downloads',
'admin.plugins.updatesAvailable': '{count} updates available for your plugins.',
'admin.plugins.updatesAvailable.one': '{count} update available for your plugins.',
'admin.plugins.newerNeedsTrek': 'v{version} available — needs TREK {range}',
'admin.plugins.updateAll': 'Update all',
'admin.plugins.versionsTitle': 'Versions',
@@ -800,7 +802,8 @@ const admin: TranslationStrings = {
'admin.passkey.resetHint':
"Remove all of this user's passkeys (e.g. on a lost device). They can still sign in with their password.",
'admin.passkey.resetConfirm': 'Remove all passkeys for {name}?',
'admin.passkey.resetDone': 'Removed {count} passkey(s)',
'admin.passkey.resetDone': 'Removed {count} passkeys',
'admin.passkey.resetDone.one': 'Removed {count} passkey',
'admin.group.users': 'Users',
'admin.group.config': 'Configuration',
'admin.group.integration': 'Integrations',
+7 -4
View File
@@ -43,10 +43,10 @@ const atlas: TranslationStrings = {
'atlas.daysLeft': 'days left',
'atlas.streak': 'Streak',
'atlas.years': 'years',
'atlas.yearInRow': 'year in a row',
'atlas.yearsInRow': 'years in a row',
'atlas.tripIn': 'trip in',
'atlas.yearsInRow.one': 'year in a row',
'atlas.tripsIn': 'trips in',
'atlas.tripsIn.one': 'trip in',
'atlas.since': 'since',
'atlas.europe': 'Europe',
'atlas.asia': 'Asia',
@@ -57,13 +57,16 @@ const atlas: TranslationStrings = {
'atlas.other': 'Other',
'atlas.firstVisit': 'First trip',
'atlas.lastVisitLabel': 'Last trip',
'atlas.tripSingular': 'Trip',
'atlas.tripPlural': 'Trips',
'atlas.placeVisited': 'Place visited',
'atlas.tripsLabel': 'Trips',
'atlas.tripsLabel.one': 'Trip',
'atlas.placesVisited': 'Places visited',
'atlas.placesVisited.one': 'Place visited',
'atlas.planned': 'Planned',
'atlas.showPlanned': 'Show planned countries',
'atlas.plannedFor': 'Planned for',
'atlas.antarctica': 'Antarctica',
'atlas.regionPlacesLabel': 'places',
'atlas.regionPlacesLabel.one': 'place',
};
export default atlas;
+7
View File
@@ -29,6 +29,7 @@ const budget: TranslationStrings = {
'budget.editTooltip': 'Click to edit',
'budget.linkedToReservation': 'Linked to a reservation — edit the name there',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.confirm.deleteCategory.one': 'Are you sure you want to delete the category "{name}" with {count} entry?',
'budget.deleteCategory': 'Delete Category',
'budget.perPerson': 'Per Person',
'budget.paid': 'Paid',
@@ -59,6 +60,7 @@ const budget: TranslationStrings = {
'costs.outstandingItems': 'expenses need a payer',
'costs.expenses': 'Expenses',
'costs.entries': '{count} entries',
'costs.entries.one': '{count} entry',
'costs.searchPlaceholder': 'Search expenses…',
'costs.filter.all': 'All',
'costs.filter.mine': 'Paid by me',
@@ -66,6 +68,7 @@ const budget: TranslationStrings = {
'costs.filter.allCategories': 'All categories',
'costs.filter.allDays': 'All days',
'costs.expensesCount': '{count} expenses',
'costs.expensesCount.one': '{count} expense',
'costs.addExpense': 'Add expense',
'costs.view.label': 'View',
'costs.view.list': 'List',
@@ -105,6 +108,7 @@ const budget: TranslationStrings = {
'costs.settleHistory': 'Settle history',
'costs.noSettlements': 'No settled payments yet.',
'costs.paymentsSettled': '{count} payments settled',
'costs.paymentsSettled.one': '{count} payment settled',
'costs.paid': 'paid',
'costs.undo': 'Undo',
'costs.whatFor': 'What was it for?',
@@ -140,6 +144,7 @@ const budget: TranslationStrings = {
'costs.splitHint.ticket': 'List what was on the receipt and tick who had each item.',
'costs.pickSomeone': 'Pick at least one person to split with.',
'costs.splitSummary': 'Split {count} ways · {amount} each',
'costs.splitSummary.one': 'Split {count} way · {amount} each',
'costs.cat.accommodation': 'Accommodation',
'costs.cat.food': 'Food & drink',
'costs.cat.groceries': 'Groceries',
@@ -155,7 +160,9 @@ const budget: TranslationStrings = {
'costs.cat.parking': 'Parking',
'costs.cat.other': 'Other',
'costs.daysCount': '{count} days',
'costs.daysCount.one': '{count} day',
'costs.travelers': '{count} travelers',
'costs.travelers.one': '{count} traveler',
'costs.liveRate': 'live rate',
'costs.settleAll': 'Settle all',
'costs.payment': 'Payment',
+1 -1
View File
@@ -89,7 +89,7 @@ const collab: TranslationStrings = {
'collab.polls.close': 'Close',
'collab.polls.closed': 'Closed',
'collab.polls.votes': '{n} votes',
'collab.polls.vote': '{n} vote',
'collab.polls.votes.one': '{n} vote',
'collab.polls.multipleChoice': 'Multiple choice',
'collab.polls.multiChoice': 'Multiple choice',
'collab.polls.deadline': 'Deadline',
+21
View File
@@ -19,8 +19,11 @@ const collection: TranslationStrings = {
'collections.duplicateToListTitle': 'Duplicate {count} to another list',
'collections.noOtherLists': 'No other lists yet',
'collections.movedCount': 'Moved {count} places',
'collections.movedCount.one': 'Moved {count} place',
'collections.duplicatedCount': 'Duplicated {count} places',
'collections.duplicatedCount.one': 'Duplicated {count} place',
'collections.placeCount': '{count} places',
'collections.placeCount.one': '{count} place',
'collections.category': 'Category',
'collections.noCategory': 'No category',
'collections.editListTitle': 'Edit list',
@@ -42,6 +45,7 @@ const collection: TranslationStrings = {
'collections.importPickTripHint': 'Pick a trip and take its places into {name}.',
'collections.importNoTrips': 'No trips yet',
'collections.importPlacesCount': '{count} places',
'collections.importPlacesCount.one': '{count} place',
'collections.importSearchPlaces': 'Search places…',
'collections.importOnlyNew': 'Only new',
'collections.importSelectAll': 'Select all',
@@ -50,11 +54,14 @@ const collection: TranslationStrings = {
'collections.importUnscheduled': 'Unscheduled',
'collections.importOnDay': 'Day {count}',
'collections.importSelectedCount': '{count} selected',
'collections.importSelectedCount.one': '{count} selected',
'collections.importAction': 'Import {count}',
'collections.importEmptyTrip': 'This trip has no places yet',
'collections.importNothingNew': 'Everything from this trip is already on this list',
'collections.importDone': '{count} places added',
'collections.importDone.one': '{count} place added',
'collections.importDoneSkipped': '{count} were already on the list and stayed as they were',
'collections.importDoneSkipped.one': '{count} was already on the list and stayed as it was',
'collections.importDoneClean': 'They are all in {name} now',
'collections.sort.default': 'Default',
'collections.sort.nameAsc': 'Name (A–Z)',
@@ -103,6 +110,7 @@ const collection: TranslationStrings = {
'collections.markVisitedNone': 'None of these places are saved in a list',
'collections.markedVisited': 'Marked as visited',
'collections.markedVisitedTrip': '{count} places marked as visited',
'collections.markedVisitedTrip.one': '{count} place marked as visited',
'collections.copyToTrip': 'Copy to trip',
'collections.copyToTripTitle': 'Copy to trip',
@@ -114,7 +122,9 @@ const collection: TranslationStrings = {
'collections.alreadyInTrip': 'Already in this trip',
'collections.copied': 'Copied to trip',
'collections.copiedCount': 'Copied {count} places',
'collections.copiedCount.one': 'Copied {count} place',
'collections.skippedDuplicates': 'Skipped {count} duplicates',
'collections.skippedDuplicates.one': 'Skipped {count} duplicate',
'collections.copyNothing': 'Nothing to copy',
'collections.removeFromList': 'Remove from list',
'collections.removeFromListConfirm': 'Remove this place from the list?',
@@ -128,6 +138,7 @@ const collection: TranslationStrings = {
'collections.search': 'Search places',
'collections.selectMode': 'Select',
'collections.selectedCount': '{count} selected',
'collections.selectedCount.one': '{count} selected',
'collections.pickList': 'Save to list',
'collections.pickListPlaceholder': 'Choose a list',
@@ -159,14 +170,18 @@ const collection: TranslationStrings = {
'collections.file.intoHint': 'Places the list already has stay as they are, and so do its name and colour. Labels the file brings are added.',
'collections.file.confirmInto': 'Add to list',
'collections.file.doneInto': '{count} places added to {name}',
'collections.file.doneInto.one': '{count} place added to {name}',
'collections.file.doneIntoDuplicates': '{count} added to {name}, {duplicates} were already there',
'collections.file.doneIntoNothing': 'Every place in the file is already in {name}',
'collections.file.change': 'Change',
'collections.file.labelCount': '{count} labels',
'collections.file.labelCount.one': '{count} label',
'collections.file.hint':
'The places arrive as a new list of your own. Ratings, members and uploaded photos stay behind.',
'collections.file.done': '{count} places imported',
'collections.file.done.one': '{count} place imported',
'collections.file.doneSkipped': '{count} places imported, {skipped} skipped',
'collections.file.doneSkipped.one': '{count} place imported, {skipped} skipped',
'collections.file.errorTooLarge': 'That file is too large to be a list.',
'collections.file.errorUnreadable': 'That file could not be read.',
'collections.file.errorNotACollection': 'That is not a TREK list file.',
@@ -174,10 +189,13 @@ const collection: TranslationStrings = {
'collections.file.formatTrekHint': 'For another TREK, with labels and status',
'collections.file.formatGpxHint': 'Waypoints for OsmAnd, Organic Maps, Garmin and other map apps',
'collections.file.gpxOmitted': '{count} places have no coordinates and were left out of the GPX file',
'collections.file.gpxOmitted.one': '{count} place has no coordinates and was left out of the GPX file',
'collections.file.gpxNothing': 'None of these places has coordinates, so there is nothing to put in a GPX file.',
'collections.file.reading': 'Reading the file…',
'collections.file.gpxSkipped': '{count} waypoints without usable coordinates are left out.',
'collections.file.gpxSkipped.one': '{count} waypoint without usable coordinates is left out.',
'collections.file.gpxTrack': 'This file also has {count} track points. Tracks are not imported, only waypoints.',
'collections.file.gpxTrack.one': 'This file also has {count} track point. Tracks are not imported, only waypoints.',
'collections.file.gpxEmpty': 'This GPX file has no waypoints, so there is nothing to import.',
'collections.file.errorNotGpx': 'That is not a GPX file.',
'collections.file.errorTooManyPlaces':
@@ -216,6 +234,7 @@ const collection: TranslationStrings = {
'collections.widget.title': 'Collections',
'collections.widget.empty': 'No saved places yet',
'collections.widget.savedCount': '{count} saved',
'collections.widget.savedCount.one': '{count} saved',
'collections.labels.title': 'Labels',
'collections.labels.manage': 'Manage labels',
@@ -224,7 +243,9 @@ const collection: TranslationStrings = {
'collections.labels.namePlaceholder': 'e.g. Berlin',
'collections.labels.assign': 'Assign label',
'collections.labels.assignN': 'Add labels to {count} places',
'collections.labels.assignN.one': 'Add labels to {count} place',
'collections.labels.assignedCount': 'Labelled {count} places',
'collections.labels.assignedCount.one': 'Labelled {count} place',
'collections.labels.empty': 'No labels yet',
'collections.labels.emptyHint': 'Create a label first to group places in this list.',
};
+18 -10
View File
@@ -4,10 +4,12 @@ const dashboard: TranslationStrings = {
'dashboard.title': 'My Trips',
'dashboard.subtitle.loading': 'Loading trips...',
'dashboard.subtitle.trips': '{count} trips ({archived} archived)',
'dashboard.subtitle.trips.one': '{count} trip ({archived} archived)',
'dashboard.subtitle.empty': 'Start your first trip',
'dashboard.subtitle.activeOne': '{count} active trip',
'dashboard.subtitle.activeMany': '{count} active trips',
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
'dashboard.subtitle.archivedSuffix.one': ' · {count} archived',
'dashboard.newTrip': 'New Trip',
'dashboard.newTripSub': 'Plan a new trip from scratch',
'dashboard.subscribeAllTrips': 'Subscribe to all trips',
@@ -34,6 +36,8 @@ const dashboard: TranslationStrings = {
'dashboard.days': 'Days',
'dashboard.places': 'Places',
'dashboard.members': 'Buddies',
'dashboard.card.buddies': 'Buddies',
'dashboard.card.buddies.one': 'Buddy',
'dashboard.archive': 'Archive',
'dashboard.copyTrip': 'Copy',
'dashboard.copySuffix': 'copy',
@@ -48,6 +52,7 @@ const dashboard: TranslationStrings = {
'dashboard.status.tomorrow': 'Tomorrow',
'dashboard.status.past': 'Past',
'dashboard.status.daysLeft': '{count} days left',
'dashboard.status.daysLeft.one': '{count} day left',
'dashboard.toast.loadError': 'Failed to load trips',
'dashboard.loadErrorBanner': "Couldn't reach the server. Your trips are safe — please try again.",
'dashboard.retry': 'Retry',
@@ -139,6 +144,7 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.liveNow': 'Live Now',
'dashboard.mobile.tripProgress': 'Trip progress',
'dashboard.mobile.daysLeft': '{count} days left',
'dashboard.mobile.daysLeft.one': '{count} day left',
'dashboard.mobile.places': 'Places',
'dashboard.mobile.buddies': 'Buddies',
'dashboard.mobile.newTrip': 'New Trip',
@@ -156,10 +162,12 @@ const dashboard: TranslationStrings = {
'dashboard.mobile.startsToday': 'Starts today',
'dashboard.mobile.tomorrow': 'Tomorrow',
'dashboard.mobile.inDays': 'In {count} days',
'dashboard.mobile.inDays.one': 'In {count} day',
'dashboard.mobile.inMonths': 'In {count} months',
'dashboard.mobile.inMonths.one': 'In {count} month',
'dashboard.mobile.spotlightDayOf': 'Day {day} of {total}',
'dashboard.mobile.spotlightDayOne': '{count} day',
'dashboard.mobile.spotlightDaysMany': '{count} days',
'dashboard.mobile.spotlightDays': '{count} days',
'dashboard.mobile.spotlightDays.one': '{count} day',
'dashboard.mobile.completed': 'Completed',
'dashboard.mobile.currencyConverter': 'Currency Converter',
'dashboard.filter.planned': 'Planned',
@@ -170,14 +178,14 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeRecent': 'RECENT',
'dashboard.hero.tripDates': 'Trip dates',
'dashboard.hero.noDates': 'Open dates',
'dashboard.hero.travelerOne': '{count} traveler',
'dashboard.hero.travelerMany': '{count} travelers',
'dashboard.hero.destinationOne': '{count} destination',
'dashboard.hero.destinationMany': '{count} destinations',
'dashboard.hero.dayUnitOne': 'day',
'dashboard.hero.dayUnitMany': 'days',
'dashboard.hero.dayLeft': 'Day left',
'dashboard.hero.travelers': '{count} travelers',
'dashboard.hero.travelers.one': '{count} traveler',
'dashboard.hero.destinations': '{count} destinations',
'dashboard.hero.destinations.one': '{count} destination',
'dashboard.hero.dayUnit': 'days',
'dashboard.hero.dayUnit.one': 'day',
'dashboard.hero.daysLeft': 'Days left',
'dashboard.hero.daysLeft.one': 'Day left',
'dashboard.hero.lastDay': 'Last day',
'dashboard.hero.untilStart': 'Until start',
'dashboard.hero.startsIn': 'Trip starts in',
@@ -185,6 +193,7 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.ofTotal': 'of {total}',
'dashboard.atlas.tripsTotal': 'Trips total',
'dashboard.atlas.placesMapped': '{count} places mapped',
'dashboard.atlas.placesMapped.one': '{count} place mapped',
'dashboard.atlas.daysTraveled': 'Days traveled',
'dashboard.atlas.daysUnit': 'days',
'dashboard.atlas.acrossAllTrips': 'across all trips',
@@ -192,7 +201,6 @@ const dashboard: TranslationStrings = {
'dashboard.atlas.kmUnit': 'km',
'dashboard.atlas.aroundEquator': '≈ {count}× around the equator',
'dashboard.card.idea': 'Idea',
'dashboard.card.buddyOne': 'Buddy',
'dashboard.fx.from': 'From',
'dashboard.fx.to': 'To',
'dashboard.fx.unavailable': 'Rate unavailable',
+9
View File
@@ -15,6 +15,7 @@ const dawarich: TranslationStrings = {
'dawarich.syncEnabledHint': 'Off means TREK only reads Dawarich when you ask it to.',
'dawarich.test.button': 'Test connection',
'dawarich.test.success': 'Connected. {count} stays found in the last 30 days.',
'dawarich.test.success.one': 'Connected. {count} stay found in the last 30 days.',
'dawarich.test.failed': 'Could not reach Dawarich.',
'dawarich.syncNow': 'Check now',
'dawarich.connected': 'Connected',
@@ -29,6 +30,7 @@ const dawarich: TranslationStrings = {
'dawarich.toast.saveError': 'Could not save the connection',
'dawarich.toast.disconnected': 'Dawarich disconnected',
'dawarich.toast.synced': '{count} new stays found',
'dawarich.toast.synced.one': '{count} new stay found',
'dawarich.toast.syncError': 'Could not read Dawarich',
'dawarich.toast.syncRunning': 'A check is already running',
'dawarich.toast.acceptError': 'Could not add this',
@@ -80,6 +82,7 @@ const dawarich: TranslationStrings = {
'dawarich.suggestions.title': 'From Dawarich',
'dawarich.suggestions.pending': '{count} waiting for you',
'dawarich.suggestions.pending.one': '{count} waiting for you',
'dawarich.suggestions.loading': 'Reading Dawarich…',
'dawarich.suggestions.notConnected': 'Connect Dawarich in Settings to see your stays here.',
'dawarich.suggestions.unavailable': 'Dawarich could not be read.',
@@ -142,7 +145,9 @@ const dawarich: TranslationStrings = {
'dawarich.bucket.alreadyVisited': 'Already ticked off',
'dawarich.bucket.confirm': 'Tick off {count}',
'dawarich.bucket.confirmed': '{count} wishes ticked off',
'dawarich.bucket.confirmed.one': '{count} wish ticked off',
'dawarich.bucket.skipped': '{count} entries have no coordinates and could not be checked.',
'dawarich.bucket.skipped.one': '{count} entry has no coordinates and could not be checked.',
'dawarich.bucket.truncated': 'Only the first entries were checked. Run it again for the rest.',
'dawarich.bucket.visitedFrom': 'Ticked off from your Dawarich recordings',
'dawarich.bucket.clearVisit': 'Undo',
@@ -155,9 +160,12 @@ const dawarich: TranslationStrings = {
'dawarich.atlas.loading': 'Reading your recordings…',
'dawarich.atlas.empty': 'Your recordings show no countries TREK does not already have.',
'dawarich.atlas.cities': '{count} cities',
'dawarich.atlas.cities.one': '{count} city',
'dawarich.atlas.citiesOne': '1 city',
'dawarich.atlas.accept': 'Add {count} countries',
'dawarich.atlas.accept.one': 'Add {count} country',
'dawarich.atlas.accepted': '{count} countries added',
'dawarich.atlas.accepted.one': '{count} country added',
'dawarich.atlas.unresolved': 'TREK could not match these to a country: {names}.',
'dawarich.atlas.source': 'From Dawarich',
'dawarich.atlas.range': 'Looked at {from} to {to}',
@@ -168,6 +176,7 @@ const dawarich: TranslationStrings = {
'dawarich.atlas.tab.countries': 'Countries',
'dawarich.atlas.window': 'The last 12 months were looked at.',
'dawarich.selected': '{count} selected',
'dawarich.selected.one': '{count} selected',
'dawarich.again': 'Check again',
'dawarich.bucket.metersAway': '{meters} m away',
'dawarich.bucket.kilometersAway': '{km} km away',
+3 -1
View File
@@ -10,12 +10,14 @@ const files: TranslationStrings = {
'files.title': 'Files',
'files.pageTitle': 'Files & Documents',
'files.subtitle': '{count} files for {trip}',
'files.subtitle.one': '{count} file for {trip}',
'files.download': 'Download',
'files.openError': 'Could not open file',
'files.downloadPdf': 'Download PDF',
'files.count': '{count} files',
'files.countSingular': '1 file',
'files.count.one': '{count} file',
'files.uploaded': '{count} uploaded',
'files.uploaded.one': '{count} uploaded',
'files.uploadError': 'Upload failed',
'files.uploadErrorType': "This file type isn't supported",
'files.dropzone': 'Drop files here',
+3
View File
@@ -15,8 +15,10 @@ const help: TranslationStrings = {
'help.center.screens': 'Screens',
'help.center.thisScreen': 'This screen',
'help.center.subScreens': '{count} sub-screens',
'help.center.subScreens.one': '{count} sub-screen',
'help.center.subScreensLabel': 'Sub-screens',
'help.center.guidesCount': '{count} guides',
'help.center.guidesCount.one': '{count} guide',
'help.center.goToScreen': 'Go to {screen}',
'help.center.overview': 'Overview',
'help.center.howTo': 'How do I…',
@@ -28,6 +30,7 @@ const help: TranslationStrings = {
'help.center.back': 'Back',
'help.center.close': 'Close help',
'help.center.steps': '{count} steps',
'help.center.steps.one': '{count} step',
'help.center.step': 'Step {n}',
'help.center.stepsLabel': 'Steps',
'help.center.stepOf': 'Step {n} of {total}',
+1
View File
@@ -7,6 +7,7 @@ const inspector: TranslationStrings = {
'inspector.showHours': 'Show opening hours',
'inspector.files': 'Files',
'inspector.filesCount': '{count} files',
'inspector.filesCount.one': '{count} file',
'inspector.remove': 'Remove',
'inspector.removeFromDay': 'Remove from Day',
'inspector.addToDay': 'Add to Day',
+6
View File
@@ -83,6 +83,7 @@ const journey: TranslationStrings = {
'journey.detail.jumpToLast': 'Jump to the last entry',
'journey.detail.dayJump': 'Jump to a day',
'journey.detail.dayJumpCount': '{count} days',
'journey.detail.dayJumpCount.one': '{count} day',
'journey.detail.newEntry': 'New Entry',
'journey.detail.editEntry': 'Edit Entry',
'journey.detail.noEntries': 'No entries yet',
@@ -232,8 +233,10 @@ const journey: TranslationStrings = {
'journey.settings.failedToDelete': 'Failed to delete',
'journey.entries.deleteTitle': 'Delete Entry',
'journey.photosUploaded': '{count} photos uploaded',
'journey.photosUploaded.one': '{count} photo uploaded',
'journey.photosUploadFailed': 'Some photos failed to upload',
'journey.photosAdded': '{count} photos added',
'journey.photosAdded.one': '{count} photo added',
'journey.public.notFound': 'Not Found',
'journey.public.notFoundMessage': "This journey doesn't exist or the link has expired.",
'journey.public.readOnly': 'Read-only · Public Journey',
@@ -292,6 +295,7 @@ const journey: TranslationStrings = {
'journey.studio.exportSave': 'Save as PDF',
'journey.studio.exportPreparing': 'Preparing',
'journey.studio.exportSheetCount': '{count} sheets',
'journey.studio.exportSheetCount.one': '{count} sheet',
'journey.studio.undo': 'Undo',
'journey.studio.redo': 'Redo',
'journey.studio.zoomIn': 'Zoom in',
@@ -636,6 +640,7 @@ const journey: TranslationStrings = {
'journey.studio.uploading': 'Uploading {done} of {total}',
'journey.studio.dropFilesHere': 'Drop to add the pictures',
'journey.studio.videosSkipped': '{count} videos were left out. A book holds pictures.',
'journey.studio.videosSkipped.one': '{count} video was left out. A book holds pictures.',
'journey.studio.fillPage': 'Fill page',
'journey.studio.fillSpread': 'Fill spread',
'journey.studio.fillHint':
@@ -661,6 +666,7 @@ const journey: TranslationStrings = {
'journey.suggestions.restore': 'Bring back dismissed suggestions',
'journey.suggestions.restoreCount': 'Dismissed suggestions ({count})',
'journey.suggestions.restored': '{count} suggestions are back',
'journey.suggestions.restored.one': '{count} suggestion is back',
'journey.detail.addOnThisDay': 'Add an entry on this day',
'journey.detail.jumpToDay': 'Jump to {date}',
'journey.detail.searchPlaceholder': 'Search this journey',
+4 -2
View File
@@ -29,7 +29,9 @@ const map: TranslationStrings = {
'map.overview.hide': 'Hide whole trip',
'map.overview.total': 'Total distance',
'map.attribution': 'Map credits',
'map.overview.unrouted': '{count} leg(s) could not be routed, so the distances are incomplete.',
'map.overview.dayUnrouted': '{count} leg(s) of this day could not be routed',
'map.overview.unrouted': '{count} legs could not be routed, so the distances are incomplete.',
'map.overview.unrouted.one': '{count} leg could not be routed, so the distances are incomplete.',
'map.overview.dayUnrouted': '{count} legs of this day could not be routed',
'map.overview.dayUnrouted.one': '{count} leg of this day could not be routed',
};
export default map;
+1 -1
View File
@@ -7,8 +7,8 @@ const members: TranslationStrings = {
'members.invite': 'Invite',
'members.allHaveAccess': 'All users already have access.',
'members.access': 'Access',
'members.person': 'person',
'members.persons': 'persons',
'members.persons.one': 'person',
'members.you': 'you',
'members.owner': 'Owner',
'members.leaveTrip': 'Leave trip',
+2
View File
@@ -16,6 +16,7 @@ const memories: TranslationStrings = {
'memories.reviewTitle': 'Review your photos',
'memories.reviewHint': 'Click photos to exclude them from sharing.',
'memories.shareCount': 'Share {count} photos',
'memories.shareCount.one': 'Share {count} photo',
'memories.providerUrl': 'Server URL',
'memories.providerApiKey': 'API Key',
'memories.providerUsername': 'Username',
@@ -48,6 +49,7 @@ const memories: TranslationStrings = {
'memories.selectHint': 'Tap photos to select them.',
'memories.selected': 'selected',
'memories.addSelected': 'Add {count} photos',
'memories.addSelected.one': 'Add {count} photo',
'memories.alreadyAdded': 'Added',
'memories.private': 'Private',
'memories.stopSharing': 'Stop sharing',
+4
View File
@@ -4,9 +4,13 @@ const mobileJourney: TranslationStrings = {
'mobileJourney.latestJourney': 'Latest journey',
'mobileJourney.otherJourneys': 'Other journeys',
'mobileJourney.entriesCount': '{count} entries',
'mobileJourney.entriesCount.one': '{count} entry',
'mobileJourney.photosCount': '{count} photos',
'mobileJourney.photosCount.one': '{count} photo',
'mobileJourney.placesCount': '{count} places',
'mobileJourney.placesCount.one': '{count} place',
'mobileJourney.daysCount': '{count} days',
'mobileJourney.daysCount.one': '{count} day',
'mobileJourney.time': 'Time',
'mobileJourney.tags': 'Tags',
'mobileJourney.addTag': 'Add tag',
+5
View File
@@ -42,10 +42,13 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.resBadge': 'Booking',
'mobileTrip.showOnMap': 'Show on map',
'mobileTrip.statDocuments': '{count} files',
'mobileTrip.statDocuments.one': '{count} file',
'mobileTrip.statPeople': '{count} people',
'mobileTrip.statPeople.one': '{count} person',
'mobileTrip.stay': 'Stay',
'mobileTrip.tapAgainToDelete': 'Tap again to delete',
'mobileTrip.todoOpenCount': '{count} open',
'mobileTrip.todoOpenCount.one': '{count} open',
'mobileTrip.travel': 'Travel',
'mobileTrip.upNext': 'Up Next',
'mobileTrip.viewDetails': 'View details',
@@ -59,7 +62,9 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.rtStart': 'Start',
'mobileTrip.rtStayScope': 'The stay belongs to the place, so it counts on every day this stop is planned.',
'mobileTrip.rtStayLess': '{count} minutes less',
'mobileTrip.rtStayLess.one': '{count} minute less',
'mobileTrip.rtStayMore': '{count} minutes more',
'mobileTrip.rtStayMore.one': '{count} minute more',
'mobileTrip.rtNightDesktopOnly':
'Booking a night at this place works in the desktop planner. Here you can only discard it.',
'mobileTrip.rtReach': 'How far',
+2
View File
@@ -6,7 +6,9 @@ const mobileVacay: TranslationStrings = {
'mobileVacay.prevMonth': 'Previous month',
'mobileVacay.nextMonth': 'Next month',
'mobileVacay.usedCount': '{count} used',
'mobileVacay.usedCount.one': '{count} used',
'mobileVacay.leftCount': '{count} left',
'mobileVacay.leftCount.one': '{count} left',
'mobileVacay.left': 'left',
'mobileVacay.modeCompany': 'Company',
'mobileVacay.decreaseAllowance': 'Decrease allowance',
+2 -1
View File
@@ -20,7 +20,8 @@ const notif: TranslationStrings = {
'notif.collection_invite.title': 'Collection invite',
'notif.collection_invite.text': '{actor} invited you to share a collection',
'notif.photos_shared.title': 'Photos Shared',
'notif.photos_shared.text': '{actor} shared {count} photo(s) in {trip}',
'notif.photos_shared.text': '{actor} shared {count} photos in {trip}',
'notif.photos_shared.text.one': '{actor} shared {count} photo in {trip}',
'notif.collab_message.title': 'New Message',
'notif.collab_message.text': '{actor} sent a message in {trip}',
'notif.packing_tagged.title': 'Packing Assignment',
+2
View File
@@ -32,5 +32,7 @@ const notifications: TranslationStrings = {
'notifications.test.adminText': '{actor} sent a test notification to all admins.',
'notifications.test.tripTitle': '{actor} posted in your trip',
'notifications.test.tripText': 'Test notification for trip "{trip}".',
'notifications.countLabel': 'notifications',
'notifications.countLabel.one': 'notification',
};
export default notifications;
+2 -2
View File
@@ -85,8 +85,8 @@ const oauth: TranslationStrings = {
'oauth.authorize.requestDescription': 'This application is requesting access to your TREK account.',
'oauth.authorize.trustNote': 'Only grant access to applications you trust. Your data stays on your server.',
'oauth.authorize.selectScope': 'Select at least one scope',
'oauth.authorize.approveOneScope': 'Approve ({count} scope)',
'oauth.authorize.approveManyScopes': 'Approve ({count} scopes)',
'oauth.authorize.approveScopes': 'Approve ({count} scopes)',
'oauth.authorize.approveScopes.one': 'Approve ({count} scope)',
'oauth.authorize.approveAccess': 'Approve Access',
'oauth.authorize.deny': 'Deny',
'oauth.authorize.choosePermissions': 'Choose which permissions to grant',

Some files were not shown because too many files have changed in this diff Show More