Compare commits

...

20 commits
master ... i18n

Author SHA1 Message Date
Tom French f5d9f30e17 Revert "fix: correct "_many" translations"
This reverts commit adbaf27859.
2022-09-08 16:31:37 +01:00
Tom French adbaf27859 fix: correct "_many" translations
Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com>
2022-09-08 15:18:08 +01:00
Jed Fox 5217835c55
Implement localization for schedule descriptions (#225)
* monthUtils.{format → nonLocalizedFormat}

* Implement localization for schedule descriptions

* Remove outdated comment

* Add general.ordinal in Spanish

Co-Authored-By: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com>

* yay time zones?

* fix: re-add missing keys

* fix: fix broken i18n imports/initialisation

* style: linting

* fix: re-add english ordinal keys

* fix: add remaining english ordinal keys

* fix: correct dates in schedules.js

* refactor: store translations keys for loot-core in loot-core

* fix: add ns to i18n.t calls directly so parser can find them

* feat: add spanish translation from manuelcanepa

* fix: add comments to help i18n-parser to find contexts

* fix: add "many" context to spanish translations

Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com>
Co-authored-by: Tom French <tom@tomfren.ch>
2022-09-08 14:37:45 +01:00
Manuel Eduardo Cánepa Cihuelo 6fb497dec5
Adding translation to rule editor and transaction table (#224)
* #199 Adding translation to rule editor and transaction table

* Feature: Translation to discover schedule table

Fix: Some translation improvements

* fix: Fix minor after check

* Feature: More translation to account

Fix: Add *_old.json files to ignore

* Update packages/desktop-client/src/components/accounts/Account.js

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>

* fix: Workaround for know caveats

* lint: fix import order

* fix: t is not a function when empty transactions list

* Feature: Translate account filters

* Feature: Translation on transactions table

* Feature: Translate budget and the rest of bootstrap

* Update packages/desktop-client/src/locales/es-ES.json

Co-authored-by: Jed Fox <git@jedfox.com>

* fix: Using the new key for unknow error

* refactor: push useTranslation up above function definition, etc

* refactor: push useTranslation up above function definition

* refactor: set key for Trans component balanceType

* refactor: pass i18keys to Trans components explicitly

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>
Co-authored-by: Jed Fox <git@jedfox.com>
2022-09-06 10:22:22 +01:00
Tom French cbf1e18299 style: linting 2022-09-02 15:12:24 +01:00
Tom French 11186c9374 Merge branch 'master' into i18n
* master:
  Sort import in alphabetical order (#238)
  Separate external, monorepo and internal imports (#237)
  Allow `enter` to create new transaction when focused on `cleared` column (#234)
  Enforce linting in loot-design (#233)
  style: run linter (#232)
  refactor: create index.js for aql directory (#68)
  Revert "build: update yarn.lock" (#230)
  Fix handling of -0 in budget summary (#229)
  Update bug-report.yml (#228)
2022-09-02 15:10:56 +01:00
Tom French 953846732c style: remove setting of indent size to 4 in i18n-parser config 2022-08-31 00:21:17 +01:00
Tom French 55049da705 refactor: use i18n-next style of plurals 2022-08-31 00:20:33 +01:00
Tom French 618dd0f27f Merge branch 'master' into i18n
* master:
  fix: use correct comment style
  build: remove patch-package dependency from loot-design
  Conditionally set MSYS
  build: update yarn.lock
  build: use workspace ranges for monorepo dependencies
  changes needed to build on windows
2022-08-30 23:24:43 +01:00
Manuel Eduardo Cánepa Cihuelo e436c01430
#199 Adding translation to schedules list (#219)
* #199 Adding translation to schedules list

* #199 Complete translation on EditSchedule Form

* #199 Translation for status badge

* #199 Minor changes, suggested by @j-f1

Co-authored-by: Tom French <15848336+TomAFrench@users.noreply.github.com>
2022-08-30 23:22:43 +01:00
Tom French 304a384b6c style: format json with 2 space indents 2022-08-30 13:40:33 +01:00
Tom French b0f0c4a71d ci: fix workspace name in i18n job 2022-08-30 11:19:27 +01:00
Tom French 1fd3234613 ci: correct i18n job name 2022-08-30 11:16:19 +01:00
Tom French a4fe21927d feat: fallback to english rather than showing translation key 2022-08-30 11:14:00 +01:00
Tom French 5c56370920 feat: prevent translation keys which don't exist in locale files 2022-08-30 11:12:19 +01:00
Tom French 43740f18f1 Merge branch 'master' into i18n
* master: (24 commits)
  refactor: sort imports in desktop-client alphabetically
  chore: remove unused imports from desktop-client
  adm-zip to 0.5.6
  style: prettify .eslintrc.js
  build: make eslint-plugin-prettier a dependency of desktop-client
  ci: lint desktop-client in CI
  style: fix or silence linting errors in desktop-client
  chore: update remaining test scripts
  github fix indentation in issue template
  github: update issue template
  fix: correct some re-exports which were breaking things
  fix: stop trying to transform node_modules
  fix: stop the web tests from running in node environment
  fix: add ts-jest presets which are equivalent to the old `transform` properties
  fix: add esModuleInterop so that the default imports issue goes away
  fix: stop typechecking javascript files
  chore: allow mobile package to pass with no tests
  build: replace babel-jest with ts-jest
  Update bug-report.yml
  Addition: Issue template
  ...
2022-08-30 10:24:17 +01:00
Tom French 2d025d8b08 fix: get bold tags to display correctly 2022-08-24 17:22:48 +01:00
Tom French dd9d32a6ed fix: ensure that translation keys match 2022-08-24 17:21:03 +01:00
Manuel Canepa 9b3dbd187f #199 Adding some translation to check if im doing right 2022-08-24 00:18:07 -03:00
Tom French fd0d30c07c feat: add skeleton of i18n framework 2022-08-24 01:04:08 +01:00
43 changed files with 1926 additions and 411 deletions

18
.github/workflows/i18n.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: i18n
on:
push:
branches:
- master
pull_request:
branches: '*'
jobs:
check-keys:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up environment
uses: ./.github/actions/setup
- name: Check i18n keys
run: yarn workspaces foreach --verbose run check-i18n --fail-on-update

View file

@ -16,3 +16,6 @@ npm-debug.log
*kcab.*
public/kcab
# Ignore auto generated dictionaries with check-i18n
src/locales/*_old.json

View file

@ -0,0 +1,16 @@
module.exports = {
input: ['src/**/*.js'],
output: 'src/locales/$LOCALE.json',
locales: ['en-GB', 'es-ES'],
defaultNamespace: 'web',
sort: true,
// Force usage of JsxLexer for .js files as otherwise we can't pick up <Trans> components.
lexers: {
js: ['JsxLexer'],
ts: ['JsxLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JsxLexer']
}
};

View file

@ -42,6 +42,8 @@
"fs-extra": "7.0.0",
"glamor": "^2.20.40",
"html-webpack-plugin": "4.0.0-alpha.2",
"i18next": "^21.9.1",
"i18next-parser": "^6.5.0",
"identity-obj-proxy": "3.0.0",
"load-js": "^3.0.3",
"mini-css-extract-plugin": "0.4.3",
@ -61,6 +63,7 @@
"react-dev-utils": "^12.0.1",
"react-dnd": "^10.0.2",
"react-dom": "16.13.1",
"react-i18next": "^11.18.4",
"react-modal": "3.4.4",
"react-redux": "7.2.1",
"react-router": "5.2.0",
@ -86,7 +89,8 @@
"watch": "cross-env PORT=3001 node scripts/start.js",
"build": "cross-env INLINE_RUNTIME_CHUNK=false node scripts/build.js",
"build:browser": "cross-env ./bin/build-browser",
"lint": "eslint src"
"lint": "eslint src",
"check-i18n": "i18next"
},
"browserslist": [
"electron 3.0"

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useSelector, useDispatch } from 'react-redux';
import { Redirect, useParams, useHistory, useLocation } from 'react-router-dom';
@ -67,6 +68,8 @@ import {
} from './TransactionsTable';
function EmptyMessage({ onAdd }) {
const { t } = useTranslation();
return (
<View
style={{
@ -86,17 +89,15 @@ function EmptyMessage({ onAdd }) {
}}
>
<Text style={{ textAlign: 'center', lineHeight: '1.4em' }}>
For Actual to be useful, you need to <strong>add an account</strong>.
You can link an account to automatically download transactions, or
manage it locally yourself.
<Trans>{'account.needAccountMessage'}</Trans>
</Text>
<Button primary style={{ marginTop: 20 }} onClick={onAdd}>
Add account
{t('account.addAccount')}
</Button>
<View style={{ marginTop: 20, fontSize: 13, color: colors.n5 }}>
In the future, you can add accounts from the sidebar.
{t('account.addAccountInFutureFromSidebar')}
</View>
</View>
</View>
@ -104,6 +105,8 @@ function EmptyMessage({ onAdd }) {
}
function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) {
const { t } = useTranslation();
let cleared = useSheetValue({
name: balanceQuery.name + '-cleared',
query: balanceQuery.query.filter({ cleared: true })
@ -142,27 +145,27 @@ function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) {
marginRight: 3
}}
/>
All reconciled!
{t('account.allReconciled')}
</View>
) : (
<View style={{ color: colors.n3 }}>
<Text style={{ fontStyle: 'italic', textAlign: 'center' }}>
Your cleared balance{' '}
<strong>{format(cleared, 'financial')}</strong> needs{' '}
<strong>
{(targetDiff > 0 ? '+' : '') + format(targetDiff, 'financial')}
</strong>{' '}
to match
<br /> your bank{"'"}s balance of{' '}
<Text style={{ fontWeight: 700 }}>
{format(targetBalance, 'financial')}
</Text>
<Trans
i18nKey={'account.clearedBalance'}
values={{
cleared: format(cleared, 'financial'),
diff:
(targetDiff > 0 ? '+' : '') +
format(targetDiff, 'financial'),
balance: format(targetBalance, 'financial')
}}
/>
</Text>
</View>
)}
<View style={{ marginLeft: 15 }}>
<Button primary onClick={onDone}>
Done Reconciling
{t('account.doneReconciling')}
</Button>
</View>
</View>
@ -172,6 +175,7 @@ function ReconcilingMessage({ balanceQuery, targetBalance, onDone }) {
function ReconcileTooltip({ account, onReconcile, onClose }) {
let balance = useSheetValue(queries.accountBalance(account));
const { t } = useTranslation();
function onSubmit(e) {
let input = e.target.elements[0];
@ -183,10 +187,7 @@ function ReconcileTooltip({ account, onReconcile, onClose }) {
return (
<Tooltip position="bottom-right" width={275} onClose={onClose}>
<View style={{ padding: '5px 8px' }}>
<Text>
Enter the current balance of your bank account that you want to
reconcile with:
</Text>
<Text>{t('account.enterCurrentBalanceToReconcileAdvice')}</Text>
<form onSubmit={onSubmit}>
{balance != null && (
<InitialFocus>
@ -196,7 +197,7 @@ function ReconcileTooltip({ account, onReconcile, onClose }) {
/>
</InitialFocus>
)}
<Button primary>Reconcile</Button>
<Button primary>{t('account.reconcile')}</Button>
</form>
</View>
</Tooltip>
@ -239,6 +240,7 @@ function AccountMenu({
onMenuSelect
}) {
let [tooltip, setTooltip] = useState('default');
const { t } = useTranslation();
return tooltip === 'reconcile' ? (
<ReconcileTooltip
@ -259,19 +261,21 @@ function AccountMenu({
items={[
canShowBalances && {
name: 'toggle-balance',
text: (showBalances ? 'Hide' : 'Show') + ' Running Balance'
text: showBalances
? t('account.hideRunningBalance')
: t('account.showRunningBalance')
},
{ name: 'export', text: 'Export' },
{ name: 'reconcile', text: 'Reconcile' },
{ name: 'export', text: t('general.export') },
{ name: 'reconcile', text: t('account.reconcile') },
syncEnabled &&
account &&
!account.closed &&
(canSync
? { name: 'unlink', text: 'Unlink Account' }
: { name: 'link', text: 'Link Account' }),
? { name: 'unlink', text: t('account.unlinkAccount') }
: { name: 'link', text: t('account.linkAccount') }),
account.closed
? { name: 'reopen', text: 'Reopen Account' }
: { name: 'close', text: 'Close Account' }
? { name: 'reopen', text: t('account.reopenAccount') }
: { name: 'close', text: t('account.closeAccount') }
].filter(x => x)}
/>
</MenuTooltip>
@ -279,19 +283,30 @@ function AccountMenu({
}
function CategoryMenu({ onClose, onMenuSelect }) {
const { t } = useTranslation();
return (
<MenuTooltip onClose={onClose}>
<Menu
onMenuSelect={item => {
onMenuSelect(item);
}}
items={[{ name: 'export', text: 'Export' }]}
items={[{ name: 'export', text: t('general.export') }]}
/>
</MenuTooltip>
);
}
function DetailedBalance({ name, balance }) {
const balanceType = {
// t('account.selectedBalance')
selected: 'account.selectedBalance',
// t('account.clearedTotal')
cleared: 'account.clearedTotal',
// t('account.unclearedTotal')
uncleared: 'account.unclearedTotal'
};
return (
<Text
style={{
@ -302,8 +317,12 @@ function DetailedBalance({ name, balance }) {
color: colors.n5
}}
>
{name}{' '}
<Text style={{ fontWeight: 600 }}>{format(balance, 'financial')}</Text>
<Trans
i18nKey={balanceType[name] || name}
values={{
amount: format(balance, 'financial')
}}
/>
</Text>
);
}
@ -338,7 +357,8 @@ function SelectedBalance({ selectedItems }) {
if (balance == null) {
return null;
}
return <DetailedBalance name="Selected balance:" balance={balance} />;
return <DetailedBalance name="selected" balance={balance} />;
}
function MoreBalances({ balanceQuery }) {
@ -353,8 +373,8 @@ function MoreBalances({ balanceQuery }) {
return (
<View style={{ flexDirection: 'row' }}>
<DetailedBalance name="Cleared total:" balance={cleared} />
<DetailedBalance name="Uncleared total:" balance={uncleared} />
<DetailedBalance name="cleared" balance={cleared} />
<DetailedBalance name="uncleared" balance={uncleared} />
</View>
);
}
@ -454,6 +474,7 @@ function SelectedTransactionsButton({
}) {
let selectedItems = useSelectedItems();
let history = useHistory();
const { t } = useTranslation();
let types = useMemo(() => {
let items = [...selectedItems];
@ -494,37 +515,43 @@ function SelectedTransactionsButton({
items={[
...(!types.trans
? [
{ name: 'view-schedule', text: 'View schedule' },
{ name: 'post-transaction', text: 'Post transaction' },
{ name: 'skip', text: 'Skip scheduled date' }
{ name: 'view-schedule', text: t('schedules.view_one') },
{
name: 'post-transaction',
text: t('schedules.postTransaction')
},
{ name: 'skip', text: t('schedules.skipScheduledDate') }
]
: [
{ name: 'show', text: 'Show', key: 'F' },
{ name: 'delete', text: 'Delete', key: 'D' },
{ name: 'show', text: t('general.show'), key: 'F' },
{ name: 'delete', text: t('general.delete'), key: 'D' },
...(linked
? [
{
name: 'view-schedule',
text: 'View schedule',
text: t('schedules.view_one'),
disabled: selectedItems.size > 1
},
{ name: 'unlink-schedule', text: 'Unlink schedule' }
{
name: 'unlink-schedule',
text: t('schedules.unlinkSchedule')
}
]
: [
{
name: 'link-schedule',
text: 'Link schedule'
text: t('schedules.linkSchedule')
}
]),
Menu.line,
{ type: Menu.label, name: 'Edit field' },
{ name: 'date', text: 'Date' },
{ name: 'account', text: 'Account', key: 'A' },
{ name: 'payee', text: 'Payee', key: 'P' },
{ name: 'notes', text: 'Notes', key: 'N' },
{ name: 'category', text: 'Category', key: 'C' },
{ name: 'amount', text: 'Amount' },
{ name: 'cleared', text: 'Cleared', key: 'L' }
{ type: Menu.label, name: t('general.editField') },
{ name: 'date', text: t('general.date') },
{ name: 'account', text: t('general.account_one'), key: 'A' },
{ name: 'payee', text: t('general.payee_one'), key: 'P' },
{ name: 'notes', text: t('general.note_other'), key: 'N' },
{ name: 'category', text: t('general.category_one'), key: 'C' },
{ name: 'amount', text: t('general.amount') },
{ name: 'cleared', text: t('account.cleared'), key: 'L' }
])
]}
onSelect={name => {
@ -616,6 +643,7 @@ const AccountHeader = React.memo(
let [menuOpen, setMenuOpen] = useState(false);
let searchInput = useRef(null);
let splitsExpanded = useSplitsExpanded();
const { t } = useTranslation();
let canSync = syncEnabled && account && account.account_id;
if (!account) {
@ -728,7 +756,7 @@ const AccountHeader = React.memo(
}
style={{ color: 'currentColor', marginRight: 4 }}
/>{' '}
Sync
{t('general.sync')}
</>
) : (
<>
@ -737,7 +765,7 @@ const AccountHeader = React.memo(
height={13}
style={{ color: 'currentColor', marginRight: 4 }}
/>{' '}
Import
{t('general.import')}
</>
)}
</Button>
@ -749,7 +777,7 @@ const AccountHeader = React.memo(
height={10}
style={{ color: 'inherit', marginRight: 3 }}
/>{' '}
Add New
{t('general.addNew')}
</Button>
)}
<View>
@ -770,7 +798,7 @@ const AccountHeader = React.memo(
}
inputRef={searchInput}
value={search}
placeholder="Search"
placeholder={t('general.search')}
getStyle={focused => [
{
backgroundColor: 'transparent',
@ -808,8 +836,8 @@ const AccountHeader = React.memo(
onClick={onToggleSplits}
title={
splitsExpanded.state.mode === 'collapse'
? 'Collapse split transactions'
: 'Expand split transactions'
? t('account.collapseSplitTransaction_other')
: t('account.expandSplitTransaction_other')
}
>
{splitsExpanded.state.mode === 'collapse' ? (
@ -1187,11 +1215,15 @@ class AccountInternal extends React.PureComponent {
onImport = async () => {
const accountId = this.props.accountId;
const account = this.props.accounts.find(acct => acct.id === accountId);
const t = this.props.t;
if (account) {
const res = await window.Actual.openFileDialog({
filters: [
{ name: 'Financial Files', extensions: ['qif', 'ofx', 'qfx', 'csv'] }
{
name: t('general.financialFile_other'),
extensions: ['qif', 'ofx', 'qfx', 'csv']
}
]
});
@ -1216,11 +1248,12 @@ class AccountInternal extends React.PureComponent {
let normalizedName =
accountName && accountName.replace(/[()]/g, '').replace(/\s+/g, '-');
let filename = `${normalizedName || 'transactions'}.csv`;
const t = this.props.t;
window.Actual.saveFile(
exportedTransactions,
filename,
'Export Transactions'
t('general.exportTransaction_other')
);
};
@ -1334,21 +1367,24 @@ class AccountInternal extends React.PureComponent {
if (filterName) {
return filterName;
}
const t = this.props.t;
if (!account) {
if (id === 'budgeted') {
return 'Budgeted Accounts';
return t('account.budgetedAccount_other');
} else if (id === 'offbudget') {
return 'Off Budget Accounts';
return t('account.offBudgetAccount_other');
} else if (id === 'uncategorized') {
return 'Uncategorized';
return t('account.uncategorized');
} else if (!id) {
return 'All Accounts';
return t('account.allAccounts');
}
return null;
}
return (account.closed ? 'Closed: ' : '') + account.name;
return account.closed
? t('account.closedNamed', { name: account.name })
: account.name;
}
getBalanceQuery(account, id) {
@ -1387,8 +1423,10 @@ class AccountInternal extends React.PureComponent {
};
onShowTransactions = async ids => {
const t = this.props.t;
this.onApplyFilter({
customName: 'Selected transactions',
customName: t('account.selectedTransaction_other'),
filter: { id: { $oneof: ids } }
});
};
@ -1570,7 +1608,8 @@ class AccountInternal extends React.PureComponent {
replaceModal,
showExtraBalances,
expandSplits,
accountId
accountId,
t
} = this.props;
let {
transactions,
@ -1705,7 +1744,7 @@ class AccountInternal extends React.PureComponent {
fontStyle: 'italic'
}}
>
No transactions
{t('general.noTransaction_other')}
</View>
) : null
}
@ -1735,10 +1774,13 @@ class AccountInternal extends React.PureComponent {
function AccountHack(props) {
let { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
const { t } = useTranslation();
return (
<AccountInternal
{...props}
splitsExpandedDispatch={splitsExpandedDispatch}
t={t}
/>
);
}

View file

@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useReducer } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import {
@ -132,6 +133,8 @@ function ConfigureField({ field, op, value, dispatch, onApply }) {
ops = ['is'];
}
const { t } = useTranslation();
return (
<Tooltip
position="bottom-left"
@ -146,15 +149,15 @@ function ConfigureField({ field, op, value, dispatch, onApply }) {
options={
field === 'amount'
? [
['amount', 'Amount'],
['amount-inflow', 'Amount (inflow)'],
['amount-outflow', 'Amount (outflow)']
['amount', t('general.amount')],
['amount-inflow', t('general.amountIinflow')],
['amount-outflow', t('general.amountOutflow')]
]
: field === 'date'
? [
['date', 'Date'],
['month', 'Month'],
['year', 'Year']
['date', t('general.date')],
['month', t('general.month')],
['year', t('general.year')]
]
: null
}
@ -235,7 +238,7 @@ function ConfigureField({ field, op, value, dispatch, onApply }) {
});
}}
>
Apply
{t('general.apply')}
</Button>
</View>
</form>
@ -251,6 +254,8 @@ export function FilterButton({ onApply }) {
};
});
const { t } = useTranslation();
let [state, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
@ -306,7 +311,7 @@ export function FilterButton({ onApply }) {
if (isDateValid(date)) {
cond.value = formatDate(date, 'yyyy-MM');
} else {
alert('Invalid date format');
alert(t('general.invalidDateFormat'));
return;
}
} else if (cond.options.year) {
@ -314,7 +319,7 @@ export function FilterButton({ onApply }) {
if (isDateValid(date)) {
cond.value = formatDate(date, 'yyyy');
} else {
alert('Invalid date format');
alert(t('general.invalidDateFormat'));
return;
}
}

View file

@ -1,4 +1,5 @@
import React, { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import {
@ -181,6 +182,7 @@ export default function SimpleTransactionsTable({
},
[payees, categories, memoFields, selectedItems]
);
const { t } = useTranslation();
return (
<Table
@ -200,43 +202,43 @@ export default function SimpleTransactionsTable({
case 'date':
return (
<Field key={i} width={100}>
Date
{t('general.date')}
</Field>
);
case 'imported_payee':
return (
<Field key={i} width="flex">
Imported payee
{t('general.importedPayee')}
</Field>
);
case 'payee':
return (
<Field key={i} width="flex">
Payee
{t('general.payee_one')}
</Field>
);
case 'category':
return (
<Field key={i} width="flex">
Category
{t('general.category_one')}
</Field>
);
case 'account':
return (
<Field key={i} width="flex">
Account
{t('general.account_one')}
</Field>
);
case 'notes':
return (
<Field key={i} width="flex">
Notes
{t('general.note_other')}
</Field>
);
case 'amount':
return (
<Field key={i} width={75} style={{ textAlign: 'right' }}>
Amount
{t('general.amount')}
</Field>
);
default:

View file

@ -8,6 +8,7 @@ import React, {
useContext,
useReducer
} from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useSelector, useDispatch } from 'react-redux';
import {
@ -253,6 +254,7 @@ export function SplitsExpandedProvider({ children, initialMode = 'expand' }) {
export const TransactionHeader = React.memo(
({ hasSelected, showAccount, showCategory, showBalance }) => {
let dispatchSelected = useSelectedDispatch();
const { t } = useTranslation();
return (
<Row
@ -271,14 +273,18 @@ export const TransactionHeader = React.memo(
width={20}
onSelect={() => dispatchSelected({ type: 'select-all' })}
/>
<Cell value="Date" width={110} />
{showAccount && <Cell value="Account" width="flex" />}
<Cell value="Payee" width="flex" />
<Cell value="Notes" width="flex" />
{showCategory && <Cell value="Category" width="flex" />}
<Cell value="Payment" width={80} textAlign="right" />
<Cell value="Deposit" width={80} textAlign="right" />
{showBalance && <Cell value="Balance" width={85} textAlign="right" />}
<Cell value={t('general.date')} width={110} />
{showAccount && <Cell value={t('general.account_one')} width="flex" />}
<Cell value={t('general.payee_other')} width="flex" />
<Cell value={t('general.note_other')} width="flex" />
{showCategory && (
<Cell value={t('general.category_other')} width="flex" />
)}
<Cell value={t('general.payment_one')} width={80} textAlign="right" />
<Cell value={t('general.deposit_one')} width={80} textAlign="right" />
{showBalance && (
<Cell value={t('general.balance_one')} width={85} textAlign="right" />
)}
<Field width={21} truncate={false} />
<Cell value="" width={15 + styles.scrollbarWidth} />
</Row>
@ -528,6 +534,7 @@ export const Transaction = React.memo(function Transaction(props) {
onCreatePayee,
onToggleSplit
} = props;
const { t } = useTranslation();
let dispatchSelected = useSelectedDispatch();
@ -896,7 +903,7 @@ export const Transaction = React.memo(function Transaction(props) {
/>
)}
<Text style={{ fontStyle: 'italic', userSelect: 'none' }}>
Split
{t('general.split')}
</Text>
</View>
</CellButton>
@ -910,11 +917,11 @@ export const Transaction = React.memo(function Transaction(props) {
onExpose={!isPreview && (name => onEdit(id, name))}
value={
isParent
? 'Split'
? t('general.split')
: isOffBudget
? 'Off Budget'
? t('general.offBudget')
: isBudgetTransfer
? 'Transfer'
? t('general.transfer')
: ''
}
valueStyle={valueStyle}
@ -936,7 +943,7 @@ export const Transaction = React.memo(function Transaction(props) {
'name'
)
: transaction.id
? 'Categorize'
? t('general.categorize')
: ''
}
exposed={focusedField === 'category'}
@ -1049,6 +1056,8 @@ export const Transaction = React.memo(function Transaction(props) {
});
export function TransactionError({ error, isDeposit, onAddSplit, style }) {
const { t } = useTranslation();
switch (error.type) {
case 'SplitTransactionError':
if (error.version === 1) {
@ -1064,21 +1073,21 @@ export function TransactionError({ error, isDeposit, onAddSplit, style }) {
]}
data-testid="transaction-error"
>
<Text>
Amount left:{' '}
<Text style={{ fontWeight: 500 }}>
{integerToCurrency(
<Trans
i18nKey={'general.amountLeft'}
values={{
amount: integerToCurrency(
isDeposit ? error.difference : -error.difference
)}
</Text>
</Text>
)
}}
/>
<View style={{ flex: 1 }} />
<Button
style={{ marginLeft: 15, padding: '4px 10px' }}
primary
onClick={onAddSplit}
>
Add Split
{t('general.addSplit')}
</Button>
</View>
);
@ -1135,6 +1144,7 @@ function NewTransaction({
}) {
const error = transactions[0].error;
const isDeposit = transactions[0].amount > 0;
const { t } = useTranslation();
return (
<View
@ -1192,7 +1202,7 @@ function NewTransaction({
onClick={() => onClose()}
data-testid="cancel-button"
>
Cancel
{t('general.cancel')}
</Button>
{error ? (
<TransactionError
@ -1207,7 +1217,7 @@ function NewTransaction({
onClick={onAdd}
data-testid="add-button"
>
Add
{t('general.add')}
</Button>
)}
</View>
@ -1532,12 +1542,14 @@ export let TransactionTable = React.forwardRef((props, ref) => {
setPrevIsAdding(props.isAdding);
}
const { t } = useTranslation();
useEffect(() => {
if (shouldAdd.current) {
if (newTransactions[0].account == null) {
props.addNotification({
type: 'error',
message: 'Account is a required field'
message: t('transaction.accountIsRequired')
});
newNavigator.onEdit('temp', 'account');
} else {

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useBudgetMonthCount } from 'loot-design/src/components/budget/BudgetMonthCountContext';
import { View } from 'loot-design/src/components/common';
@ -16,6 +17,7 @@ function Calendar({ color, onClick }) {
export function MonthCountSelector({ maxMonths, onChange }) {
let { displayMax } = useBudgetMonthCount();
const { t } = useTranslation();
let style = { width: 15, height: 15, color: colors.n8 };
let activeStyle = { color: colors.n5 };
@ -50,7 +52,7 @@ export function MonthCountSelector({ maxMonths, onChange }) {
transform: 'scale(1.2)'
}
}}
title="Choose the number of months shown at a time"
title={t('budget.chooseNumberMonths')}
>
{calendars}
</View>

View file

@ -327,7 +327,7 @@ class Budget extends React.PureComponent {
pathname: '/accounts',
state: {
goBack: true,
filterName: `${categoryName} (${monthUtils.format(
filterName: `${categoryName} (${monthUtils.nonLocalizedFormat(
month,
'MMMM yyyy'
)})`,

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
@ -12,6 +13,8 @@ import { useBootstrapped, Title } from './common';
import { ConfirmPasswordForm } from './ConfirmPasswordForm';
export default function Bootstrap() {
const { t } = useTranslation();
let dispatch = useDispatch();
let history = useHistory();
let [error, setError] = useState(null);
@ -21,13 +24,13 @@ export default function Bootstrap() {
function getErrorMessage(error) {
switch (error) {
case 'invalid-password':
return 'Password cannot be empty';
return t('bootstrap.passwordCannotBeEmpty');
case 'password-match':
return 'Passwords do not match';
return t('bootstrap.passwordsDoNotMatch');
case 'network-failure':
return 'Unable to contact the server';
return t('bootstrap.unableToContactTheServer');
default:
return "Whoops, an error occurred on our side! We'll try to get it fixed soon.";
return t('bootstrap.unknownError');
}
}
@ -53,7 +56,7 @@ export default function Bootstrap() {
return (
<>
<View style={{ width: 450, marginTop: -30 }}>
<Title text="Bootstrap this Actual instance" />
<Title text={t('bootstrap.title')} />
<Text
style={{
fontSize: 16,
@ -61,7 +64,7 @@ export default function Bootstrap() {
lineHeight: 1.4
}}
>
Set a password for this server instance
{t('bootstrap.setPassword')}
</Text>
{error && (
@ -84,7 +87,7 @@ export default function Bootstrap() {
style={{ fontSize: 15, color: colors.b4, marginRight: 15 }}
onClick={onDemo}
>
Try Demo
{t('bootstrap.tryDemo')}
</Button>
}
onSetPassword={onSetPassword}

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
@ -14,17 +15,18 @@ export default function ChangePassword() {
let history = useHistory();
let [error, setError] = useState(null);
let [msg, setMessage] = useState(null);
const { t } = useTranslation();
function getErrorMessage(error) {
switch (error) {
case 'invalid-password':
return 'Password cannot be empty';
return t('bootstrap.passwordCannotBeEmpty');
case 'password-match':
return 'Passwords do not match';
return t('bootstrap.passwordsDoNotMatch');
case 'network-failure':
return 'Unable to contact the server';
return t('bootstrap.unableToContactTheServer');
default:
return 'Internal server error';
return t('bootstrap.unknownError');
}
}
@ -35,7 +37,7 @@ export default function ChangePassword() {
if (error) {
setError(error);
} else {
setMessage('Password successfully changed');
setMessage(t('bootstrap.passwordSuccessfullyChanged'));
setTimeout(() => {
history.push('/');
@ -46,7 +48,7 @@ export default function ChangePassword() {
return (
<>
<View style={{ width: 500, marginTop: -30 }}>
<Title text="Change server password" />
<Title text={t('bootstrap.changeServerPassword')} />
<Text
style={{
fontSize: 16,
@ -54,8 +56,7 @@ export default function ChangePassword() {
lineHeight: 1.4
}}
>
This will change the password for this server instance. All existing
sessions will stay logged in.
{t('bootstrap.thisWillChangeThePasswordAdvice')}
</Text>
{error && (

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import {
@ -258,7 +259,7 @@ function ScheduleDescription({ id }) {
</Text>
<Text style={{ margin: '0 5px' }}> </Text>
<Text style={{ flexShrink: 0 }}>
Next: {monthUtils.format(schedule.next_date, dateFormat)}
Next: {monthUtils.nonLocalizedFormat(schedule.next_date, dateFormat)}
</Text>
</View>
<StatusBadge status={status} />
@ -280,7 +281,6 @@ function ActionEditor({ ops, action, editorStyle, onChange, onDelete, onAdd }) {
return (
<Editor style={editorStyle} error={error}>
{/*<OpSelect ops={ops} value={op} onChange={onChange} />*/}
{op === 'set' ? (
<>
<View style={{ padding: '5px 10px', lineHeight: '1em' }}>
@ -325,6 +325,7 @@ function ActionEditor({ ops, action, editorStyle, onChange, onDelete, onAdd }) {
function StageInfo() {
let [open, setOpen] = useState();
const { t } = useTranslation();
return (
<View style={{ position: 'relative', marginLeft: 5 }}>
@ -346,9 +347,7 @@ function StageInfo() {
lineHeight: 1.5
}}
>
The stage of a rule allows you to force a specific order. Pre rules
always run first, and post rules always run last. Within each stage
rules are automatically ordered from least to most specific.
{t('rules.stageOfRuleAdvice')}
</Tooltip>
)}
</View>
@ -554,6 +553,7 @@ export default function EditRule({
let [transactions, setTransactions] = useState([]);
let dispatch = useDispatch();
let scrollableEl = useRef();
const { t } = useTranslation();
useEffect(() => {
dispatch(initiallyLoadPayees());
@ -687,7 +687,7 @@ export default function EditRule({
return (
<Modal
title="Rule"
title={t('general.rule_one')}
padding={0}
{...modalProps}
style={[modalProps.style, { flex: 'inherit', maxWidth: '90%' }]}
@ -713,7 +713,7 @@ export default function EditRule({
}}
>
<Text style={{ color: colors.n4, marginRight: 15 }}>
Stage of rule:
{t('rules.stageOfRule')}
</Text>
<Stack direction="row" align="center" spacing={1}>
@ -721,19 +721,19 @@ export default function EditRule({
selected={stage === 'pre'}
onSelect={() => onChangeStage('pre')}
>
Pre
{t('rules.stages.pre')}
</StageButton>
<StageButton
selected={stage === null}
onSelect={() => onChangeStage(null)}
>
Default
{t('rules.stages.default')}
</StageButton>
<StageButton
selected={stage === 'post'}
onSelect={() => onChangeStage('post')}
>
Post
{t('rules.stages.post')}
</StageButton>
<StageInfo />
@ -752,7 +752,7 @@ export default function EditRule({
<View style={{ flexShrink: 0 }}>
<View style={{ marginBottom: 30 }}>
<Text style={{ color: colors.n4, marginBottom: 15 }}>
If all these conditions match:
{t('rules.ifAllTheseConditionsMatch')}
</Text>
<ConditionsList
@ -764,7 +764,7 @@ export default function EditRule({
</View>
<Text style={{ color: colors.n4, marginBottom: 15 }}>
Then apply these actions:
{t('rules.thenApplyTheseActions')}
</Text>
<View style={{ flex: 1 }}>
{actions.length === 0 ? (
@ -772,7 +772,7 @@ export default function EditRule({
style={{ alignSelf: 'flex-start' }}
onClick={addInitialAction}
>
Add action
{t('rules.addAction')}
</Button>
) : (
<Stack spacing={2}>
@ -807,7 +807,7 @@ export default function EditRule({
}}
>
<Text style={{ color: colors.n4, marginBottom: 0 }}>
This rule applies to these transactions:
{t('rules.thisRuleAppliesToTheseTransactions')}
</Text>
<View style={{ flex: 1 }} />
@ -815,7 +815,7 @@ export default function EditRule({
disabled={selectedInst.items.size === 0}
onClick={onApply}
>
Apply actions ({selectedInst.items.size})
{t('rules.applyAction', { size: selectedInst.items.size })}
</Button>
</View>
@ -830,9 +830,11 @@ export default function EditRule({
justify="flex-end"
style={{ marginTop: 20 }}
>
<Button onClick={() => modalProps.onClose()}>Cancel</Button>
<Button onClick={() => modalProps.onClose()}>
{t('general.cancel')}
</Button>
<Button primary onClick={() => onSave()}>
Save
{t('general.save')}
</Button>
</Stack>
</View>

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { format as formatDate, parseISO } from 'date-fns';
@ -52,6 +53,7 @@ export function Value({
data: dataProp,
describe = x => x.name
}) {
const { i18n } = useTranslation();
let { data, dateFormat } = useSelector(state => {
let data;
if (dataProp) {
@ -95,7 +97,7 @@ export function Value({
} else if (field === 'date') {
if (value) {
if (value.frequency) {
return getRecurringDescription(value);
return getRecurringDescription(value, i18n);
}
return formatDate(parseISO(value), dateFormat);
}

View file

@ -53,7 +53,7 @@ function CashFlow() {
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy')
pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy')
}))
.reverse();

View file

@ -51,7 +51,7 @@ function NetWorth({ accounts }) {
.rangeInclusive(earliestMonth, monthUtils.currentMonth())
.map(month => ({
name: month,
pretty: monthUtils.format(month, 'MMMM, yyyy')
pretty: monthUtils.nonLocalizedFormat(month, 'MMMM, yyyy')
}))
.reverse();

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useHistory } from 'react-router-dom';
import Platform from 'loot-core/src/client/platform';
@ -35,11 +36,12 @@ let ROW_HEIGHT = 43;
function DiscoverSchedulesTable({ schedules, loading }) {
let selectedItems = useSelectedItems();
let dispatchSelected = useSelectedDispatch();
let { t, i18n } = useTranslation();
function renderItem({ item }) {
let selected = selectedItems.has(item.id);
let amountOp = item._conditions.find(c => c.field === 'amount').op;
let recurDescription = getRecurringDescription(item.date);
let recurDescription = getRecurringDescription(item.date, i18n);
return (
<Row
@ -89,13 +91,13 @@ function DiscoverSchedulesTable({ schedules, loading }) {
selected={selectedItems.size > 0}
onSelect={() => dispatchSelected({ type: 'select-all' })}
/>
<Field width="flex">Payee</Field>
<Field width="flex">Account</Field>
<Field width="flex">{t('general.payee_one')}</Field>
<Field width="flex">{t('general.account_one')}</Field>
<Field width="auto" style={{ flex: 1.5 }}>
When
{t('general.when')}
</Field>
<Field width={100} style={{ textAlign: 'right' }}>
Amount
{t('general.amount')}
</Field>
</TableHeader>
<Table
@ -107,7 +109,7 @@ function DiscoverSchedulesTable({ schedules, loading }) {
loading={loading}
isSelected={id => selectedItems.has(id)}
renderItem={renderItem}
renderEmpty="No schedules found"
renderEmpty={t('schedules.noSchedulesFound')}
/>
</View>
);
@ -161,24 +163,19 @@ export default function DiscoverSchedules() {
setCreating(false);
history.goBack();
}
const { t } = useTranslation();
return (
<Page title="Found schedules" modalSize={{ width: 850, height: 650 }}>
<Page
title={t('schedules.foundSchedules')}
modalSize={{ width: 850, height: 650 }}
>
<P>{t('schedules.foundSomePossibleSchedulesAdvice')}</P>
<P>{t('schedules.expectedSchedulesAdvice')}</P>
<P>
We found some possible schedules in your current transactions. Select
the ones you want to create.
</P>
<P>
If you expected a schedule here and don't see it, it might be because
the payees of the transactions don't match. Make sure you rename payees
on all transactions for a schedule to be the same payee.
</P>
<P>
You can always do this later
{Platform.isBrowser
? ' from the "Find schedules" item in the sidebar menu'
: ' from the "Tools > Find schedules" menu item'}
.
? t('schedules.doFromFindSchedules')
: t('schedules.doFromToolsFindSchedules')}
</P>
<SelectedProvider instance={selectedInst}>
@ -194,14 +191,16 @@ export default function DiscoverSchedules() {
justify="flex-end"
style={{ paddingTop: 20 }}
>
<Button onClick={() => history.goBack()}>Do nothing</Button>
<Button onClick={() => history.goBack()}>
{t('general.doNothing')}
</Button>
<ButtonWithLoading
primary
loading={creating}
disabled={selectedInst.items.size === 0}
onClick={onCreate}
>
Create schedules
{t('schedules.createSchedule', { count: selectedInst.items.size })}
</ButtonWithLoading>
</Stack>
</Page>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useReducer } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useParams, useHistory } from 'react-router-dom';
@ -93,6 +94,7 @@ export default function ScheduleDetails() {
});
let pageType = usePageType();
const { t } = useTranslation();
let [state, dispatch] = useReducer(
(state, action) => {
@ -366,8 +368,10 @@ export default function ScheduleDetails() {
if (res.error) {
dispatch({
type: 'form-error',
error:
'An error occurred while saving. Please contact help@actualbudget.com for support.'
// Note: email is outside of translation to be easily replace on future
error: t('support.anErrorOccuredWhileSaving', {
email: 'help@actualbudget.com'
})
});
} else {
if (adding) {
@ -424,15 +428,19 @@ export default function ScheduleDetails() {
return (
<Page
title={payee ? `Schedule: ${payee.name}` : 'Schedule'}
title={
payee
? t('schedules.scheduleNamed', { name: payee.name })
: t('general.schedule')
}
modalSize="medium"
>
<Stack direction="row" style={{ marginTop: 20 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title="Payee" />
<FormLabel title={t('general.payee_one')} />
<PayeeAutocomplete
value={state.fields.payee}
inputProps={{ placeholder: '(none)' }}
inputProps={{ placeholder: t('schedules.none') }}
onSelect={id =>
dispatch({ type: 'set-field', field: 'payee', value: id })
}
@ -440,10 +448,10 @@ export default function ScheduleDetails() {
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title="Account" />
<FormLabel title={t('general.account_one')} />
<AccountAutocomplete
value={state.fields.account}
inputProps={{ placeholder: '(none)' }}
inputProps={{ placeholder: t('schedules.none') }}
onSelect={id =>
dispatch({ type: 'set-field', field: 'account', value: id })
}
@ -452,18 +460,21 @@ export default function ScheduleDetails() {
<FormField style={{ flex: 1 }}>
<Stack direction="row" align="center" style={{ marginBottom: 3 }}>
<FormLabel title="Amount" style={{ margin: 0, flex: 1 }} />
<FormLabel
title={t('general.amount')}
style={{ margin: 0, flex: 1 }}
/>
<OpSelect
ops={['is', 'isapprox', 'isbetween']}
value={state.fields.amountOp}
formatOp={op => {
switch (op) {
case 'is':
return 'is exactly';
return t('schedules.isExactly');
case 'isapprox':
return 'is approximately';
return t('schedules.isApproximately');
case 'isbetween':
return 'is between';
return t('schedules.isBetween');
default:
throw new Error('Invalid op for select: ' + op);
}
@ -505,7 +516,7 @@ export default function ScheduleDetails() {
</Stack>
<View style={{ marginTop: 20 }}>
<FormLabel title="Date" />
<FormLabel title={t('general.date')} />
</View>
<Stack direction="row" align="flex-start">
@ -530,7 +541,7 @@ export default function ScheduleDetails() {
{state.upcomingDates && (
<View style={{ fontSize: 13, marginTop: 20 }}>
<Text style={{ color: colors.n4, fontWeight: 600 }}>
Upcoming dates
{t('schedules.upcomingDates')}
</Text>
<Stack
direction="column"
@ -538,7 +549,9 @@ export default function ScheduleDetails() {
style={{ marginTop: 10, color: colors.n4 }}
>
{state.upcomingDates.map(date => (
<View>{monthUtils.format(date, `${dateFormat} EEEE`)}</View>
<View>
{monthUtils.nonLocalizedFormat(date, `${dateFormat} EEEE`)}
</View>
))}
</Stack>
</View>
@ -562,7 +575,7 @@ export default function ScheduleDetails() {
}}
/>
<label for="form_repeats" style={{ userSelect: 'none' }}>
Repeats
{t('general.repeats')}
</label>
</View>
@ -593,7 +606,7 @@ export default function ScheduleDetails() {
}}
/>
<label for="form_posts_transaction" style={{ userSelect: 'none' }}>
Automatically add transaction
{t('schedules.automaticallyAddTransaction')}
</label>
</View>
@ -607,8 +620,7 @@ export default function ScheduleDetails() {
lineHeight: '1.4em'
}}
>
If checked, the schedule will automatically create transactions for
you in the specified account
{t('schedules.automaticallyAddTransactionAdvice')}
</Text>
{!adding && state.schedule.rule && (
@ -622,11 +634,11 @@ export default function ScheduleDetails() {
width: 350
}}
>
This schedule has custom conditions and actions
{t('schedules.thisScheduleHasCustomConditionsAndActions')}
</Text>
)}
<Button onClick={() => onEditRule()} disabled={adding}>
Edit as rule
{t('schedules.editAsRule')}
</Button>
</Stack>
)}
@ -638,11 +650,11 @@ export default function ScheduleDetails() {
{adding ? (
<View style={{ flexDirection: 'row', padding: '5px 0' }}>
<Text style={{ color: colors.n4 }}>
These transactions match this schedule:
{t('schedules.theseTransactionsMatchThisSchedule')}
</Text>
<View style={{ flex: 1 }} />
<Text style={{ color: colors.n6 }}>
Select transactions to link on save
{t('schedules.selectTransactionsToLinkOnSave')}
</Text>
</View>
) : (
@ -657,7 +669,7 @@ export default function ScheduleDetails() {
}}
onClick={() => onSwitchTransactions('linked')}
>
Linked transactions
{t('schedules.linkedTransactions')}
</Button>{' '}
<Button
bare
@ -670,15 +682,20 @@ export default function ScheduleDetails() {
}}
onClick={() => onSwitchTransactions('matched')}
>
Find matching transactions
{t('schedules.findMatchingTransactions')}
</Button>
<View style={{ flex: 1 }} />
<SelectedItemsButton
name="transactions"
items={
state.transactionsMode === 'linked'
? [{ name: 'unlink', text: 'Unlink from schedule' }]
: [{ name: 'link', text: 'Link to schedule' }]
? [
{
name: 'unlink',
text: t('schedules.unlinkFromSchedule')
}
]
: [{ name: 'link', text: t('schedules.linkToSchedule') }]
}
onSelect={(name, ids) => {
switch (name) {
@ -716,10 +733,10 @@ export default function ScheduleDetails() {
>
{state.error && <Text style={{ color: colors.r4 }}>{state.error}</Text>}
<Button style={{ marginRight: 10 }} onClick={() => history.goBack()}>
Cancel
{t('general.cancel')}
</Button>
<Button primary onClick={onSave}>
{adding ? 'Add' : 'Save'}
{adding ? t('general.add') : t('general.save')}
</Button>
</Stack>
</Page>

View file

@ -1,4 +1,5 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import * as monthUtils from 'loot-core/src/shared/months';
@ -30,6 +31,8 @@ export let ROW_HEIGHT = 43;
function OverflowMenu({ schedule, status, onAction }) {
let [open, setOpen] = useState(false);
const { t } = useTranslation();
return (
<View>
<Button
@ -60,15 +63,15 @@ function OverflowMenu({ schedule, status, onAction }) {
items={[
status === 'due' && {
name: 'post-transaction',
text: 'Post transaction'
text: t('schedules.postTransaction')
},
...(schedule.completed
? [{ name: 'restart', text: 'Restart' }]
? [{ name: 'restart', text: t('general.restart') }]
: [
{ name: 'skip', text: 'Skip next date' },
{ name: 'complete', text: 'Complete' }
{ name: 'skip', text: t('schedules.skipNextDate') },
{ name: 'complete', text: t('general.complete') }
]),
{ name: 'delete', text: 'Delete' }
{ name: 'delete', text: t('general.delete') }
]}
/>
</Tooltip>
@ -82,6 +85,8 @@ export function ScheduleAmountCell({ amount, op }) {
let str = integerToCurrency(Math.abs(num || 0));
let isApprox = op === 'isapprox' || op === 'isbetween';
const { t } = useTranslation();
return (
<Cell
width={100}
@ -101,7 +106,7 @@ export function ScheduleAmountCell({ amount, op }) {
lineHeight: '1em',
marginRight: 10
}}
title={(isApprox ? 'Approximately ' : '') + str}
title={t('general.approximatelyWithAmount', { amount: str })}
>
~
</View>
@ -114,7 +119,9 @@ export function ScheduleAmountCell({ amount, op }) {
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
title={(isApprox ? 'Approximately ' : '') + str}
title={
isApprox ? t('general.approximatelyWithAmount', { amount: str }) : str
}
>
{num > 0 ? `+${str}` : `${str}`}
</Text>
@ -137,6 +144,8 @@ export function SchedulesTable({
let [showCompleted, setShowCompleted] = useState(false);
const { t } = useTranslation();
let items = useMemo(() => {
if (!allowCompleted) {
return schedules.filter(s => !s.completed);
@ -172,7 +181,7 @@ export function SchedulesTable({
</Field>
<Field width={110}>
{item.next_date
? monthUtils.format(item.next_date, dateFormat)
? monthUtils.nonLocalizedFormat(item.next_date, dateFormat)
: null}
</Field>
<Field width={120} style={{ alignItems: 'flex-start' }}>
@ -221,7 +230,7 @@ export function SchedulesTable({
color: colors.n6
}}
>
Show completed schedules
{t('schedules.showCompletedSchedules')}
</Field>
</Row>
);
@ -232,16 +241,16 @@ export function SchedulesTable({
return (
<>
<TableHeader height={ROW_HEIGHT} inset={15} version="v2">
<Field width="flex">Payee</Field>
<Field width="flex">Account</Field>
<Field width={110}>Next date</Field>
<Field width={120}>Status</Field>
<Field width="flex">{t('general.payee_one')}</Field>
<Field width="flex">{t('general.account_one')}</Field>
<Field width={110}>{t('schedules.nextDate')}</Field>
<Field width={120}>{t('general.status')}</Field>
<Field width={100} style={{ textAlign: 'right' }}>
Amount
{t('general.amount')}
</Field>
{!minimal && (
<Field width={80} style={{ textAlign: 'center' }}>
Recurring
{t('general.recurring')}
</Field>
)}
{!minimal && <Field width={40}></Field>}
@ -253,7 +262,7 @@ export function SchedulesTable({
style={[{ flex: 1, backgroundColor: 'transparent' }, style]}
items={items}
renderItem={renderItem}
renderEmpty="No schedules"
renderEmpty={t('schedules.noSchedules')}
allowPopupsEscape={items.length < 6}
/>
</>

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { titleFirst } from 'loot-core/src/shared/util';
import { View, Text } from 'loot-design/src/components/common';
@ -11,52 +12,62 @@ import FavoriteStar from 'loot-design/src/svg/v2/FavoriteStar';
import ValidationCheck from 'loot-design/src/svg/v2/ValidationCheck';
export function getStatusProps(status) {
let color, backgroundColor, Icon;
let color, backgroundColor, Icon, title;
const { t } = useTranslation();
switch (status) {
case 'missed':
color = colors.r1;
backgroundColor = colors.r10;
Icon = EditSkull1;
title = t('status.missed');
break;
case 'due':
color = colors.y1;
backgroundColor = colors.y9;
Icon = AlertTriangle;
title = t('status.due');
break;
case 'upcoming':
color = colors.p1;
backgroundColor = colors.p10;
Icon = CalendarIcon;
title = t('status.upcoming');
break;
case 'paid':
color = colors.g2;
backgroundColor = colors.g10;
Icon = ValidationCheck;
title = t('status.paid');
break;
case 'completed':
color = colors.n4;
backgroundColor = colors.n11;
Icon = FavoriteStar;
title = t('status.completed');
break;
case 'pending':
color = colors.g4;
backgroundColor = colors.g11;
Icon = CalendarIcon;
title = t('status.pending');
break;
case 'scheduled':
color = colors.n1;
backgroundColor = colors.n11;
Icon = CalendarIcon;
title = t('status.scheduled');
break;
default:
color = colors.n1;
backgroundColor = colors.n11;
Icon = CheckCircle1;
title = status;
break;
}
return { color, backgroundColor, Icon };
return { title, color, backgroundColor, Icon };
}
export function StatusIcon({ status }) {
@ -66,7 +77,7 @@ export function StatusIcon({ status }) {
}
export function StatusBadge({ status, style }) {
let { color, backgroundColor, Icon } = getStatusProps(status);
let { title, color, backgroundColor, Icon } = getStatusProps(status);
return (
<View
style={[
@ -90,7 +101,7 @@ export function StatusBadge({ status, style }) {
marginRight: 7
}}
/>
<Text style={{ lineHeight: '1em' }}>{titleFirst(status)}</Text>
<Text style={{ lineHeight: '1em' }}>{titleFirst(title)}</Text>
</View>
);
}

View file

@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useSchedules } from 'loot-core/src/client/data-hooks/schedules';
@ -13,6 +14,8 @@ export default function Schedules() {
let scheduleData = useSchedules();
const { t } = useTranslation();
if (scheduleData == null) {
return null;
}
@ -52,7 +55,7 @@ export default function Schedules() {
}
return (
<Page title="Schedules">
<Page title={t('general.schedule_other')}>
<View
style={{
marginTop: 20,
@ -72,7 +75,7 @@ export default function Schedules() {
<View style={{ alignItems: 'flex-end', margin: '20px 0', flexShrink: 0 }}>
<Button primary onClick={onAdd}>
Add new schedule
{t('schedules.addNewSchedule')}
</Button>
</View>
</Page>

View file

@ -16,7 +16,7 @@ import Navigation from './Navigation';
function Overspending({ navigationProps, stepTwo }) {
let currentMonth = monthUtils.currentMonth();
let sheetName = monthUtils.sheetForMonth(currentMonth);
let month = monthUtils.format(currentMonth, 'MMM');
let month = monthUtils.nonLocalizedFormat(currentMonth, 'MMM');
let [minimized, toggle] = useMinimized();
return (

View file

@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
integerToCurrency,
@ -52,6 +53,8 @@ export function BetweenAmountInput({ defaultValue, onChange }) {
let [num1, setNum1] = useState(defaultValue.num1);
let [num2, setNum2] = useState(defaultValue.num2);
const { t } = useTranslation();
return (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<AmountInput
@ -61,7 +64,7 @@ export function BetweenAmountInput({ defaultValue, onChange }) {
onChange({ num1: value, num2 });
}}
/>
<View style={{ margin: '0 5px' }}>and</View>
<View style={{ margin: '0 5px' }}>{t('general.and')}</View>
<AmountInput
defaultValue={num2}
onChange={value => {

View file

@ -5,6 +5,7 @@ import './browser-preload';
// A hack for now: this makes sure it's appended before glamor
import '@reach/listbox/styles.css';
import './locales';
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

View file

@ -0,0 +1,163 @@
{
"account": {
"addAccount": "Add account",
"addAccountInFutureFromSidebar": "In the future, you can add accounts from the sidebar.",
"allAccounts": "All Accounts",
"allReconciled": "All reconciled!",
"budgetedAccount_other": "Budgeted Accounts",
"cleared": "Cleared",
"clearedBalance": "Your cleared balance <strong>{{cleared}}</strong> needs <strong>{{diff}}</strong> to match your bank's balance of <strong>{{balance}}</strong>",
"clearedTotal": "Cleared Total: <strong>{{amount}}</strong>",
"closeAccount": "Close Account",
"closedNamed": "Close: {{name}}",
"collapseSplitTransaction_other": "Collapse split transactions",
"doneReconciling": "Done Reconciling",
"enterCurrentBalanceToReconcileAdvice": "Enter the current balance of your bank account that you want to reconcile with:",
"expandSplitTransaction_other": "Expand split transactions",
"hideRunningBalance": "Hide Running Balance",
"linkAccount": "Link account",
"needAccountMessage": "For Actual to be useful, you need to <strong>add an account</strong>. You can link an account to automatically download transactions, or manage it locally yourself.",
"offBudgetAccount_other": "Off Budget Accounts",
"reconcile": "Reconcile",
"reopenAccount": "Reopen account",
"selectedBalance": "Selected Balance: <strong>{{amount}}</strong>",
"selectedTransaction_other": "Selected Transactions",
"showRunningBalance": "Show Running Balance",
"uncategorized": "Uncategorized",
"unclearedTotal": "Uncleared Total: <strong>{{amount}}</strong>",
"unlinkAccount": "Unlink Account"
},
"bootstrap": {
"changeServerPassword": "Change server password",
"passwordCannotBeEmpty": "Password cannot be empty",
"passwordsDoNotMatch": "Passwords do not match",
"passwordSuccessfullyChanged": "Password Successfully changed",
"setPassword": "Set a password for this server instance",
"thisWillChangeThePasswordAdvice": "This will change the password for this server instance. All existing sessions will stay logged in.",
"title": "Bootstrap this Actual instance",
"tryDemo": "Try Demo",
"unableToContactTheServer": "Unable to contact the server",
"unknownError": "Whoops, an error occurred on our side! We'll try to get it fixed soon."
},
"budget": {
"chooseNumberMonths": "Choose the number of months shown at a time"
},
"general": {
"account_one": "Account",
"account_other": "Accounts",
"add": "Add",
"addNew": "Add new",
"addSplit": "Add Split",
"amount": "Amount",
"amountIinflow": "Amount (inflow)",
"amountLeft": "Amount left: <strong>{{amount}}</strong>",
"amountOutflow": "Amount (outflow)",
"and": "and",
"apply": "Apply",
"approximatelyWithAmount": "Approximately {{amount}}",
"balance_one": "Balance",
"cancel": "Cancel",
"categorize": "Categorize",
"category_one": "Category",
"category_other": "Categories",
"complete": "Complete",
"date": "Date",
"delete": "Delete",
"deposit_one": "Deposit",
"doNothing": "Do nothing",
"editField": "Edit field",
"export": "Export",
"exportTransaction_other": "Export Transactions",
"financialFile_other": "Financial Files",
"import": "Import",
"importedPayee": "Imported payee",
"invalidDateFormat": "Invalid date format",
"month": "Month",
"note_other": "Notes",
"noTransaction_other": "No transactions",
"offBudget": "Off Budget",
"payee_one": "Payee",
"payee_other": "Payees",
"payment_one": "Payment",
"recurring": "Recurring",
"repeats": "Repeats",
"restart": "Restart",
"rule_one": "Rule",
"save": "Save",
"schedule": "Schedule",
"schedule_other": "Schedules",
"search": "Search",
"show": "Show",
"split": "Split",
"status": "Status",
"sync": "Sync",
"transfer": "Transfer",
"when": "When",
"year": "Year"
},
"rules": {
"addAction": "Add action",
"applyAction": "Apply action ({{size}})",
"ifAllTheseConditionsMatch": "If all these conditions match:",
"stageOfRule": "Stage of rule:",
"stageOfRuleAdvice": "The stage of a rule allows you to force a specific order. Pre rules always run first, and post rules always run last. Within each stage rules are automatically ordered from least to most specific.",
"stages": {
"default": "Default",
"post": "Post",
"pre": "Pre"
},
"thenApplyTheseActions": "Then apply these actions:",
"thisRuleAppliesToTheseTransactions": "This rule applies to these transactions:"
},
"schedules": {
"addNewSchedule": "Add new schedule",
"automaticallyAddTransaction": "Automatically add transaction",
"automaticallyAddTransactionAdvice": "If checked, the schedule will automatically create transactions for you in the specified account",
"createSchedule_one": "Create schedule",
"createSchedule_other": "Create schedules",
"doFromFindSchedules": "You can always do this later from the \"Find schedules\" item in the sidebar menu",
"doFromToolsFindSchedules": "You can always do this later from the \"Tools > Find schedules\" menu item",
"editAsRule": "Edit as rule",
"expectedSchedulesAdvice": "If you expected a schedule here and don't see it, it might be because the payees of the transactions don't match. Make sure you rename payees on all transactions for a schedule to be the same payee.",
"findMatchingTransactions": "Find matching transactions",
"foundSchedules": "Found schedules",
"foundSomePossibleSchedulesAdvice": "We found some possible schedules in your current transactions. Select the ones you want to create.",
"isApproximately": "is approximately",
"isBetween": "is between",
"isExactly": "is exactly",
"linkedTransactions": "Linked transactions",
"linkSchedule": "Link Schedule",
"linkToSchedule": "Link to schedule",
"nextDate": "Next date",
"none": "(none)",
"noSchedules": "No schedules",
"noSchedulesFound": "No schedules found",
"postTransaction": "Post transaction",
"scheduleNamed": "Schedule: {{name}}",
"selectTransactionsToLinkOnSave": "Select transactions to link on save",
"showCompletedSchedules": "Show completed schedules",
"skipNextDate": "Skip next date",
"skipScheduledDate": "Skip scheduled date",
"theseTransactionsMatchThisSchedule": "These transactions match this schedule:",
"thisScheduleHasCustomConditionsAndActions": "This schedule has custom conditions and actions",
"unlinkFromSchedule": "Unlink from schedule",
"unlinkSchedule": "Unlink Schedule",
"upcomingDates": "Upcoming dates",
"view_one": "View schedule"
},
"status": {
"completed": "completed",
"due": "due",
"missed": "missed",
"paid": "paid",
"pending": "pending",
"scheduled": "scheduled",
"upcoming": "upcoming"
},
"support": {
"anErrorOccuredWhileSaving": "An error occurred while saving. Please contact {{email}} for support."
},
"transaction": {
"accountIsRequired": "Account is a required field"
}
}

View file

@ -0,0 +1,164 @@
{
"account": {
"addAccount": "Agregar cuenta",
"addAccountInFutureFromSidebar": "En el futuro puedes agregar cuentas desde la barra lateral.",
"allAccounts": "Todas las cuentas",
"allReconciled": "¡Todo conciliado!",
"budgetedAccount_other": "Cuentas presupuestadas",
"cleared": "Aclarada",
"clearedBalance": "Su saldo compensado <strong>{{cleared}}</strong> necesita <strong>{{diff}}</strong> para coincidir con el saldo del banco de <strong>{{balance}}</strong>",
"clearedTotal": "Total aclarado: <strong>{{amount}}</strong>",
"closeAccount": "Cerrar cuenta",
"closedNamed": "Cerrada: {{name}}",
"collapseSplitTransaction_other": "Colapsar transacciones divididas",
"doneReconciling": "Conciliación finalizada",
"enterCurrentBalanceToReconcileAdvice": "Ingrese el saldo actual de su cuenta que desea conciliar:",
"expandSplitTransaction_other": "Expandir transacciones divididas",
"hideRunningBalance": "Ocultar saldo actualizado",
"linkAccount": "Vincular cuenta",
"needAccountMessage": "Para que Actual sea útil, debe <strong>agregar una cuenta</strong>. Puedes vincular la cuenta para descargar transacciones automáticamente o administrala localmente.",
"offBudgetAccount_other": "Cuentas sin presupuestar",
"reconcile": "Conciliar",
"reopenAccount": "Re abrir cuenta",
"selectedBalance": "Balance seleccionado: <strong>{{amount}}</strong>",
"selectedTransaction_other": "Transacciones seleccionadas",
"showRunningBalance": "Mostrar saldo actualizado",
"uncategorized": "Sin categorizar",
"unclearedTotal": "Total sin aclarar: <strong>{{amount}}</strong>",
"unlinkAccount": "Desvincular cuenta"
},
"bootstrap": {
"changeServerPassword": "Cambiar contraseña del servidor",
"passwordCannotBeEmpty": "La contraseña no puede estar vacía",
"passwordsDoNotMatch": "Las contraseñas no coinciden",
"passwordSuccessfullyChanged": "La contraseña fue modificada exitosamente",
"setPassword": "Establecer una contraseña para esta instancia de servidor",
"thisWillChangeThePasswordAdvice": "Este proceso modificará la contraseña para el servidor. Todas las sesiones existentes permanecerán conectadas.",
"title": "Bootstrap esta instancia de Actual",
"tryDemo": "Probar Demo",
"unableToContactTheServer": "Incapaz de conectarse con el servidor",
"unknownError": "¡Vaya, ocurrió un error de nuestro lado! Intentaremos solucionarlo pronto."
},
"budget": {
"chooseNumberMonths": "Seleccióne el numero de meses para mostrar a la vez"
},
"general": {
"account_one": "Cuenta",
"account_other": "Cuentas",
"add": "Agregar",
"addNew": "Agregar nuevo",
"addSplit": "Agregar división",
"amount": "Monto",
"amountIinflow": "Monto (Entrada)",
"amountLeft": "Monto restante: <strong>{{amount}}</strong>",
"amountOutflow": "Monto (Salida)",
"and": "y",
"apply": "Aplicar",
"approximatelyWithAmount": "Aproximadamente {{amount}}",
"balance_one": "Balance",
"cancel": "Cancelar",
"categorize": "Categorizar",
"category_one": "Categoría",
"category_other": "Categorías",
"complete": "Completar",
"date": "Fecha",
"delete": "Borrar",
"deposit_one": "Depósito",
"doNothing": "No hacer nada",
"editField": "Editar campo",
"export": "Exportar",
"exportTransaction_other": "Exportar transacciones",
"financialFile_other": "Archivos financieros",
"import": "Importar",
"importedPayee": "Beneficiario importado",
"invalidDateFormat": "Formato de fecha inválido",
"month": "Mes",
"note_other": "Notas",
"noTransaction_other": "Sin transacciones",
"offBudget": "Fuera de presupuesto",
"payee_one": "Beneficiario",
"payee_other": "Beneficiarios",
"payment_one": "Pago",
"recurring": "Periódico",
"repeats": "Repetir",
"restart": "Reiniciar",
"rule_one": "Regla",
"save": "Guardar",
"schedule": "Agenda",
"schedule_other": "Agendas",
"search": "Buscar",
"show": "Mostrar",
"split": "Dividir",
"status": "Estado",
"sync": "Sincronizar",
"transfer": "Transferencia",
"when": "Cuando",
"year": "Año"
},
"rules": {
"addAction": "Agregar acción",
"applyAction": "Aplicar acción ({{size}})",
"ifAllTheseConditionsMatch": "Si todas estas condiciones coinciden:",
"stageOfRule": "Etapa de la regla:",
"stageOfRuleAdvice": "La etapa de una regla le permite forzar un orden específico. Reglas previas siempre se ejecuta primero y las reglas posteriores siempre se ejecutan al final. Dentro de cada etapa las reglas se ordenan automáticamente de menos a más específicas.",
"stages": {
"default": "Por defecto",
"post": "Posterior",
"pre": "Previa"
},
"thenApplyTheseActions": "Aplíque éstas acciones:",
"thisRuleAppliesToTheseTransactions": "Ésta regla aplica a las siguientes transacciones:"
},
"schedules": {
"addNewSchedule": "Agregar nueva agenda",
"automaticallyAddTransaction": "Agregar transacción automáticamente",
"automaticallyAddTransactionAdvice": "Si se selecciona, la agenda creará automáticamente una transacción para la cuenta especificada",
"createSchedule_one": "Crear agenda",
"createSchedule_many": "Crear agendas",
"createSchedule_other": "Crear agendas",
"doFromFindSchedules": "Puedes hacerlo después desde el ítem de menú \"Buscar agendas\" en la barra lateral",
"doFromToolsFindSchedules": "Puedes hacerlo después desde el ítem de menú \"Herramientas > Buscar agendas\"",
"editAsRule": "Editar como regla",
"expectedSchedulesAdvice": "Si estabas esperando encontrar alguna agenda y no se visualiza, puede deberse a que los beneficiarios de las transacciones no coincidan. Asegurate de cambiar el nombre los beneficiarios en todas las transacciones de una agenda para que sean el mismo.",
"findMatchingTransactions": "Encontrar transacciones que coincidan",
"foundSchedules": "Agendas encontradas",
"foundSomePossibleSchedulesAdvice": "Encontramos posibles agendas en la transacción actual. Selecciona las que desea crear.",
"isApproximately": "es aproximadamente",
"isBetween": "está entre",
"isExactly": "es exactamente",
"linkedTransactions": "Transacciones vinculadas",
"linkSchedule": "Vincular agenda",
"linkToSchedule": "Vincular a agenda",
"nextDate": "Próxima fecha",
"none": "(ninguno)",
"noSchedules": "Sin agendas",
"noSchedulesFound": "No se encontraron agendas",
"postTransaction": "Publicar transacción",
"scheduleNamed": "Agenda: {{name}}",
"selectTransactionsToLinkOnSave": "Seleccionar transacciones para vincular al guardar",
"showCompletedSchedules": "Mostrar agendas completadas",
"skipNextDate": "Saltar próxima fecha",
"skipScheduledDate": "Saltar próxima fecha agendada",
"theseTransactionsMatchThisSchedule": "Éstas transacciones coinciden con la agenda",
"thisScheduleHasCustomConditionsAndActions": "Ésta agenda tiene condiciones y acciones personalizadas",
"unlinkFromSchedule": "Desvincular de la agenda",
"unlinkSchedule": "Desvincular agenda",
"upcomingDates": "Próximas fechas",
"view_one": "Ver agenda"
},
"status": {
"completed": "completo",
"due": "adeudado",
"missed": "omitido",
"paid": "pago",
"pending": "pendiente",
"scheduled": "agendado",
"upcoming": "próximo"
},
"support": {
"anErrorOccuredWhileSaving": "Ocurrió un error al guardar. Por favor, póngase en contacto con {{email}} para obtener asistencia."
},
"transaction": {
"accountIsRequired": "Cuenta es un campo requerido"
}
}

View file

@ -0,0 +1,40 @@
import { initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import enUKCore from 'loot-core/src/locales/en-GB.json';
import esESCore from 'loot-core/src/locales/es-ES.json';
import enUK from './en-GB.json';
import esES from './es-ES.json';
const resources = {
en: {
web: enUK,
core: enUKCore
},
es: {
web: esES,
core: esESCore
}
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
defaultNS: 'web',
lng: 'es', // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
// We enforce that a locales have all keys so we treat empty string as missing value.
returnEmptyString: false,
fallbackLng: 'en',
interpolation: {
escapeValue: false // react already safes from xss
}
});
export default i18n;

View file

@ -0,0 +1,16 @@
module.exports = {
input: ['src/**/*.js'],
output: 'src/locales/$LOCALE.json',
locales: ['en-GB', 'es-ES'],
defaultNamespace: 'core',
sort: true,
// Force usage of JsxLexer for .js files as otherwise we can't pick up <Trans> components.
lexers: {
js: ['JsxLexer'],
ts: ['JsxLexer'],
jsx: ['JsxLexer'],
tsx: ['JsxLexer'],
default: ['JsxLexer']
}
};

View file

@ -12,7 +12,8 @@
"lint": "eslint src",
"test": "npm-run-all -cp 'test:*'",
"test:node": "jest -c jest.config.js",
"test:web": "jest -c jest.web.config.js"
"test:web": "jest -c jest.web.config.js",
"check-i18n": "i18next"
},
"author": "",
"license": "ISC",
@ -56,6 +57,8 @@
"fake-indexeddb": "^3.1.3",
"fast-check": "2.13.0",
"fast-glob": "^2.2.0",
"i18next": "^21.9.1",
"i18next-parser": "^6.5.0",
"jest": "^28.1.0",
"jsverify": "^0.8.4",
"lru-cache": "^5.1.1",

View file

@ -0,0 +1,27 @@
{
"general": {
"ordinal_one": "{{count}}st",
"ordinal_two": "{{count}}nd",
"ordinal_few": "{{count}}rd",
"ordinal_other": "{{count}}th"
},
"schedules": {
"recurring": {
"monthly_one": "Every month on the {{day}}",
"monthly_other": "Every {{count}} months on the {{day}}",
"monthlyPattern_one": "Every month on the {{pattern}}",
"monthlyPattern_other": "Every {{count}} months on the {{pattern}}",
"pattern": {
"lastDay": "last day",
"lastWeekday": "last {{dayName}}",
"lastWeekday_sameDay": "last",
"weekAndDay": "{{week}} {{dayName}}",
"weekAndDay_sameDay": "{{week}}"
},
"weekly_one": "Every week on {{day}}",
"weekly_other": "Every {{count}} weeks on {{day}}",
"yearly_one": "Every year on {{day}}",
"yearly_other": "Every {{count}} years on {{day}}"
}
}
}

View file

@ -0,0 +1,28 @@
{
"general": {
"ordinal_other": "{{count}}º"
},
"schedules": {
"recurring": {
"monthly_one": "Cada {{day}} día del mes",
"monthly_many": "Cada {{count}} de meses el {{day}} día",
"monthly_other": "Cada {{count}} meses el {{day}} día",
"monthlyPattern_one": "Cada mes {{pattern}}",
"monthlyPattern_many": "Cada {{count}} de meses {{pattern}}",
"monthlyPattern_other": "Cada {{count}} meses {{pattern}}",
"pattern": {
"lastDay": "el último día",
"lastWeekday": "el último {{dayName}}",
"lastWeekday_sameDay": "el último",
"weekAndDay": "{{week}} {{dayName}}",
"weekAndDay_sameDay": "{{week}}"
},
"weekly_one": "Cada semana {{day}}",
"weekly_many": "Cada {{count}} de semanas el {{day}}",
"weekly_other": "Cada {{count}} semanas el {{day}}",
"yearly_one": "Cada año el {{day}}",
"yearly_many": "Cada {{count}} de años el {{day}}",
"yearly_other": "Cada {{count}} años el {{day}}"
}
}
}

View file

@ -210,11 +210,11 @@ export function sheetForMonth(month) {
return 'budget' + month.replace('-', '');
}
export function nameForMonth(month) {
return d.format(_parse(month), "MMMM 'yy");
export function format(month, opts, locale) {
return Intl.DateTimeFormat(locale, opts).format(_parse(month));
}
export function format(month, str) {
export function nonLocalizedFormat(month, str) {
return d.format(_parse(month), str);
}

View file

@ -42,39 +42,49 @@ export function getHasTransactionsQuery(schedules) {
.select(['schedule', 'date']);
}
function makeNumberSuffix(num) {
// Slight abuse of date-fns to turn a number like "1" into the full
// form "1st" but formatting a date with that number
return monthUtils.format(new Date(2020, 0, num, 12), 'do');
}
function prettyDayName(day) {
function prettyDayName(day, locale) {
let days = {
SU: 'Sunday',
MO: 'Monday',
TU: 'Tuesday',
WE: 'Wednesday',
TH: 'Thursday',
FR: 'Friday',
SA: 'Saturday'
SU: new Date('2020-01-05T12:00:00.000Z'),
MO: new Date('2020-01-06T12:00:00.000Z'),
TU: new Date('2020-01-07T12:00:00.000Z'),
WE: new Date('2020-01-08T12:00:00.000Z'),
TH: new Date('2020-01-09T12:00:00.000Z'),
FR: new Date('2020-01-10T12:00:00.000Z'),
SA: new Date('2020-01-11T12:00:00.000Z')
};
return days[day];
return Intl.DateTimeFormat(locale, { weekday: 'long' }).format(days[day]);
}
export function getRecurringDescription(config) {
function formatMonthAndDay(month, i18n) {
let parts = Intl.DateTimeFormat(i18n.resolvedLanguage, {
month: 'short',
day: 'numeric'
}).formatToParts(monthUtils.parseDate(month));
let dayPart = parts.find(p => p.type === 'day');
dayPart.value = i18n.t('general.ordinal', {
count: monthUtils.parseDate(month).getDate(),
ordinal: true,
ns: 'core'
});
return parts.map(part => part.value).join('');
}
export function getRecurringDescription(config, i18n) {
let interval = config.interval || 1;
switch (config.frequency) {
case 'weekly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} weeks` : 'week';
desc += ' on ' + monthUtils.format(config.start, 'EEEE');
return desc;
return i18n.t('schedules.recurring.weekly', {
count: interval,
day: monthUtils.format(
config.start,
{ weekday: 'long' },
i18n.resolvedLanguage
),
ns: 'core'
});
}
case 'monthly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} months` : 'month';
if (config.patterns && config.patterns.length > 0) {
// Sort the days ascending. We filter out -1 because that
// represents "last days" and should always be last, but this
@ -95,56 +105,101 @@ export function getRecurringDescription(config) {
// Add on all -1 values to the end
patterns = patterns.concat(config.patterns.filter(p => p.value === -1));
desc += ' on the ';
let strs = [];
let uniqueDays = new Set(patterns.map(p => p.type));
let isSameDay = uniqueDays.length === 1 && !uniqueDays.has('day');
let context =
uniqueDays.length === 1 && !uniqueDays.has('day')
? 'sameDay'
: undefined;
for (let pattern of patterns) {
if (pattern.type === 'day') {
if (pattern.value === -1) {
strs.push('last day');
strs.push(
i18n.t('schedules.recurring.pattern.lastDay', {
ns: 'core'
})
);
} else {
// Example: 15th day
strs.push(makeNumberSuffix(pattern.value));
strs.push(
i18n.t('general.ordinal', {
count: pattern.value,
ordinal: true,
ns: 'core'
})
);
}
} else {
let dayName = isSameDay ? '' : ' ' + prettyDayName(pattern.type);
let dayName = prettyDayName(
pattern.type,
i18n.resolvedLanguage,
i18n.resolvedLanguage
);
if (pattern.value === -1) {
// Example: last Monday
strs.push('last' + dayName);
// t('schedules.recurring.pattern.lastWeekday')
// t('schedules.recurring.pattern.lastWeekday_sameDay')
strs.push(
i18n.t('schedules.recurring.pattern.lastWeekday', {
context,
dayName,
ns: 'core'
})
);
} else {
// Example: 3rd Monday
strs.push(makeNumberSuffix(pattern.value) + dayName);
// t('schedules.recurring.pattern.weekAndDay')
// t('schedules.recurring.pattern.weekAndDay_sameDay')
strs.push(
i18n.t('schedules.recurring.pattern.weekAndDay', {
context,
week: i18n.t('general.ordinal', {
count: pattern.value,
ordinal: true,
ns: 'core'
}),
dayName,
ns: 'core'
})
);
}
}
}
if (strs.length > 2) {
desc += strs.slice(0, strs.length - 1).join(', ');
desc += ', and ';
desc += strs[strs.length - 1];
} else {
desc += strs.join(' and ');
}
if (isSameDay) {
desc += ' ' + prettyDayName(patterns[0].type);
}
return i18n.t('schedules.recurring.monthlyPattern', {
context,
count: interval,
day: prettyDayName(patterns[0].type, i18n.resolvedLanguage),
pattern: new Intl.ListFormat(i18n.resolvedLanguage, {
style: 'long',
type: 'conjunction'
}).format(strs),
ns: 'core'
});
} else {
desc += ' on the ' + monthUtils.format(config.start, 'do');
return i18n.t('schedules.recurring.monthly', {
count: interval,
day: i18n.t('general.ordinal', {
count: monthUtils.parseDate(config.start).getDate(),
ordinal: true,
ns: 'core'
}),
ns: 'core'
});
}
return desc;
}
case 'yearly': {
let desc = 'Every ';
desc += interval !== 1 ? `${interval} years` : 'year';
desc += ' on ' + monthUtils.format(config.start, 'LLL do');
return desc;
return i18n.t(
'schedules.recurring.yearly',
{
count: interval,
day: formatMonthAndDay(config.start, i18n),
ns: 'core'
},
i18n.resolvedLanguage
);
}
default:
return 'Recurring error';

View file

@ -1,7 +1,18 @@
import i18n from 'i18next';
import MockDate from 'mockdate';
import enUKCore from '../locales/en-GB.json';
import { getRecurringDescription } from './schedules';
i18n.init({
lng: 'en',
resources: {
en: {
core: enUKCore
}
}
});
describe('recurring date description', () => {
beforeEach(() => {
MockDate.set(new Date(2021, 4, 14));
@ -9,149 +20,197 @@ describe('recurring date description', () => {
it('describes weekly interval', () => {
expect(
getRecurringDescription({ start: '2021-05-17', frequency: 'weekly' })
getRecurringDescription(
{ start: '2021-05-17', frequency: 'weekly' },
i18n
)
).toBe('Every week on Monday');
expect(
getRecurringDescription({
start: '2021-05-17',
frequency: 'weekly',
interval: 2
})
getRecurringDescription(
{
start: '2021-05-17',
frequency: 'weekly',
interval: 2
},
i18n
)
).toBe('Every 2 weeks on Monday');
});
it('describes monthly interval', () => {
expect(
getRecurringDescription({ start: '2021-04-25', frequency: 'monthly' })
getRecurringDescription(
{ start: '2021-04-25', frequency: 'monthly' },
i18n
)
).toBe('Every month on the 25th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
interval: 2
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
interval: 2
},
i18n
)
).toBe('Every 2 months on the 25th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 25 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 25 }]
},
i18n
)
).toBe('Every month on the 25th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
interval: 2,
patterns: [{ type: 'day', value: 25 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
interval: 2,
patterns: [{ type: 'day', value: 25 }]
},
i18n
)
).toBe('Every 2 months on the 25th');
// Last day should work
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 31 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: 31 }]
},
i18n
)
).toBe('Every month on the 31st');
// -1 should work, representing the last day
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: -1 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'day', value: -1 }]
},
i18n
)
).toBe('Every month on the last day');
// Day names should work
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: 2 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: 2 }]
},
i18n
)
).toBe('Every month on the 2nd Friday');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: -1 }]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [{ type: 'FR', value: -1 }]
},
i18n
)
).toBe('Every month on the last Friday');
});
it('describes monthly interval with multiple days', () => {
// Note how order doesn't matter - the day should be sorted
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 15 },
{ type: 'day', value: 3 },
{ type: 'day', value: 20 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 15 },
{ type: 'day', value: 3 },
{ type: 'day', value: 20 }
]
},
i18n
)
).toBe('Every month on the 3rd, 15th, and 20th');
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'day', value: 20 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'day', value: 20 }
]
},
i18n
)
).toBe('Every month on the 3rd, 20th, and last day');
// Mix days and day names
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'FR', value: 2 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'day', value: 3 },
{ type: 'day', value: -1 },
{ type: 'FR', value: 2 }
]
},
i18n
)
).toBe('Every month on the 2nd Friday, 3rd, and last day');
// When there is a mixture of types, day names should always come first
expect(
getRecurringDescription({
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'SA', value: 1 },
{ type: 'day', value: 2 },
{ type: 'FR', value: 3 },
{ type: 'day', value: 10 }
]
})
getRecurringDescription(
{
start: '2021-04-25',
frequency: 'monthly',
patterns: [
{ type: 'SA', value: 1 },
{ type: 'day', value: 2 },
{ type: 'FR', value: 3 },
{ type: 'day', value: 10 }
]
},
i18n
)
).toBe('Every month on the 1st Saturday, 3rd Friday, 2nd, and 10th');
});
it('describes yearly interval', () => {
expect(
getRecurringDescription({ start: '2021-05-17', frequency: 'yearly' })
getRecurringDescription(
{ start: '2021-05-17', frequency: 'yearly' },
i18n
)
).toBe('Every year on May 17th');
expect(
getRecurringDescription({
start: '2021-05-17',
frequency: 'yearly',
interval: 2
})
getRecurringDescription(
{
start: '2021-05-17',
frequency: 'yearly',
interval: 2
},
i18n
)
).toBe('Every 2 years on May 17th');
});
});

View file

@ -1,4 +1,5 @@
import React, { useEffect, useReducer, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { sendCatch } from 'loot-core/src/platform/client/fetch';
@ -12,8 +13,6 @@ import SubtractIcon from 'loot-design/src/svg/Subtract';
import { Button, Select, Input, Tooltip, View, Text, Stack } from './common';
import DateSelect from './DateSelect';
const DATE_FORMAT = 'yyyy-MM-dd';
// ex: There is no 6th Friday of the Month
const MAX_DAY_OF_WEEK_INTERVAL = 5;
@ -62,7 +61,7 @@ function unparseConfig(parsed) {
function createMonthlyRecurrence(startDate) {
return {
value: parseInt(monthUtils.format(startDate, 'd')),
value: parseInt(monthUtils.nonLocalizedFormat(startDate, 'd')),
type: 'day'
};
}
@ -152,8 +151,8 @@ function SchedulePreview({ previewDates }) {
<Stack direction="row" spacing={4} style={{ marginTop: 10 }}>
{previewDates.map(d => (
<View>
<Text>{monthUtils.format(d, dateFormat)}</Text>
<Text>{monthUtils.format(d, 'EEEE')}</Text>
<Text>{monthUtils.nonLocalizedFormat(d, dateFormat)}</Text>
<Text>{monthUtils.nonLocalizedFormat(d, 'EEEE')}</Text>
</View>
))}
</Stack>
@ -370,6 +369,7 @@ export default function RecurringSchedulePicker({
onChange
}) {
let { isOpen, close, getOpenEvents } = useTooltip();
let { i18n } = useTranslation();
function onSave(config) {
onChange(config);
@ -379,7 +379,7 @@ export default function RecurringSchedulePicker({
return (
<View>
<Button {...getOpenEvents()} style={[{ textAlign: 'left' }, buttonStyle]}>
{value ? getRecurringDescription(value) : 'No recurring date'}
{value ? getRecurringDescription(value, i18n) : 'No recurring date'}
</Button>
{isOpen && (
<RecurringScheduleTooltip

View file

@ -1256,7 +1256,7 @@ export const MonthPicker = scope(lively => {
function getCurrentMonthName(startMonth, currentMonth) {
return monthUtils.getYear(startMonth) === monthUtils.getYear(currentMonth)
? monthUtils.format(currentMonth, 'MMM')
? monthUtils.nonLocalizedFormat(currentMonth, 'MMM')
: null;
}
@ -1264,7 +1264,7 @@ export const MonthPicker = scope(lively => {
const currentMonth = monthUtils.currentMonth();
const range = getRangeForYear(currentMonth);
const monthNames = range.map(month => {
return monthUtils.format(month, 'MMM');
return monthUtils.nonLocalizedFormat(month, 'MMM');
});
return {
@ -1314,7 +1314,7 @@ export const MonthPicker = scope(lively => {
flex: '0 0 40px'
}}
>
{monthUtils.format(year, 'yyyy')}
{monthUtils.nonLocalizedFormat(year, 'yyyy')}
</View>
<ElementQuery
sizes={[

View file

@ -321,7 +321,7 @@ export default React.memo(function BudgetSummary({ month }) {
currentMonth === month && { textDecoration: 'underline' }
])}
>
{monthUtils.format(month, 'MMMM')}
{monthUtils.nonLocalizedFormat(month, 'MMMM')}
</div>
<View

View file

@ -271,7 +271,10 @@ export default React.memo(function BudgetSummary({ month }) {
setMenuOpen(false);
}
let prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
let prevMonthName = monthUtils.nonLocalizedFormat(
monthUtils.prevMonth(month),
'MMM'
);
let ExpandOrCollapseIcon = collapsed ? ArrowButtonDown1 : ArrowButtonUp1;
@ -337,7 +340,7 @@ export default React.memo(function BudgetSummary({ month }) {
currentMonth === month && { textDecoration: 'underline' }
])}
>
{monthUtils.format(month, 'MMMM')}
{monthUtils.nonLocalizedFormat(month, 'MMMM')}
</div>
<View

View file

@ -1092,7 +1092,7 @@ export function BudgetHeader({
}
]}
>
{monthUtils.format(currentMonth, "MMMM ''yy")}
{monthUtils.nonLocalizedFormat(currentMonth, "MMMM ''yy")}
</Text>
{editMode ? (
<Button

View file

@ -692,7 +692,7 @@ export function DateHeader({ date }) {
}}
>
<Text style={[styles.text, { fontSize: 13, color: colors.n4 }]}>
{monthUtils.format(date, 'MMMM dd, yyyy')}
{monthUtils.nonLocalizedFormat(date, 'MMMM dd, yyyy')}
</Text>
</ListItem>
);

View file

@ -4,7 +4,7 @@ import { connect } from 'react-redux';
import * as d from 'date-fns';
import * as actions from 'loot-core/src/client/actions';
import { format as formatDate_ } from 'loot-core/src/shared/months';
import { nonLocalizedFormat as formatDate_ } from 'loot-core/src/shared/months';
import {
amountToCurrency,
amountToInteger,

View file

@ -28,7 +28,10 @@ import {
} from 'loot-core/src/shared/categories.js';
function BudgetSummary({ month, onClose }) {
const prevMonthName = monthUtils.format(monthUtils.prevMonth(month), 'MMM');
const prevMonthName = monthUtils.nonLocalizedFormat(
monthUtils.prevMonth(month),
'MMM'
);
return (
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}>

871
yarn.lock

File diff suppressed because it is too large Load diff