diff --git a/Justfile b/Justfile index 5016b37a768..e5369430862 100644 --- a/Justfile +++ b/Justfile @@ -416,6 +416,22 @@ STACKTEST := STACK + ' test --fast' @anchortest: tools/checkanchors +# regenerate the translation template (hledger-lib/locale/hledger.pot) from the sources +@i18n-pot: + tools/i18n-extract.py -o hledger-lib/locale/hledger.pot + +# check the translation catalogs against the sources: stale entries fail, untranslated ones are counted +@i18n-check: + tools/i18n-extract.py --check hledger-lib/locale/*.po + +# merge new and changed source strings into the translation catalogs (needs gettext's msgmerge) +@i18n-merge: i18n-pot + for f in hledger-lib/locale/*.po; do msgmerge --update --previous --backup=none "$f" hledger-lib/locale/hledger.pot; done + +# write a pseudo-locale catalog to ~/.config/hledger/locale/xx.po; then `hledger ... --lang xx` shows any output that is still English +@i18n-pseudo: + mkdir -p ~/.config/hledger/locale && tools/i18n-extract.py --pseudo -o ~/.config/hledger/locale/xx.po && echo "wrote ~/.config/hledger/locale/xx.po" + # # stack build --dry-run all hledger packages ensuring an install plan with default snapshot) # buildplantest: # buildplantest-stack.yaml diff --git a/doc/CONTRIBUTING.md b/doc/CONTRIBUTING.md index 7c285f732c4..0ae81428ee8 100644 --- a/doc/CONTRIBUTING.md +++ b/doc/CONTRIBUTING.md @@ -46,7 +46,7 @@ and for more project scripts run `just` in the main repo. - Share what you've learned so far to help others. This is a quadruple win - it helps them, improves your own understanding, builds community, and frees up maintainer time! -- Add translation to your language. Starting with the [tldr](https://github.com/hledgerorg/hledger/tree/main/doc/tldr) has high value. Or if you want to spend minimal effort, then just translate the [top level account names](https://github.com/hledgerorg/hledger/tree/main/examples/i18n) +- Add translation to your language. Starting with the [tldr](https://github.com/hledgerorg/hledger/tree/main/doc/tldr) has high value. Or if you want to spend minimal effort, then just translate the [top level account names](https://github.com/hledgerorg/hledger/tree/main/examples/i18n). To translate hledger's own report headings and interfaces, see [TRANSLATING](TRANSLATING.md); no programming needed. ## Funder ? diff --git a/doc/TRANSLATING.md b/doc/TRANSLATING.md new file mode 100644 index 00000000000..65ba4153e0c --- /dev/null +++ b/doc/TRANSLATING.md @@ -0,0 +1,313 @@ +# Translating hledger + +hledger can show the structural text of its output in your language: +report titles and section headings, column headings, month names, the +names of hledger-ui's screens, and hledger-web's pages and forms. This +guide is for anyone who would like to add or improve a language. You do +not need to be a programmer or a professional translator, and you do not +need to build hledger: you can work with an installed release and see +your translation in action within minutes. + +Things that are never translated, so you do not have to look for them: +your own data (account names, descriptions, amounts), dates (always +`2026-01-31`), number formats (these come from your journal's commodity +settings), error messages, the command line help, the manuals, and the +column headings of CSV, TSV and JSON output, which other programs read. +Translating the manuals is a separate, larger project. + +A translation is one text file per language, in the "PO" format that +translation tools everywhere understand. hledger's built-in catalogs +live in the source tree at `hledger-lib/locale/`, one `LANG.po` per +language, next to the template `hledger.pot` that lists every +translatable string. + +## What you need + +- hledger with the `--lang` option (check with `hledger --help | grep lang`). +- The template, `hledger.pot`. Download it from + + or take it from a source checkout. +- Either [Poedit](https://poedit.net) (free, runs on Windows, macOS and + Linux, recommended if PO files are new to you), or any plain text + editor. Weblate and Lokalize work too. +- A small journal to try things on. The one used below is + `examples/sample.journal` in the hledger source, or paste this into a + file called `sample.journal`: + +```journal +2026-01-01 opening balance + assets:bank:checking 100 EUR + equity:opening + +2026-02-14 groceries + expenses:food 25 EUR + assets:bank:checking +``` + +## The workflow in short + +1. Create `LANG.po` from `hledger.pot`. +2. Translate the entries. +3. Put the file in hledger's config directory and run hledger with `--lang LANG`. +4. Repeat 2 and 3 until you are happy. +5. Send the file in. + +The rest of this guide goes through each step with French as the example. + +## Step 1: create your language's file + +Language files are named by their language tag: `de.po` for German, +`fr.po` for French, `pt-BR.po` for Brazilian Portuguese, `zh-Hans.po` +for Simplified Chinese. A plain two-letter tag is usually right; add a +region or script only when the language really differs by it. + +**With Poedit:** File > New From POT/PO File, choose `hledger.pot`, pick +your language when asked, and save as `fr.po`. Poedit fills in the file +header, including the plural rule for your language. + +**With a text editor:** copy `hledger.pot` to `fr.po` and edit the block +at the top: + +```po +msgid "" +msgstr "" +"Project-Id-Version: hledger\n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +``` + +Set `Language` to your tag, keep the charset as UTF-8, and set +`Plural-Forms` to your language's rule. Common ones: + +| Languages | Plural-Forms | +|---|---| +| English, German, Dutch, Spanish, Italian, Swedish, Turkish | `nplurals=2; plural=(n != 1);` | +| French, Portuguese (Brazil) | `nplurals=2; plural=(n > 1);` | +| Chinese, Japanese, Korean, Vietnamese, Thai | `nplurals=1; plural=0;` | +| Russian, Ukrainian, Polish, Czech | see the [GNU gettext list](https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html) | + +Also remove the `#, fuzzy` line above the header if it is there: it +marks the whole file as a draft. + +## Step 2: translate the entries + +Each string is one entry. Here is one from the template: + +```po +#. the report title, eg "Monthly Balance Sheet 2024 (Historical Ending Balances), valued at period ends". {clarification} brings its own leading space when present. +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:151 +#, python-brace-format +msgid "{report} {dates}{clarification}{valuation}" +msgstr "" +``` + +- `msgid` is the English text. Never change it. +- `msgstr` is where your translation goes. Leave it empty for anything + you are not sure about: an empty translation shows the English text + (or, for a language hledger already ships, the built-in translation), + so a partly translated file is fine and useful. +- `#.` lines are notes from the developers about where and how the text + is used. Read them; they say things like "this precedes a report + title" or "stand-alone month name, used as a column heading". +- `#:` lines say where in the source the text comes from. You can ignore them. +- `#, python-brace-format` means the text contains placeholders. + +In Poedit the same entry appears as a row; the notes show in the right +hand panel. + +### Placeholders + +`{report}`, `{dates}`, `{account}` and similar are placeholders that +hledger fills in when it runs. Keep each one exactly as it is, but put +it where your language needs it. For example, "Transactions in +{account}" becomes "Buchungen in {account}" in German, and a language +that puts the date first can write "{dates} {report}" for the +title template. Do not translate the word inside the braces. Poedit +warns you if a placeholder goes missing. + +### Contexts + +A few short words appear in more than one place with different +meanings, so they carry a context line: + +```po +msgctxt "column heading" +msgid "Total" +msgstr "Gesamt" +``` + +Translate each context separately; the same English word may want +different translations. Poedit shows the context next to the entry. + +### Spaces, punctuation and capitals + +Some entries deliberately start with a comma and a space, like +`", valued at {date}"`, or end with a colon, like `"Net:"`, because +they are appended to other text. Keep that shape. Keep the capitalization +style of the English too: headings are capitalized, hledger-ui screen +names are not. + +### Whole phrases + +A report title with a reporting interval, like "Monthly Balance Sheet", +is one entry, not "Monthly" and "Balance Sheet" joined together. That +makes for more entries (each report has one per interval), but each is +a complete phrase, so you can inflect, reorder or join the words however +your language needs: "Monatliche Bilanz", "Bilan mensuel", "月次貸借対照表". + +### Month names + +There are two sets, with contexts `month` (January) and `month abbrev` +(Jan). Use the stand-alone, nominative form, since they are used as +column headings, not inside dates. Short forms of three or four +characters keep the columns narrow, but longer ones work. + +### Never markup + +Translations are always shown as plain text, including on web pages, so +`` or `&` in a translation will appear literally. + +## Step 3: try it out + +hledger looks for translations in the `locale` folder of its config +directory, and uses one found there in preference to the built-in +catalog for the same language. So you can test without rebuilding +anything: + +| System | Put your file at | +|---|---| +| Linux, macOS | `~/.config/hledger/locale/fr.po` | +| Windows | `%APPDATA%\hledger\locale\fr.po` | + +Then, once a few entries are translated, run some reports with `--lang fr` +(this is what a French file translating the balance sheet's four strings gives): + +``` +$ hledger -f sample.journal balancesheet --lang fr +Bilan 2026-02-14 + + || 2026-02-14 +======================++============ + Actifs || +----------------------++------------ + assets:bank:checking || 75 EUR +----------------------++------------ + || 75 EUR +======================++============ + Passifs || +----------------------++------------ +----------------------++------------ + || 0 +======================++============ + Solde: || 75 EUR +``` + +Commands that between them show most of the translatable text: + +``` +hledger -f sample.journal balancesheet --lang fr +hledger -f sample.journal incomestatement --lang fr -M -T -A +hledger -f sample.journal cashflow --lang fr +hledger -f sample.journal balance --lang fr -M +hledger -f sample.journal balance --lang fr -Q --value=then +hledger -f sample.journal balance --lang fr --budget -M +hledger-ui -f sample.journal --lang fr +hledger-web -f sample.journal --lang fr +``` + +For hledger-web you can also leave `--lang` off and open +`http://127.0.0.1:5000/?_LANG=fr` in your browser, or set your browser's +preferred language to French; the choice is remembered in a cookie. The +add form (press `a`), its validation messages (submit it empty), the +help dialog (press `h`) and the file management pages (the wrench icon, +with `--allow=edit`) each have their own strings. hledger-web reads the +catalogs when it starts, so restart it after editing your file; hledger +itself reads the file on every run. + +If hledger prints a warning that it is ignoring your catalog, the file +has a syntax problem, usually an unclosed quote or a stray line; the +warning names the line. Poedit will not save an invalid file, so this +mostly happens with hand-edited ones. + +To find what is still in English, compare with the same commands run +without `--lang`, or look at Poedit's counter of untranslated entries. + +## Step 4: send it in + +When you are happy with it, contribute the file: + +- If you use GitHub: add it as `hledger-lib/locale/fr.po` and open a pull + request. Two one-line changes are also needed to build it into hledger, + and a maintainer will add them if you would rather not: list the file + under `extra-source-files` in `hledger-lib/package.yaml`, and add + `("fr", $(embedFileRelativeBytes "locale/fr.po"))` to + `builtinCatalogSources` in `hledger-lib/Hledger/Utils/I18n.hs`. +- Otherwise, attach the file to an issue or a message on the + [mail list](https://hledger.org/support.html), and someone will add it. + +Please say in the pull request or message which terminology you chose +for the accounting terms (assets, liabilities, equity, revenues, +expenses), and why, especially where the everyday word differs from the +term accountants use in your country. The German catalog's choices are +explained in `hledger-lib/locale/README.md` as an example. Having a +second speaker, ideally someone who reads financial statements, look +over the file before it ships is worth a lot; hledger's maintainers +usually can not check a language themselves. + +## Keeping a translation up to date + +When hledger's English text changes, the template changes with it. To +refresh your file: + +- In Poedit: Translation > Update from POT File, choosing the new + `hledger.pot`. New entries appear untranslated; entries whose English + changed are marked "needs work" (fuzzy) with your old translation kept + for reference. Fix those, since fuzzy entries are not used until you + clear the flag (the English text, or the built-in translation, shows + instead). +- With the gettext tools: `msgmerge --update --previous fr.po hledger.pot` + does the same. + +In the hledger repository, `just i18n-merge` refreshes every built-in +catalog this way and `just i18n-check` lists stale entries and counts +untranslated ones, so a maintainer can tell you what a language needs. + +## For developers + +How strings become translatable, and the rules that keep them +translatable: + +- `tr trs "Text"` translates a literal; `trc trs "context" "Text"` + adds a context for a short word used in several senses; `trf trs + "Text with {name}" [("name", value)]` fills placeholders after + translation; `trn trs n "{n} day" "{n} days"` picks a plural form. + `i18n "Text"` and `i18nc "context" "Text"` mark a literal that is + stored now and translated later. In hledger-web templates, use + `_{HMsg "Text"}` and `_{HMsgc "context" "Text"}`. The `trs` value + comes from `translations_` in `ReportOpts`, or from `getViewData` in + hledger-web. See `Hledger.Utils.I18n`. +- Write each call on one line, so the extraction tool finds it, and put + a `-- TRANSLATORS: ...` comment on the line above when a translator + would need context. +- One entry per sentence. Do not build sentences from translated + fragments, since word order differs between languages; use one + template with placeholders instead. +- Placeholders are `{name}` and are substituted after translation. Never + pass a translation to `printf`. +- Keep the English string byte for byte as it was, including trailing + colons, so that English output is unchanged. +- Never translate: journal-format output, csv/tsv/json headings, error + messages, anything a program parses. Never insert a translation into + HTML unescaped. +- Tooling: `just i18n-pot` regenerates `hledger-lib/locale/hledger.pot` + from the sources with `tools/i18n-extract.py`; `just i18n-check` + compares the catalogs with it; `just i18n-merge` runs msgmerge on them; + `just i18n-pseudo` writes a catalog that brackets every string, so + `hledger ... --lang xx` shows any output that is still hard-coded. +- The unit tests parse every built-in catalog and check that + translations keep their placeholders; `hledger/test/i18n.test`, + the yesod tests and `hledger-web/test/browser/i18n.spec.js` cover the + German output end to end. diff --git a/doc/common.m4 b/doc/common.m4 index fa077964ff4..ce3d34d30dd 100644 --- a/doc/common.m4 +++ b/doc/common.m4 @@ -164,5 +164,8 @@ General help flags: --debug=[1-9] show this much debug output (default: 1) --pager=YN use a pager when needed ? y/yes (default) or n/no --color=YNA --colour use ANSI color ? y/yes, n/no, or auto (default) + --lang=LANG language for report titles and headings: a + language tag like de, auto (from the environment), + or en (default) ``` }} )m4_dnl ' diff --git a/doc/dev.md b/doc/dev.md index f4c2b65a22b..de31e593891 100644 --- a/doc/dev.md +++ b/doc/dev.md @@ -42,6 +42,7 @@ and then symlinked into the hledger_site repo for rendering on hledger.org. - [RELEASING](RELEASING.md) - release process - [VERSIONNUMBERS](VERSIONNUMBERS.md) - version numbering policy - [DOCS](DOCS.md) - documentation structure and maintenance +- [TRANSLATING](TRANSLATING.md) - contributing a language **Project** diff --git a/hledger-lib/Hledger/Data/Dates.hs b/hledger-lib/Hledger/Data/Dates.hs index f251607f3e8..3b20e638552 100644 --- a/hledger-lib/Hledger/Data/Dates.hs +++ b/hledger-lib/Hledger/Data/Dates.hs @@ -44,6 +44,7 @@ module Hledger.Data.Dates ( showDateSpan, showDateSpanDebug, showDateSpanAbbrev, + showDateSpanAbbrevWith, showDateSpanFull, elapsedSeconds, prevday, @@ -155,6 +156,10 @@ showDateSpanDebug (DateSpan b e)= "DateSpan (" <> show b <> ") (" <> show e <> " showDateSpanAbbrev :: DateSpan -> Text showDateSpanAbbrev = showPeriodAbbrev . dateSpanAsPeriod +-- | Like showDateSpanAbbrev, but take the month names from this time locale. +showDateSpanAbbrevWith :: TimeLocale -> DateSpan -> Text +showDateSpanAbbrevWith loc = showPeriodAbbrevWith loc . dateSpanAsPeriod + -- | Render a datespan as a full ISO date range "YYYY-MM-DD..YYYY-MM-DD" -- (inclusive end), regardless of whether it represents a standard -- calendar period. Open ends are shown as just "..". diff --git a/hledger-lib/Hledger/Data/Period.hs b/hledger-lib/Hledger/Data/Period.hs index 4a0869db851..b076a014255 100644 --- a/hledger-lib/Hledger/Data/Period.hs +++ b/hledger-lib/Hledger/Data/Period.hs @@ -16,6 +16,7 @@ module Hledger.Data.Period ( ,periodTextWidth ,showPeriod ,showPeriodAbbrev + ,showPeriodAbbrevWith ,periodStart ,periodEnd ,periodNext @@ -199,11 +200,15 @@ showPeriod PeriodAll = ".." -- >>> showPeriodAbbrev (WeekPeriod (fromGregorian 2024 12 30)) -- "W01" showPeriodAbbrev :: Period -> Text -showPeriodAbbrev (MonthPeriod _ m) -- Jan +showPeriodAbbrev = showPeriodAbbrevWith defaultTimeLocale + +-- | Like showPeriodAbbrev, but take the month names from this time locale. +showPeriodAbbrevWith :: TimeLocale -> Period -> Text +showPeriodAbbrevWith loc (MonthPeriod _ m) -- Jan | m > 0 && m <= length monthnames = T.pack . snd $ monthnames !! (m-1) - where monthnames = months defaultTimeLocale -showPeriodAbbrev (WeekPeriod b) = T.pack $ formatTime defaultTimeLocale "W%V" b -- Www -showPeriodAbbrev p = showPeriod p + where monthnames = months loc +showPeriodAbbrevWith _ (WeekPeriod b) = T.pack $ formatTime defaultTimeLocale "W%V" b -- Www +showPeriodAbbrevWith _ p = showPeriod p periodStart :: Period -> Maybe Day periodStart p = fromEFDay <$> mb diff --git a/hledger-lib/Hledger/Reports/ReportOptions.hs b/hledger-lib/Hledger/Reports/ReportOptions.hs index 7945d6e7b86..d8326f88af4 100644 --- a/hledger-lib/Hledger/Reports/ReportOptions.hs +++ b/hledger-lib/Hledger/Reports/ReportOptions.hs @@ -89,6 +89,7 @@ import Data.Time.Calendar (Day, addDays) import Data.Default (Default(..)) import Safe (lastDef, lastMay, maximumMay, readMay) +import Hledger.Utils.I18n (Translations, noTranslations, trTimeLocale) import Hledger.Data import Hledger.Query import Hledger.Utils @@ -204,6 +205,9 @@ data ReportOpts = ReportOpts { -- subreport titles to use in compound reports. An empty string -- means "suppress all default subreport titles". ,subreport_titles_ :: Maybe T.Text + -- | Translations for the report's structural text (titles, headings, + -- month names), selected by --lang. English by default. + ,translations_ :: Translations } deriving (Show) instance Default ReportOpts where def = defreportopts @@ -250,6 +254,7 @@ defreportopts = ReportOpts , period_titles_ = PTCompact , title_ = Nothing , subreport_titles_ = Nothing + , translations_ = noTranslations } -- | Generate a ReportOpts from raw command-line input, given a day and whether to use ANSI colour/styles in standard output. @@ -938,16 +943,16 @@ reportPeriodOrJournalLastDay rspec j = reportPeriodLastDay rspec <|> journalOrPr -- - ending-balance reports: the period's end date -- -- - balance change reports where the periods are months and all in the same year: --- the short month name in the current locale +-- the short month name, translated according to --lang -- -- - all other balance change reports: a description of the datespan, -- abbreviated to compact form if possible (see showDateSpan). -reportPeriodName :: PeriodTitles -> BalanceAccumulation -> [DateSpan] -> DateSpan -> T.Text -reportPeriodName ph balanceaccumulation spans = - case balanceaccumulation of - PerPeriod -> case ph of +reportPeriodName :: ReportOpts -> [DateSpan] -> DateSpan -> T.Text +reportPeriodName ReportOpts{period_titles_, balanceaccum_, translations_} spans = + case balanceaccum_ of + PerPeriod -> case period_titles_ of PTDates -> showDateSpanFull - PTCompact -> if multiyear then showDateSpan else showDateSpanAbbrev + PTCompact -> if multiyear then showDateSpan else showDateSpanAbbrevWith (trTimeLocale translations_) where multiyear = (>1) $ length $ nubSort $ map spanStartYear spans _ -> maybe "" (showDate . prevday) . spanEnd diff --git a/hledger-lib/Hledger/Utils.hs b/hledger-lib/Hledger/Utils.hs index c085cef4525..0e610e9d4f2 100644 --- a/hledger-lib/Hledger/Utils.hs +++ b/hledger-lib/Hledger/Utils.hs @@ -89,6 +89,7 @@ import Lens.Micro ((&), (.~)) import Lens.Micro.TH (DefName(TopName), lensClass, lensField, makeLensesWith, classyRules) import Hledger.Utils.Debug +import Hledger.Utils.I18n (tests_I18n) import Hledger.Utils.Parse import Hledger.Utils.IO import Hledger.Utils.Regex @@ -329,5 +330,6 @@ makeHledgerClassyLenses x = flip makeLensesWith x $ classyRules queryFields = Set.fromList ["period", "statuses", "depth", "date2", "real", "querystring"] tests_Utils = testGroup "Utils" [ + tests_I18n, tests_Text ] diff --git a/hledger-lib/Hledger/Utils/I18n.hs b/hledger-lib/Hledger/Utils/I18n.hs new file mode 100644 index 00000000000..3a2abe6df6c --- /dev/null +++ b/hledger-lib/Hledger/Utils/I18n.hs @@ -0,0 +1,832 @@ +{-| +Internationalization support for hledger's user-facing output. + +Translations live in catalogs in the gettext PO file format, which the +usual translator tools (Poedit, Weblate, Lokalize) understand. Built-in +catalogs are embedded in the executables; a user can override one, or add +a language, with a file in their hledger config directory (see +'translationsOverrideDir'). Lookups are pure functions of a 'Translations' +value, so there is no process-global locale state, and hledger-web can +serve a different language to each request. + +Conventions: + +- The msgid is the English text. A missing or empty translation falls + back to it, and 'noTranslations' is the identity, so English output is + unaffected by this machinery. + +- Parameters are @{name}@ placeholders substituted after lookup ('trf'), + never printf formats: a translation can not crash the program. + +- Short words used in more than one sense carry a context ('trc'). + +- A malformed catalog is reported as a warning and ignored. + +This module is experimental and its API may change. +-} + +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Hledger.Utils.I18n ( + -- * Translations + Translations(..), + PluralForms(..), + noTranslations, + tr, + trc, + trf, + trn, + i18n, + i18nc, + trTimeLocale, + translationsForLangOption, + substitutePlaceholders, + placeholders, + -- * Catalogs + parsePo, + mergeTranslations, + builtinTranslations, + builtinLanguages, + availableLanguages, + loadTranslations, + loadAllTranslations, + translationsOverrideDir, + -- * Language tags + isValidLangTag, + normalizeLangTag, + langTagCandidates, + resolveLang, + langPrefsFromEnv, + -- * Plural forms + parsePluralForms, + -- * Tests + tests_I18n, +) where + +import Control.Monad (unless, void, when) +import Control.Monad.Combinators.Expr (Operator(..), makeExprParser) +import Data.Bifunctor (first) +import Data.ByteString (ByteString) +import Data.ByteString qualified as BS +import Data.Char (chr, isAlpha, isAlphaNum, isAscii, isHexDigit, toLower) +import Data.Either (isLeft, rights) +import Data.List (inits, intercalate, partition, sort, sortOn) +import Data.Map.Strict (Map) +import Data.Map.Strict qualified as M +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, mapMaybe) +import Data.Set (Set) +import Data.Set qualified as S +import Data.Text (Text) +import Data.Text qualified as T +import Data.Text.Encoding qualified as TE +import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime) +import Data.Time.Format (TimeLocale(..), defaultTimeLocale) +import Data.Void (Void) +import Numeric (readHex, readOct) +import System.Directory (XdgDirectory(..), doesDirectoryExist, getFileSize, getXdgDirectory, listDirectory) +import System.Environment (getEnvironment) +import System.FilePath ((), dropExtension, takeExtension) +import Text.Megaparsec +import Text.Megaparsec.Char +import Text.Megaparsec.Char.Lexer qualified as L +import Text.Printf (printf) +import Text.Read (readMaybe) + +import Hledger.Utils.Debug (debugLevel, dbg1MsgIO) +import Hledger.Utils.IO (embedFileRelativeBytes, usageError, warnIO) +import Hledger.Utils.Test + +-- * Translations + +-- | A set of translations for one language, looked up by English text +-- (optionally qualified by a context, see 'trc'). +data Translations = Translations + { trLang :: Text -- ^ language tag, eg "de", "pt-BR", "zh-Hans" + , trMessages :: Map Text Text -- ^ msgid (or "context\x04msgid") -> translation + , trPlurals :: Map Text [Text] -- ^ singular msgid -> one translation per plural form + , trPluralForms :: Maybe PluralForms -- ^ this language's plural rule, from the catalog header + } + +-- | A language's plural rule: how many forms it has, and which form a +-- count selects. +data PluralForms = PluralForms + { pfCount :: Int + , pfRule :: Int -> Int + } + +-- | Shows only the language, so that option dumps stay readable. +instance Show Translations where + show t = "Translations " ++ show (trLang t) + +-- | English: every lookup returns its argument. +noTranslations :: Translations +noTranslations = Translations "en" M.empty M.empty Nothing + +-- | Translate this English text, or return it unchanged if there is no translation. +tr :: Translations -> Text -> Text +tr t s = fromMaybe s $ M.lookup s (trMessages t) + +-- | Like 'tr', but for a short text that is used in more than one sense. +-- The context (eg "column heading") disambiguates it in the catalog. +trc :: Translations -> Text -> Text -> Text +trc t ctx s = fromMaybe s $ M.lookup (msgKey (Just ctx) s) (trMessages t) + +-- | Translate this English template, then fill in its @{name}@ placeholders. +-- Placeholders that are not given stay as they are. Values are inserted +-- verbatim, without being scanned for placeholders themselves. +trf :: Translations -> Text -> [(Text, Text)] -> Text +trf t s params = substitutePlaceholders params (tr t s) + +-- | Translate a count phrase, choosing the plural form this language uses +-- for the count, then fill in the @{n}@ placeholder. The two English forms +-- are the msgid and msgid_plural; without a translation, the singular is +-- used for 1 and the plural otherwise. +trn :: Translations -> Int -> Text -> Text -> Text +trn t n singular plural = substitutePlaceholders [("n", T.pack (show n))] form + where + form = fromMaybe english $ do + forms <- M.lookup singular (trPlurals t) + let i = maybe (\k -> if k == 1 then 0 else 1) pfRule (trPluralForms t) n + f <- if i >= 0 && i < length forms then Just (forms !! i) else Nothing + if T.null f then Nothing else Just f + english = if n == 1 then singular else plural + +-- | Mark an English literal that will be translated later, by 'tr', at the +-- point where it is displayed. This is the identity; the catalog +-- extraction tool looks for it. (Like gettext's N_.) +i18n :: Text -> Text +i18n = id + +-- | Like 'i18n', for a literal that will be translated with a context by 'trc'. +i18nc :: Text -> Text -> Text +i18nc _ctx s = s + +-- | The default time locale with month names translated, for formatting +-- dates. The names are looked up with contexts "month" (full name) and +-- "month abbrev" (short name, used as a column heading). +trTimeLocale :: Translations -> TimeLocale +trTimeLocale t = defaultTimeLocale{ months = zipWith (\f a -> (name "month" f, name "month abbrev" a)) fulls abbrevs } + where + name ctx = T.unpack . trc t ctx + -- TRANSLATORS: stand-alone (nominative) month names, as used in a column heading. + fulls = + [ i18nc "month" "January", i18nc "month" "February", i18nc "month" "March" + , i18nc "month" "April", i18nc "month" "May", i18nc "month" "June" + , i18nc "month" "July", i18nc "month" "August", i18nc "month" "September" + , i18nc "month" "October", i18nc "month" "November", i18nc "month" "December" + ] + -- TRANSLATORS: short stand-alone month names, as used in a column heading. + abbrevs = + [ i18nc "month abbrev" "Jan", i18nc "month abbrev" "Feb", i18nc "month abbrev" "Mar" + , i18nc "month abbrev" "Apr", i18nc "month abbrev" "May", i18nc "month abbrev" "Jun" + , i18nc "month abbrev" "Jul", i18nc "month abbrev" "Aug", i18nc "month abbrev" "Sep" + , i18nc "month abbrev" "Oct", i18nc "month abbrev" "Nov", i18nc "month abbrev" "Dec" + ] + +-- | Choose translations according to the value of a --lang option. +-- No option means English, without reading any files. "auto" follows the +-- LANGUAGE, LC_ALL, LC_MESSAGES and LANG environment variables (see +-- 'langPrefsFromEnv'), falling back to English. Anything else is a +-- language tag, which must have a catalog, built-in or in +-- 'translationsOverrideDir'; otherwise a usage error is raised. +translationsForLangOption :: Maybe String -> IO Translations +translationsForLangOption Nothing = return noTranslations +translationsForLangOption (Just "auto") = do + available <- availableLanguages + prefs <- langPrefsFromEnv <$> getEnvironment + maybe (return noTranslations) loadTranslations $ resolveLang available prefs +translationsForLangOption (Just s) + | map toLower s `elem` ["c", "posix"] = return noTranslations + | otherwise = do + available <- availableLanguages + case resolveLang available [T.pack s] of + Just lang -> loadTranslations lang + Nothing -> usageError $ "--lang: no translations are available for " ++ show s + ++ " (available: " ++ T.unpack (T.intercalate ", " available) ++ ")" + +-- | Split a template into its literal text and its @{name}@ placeholders. +scanPlaceholders :: Text -> [Either Text Text] +scanPlaceholders s = case T.breakOn "{" s of + (before, rest) + | T.null rest -> [Left before | not (T.null before)] + | otherwise -> + let (name, rest') = T.break (\c -> c == '}' || c == '{' || not (isNameChar c)) (T.drop 1 rest) + in case T.uncons rest' of + Just ('}', remaining) | not (T.null name) -> Left before : Right name : scanPlaceholders remaining + _ -> Left (before <> "{") : scanPlaceholders (T.drop 1 rest) + +-- | Replace each @{name}@ in the text with the value given for that name. +-- Names not given are left in place; values are not scanned again. +substitutePlaceholders :: [(Text, Text)] -> Text -> Text +substitutePlaceholders params = T.concat . map (either id fill) . scanPlaceholders + where fill name = fromMaybe ("{" <> name <> "}") (lookup name params) + +-- | The placeholder names in a template. +placeholders :: Text -> Set Text +placeholders = S.fromList . rights . scanPlaceholders + +isNameChar :: Char -> Bool +isNameChar c = isAlphaNum c || c == '_' + +msgKey :: Maybe Text -> Text -> Text +msgKey mctx s = maybe s (\c -> c <> "\x04" <> s) mctx + +-- * Catalogs + +-- | The built-in catalogs, embedded at build time. +builtinCatalogSources :: [(Text, ByteString)] +builtinCatalogSources = + [ ("de", $(embedFileRelativeBytes "locale/de.po")) + ] + +-- | The built-in catalogs, parsed. A catalog that fails to parse is +-- omitted; the unit tests ensure that does not happen in a release. +builtinTranslations :: Map Text Translations +builtinTranslations = M.fromList + [ (lang, t) + | (lang, bs) <- builtinCatalogSources + , Right s <- [TE.decodeUtf8' bs] + , Right t <- [parsePo ("built-in catalog " ++ T.unpack lang) lang s] + ] + +-- | The languages with a built-in catalog, plus English. +builtinLanguages :: [Text] +builtinLanguages = "en" : M.keys builtinTranslations + +-- | The directory where a user can put a translation catalog named +-- LANG.po, to override a built-in one or to add a language: +-- the locale subdirectory of hledger's config directory +-- (~/.config/hledger/locale on unix, %APPDATA%\\hledger\\locale on windows). +translationsOverrideDir :: IO FilePath +translationsOverrideDir = ( "locale") <$> getXdgDirectory XdgConfig "hledger" + +-- | The largest user catalog we will read. +maxCatalogSize :: Integer +maxCatalogSize = 1024 * 1024 + +-- | The user's catalog files, by language tag. The tag is taken from the +-- file name and normalized, so DE.po and de.po both serve "de". +overrideCatalogFiles :: IO [(Text, FilePath)] +overrideCatalogFiles = do + dir <- translationsOverrideDir + exists <- doesDirectoryExist dir + files <- if exists then listDirectory dir else return [] + return [ (tag, dir f) | f <- sort files, takeExtension f == ".po" + , Just tag <- [normalizeLangTag (T.pack (dropExtension f))] ] + +-- | Read the user's catalog for this language tag, if there is one and it +-- is usable, returning it with its path. Problems are reported as +-- warnings and the catalog ignored. +readOverrideCatalog :: Text -> IO (Maybe (FilePath, Translations)) +readOverrideCatalog lang = do + files <- overrideCatalogFiles + case lookup lang files of + Nothing -> return Nothing + Just f -> do + size <- getFileSize f + if size > maxCatalogSize + then Nothing <$ warnIO ("ignoring translation catalog " ++ f ++ ": it is larger than 1 MiB") + else do + bs <- BS.readFile f + case TE.decodeUtf8' bs of + Left _ -> Nothing <$ warnIO ("ignoring translation catalog " ++ f ++ ": it is not valid UTF-8") + Right t -> case parsePo f lang t of + Left err -> Nothing <$ warnIO ("ignoring translation catalog " ++ f ++ ":\n" ++ err) + Right c -> return (Just (f, c)) + +-- | The language tags for which a catalog exists, built-in or in the +-- user's override directory. Always includes "en". +-- +-- Listing the built-in languages parses the built-in catalogs (whichever +-- of them parse are the ones available), once per process. With --debug, +-- reports how long that took. +availableLanguages :: IO [Text] +availableLanguages = do + t0 <- getCurrentTime + let nbuiltin = length builtinLanguages - 1 + t1 <- nbuiltin `seq` getCurrentTime + when (debugLevel >= 1) $ + dbg1MsgIO $ printf "translations: parsed %d built-in catalogs, %.1f ms" nbuiltin (msSince t0 t1) + overrides <- map fst <$> overrideCatalogFiles + return $ S.toList $ S.fromList $ builtinLanguages ++ overrides + +-- | Load the translations for this language tag (which should be one +-- returned by 'availableLanguages'): the built-in catalog if any, with +-- the user's catalog merged over it if any. With --debug, reports what +-- was loaded, its size, and how long it took. +loadTranslations :: Text -> IO Translations +loadTranslations lang = do + t0 <- getCurrentTime + let mbuiltin = M.lookup lang builtinTranslations + builtin = fromMaybe noTranslations{trLang = lang} mbuiltin + moverride <- readOverrideCatalog lang + let trs = maybe builtin (mergeTranslations builtin . snd) moverride + when (debugLevel >= 1) $ do + -- Force the merged catalog so that the time is real. + let entries = M.size (trMessages trs) + M.size (trPlurals trs) + t1 <- entries `seq` getCurrentTime + let chars = sum (map T.length (M.keys (trMessages trs) ++ M.elems (trMessages trs))) + + sum (map T.length (M.keys (trPlurals trs) ++ concat (M.elems (trPlurals trs)))) + let sources = [ "built-in" | isJust mbuiltin ] ++ [ f | Just (f, _) <- [moverride] ] + dbg1MsgIO $ printf "translations: loaded %s (%s): %d entries, ~%d KB of text, %.1f ms" + (T.unpack lang) (if null sources then "no catalog" else intercalate ", " sources) entries (chars `div` 1024) (msSince t0 t1) + return trs + +-- | Milliseconds between two times, for debug output. +msSince :: UTCTime -> UTCTime -> Double +msSince t0 t1 = realToFrac (diffUTCTime t1 t0) * 1000 + +-- | Load the translations for every available language. +loadAllTranslations :: IO (Map Text Translations) +loadAllTranslations = do + langs <- availableLanguages + M.fromList <$> mapM (\l -> (,) l <$> loadTranslations l) langs + +-- | Merge two catalogs; entries in the second take precedence. +mergeTranslations :: Translations -> Translations -> Translations +mergeTranslations base override = Translations + { trLang = trLang override + , trMessages = M.union (trMessages override) (trMessages base) + , trPlurals = M.union (trPlurals override) (trPlurals base) + , trPluralForms = trPluralForms override <|> trPluralForms base + } + +-- ** PO parsing + +type PoParser = Parsec Void Text + +data PoEntry = PoEntry + { peFlags :: [Text] + , peCtx :: Maybe Text + , peId :: Text + , peIdPlural :: Maybe Text + , peStr :: Text + , peStrs :: [(Int, Text)] + } + +-- | Parse a catalog in PO format. The arguments are a name for error +-- messages, the language tag to record, and the file's content. +-- +-- Handles what translator tools produce: the header entry, translator and +-- extracted comments, references, flags, previous-msgid comments, +-- obsolete entries, contexts, plural forms, multi-line strings, C escapes, +-- a byte order mark and CRLF line endings. Entries flagged fuzzy are +-- skipped (except that the header's Plural-Forms is still used), and an +-- empty translation counts as untranslated. Duplicate entries and a +-- non-UTF-8 charset are errors. +parsePo :: String -> Text -> Text -> Either String Translations +parsePo name lang input = do + entries <- first errorBundlePretty $ runParser poEntries name (cleanup input) + buildTranslations name lang entries + where + cleanup = T.replace "\r\n" "\n" . T.dropWhile (== '\xFEFF') + +poEntries :: PoParser [PoEntry] +poEntries = do + space + done <- atEnd + if done + then return [] + else do + flagss <- many commentLine + mentry <- optional entryFields + case mentry of + Nothing + | null flagss -> fail "expected a comment line or a msgid" + | otherwise -> poEntries -- comments with no entry, eg an obsolete (#~) entry + Just e -> (e{peFlags = concat flagss} :) <$> poEntries + +-- | A comment line. Returns the flags if it is a "#," flags line. +commentLine :: PoParser [Text] +commentLine = do + _ <- char '#' + flags <- (char ',' *> (map T.strip . T.splitOn "," <$> restOfLine)) <|> ([] <$ restOfLine) + space + return flags + where restOfLine = takeWhileP Nothing (/= '\n') + +entryFields :: PoParser PoEntry +entryFields = do + ctx <- optional (keyword "msgctxt" *> strings) + mid <- keyword "msgid" *> strings + mpl <- optional (keyword "msgid_plural" *> strings) + case mpl of + Nothing -> do + s <- keyword "msgstr" *> strings + return PoEntry{peFlags = [], peCtx = ctx, peId = mid, peIdPlural = Nothing, peStr = s, peStrs = []} + Just pl -> do + ss <- some indexedMsgstr + return PoEntry{peFlags = [], peCtx = ctx, peId = mid, peIdPlural = Just pl, peStr = "", peStrs = ss} + +keyword :: Text -> PoParser () +keyword k = void $ try (string k <* notFollowedBy (satisfy (\c -> isAlphaNum c || c == '_' || c == '[')) <* hspace) + +indexedMsgstr :: PoParser (Int, Text) +indexedMsgstr = do + _ <- try (string "msgstr[") + n <- L.decimal + _ <- char ']' + hspace + s <- strings + return (n, s) + +-- | One or more quoted strings, possibly on several lines, concatenated. +strings :: PoParser Text +strings = T.concat <$> some (quotedString <* space) + +-- Runs of ordinary characters are taken as slices of the input; only +-- escapes are handled a character at a time. +quotedString :: PoParser Text +quotedString = T.concat <$> (char '"' *> manyTill piece (char '"')) + where + piece = takeWhile1P Nothing (\c -> c /= '"' && c /= '\\' && c /= '\n') + <|> (T.singleton <$> (char '\\' *> escape)) + escape = choice + [ '\n' <$ char 'n' + , '\t' <$ char 't' + , '\r' <$ char 'r' + , '\\' <$ char '\\' + , '"' <$ char '"' + , '\a' <$ char 'a' + , '\b' <$ char 'b' + , '\f' <$ char 'f' + , '\v' <$ char 'v' + , char 'x' *> (fromCode readHex <$> takeWhile1P (Just "hex digit") isHexDigit) + , fromCode readOct . T.pack <$> count' 1 3 octDigitChar + , anySingle -- an unknown escape: keep the character + ] + fromCode reader s = case reader (T.unpack s) of + [(n, "")] | n <= 0x10FFFF -> chr n + _ -> '\xFFFD' + +buildTranslations :: String -> Text -> [PoEntry] -> Either String Translations +buildTranslations name lang entries = do + let (headers, rest) = partition isHeader entries + headerFields = maybe [] (parseHeader . peStr) (listToMaybe headers) + case lookup "content-type" headerFields >>= charsetOf of + Just cs | cs `notElem` ["utf-8", "utf8", "charset"] -> + Left $ name ++ ": unsupported charset " ++ T.unpack cs ++ " (translation catalogs must be UTF-8)" + _ -> Right () + pf <- case lookup "plural-forms" headerFields of + Nothing -> Right Nothing + Just s -> maybe (Left $ name ++ ": could not parse the Plural-Forms header: " ++ T.unpack s) + (Right . Just) (parsePluralForms s) + let live = filter (notElem "fuzzy" . peFlags) rest + dups = M.keys $ M.filter (> 1) $ M.fromListWith (+) [ (entryKey e, 1 :: Int) | e <- live ] + unless (null dups) $ + Left $ name ++ ": duplicate entries for: " ++ unwords (map (show . T.replace "\x04" "|") dups) + let msgs = M.fromList [ (entryKey e, s) | e <- live, let s = singularStr e, not (T.null s) ] + plurals = M.fromList [ (entryKey e, forms) | e <- live, isJust (peIdPlural e) + , let forms = map snd (sortOn fst (peStrs e)), any (not . T.null) forms ] + return Translations{trLang = lang, trMessages = msgs, trPlurals = plurals, trPluralForms = pf} + where + isHeader e = T.null (peId e) && isNothing (peCtx e) && isNothing (peIdPlural e) + entryKey e = msgKey (peCtx e) (peId e) + singularStr e = case peIdPlural e of + Nothing -> peStr e + Just _ -> fromMaybe "" (lookup 0 (peStrs e)) + parseHeader s = + [ (T.toLower (T.strip k), T.strip (T.drop 1 v)) + | l <- T.lines s, let (k, v) = T.breakOn ":" l, not (T.null v) ] + charsetOf ct = listToMaybe + [ T.strip (T.drop 8 p) | p <- map (T.toLower . T.strip) (T.splitOn ";" ct), "charset=" `T.isPrefixOf` p ] + +-- ** Plural forms + +-- | Parse a PO header's Plural-Forms value, eg +-- "nplurals=2; plural=(n != 1);". The plural expression is the C +-- expression subset gettext allows: n, integers, parentheses, !, %, +-- comparisons, &&, ||, and ?:. +parsePluralForms :: Text -> Maybe PluralForms +parsePluralForms s = do + let fields = [ (T.strip k, T.strip (T.drop 1 v)) | p <- T.splitOn ";" s, let (k, v) = T.breakOn "=" p, not (T.null v) ] + n <- lookup "nplurals" fields >>= readMaybe . T.unpack + e <- lookup "plural" fields + expr <- parseMaybe (space *> pluralExpr <* eof) e + return PluralForms{pfCount = n, pfRule = \k -> evalPlural k expr} + +data PExpr + = PN + | PLit Int + | PNot PExpr + | PBin Text PExpr PExpr + | PCond PExpr PExpr PExpr + +evalPlural :: Int -> PExpr -> Int +evalPlural n = go + where + go PN = n + go (PLit i) = i + go (PNot e) = if go e == 0 then 1 else 0 + go (PCond c a b) = if go c /= 0 then go a else go b + go (PBin op a b) = + let x = go a + y = go b + in case op of + "%" -> if y == 0 then 0 else x `mod` y + "<" -> fromBool (x < y) + "<=" -> fromBool (x <= y) + ">" -> fromBool (x > y) + ">=" -> fromBool (x >= y) + "==" -> fromBool (x == y) + "!=" -> fromBool (x /= y) + "&&" -> fromBool (x /= 0 && y /= 0) + "||" -> fromBool (x /= 0 || y /= 0) + _ -> 0 + fromBool b = if b then 1 else 0 + +pluralExpr :: PoParser PExpr +pluralExpr = do + c <- binaryExpr + (do _ <- sym "?" + a <- pluralExpr + _ <- sym ":" + b <- pluralExpr + return (PCond c a b)) + <|> return c + where + sym = L.symbol space + binaryExpr = makeExprParser term table + table = + [ [Prefix (PNot <$ L.lexeme space (try (char '!' <* notFollowedBy (char '='))))] + , [binary "%"] + , [binary "<=", binary ">=", binary "<", binary ">"] + , [binary "==", binary "!="] + , [binary "&&"] + , [binary "||"] + ] + binary op = InfixL (PBin op <$ sym op) + term = choice + [ PN <$ L.lexeme space (try (char 'n' <* notFollowedBy alphaNumChar)) + , PLit <$> L.lexeme space L.decimal + , between (sym "(") (sym ")") pluralExpr + ] + +-- * Language tags + +-- | Is this a well-formed language tag, safe to use in a cookie ? Two or three letters, then optional subtags of one to eight +-- letters or digits, separated by hyphens. +isValidLangTag :: Text -> Bool +isValidLangTag t = case T.splitOn "-" t of + (l : subs) -> T.length l `elem` [2, 3] && T.all isAsciiAlpha l && all okSub subs && T.length t <= 35 + _ -> False + where + okSub s = not (T.null s) && T.length s <= 8 && T.all (\c -> isAscii c && isAlphaNum c) s + isAsciiAlpha c = isAscii c && isAlpha c + +-- | Convert a language tag as found in the environment or an HTTP header +-- to canonical form, or Nothing if it does not name a language. +-- +-- Handles POSIX locale names (de_DE.UTF-8\@euro becomes de-DE; C, POSIX +-- and an empty value become Nothing), Accept-Language quality suffixes, +-- and letter case (pt-br becomes pt-BR). Chinese region tags map to the +-- script tags zh-Hans and zh-Hant, and Norwegian's no to nb. +normalizeLangTag :: Text -> Maybe Text +normalizeLangTag raw + | T.null base || isC = Nothing + | otherwise = do + let parts = filter (not . T.null) $ T.splitOn "-" $ T.replace "_" "-" base + tag <- case parts of + (l : rest) | T.length l `elem` [2, 3] && T.all isAlpha l -> + Just $ T.intercalate "-" (T.toLower l : modifierSubtag ++ map canonSub rest) + _ -> Nothing + let tag' = alias tag + if isValidLangTag tag' then Just tag' else Nothing + where + value = T.strip $ T.takeWhile (/= ';') raw + (base, suffix) = T.break (\c -> c == '.' || c == '@') value + modifier = T.drop 1 $ T.dropWhile (/= '@') suffix + modifierSubtag = case T.toLower modifier of + "latin" -> ["Latn"] + "cyrillic" -> ["Cyrl"] + _ -> [] + isC = T.toLower base `elem` ["c", "posix"] + canonSub s + | T.length s == 4 && T.all isAlpha s = T.toTitle s -- script + | T.length s == 2 && T.all isAlpha s = T.toUpper s -- region + | otherwise = s + alias t = case T.toLower t of + "zh" -> "zh-Hans" + "zh-cn" -> "zh-Hans" + "zh-sg" -> "zh-Hans" + "zh-hans" -> "zh-Hans" + "zh-tw" -> "zh-Hant" + "zh-hk" -> "zh-Hant" + "zh-mo" -> "zh-Hant" + "zh-hant" -> "zh-Hant" + "no" -> "nb" + "no-no" -> "nb" + _ -> t + +-- | The tags to try for a language tag, most specific first: +-- the tag itself, then with subtags dropped from the right. +langTagCandidates :: Text -> [Text] +langTagCandidates t = map (T.intercalate "-") $ reverse $ drop 1 $ inits $ T.splitOn "-" t + +-- | Given the available language tags and a list of preferred tags in +-- order of preference (raw, as from the environment or a browser), +-- choose the first available one. Each preference is tried with its +-- subtags dropped from the right before moving on to the next, so that +-- de-CH followed by en chooses de over en when only de is available. +resolveLang :: [Text] -> [Text] -> Maybe Text +resolveLang available prefs = listToMaybe + [ a + | p <- mapMaybe normalizeLangTag prefs + , c <- langTagCandidates p + , a <- available + , T.toLower a == T.toLower c + ] + +-- | The languages a user prefers, in order, according to these +-- environment variables, following gettext: the effective locale is +-- LC_ALL, else LC_MESSAGES, else LANG; if that is unset or C/POSIX the +-- result is empty (English) and LANGUAGE is ignored; otherwise the +-- colon-separated LANGUAGE list is consulted first, then the locale. +langPrefsFromEnv :: [(String, String)] -> [Text] +langPrefsFromEnv env = case effective of + Nothing -> [] + Just loc + | isNothing (normalizeLangTag loc) -> [] + | otherwise -> mapMaybe normalizeLangTag (languageList ++ [loc]) + where + get k = case lookup k env of + Just v | not (null v) -> Just (T.pack v) + _ -> Nothing + effective = get "LC_ALL" <|> get "LC_MESSAGES" <|> get "LANG" + languageList = maybe [] (T.splitOn ":") (get "LANGUAGE") + +-- * Tests + +-- i18n-extract: off + +tests_I18n :: TestTree +tests_I18n = testGroup "I18n" [ + testCase "substitutePlaceholders" $ do + substitutePlaceholders [("a", "1")] "x {a} y" @?= "x 1 y" + substitutePlaceholders [("a", "{b}"), ("b", "2")] "{a}{b}" @?= "{b}2" + substitutePlaceholders [] "{unknown} { }" @?= "{unknown} { }" + substitutePlaceholders [("a", "1")] "{a}{a}" @?= "11" + + ,testCase "placeholders" $ do + placeholders "a {x} {y_1} {bad name} { {z}" @?= S.fromList ["x", "y_1", "z"] + placeholders "" @?= S.empty + + ,testCase "parsePo" $ do + t <- either assertFailure return $ parsePo "sample" "de" samplePo + trLang t @?= "de" + tr t "Balance Sheet" @?= "Bilanz" + tr t "Untranslated" @?= "Untranslated" + tr t "Empty" @?= "Empty" + tr t "Fuzzy" @?= "Fuzzy" + tr t "Obsolete" @?= "Obsolete" + trc t "column heading" "Total" @?= "Summe" + tr t "Total" @?= "Total" + tr t "Multi" @?= "line one\nline two \"quoted\" \\ \252 A" + trn t 1 "{n} day" "{n} days" @?= "1 Tag" + trn t 2 "{n} day" "{n} days" @?= "2 Tage" + trn t 0 "{n} day" "{n} days" @?= "0 Tage" + tr t "{n} day" @?= "{n} Tag" + trf t "Balance changes in {period}:" [("period", "2024")] @?= "Saldo\228nderungen in 2024:" + maybe (-1) pfCount (trPluralForms t) @?= 2 + + ,testCase "parsePo errors" $ do + let bad = assertBool "expected a parse failure" . isLeft . parsePo "t" "de" + bad "msgid \"a\"\nmsgstr \"b\"\nmsgid \"a\"\nmsgstr \"c\"\n" + bad "garbage\n" + bad "msgid \"a\"\nmsgstr \"b\n" + bad "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=ISO-8859-1\\n\"\n" + bad "msgid \"\"\nmsgstr \"Plural-Forms: nplurals=2; plural=(n +);\\n\"\n" + + ,testCase "parsePo tolerates" $ do + let ok = assertBool "expected a successful parse" . not . isLeft . parsePo "t" "de" + ok "" + ok "# just a comment\n" + ok "\xFEFFmsgid \"a\"\r\nmsgstr \"b\"\r\n" + ok "#~ msgid \"old\"\n#~ msgstr \"alt\"\n" + ok "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=CHARSET\\n\"\n" + + ,testCase "plural rules" $ do + let rule s = maybe (const (-1)) pfRule (parsePluralForms s) + en = rule "nplurals=2; plural=(n != 1);" + fr = rule "nplurals=2; plural=(n > 1);" + ru = rule "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" + zh = rule "nplurals=1; plural=0;" + ar = rule "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);" + pl = rule "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" + map en [0, 1, 2] @?= [1, 0, 1] + map fr [0, 1, 2] @?= [0, 0, 1] + map ru [1, 2, 5, 11, 21, 22, 25] @?= [0, 1, 2, 2, 0, 1, 2] + map zh [0, 1, 5] @?= [0, 0, 0] + map ar [0, 1, 2, 3, 11, 100] @?= [0, 1, 2, 3, 4, 5] + map pl [1, 2, 5, 12, 22] @?= [0, 1, 2, 2, 1] + maybe 0 pfCount (parsePluralForms "nplurals=6; plural=0;") @?= 6 + + ,testCase "normalizeLangTag" $ do + normalizeLangTag "de_DE.UTF-8@euro" @?= Just "de-DE" + normalizeLangTag "de" @?= Just "de" + normalizeLangTag "C" @?= Nothing + normalizeLangTag "POSIX" @?= Nothing + normalizeLangTag "C.UTF-8" @?= Nothing + normalizeLangTag "" @?= Nothing + normalizeLangTag "zh_CN" @?= Just "zh-Hans" + normalizeLangTag "zh-TW" @?= Just "zh-Hant" + normalizeLangTag "zh-hant-tw" @?= Just "zh-Hant-TW" + normalizeLangTag "sr_RS@latin" @?= Just "sr-Latn-RS" + normalizeLangTag "pt-br" @?= Just "pt-BR" + normalizeLangTag "no_NO" @?= Just "nb" + normalizeLangTag "en-US;q=0.8" @?= Just "en-US" + normalizeLangTag "ast" @?= Just "ast" + normalizeLangTag "../x" @?= Nothing + normalizeLangTag "de/../x" @?= Nothing + + ,testCase "isValidLangTag" $ do + isValidLangTag "de" @?= True + isValidLangTag "zh-Hant-TW" @?= True + isValidLangTag "d" @?= False + isValidLangTag "de-" @?= False + isValidLangTag "../de" @?= False + isValidLangTag "de.po" @?= False + + ,testCase "langTagCandidates" $ + langTagCandidates "zh-Hant-TW" @?= ["zh-Hant-TW", "zh-Hant", "zh"] + + ,testCase "resolveLang" $ do + resolveLang ["en", "de"] ["de-CH", "en", "de"] @?= Just "de" + resolveLang ["en", "de"] ["fr", "en-GB"] @?= Just "en" + resolveLang ["en", "de"] ["fr"] @?= Nothing + resolveLang ["en", "de"] ["../etc"] @?= Nothing + resolveLang ["en", "de"] ["DE"] @?= Just "de" + resolveLang ["en", "zh-Hans"] ["zh_CN"] @?= Just "zh-Hans" + + ,testCase "langPrefsFromEnv" $ do + langPrefsFromEnv [("LANG", "de_DE.UTF-8")] @?= ["de-DE"] + langPrefsFromEnv [("LC_ALL", "C"), ("LANGUAGE", "de"), ("LANG", "de_DE.UTF-8")] @?= [] + langPrefsFromEnv [("LANGUAGE", "fr:de"), ("LANG", "en_US.UTF-8")] @?= ["fr", "de", "en-US"] + langPrefsFromEnv [("LC_MESSAGES", "de_AT"), ("LANG", "C")] @?= ["de-AT"] + langPrefsFromEnv [("LC_ALL", ""), ("LANG", "de")] @?= ["de"] + langPrefsFromEnv [] @?= [] + + ,testCase "built-in catalogs" $ + mapM_ checkBuiltin builtinCatalogSources + ] + where + checkBuiltin (lang, bs) = do + s <- either (\e -> assertFailure $ T.unpack lang ++ ": not UTF-8: " ++ show e) return $ TE.decodeUtf8' bs + t <- either assertFailure return $ parsePo (T.unpack lang) lang s + let msgid k = T.takeWhileEnd (/= '\x04') k + mapM_ (\(k, v) -> assertEqual ("placeholders differ in " ++ T.unpack lang ++ " translation of " ++ show (msgid k)) + (placeholders (msgid k)) (placeholders v)) + (M.toList (trMessages t)) + mapM_ (\(k, vs) -> mapM_ (\v -> assertBool ("unknown placeholder in " ++ T.unpack lang ++ " plural of " ++ show (msgid k)) + (placeholders v `S.isSubsetOf` S.insert "n" (placeholders (msgid k)))) vs) + (M.toList (trPlurals t)) + +samplePo :: Text +samplePo = T.unlines + [ "# German translations for the tests." + , "#, fuzzy" + , "msgid \"\"" + , "msgstr \"\"" + , "\"Project-Id-Version: hledger\\n\"" + , "\"Language: de\\n\"" + , "\"Content-Type: text/plain; charset=UTF-8\\n\"" + , "\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\"" + , "" + , "#. Report title." + , "#: hledger/Hledger/Cli/Commands/Balancesheet.hs:23" + , "#, python-brace-format" + , "msgid \"Balance Sheet\"" + , "msgstr \"Bilanz\"" + , "" + , "msgid \"Untranslated\"" + , "msgstr \"\"" + , "" + , "msgid \"Empty\"" + , "msgstr \"\"" + , "" + , "#, fuzzy, python-brace-format" + , "#| msgid \"Fuzy\"" + , "msgid \"Fuzzy\"" + , "msgstr \"Unscharf\"" + , "" + , "msgctxt \"column heading\"" + , "msgid \"Total\"" + , "msgstr \"Summe\"" + , "" + , "msgid \"Multi\"" + , "msgstr \"\"" + , " \"line one\\n\"" + , " \"line two \\\"quoted\\\" \\\\ \\374 \\x41\"" + , "" + , "msgid \"{n} day\"" + , "msgid_plural \"{n} days\"" + , "msgstr[0] \"{n} Tag\"" + , "msgstr[1] \"{n} Tage\"" + , "" + , "msgid \"Balance changes in {period}:\"" + , "msgstr \"Saldo\228nderungen in {period}:\"" + , "" + , "#~ msgid \"Obsolete\"" + , "#~ msgstr \"Veraltet\"" + ] diff --git a/hledger-lib/Hledger/Utils/IO.hs b/hledger-lib/Hledger/Utils/IO.hs index c4f0fb6ee32..3d08b694a87 100644 --- a/hledger-lib/Hledger/Utils/IO.hs +++ b/hledger-lib/Hledger/Utils/IO.hs @@ -40,6 +40,7 @@ module Hledger.Utils.IO ( rulesDirName, getHomeSafe, embedFileRelative, + embedFileRelativeBytes, expandHomePath, expandPath, expandGlob, @@ -142,7 +143,7 @@ import Data.Colour.RGBSpace (RGB(RGB)) import Data.Colour.RGBSpace.HSL (lightness) import Data.Colour.SRGB (sRGB) import Data.Encoding (DynEncoding) -import Data.FileEmbed (makeRelativeToProject, embedStringFile) +import Data.FileEmbed (makeRelativeToProject, embedFile, embedStringFile) import Data.Functor ((<&>)) import Data.IORef (IORef, newIORef, readIORef, writeIORef) import Data.List hiding (uncons) @@ -575,6 +576,11 @@ textToHandle t = do embedFileRelative :: FilePath -> Q Exp embedFileRelative f = makeRelativeToProject f >>= embedStringFile +-- | Like embedFileRelative, but embeds the file's raw bytes as a ByteString, +-- so that its encoding does not depend on the build machine's locale. +embedFileRelativeBytes :: FilePath -> Q Exp +embedFileRelativeBytes f = makeRelativeToProject f >>= embedFile + -- -- | Like hereFile, but takes a path relative to the package directory. -- -- Similar to embedFileRelative ? -- hereFileRelative :: FilePath -> Q Exp diff --git a/hledger-lib/hledger-lib.cabal b/hledger-lib/hledger-lib.cabal index e3964e587e2..c443195d84b 100644 --- a/hledger-lib/hledger-lib.cabal +++ b/hledger-lib/hledger-lib.cabal @@ -38,6 +38,7 @@ tested-with: extra-source-files: CHANGES.md README.md + locale/de.po test/unittest.hs test/doctests.hs @@ -103,6 +104,7 @@ library Hledger.Reports.ReportTypes Hledger.Utils Hledger.Utils.Debug + Hledger.Utils.I18n Hledger.Utils.IO Hledger.Utils.Parse Hledger.Utils.Regex diff --git a/hledger-lib/locale/README.md b/hledger-lib/locale/README.md new file mode 100644 index 00000000000..3c843bc96f5 --- /dev/null +++ b/hledger-lib/locale/README.md @@ -0,0 +1,62 @@ +# Translations + +This directory holds hledger's localization (l10n) files: one gettext PO +catalog per language, translating the structural text of hledger's own +output (report titles, headings, month names, the hledger-ui and +hledger-web interfaces). The machinery that makes hledger translatable, +its internationalization (i18n) support, is `Hledger.Utils.I18n`; the +`--lang` option selects a catalog. Localization here means the language +of hledger's text only: number and date formats are not localized, since +hledger takes number styles from the journal and keeps ISO dates. + +- `hledger.pot` is the template, generated from the sources by + `tools/i18n-extract.py` (`just i18n-pot`). Do not edit it by hand. +- `LANG.po` is a language's catalog, named by its tag (`de`, `pt-BR`, + `zh-Hans`). A catalog is built into the executables when it is listed + both in package.yaml's extra-source-files and in `builtinCatalogSources` + in `Hledger/Utils/I18n.hs` (see doc/TRANSLATING.md, step 4). + +## Translating + +The step-by-step guide for translators, and the developer notes on +marking strings and the `just i18n-*` tooling, are in +[doc/TRANSLATING.md](../../doc/TRANSLATING.md). + +## German + +The report vocabulary follows Henning Thielemann's choices in PR #2735, +so that the two catalogs agree: everyday, cash-basis terms (Einnahmen, +Ausgaben, Einnahmenüberschussrechnung), which fit hledger's typical +personal and small-business use better than the accrual terms of the +German commercial code (Erträge, Aufwendungen, Gewinn- und +Verlustrechnung, Vermögenswerte). Anyone keeping books under HGB can put +those in `~/.config/hledger/locale/de.po`, which overrides the built-in +catalog entry by entry. + +| English | German | +|---|---| +| Balance Sheet / With Equity | Bilanz / Bilanz mit Eigenkapital | +| Income Statement | Einnahmenüberschussrechnung | +| Cashflow Statement | Kapitalflussrechnung | +| Assets | Vermögen | +| Liabilities | Verbindlichkeiten | +| Equity | Eigenkapital | +| Revenues | Einnahmen | +| Expenses | Ausgaben | +| Cash flows | Kapitalflüsse | +| Net: | Überschuss: | +| Total / Average | Gesamt / Durchschnitt | +| Commodity | Einheit | +| Account | Konto | +| Balance changes | Saldoänderungen | +| Ending balances (historical) | Endsalden (historisch) | +| Budget performance | Soll-Ist-Vergleich | + +Known inconsistency: `examples/i18n/de.journal` names its accounts +aktiva, passiva, erträge and aufwendungen. + +Not yet translatable: the `W` prefix of the week headings in weekly +reports (`W23`), which is hard-coded in the period rendering; German +would want `KW23`. Interval words precede a report title ("Monatliche +Bilanz") and are inflected for the feminine, which all four report titles +happen to share. diff --git a/hledger-lib/locale/de.po b/hledger-lib/locale/de.po new file mode 100644 index 00000000000..ace2dba68ea --- /dev/null +++ b/hledger-lib/locale/de.po @@ -0,0 +1,772 @@ +# German translations for hledger. +# This file is distributed under the same license as hledger. +# +msgid "" +msgstr "" +"Project-Id-Version: hledger\n" +"Report-Msgid-Bugs-To: https://github.com/simonmichael/hledger/issues\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. Balance Sheet titles, one per reporting interval. +msgid "Balance Sheet" +msgstr "Bilanz" + +msgid "Daily Balance Sheet" +msgstr "Tägliche Bilanz" + +msgid "Weekly Balance Sheet" +msgstr "Wöchentliche Bilanz" + +msgid "Biweekly Balance Sheet" +msgstr "Zweiwöchentliche Bilanz" + +msgid "Monthly Balance Sheet" +msgstr "Monatliche Bilanz" + +msgid "Bimonthly Balance Sheet" +msgstr "Zweimonatliche Bilanz" + +msgid "Quarterly Balance Sheet" +msgstr "Vierteljährliche Bilanz" + +msgid "Half-yearly Balance Sheet" +msgstr "Halbjährliche Bilanz" + +msgid "Yearly Balance Sheet" +msgstr "Jährliche Bilanz" + +msgid "Biennial Balance Sheet" +msgstr "Zweijährliche Bilanz" + +msgid "Periodic Balance Sheet" +msgstr "Periodische Bilanz" + +#. Balance Sheet With Equity titles, one per reporting interval. +msgid "Balance Sheet With Equity" +msgstr "Bilanz mit Eigenkapital" + +msgid "Daily Balance Sheet With Equity" +msgstr "Tägliche Bilanz mit Eigenkapital" + +msgid "Weekly Balance Sheet With Equity" +msgstr "Wöchentliche Bilanz mit Eigenkapital" + +msgid "Biweekly Balance Sheet With Equity" +msgstr "Zweiwöchentliche Bilanz mit Eigenkapital" + +msgid "Monthly Balance Sheet With Equity" +msgstr "Monatliche Bilanz mit Eigenkapital" + +msgid "Bimonthly Balance Sheet With Equity" +msgstr "Zweimonatliche Bilanz mit Eigenkapital" + +msgid "Quarterly Balance Sheet With Equity" +msgstr "Vierteljährliche Bilanz mit Eigenkapital" + +msgid "Half-yearly Balance Sheet With Equity" +msgstr "Halbjährliche Bilanz mit Eigenkapital" + +msgid "Yearly Balance Sheet With Equity" +msgstr "Jährliche Bilanz mit Eigenkapital" + +msgid "Biennial Balance Sheet With Equity" +msgstr "Zweijährliche Bilanz mit Eigenkapital" + +msgid "Periodic Balance Sheet With Equity" +msgstr "Periodische Bilanz mit Eigenkapital" + +#. Income Statement titles, one per reporting interval. +msgid "Income Statement" +msgstr "Einnahmenüberschussrechnung" + +msgid "Daily Income Statement" +msgstr "Tägliche Einnahmenüberschussrechnung" + +msgid "Weekly Income Statement" +msgstr "Wöchentliche Einnahmenüberschussrechnung" + +msgid "Biweekly Income Statement" +msgstr "Zweiwöchentliche Einnahmenüberschussrechnung" + +msgid "Monthly Income Statement" +msgstr "Monatliche Einnahmenüberschussrechnung" + +msgid "Bimonthly Income Statement" +msgstr "Zweimonatliche Einnahmenüberschussrechnung" + +msgid "Quarterly Income Statement" +msgstr "Vierteljährliche Einnahmenüberschussrechnung" + +msgid "Half-yearly Income Statement" +msgstr "Halbjährliche Einnahmenüberschussrechnung" + +msgid "Yearly Income Statement" +msgstr "Jährliche Einnahmenüberschussrechnung" + +msgid "Biennial Income Statement" +msgstr "Zweijährliche Einnahmenüberschussrechnung" + +msgid "Periodic Income Statement" +msgstr "Periodische Einnahmenüberschussrechnung" + +#. Cashflow Statement titles, one per reporting interval. +msgid "Cashflow Statement" +msgstr "Kapitalflussrechnung" + +msgid "Daily Cashflow Statement" +msgstr "Tägliche Kapitalflussrechnung" + +msgid "Weekly Cashflow Statement" +msgstr "Wöchentliche Kapitalflussrechnung" + +msgid "Biweekly Cashflow Statement" +msgstr "Zweiwöchentliche Kapitalflussrechnung" + +msgid "Monthly Cashflow Statement" +msgstr "Monatliche Kapitalflussrechnung" + +msgid "Bimonthly Cashflow Statement" +msgstr "Zweimonatliche Kapitalflussrechnung" + +msgid "Quarterly Cashflow Statement" +msgstr "Vierteljährliche Kapitalflussrechnung" + +msgid "Half-yearly Cashflow Statement" +msgstr "Halbjährliche Kapitalflussrechnung" + +msgid "Yearly Cashflow Statement" +msgstr "Jährliche Kapitalflussrechnung" + +msgid "Biennial Cashflow Statement" +msgstr "Zweijährliche Kapitalflussrechnung" + +msgid "Periodic Cashflow Statement" +msgstr "Periodische Kapitalflussrechnung" + +#. Subreport (section) titles. +msgid "Assets" +msgstr "Vermögen" + +msgid "Liabilities" +msgstr "Verbindlichkeiten" + +msgid "Equity" +msgstr "Eigenkapital" + +msgid "Revenues" +msgstr "Einnahmen" + +msgid "Expenses" +msgstr "Ausgaben" + +msgid "Cash flows" +msgstr "Kapitalflüsse" + +#. Report title templates. +#, python-brace-format +msgid "{report} {dates}{clarification}{valuation}" +msgstr "{report} {dates}{clarification}{valuation}" + +#, python-brace-format +msgid "{report} in {dates}{valuation}:" +msgstr "{report} in {dates}{valuation}:" + +#, python-brace-format +msgid "Budget performance in {dates}{valuation}:" +msgstr "Soll-Ist-Vergleich in {dates}{valuation}:" + +#. Title clarifications. +msgid "(Period-End Value Changes)" +msgstr "(Wertänderungen zum Periodenende)" + +msgid "(Cumulative Period-End Value Changes)" +msgstr "(Kumulierte Wertänderungen zum Periodenende)" + +msgid "(Incremental Gain)" +msgstr "(Inkrementeller Gewinn)" + +msgid "(Cumulative Gain)" +msgstr "(Kumulierter Gewinn)" + +msgid "(Historical Gain)" +msgstr "(Historischer Gewinn)" + +msgid "(Balance Changes)" +msgstr "(Saldoänderungen)" + +msgid "(Cumulative Ending Balances)" +msgstr "(Kumulierte Endsalden)" + +msgid "(Historical Ending Balances)" +msgstr "(Historische Endsalden)" + +#. Balance report kinds. +msgid "Period-end value changes" +msgstr "Wertänderungen zum Periodenende" + +msgid "Cumulative period-end value changes" +msgstr "Kumulierte Wertänderungen zum Periodenende" + +msgid "Incremental gain" +msgstr "Inkrementeller Gewinn" + +msgid "Cumulative gain" +msgstr "Kumulierter Gewinn" + +msgid "Historical gain" +msgstr "Historischer Gewinn" + +msgid "Balance changes" +msgstr "Saldoänderungen" + +msgid "Ending balances (cumulative)" +msgstr "Endsalden (kumuliert)" + +msgid "Ending balances (historical)" +msgstr "Endsalden (historisch)" + +#. Valuation descriptions, appended to a title. +msgid ", converted to cost" +msgstr ", zu Anschaffungskosten" + +msgid ", valued at posting date" +msgstr ", bewertet zum Buchungsdatum" + +msgid ", valued at period ends" +msgstr ", bewertet zum Periodenende" + +msgid ", current value" +msgstr ", aktueller Wert" + +#, python-brace-format +msgid ", valued at {date}" +msgstr ", bewertet zum {date}" + +#. Column headings and the totals row. +msgctxt "column heading" +msgid "Total" +msgstr "Gesamt" + +msgctxt "column heading" +msgid "Average" +msgstr "Durchschnitt" + +msgid "Net:" +msgstr "Überschuss:" + +#. Spreadsheet sheet names. +msgid "Balance Report" +msgstr "Saldenbericht" + +msgid "Multi-period Balance Report" +msgstr "Saldenbericht nach Perioden" + +msgid "Budget Report" +msgstr "Soll-Ist-Vergleich" + +#. Month names, stand-alone form. +msgctxt "month" +msgid "January" +msgstr "Januar" + +msgctxt "month" +msgid "February" +msgstr "Februar" + +msgctxt "month" +msgid "March" +msgstr "März" + +msgctxt "month" +msgid "April" +msgstr "April" + +msgctxt "month" +msgid "May" +msgstr "Mai" + +msgctxt "month" +msgid "June" +msgstr "Juni" + +msgctxt "month" +msgid "July" +msgstr "Juli" + +msgctxt "month" +msgid "August" +msgstr "August" + +msgctxt "month" +msgid "September" +msgstr "September" + +msgctxt "month" +msgid "October" +msgstr "Oktober" + +msgctxt "month" +msgid "November" +msgstr "November" + +msgctxt "month" +msgid "December" +msgstr "Dezember" + +msgctxt "month abbrev" +msgid "Jan" +msgstr "Jan" + +msgctxt "month abbrev" +msgid "Feb" +msgstr "Feb" + +msgctxt "month abbrev" +msgid "Mar" +msgstr "Mär" + +msgctxt "month abbrev" +msgid "Apr" +msgstr "Apr" + +msgctxt "month abbrev" +msgid "May" +msgstr "Mai" + +msgctxt "month abbrev" +msgid "Jun" +msgstr "Jun" + +msgctxt "month abbrev" +msgid "Jul" +msgstr "Jul" + +msgctxt "month abbrev" +msgid "Aug" +msgstr "Aug" + +msgctxt "month abbrev" +msgid "Sep" +msgstr "Sep" + +msgctxt "month abbrev" +msgid "Oct" +msgstr "Okt" + +msgctxt "month abbrev" +msgid "Nov" +msgstr "Nov" + +msgctxt "month abbrev" +msgid "Dec" +msgstr "Dez" + +#. hledger-ui screens. +msgid "Cash accounts" +msgstr "Zahlungsmittelkonten" + +msgid "Balance sheet accounts" +msgstr "Bilanzkonten" + +msgid "Income statement accounts" +msgstr "GuV-Konten" + +msgid "All accounts" +msgstr "Alle Konten" + +msgid "account balances" +msgstr "Kontosalden" + +msgid "account changes" +msgstr "Kontobewegungen" + +msgid "cash balances" +msgstr "Zahlungsmittelsalden" + +msgid "balance sheet balances" +msgstr "Bilanzsalden" + +msgid "income statement changes" +msgstr "GuV-Bewegungen" + +#. hledger-web: search form and help. +msgctxt "search box" +msgid "Search" +msgstr "Suchen" + +msgid "Enter hledger search patterns to filter the data below" +msgstr "hledger-Suchmuster eingeben, um die Daten unten zu filtern" + +msgid "Clear search terms" +msgstr "Suchbegriffe löschen" + +msgid "Apply search terms" +msgstr "Suchbegriffe anwenden" + +msgid "Manage journal files" +msgstr "Journaldateien verwalten" + +msgid "Show search and general help" +msgstr "Such- und allgemeine Hilfe anzeigen" + +msgid "Close" +msgstr "Schließen" + +msgid "Help" +msgstr "Hilfe" + +msgid "Keyboard shortcuts" +msgstr "Tastenkürzel" + +msgid "or maybe" +msgstr "oder" + +msgid "view this help (escape or click to exit)" +msgstr "diese Hilfe anzeigen (Escape oder Klick zum Schließen)" + +msgid "go to the Journal view (home)" +msgstr "zur Journalansicht (Startseite)" + +msgid "add a transaction (escape to cancel)" +msgstr "eine Buchung hinzufügen (Escape zum Abbrechen)" + +msgid "toggle sidebar" +msgstr "Seitenleiste ein-/ausblenden" + +msgid "focus search form" +msgstr "zum Suchfeld springen" + +msgid "hide empty accounts in sidebar" +msgstr "leere Konten in der Seitenleiste ausblenden" + +msgid "General" +msgstr "Allgemein" + +msgid "The Journal view shows general journal entries, representing zero-sum movements of money (or other commodity) between hierarchical accounts" +msgstr "Die Journalansicht zeigt Journalbuchungen: ausgeglichene Bewegungen von Geld (oder anderen Werten) zwischen hierarchischen Konten" + +msgid "The sidebar shows the resulting accounts and their final balances" +msgstr "Die Seitenleiste zeigt die Konten und ihre Endsalden" + +msgid "Parent account balances include subaccount balances" +msgstr "Salden übergeordneter Konten schließen die Unterkonten ein" + +msgid "Multiple currencies in balances are displayed one above the other" +msgstr "Mehrere Währungen in Salden werden untereinander angezeigt" + +msgid "Click account name links to see transactions affecting that account, with running balance" +msgstr "Kontonamen anklicken, um die Buchungen dieses Kontos mit laufendem Saldo zu sehen" + +msgid "Click date links to see journal entries on that date" +msgstr "Datumslinks anklicken, um die Journalbuchungen dieses Tages zu sehen" + +msgctxt "help heading" +msgid "Search" +msgstr "Suche" + +msgid "Search patterns with spaces should be enclosed in quotes." +msgstr "Suchmuster mit Leerzeichen in Anführungszeichen setzen." + +msgid "match account names" +msgstr "Kontonamen finden" + +msgid "match account types" +msgstr "Kontotypen finden" + +msgid "match dates" +msgstr "Daten finden" + +msgid "match status" +msgstr "Status finden" + +msgid "match transaction codes" +msgstr "Buchungscodes finden" + +msgid "or" +msgstr "oder" + +msgid "match any visible field (account, amount, comment, description, code), or a date if REGEX is also a period expression" +msgstr "ein beliebiges sichtbares Feld finden (Konto, Betrag, Kommentar, Beschreibung, Code), oder ein Datum, wenn REGEX auch ein Periodenausdruck ist" + +msgid "match transaction descriptions" +msgstr "Buchungsbeschreibungen finden" + +msgid "match payee part of descriptions" +msgstr "den Empfängerteil der Beschreibung finden" + +msgid "match note part of descriptions" +msgstr "den Notizteil der Beschreibung finden" + +msgid "match unsigned magnitudes, or with signed N, signed amounts. For single-commodity amounts only." +msgstr "Beträge nach Größe finden, mit vorzeichenbehaftetem N nach Vorzeichen. Nur für Beträge mit einer Währung." + +msgid "match currencies/commodities. Must match the whole symbol/name. To match dollar sign, write" +msgstr "Währungen finden. Muss das ganze Symbol treffen. Für das Dollarzeichen schreibe" + +msgid "match tags, or tag and value" +msgstr "Tags finden, oder Tag und Wert" + +msgid "match postings' realness/virtualness" +msgstr "reale/virtuelle Buchungszeilen finden" + +msgid "prepend not: to negate a search term" +msgstr "not: voranstellen, um einen Suchbegriff zu verneinen" + +msgid "match with a boolean query (and, or, not, (..))" +msgstr "mit einem booleschen Ausdruck finden (and, or, not, (..))" + +msgid "match transactions where any posting matches" +msgstr "Buchungen finden, bei denen eine Buchungszeile passt" + +msgid "match transactions where all postings match" +msgstr "Buchungen finden, bei denen alle Buchungszeilen passen" + +msgid "clip account names at this depth" +msgstr "Kontonamen auf diese Tiefe kürzen" + +#. hledger-web: add form. +msgid "Add a transaction" +msgstr "Buchung hinzufügen" + +msgid "Add a new transaction to the journal" +msgstr "Eine neue Buchung zum Journal hinzufügen" + +msgid "Pick a date" +msgstr "Datum wählen" + +#, python-brace-format +msgid "Account {n}" +msgstr "Konto {n}" + +#, python-brace-format +msgid "Amount {n}" +msgstr "Betrag {n}" + +msgctxt "placeholder" +msgid "Date" +msgstr "Datum" + +msgctxt "placeholder" +msgid "Description" +msgstr "Beschreibung" + +msgid "Add to:" +msgstr "Hinzufügen zu:" + +msgid "Save" +msgstr "Speichern" + +msgid "Transaction added." +msgstr "Buchung hinzugefügt." + +msgid "Invalid date format" +msgstr "Ungültiges Datumsformat" + +msgid "Missing amount" +msgstr "Betrag fehlt" + +msgid "Missing account" +msgstr "Konto fehlt" + +#, python-brace-format +msgid "Invalid value: {error}" +msgstr "Ungültiger Wert: {error}" + +msgid "Postings validation failed" +msgstr "Prüfung der Buchungszeilen fehlgeschlagen" + +#. hledger-web: journal and register pages. +msgid "General Journal" +msgstr "Hauptjournal" + +#. Browser tab titles. +msgid "journal - hledger-web" +msgstr "Journal - hledger-web" + +msgid "register - hledger-web" +msgstr "Buchungen - hledger-web" + +#, python-brace-format +msgid "Transactions in {account}" +msgstr "Buchungen in {account}" + +#, python-brace-format +msgid "Transactions in {account} (excluding subaccounts)" +msgstr "Buchungen in {account} (ohne Unterkonten)" + +#, python-brace-format +msgid "{title}, filtered" +msgstr "{title}, gefiltert" + +msgid "all accounts" +msgstr "alle Konten" + +#, python-brace-format +msgid "{account} (excluding subaccounts)" +msgstr "{account} (ohne Unterkonten)" + +#. hledger-web: balance page. +msgid "balance - hledger-web" +msgstr "Salden - hledger-web" + +msgid "Balance report" +msgstr "Saldenbericht" + +msgid "Could not parse the period expression:" +msgstr "Der Periodenausdruck konnte nicht gelesen werden:" + +msgid "Report:" +msgstr "Bericht:" + +msgid "Balance" +msgstr "Salden" + +msgid "Yearly" +msgstr "Jährlich" + +msgid "Quarterly" +msgstr "Vierteljährlich" + +msgid "Monthly" +msgstr "Monatlich" + +msgid "Weekly" +msgstr "Wöchentlich" + +msgid "Daily" +msgstr "Täglich" + +msgid "Show the balance report" +msgstr "Saldenbericht anzeigen" + +msgid "Show the yearly balance report" +msgstr "Jährlichen Saldenbericht anzeigen" + +msgid "Show the quarterly balance report" +msgstr "Vierteljährlichen Saldenbericht anzeigen" + +msgid "Show the monthly balance report" +msgstr "Monatlichen Saldenbericht anzeigen" + +msgid "Show the weekly balance report" +msgstr "Wöchentlichen Saldenbericht anzeigen" + +msgid "Show the daily balance report" +msgstr "Täglichen Saldenbericht anzeigen" + +msgctxt "column heading" +msgid "Date" +msgstr "Datum" + +msgctxt "column heading" +msgid "Description" +msgstr "Beschreibung" + +msgctxt "column heading" +msgid "Account" +msgstr "Konto" + +msgctxt "column heading" +msgid "Amount" +msgstr "Betrag" + +msgctxt "column heading" +msgid "To/From Account(s)" +msgstr "Von/Nach Konto" + +msgctxt "column heading" +msgid "Amount Out/In" +msgstr "Betrag Aus/Ein" + +msgctxt "column heading" +msgid "Historical Total" +msgstr "Gesamt (historisch)" + +msgctxt "column heading" +msgid "Period Total" +msgstr "Gesamt (Periode)" + +#. hledger-web: sidebar. +msgid "Journal" +msgstr "Journal" + +msgid "Show general journal entries, most recent first" +msgstr "Journalbuchungen anzeigen, neueste zuerst" + +msgid "Show transactions affecting this account and subaccounts" +msgstr "Buchungen dieses Kontos und seiner Unterkonten anzeigen" + +msgid "Show transactions affecting this account but not subaccounts" +msgstr "Buchungen dieses Kontos ohne Unterkonten anzeigen" + +msgid "only" +msgstr "nur" + +#. hledger-web: file management. +msgid "Edit journal" +msgstr "Journal bearbeiten" + +msgid "Your journal's files" +msgstr "Die Dateien Ihres Journals" + +msgctxt "column heading" +msgid "File" +msgstr "Datei" + +msgid "Edit" +msgstr "Bearbeiten" + +msgid "Upload" +msgstr "Hochladen" + +msgid "Download" +msgstr "Herunterladen" + +msgid "Upload journal" +msgstr "Journal hochladen" + +msgid "Upload to file" +msgstr "Hochladen in Datei" + +msgid "Are you sure? This will overwrite your journal!" +msgstr "Sind Sie sicher? Dies überschreibt Ihr Journal!" + +msgid "Select file" +msgstr "Datei auswählen" + +#, python-brace-format +msgid "Encoding error: '{error}'. If your file is not UTF-8 encoded, try the 'edit form', where the transcoding should be handled by the browser." +msgstr "Kodierungsfehler: '{error}'. Wenn Ihre Datei nicht UTF-8-kodiert ist, versuchen Sie das Bearbeitungsformular, wo der Browser die Umkodierung übernimmt." + +#, python-brace-format +msgid "Failed to load journal: {error}" +msgstr "Journal konnte nicht geladen werden: {error}" + +#, python-brace-format +msgid "File {file} uploaded successfully" +msgstr "Datei {file} erfolgreich hochgeladen" + +msgid "Edit file" +msgstr "Datei bearbeiten" + +msgid "File format help" +msgstr "Hilfe zum Dateiformat" + +msgid "Go back" +msgstr "Zurück" + +#, python-brace-format +msgid "Saved journal {file}" +msgstr "Journal {file} gespeichert" + +msgctxt "column heading" +msgid "Commodity" +msgstr "Einheit" + +#. The register command's spreadsheet sheet name. +msgid "Register" +msgstr "Register" diff --git a/hledger-lib/locale/hledger.pot b/hledger-lib/locale/hledger.pot new file mode 100644 index 00000000000..d2fddf89a33 --- /dev/null +++ b/hledger-lib/locale/hledger.pot @@ -0,0 +1,1082 @@ +# Translation template for hledger. +# This file is distributed under the same license as hledger. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: hledger\n" +"Report-Msgid-Bugs-To: https://github.com/simonmichael/hledger/issues\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:174 +msgctxt "month" +msgid "January" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:174 +msgctxt "month" +msgid "February" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:174 +msgctxt "month" +msgid "March" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:175 +msgctxt "month" +msgid "April" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:175 +msgctxt "month" +msgid "May" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:175 +msgctxt "month" +msgid "June" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:176 +msgctxt "month" +msgid "July" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:176 +msgctxt "month" +msgid "August" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:176 +msgctxt "month" +msgid "September" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:177 +msgctxt "month" +msgid "October" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:177 +msgctxt "month" +msgid "November" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:177 +msgctxt "month" +msgid "December" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:181 +msgctxt "month abbrev" +msgid "Jan" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:181 +msgctxt "month abbrev" +msgid "Feb" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:181 +msgctxt "month abbrev" +msgid "Mar" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:182 +msgctxt "month abbrev" +msgid "Apr" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:182 +msgctxt "month abbrev" +msgid "May" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:182 +msgctxt "month abbrev" +msgid "Jun" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:183 +msgctxt "month abbrev" +msgid "Jul" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:183 +msgctxt "month abbrev" +msgid "Aug" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:183 +msgctxt "month abbrev" +msgid "Sep" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:184 +msgctxt "month abbrev" +msgid "Oct" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:184 +msgctxt "month abbrev" +msgid "Nov" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:184 +msgctxt "month abbrev" +msgid "Dec" +msgstr "" + +#. the report title, eg "Monthly Balance Sheet 2024 (Historical Ending Balances), valued at period ends". {clarification} brings its own leading space when present. +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:151 +#, python-brace-format +msgid "{report} {dates}{clarification}{valuation}" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:174 +msgid "(Period-End Value Changes)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:175 +msgid "(Cumulative Period-End Value Changes)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:176 +msgid "(Incremental Gain)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:177 +msgid "(Cumulative Gain)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:178 +msgid "(Historical Gain)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:179 +msgid "(Balance Changes)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:180 +msgid "(Cumulative Ending Balances)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:181 +msgid "(Historical Ending Balances)" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:186 hledger/Hledger/Cli/Commands/Balance.hs:903 hledger/Hledger/Cli/Commands/Balance.hs:1111 +msgid ", converted to cost" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:189 hledger/Hledger/Cli/Commands/Balance.hs:906 hledger/Hledger/Cli/Commands/Balance.hs:1114 +msgid ", valued at posting date" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:191 hledger/Hledger/Cli/Commands/Balance.hs:908 hledger/Hledger/Cli/Commands/Balance.hs:1115 +msgid ", valued at period ends" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:192 hledger/Hledger/Cli/Commands/Balance.hs:909 hledger/Hledger/Cli/Commands/Balance.hs:1116 +msgid ", current value" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:193 hledger/Hledger/Cli/Commands/Balance.hs:910 hledger/Hledger/Cli/Commands/Balance.hs:1117 +#, python-brace-format +msgid ", valued at {date}" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:308 +msgid "Net:" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:413 +msgid "Budget Report" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:426 +msgid "Multi-period Balance Report" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:438 +msgid "Balance Report" +msgstr "" + +#. the multi-period balance report title, eg "Balance changes in 2024, valued at period ends:". +#: hledger/Hledger/Cli/Commands/Balance.hs:889 +#, python-brace-format +msgid "{report} in {dates}{valuation}:" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:893 +msgid "Period-end value changes" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:894 +msgid "Cumulative period-end value changes" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:895 +msgid "Incremental gain" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:896 +msgid "Cumulative gain" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:897 +msgid "Historical gain" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:898 +msgid "Balance changes" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:899 +msgid "Ending balances (cumulative)" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:900 +msgid "Ending balances (historical)" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:961 hledger/Hledger/Cli/Commands/Balance.hs:1151 +msgctxt "column heading" +msgid "Commodity" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:971 hledger/Hledger/Cli/Commands/Balance.hs:1153 hledger-web/Hledger/Web/Handler/RegisterR.hs:58 +msgctxt "column heading" +msgid "Total" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:972 hledger/Hledger/Cli/Commands/Balance.hs:1154 +msgctxt "column heading" +msgid "Average" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/Commands/Balance.hs:1107 +#, python-brace-format +msgid "Budget performance in {dates}{valuation}:" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:28 +msgid "Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:29 +msgid "Daily Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:30 +msgid "Weekly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:31 +msgid "Biweekly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:32 +msgid "Monthly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:33 +msgid "Bimonthly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:34 +msgid "Quarterly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:35 +msgid "Half-yearly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:36 +msgid "Yearly Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:37 +msgid "Biennial Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:38 +msgid "Periodic Balance Sheet" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:41 hledger/Hledger/Cli/Commands/Balancesheetequity.hs:42 +msgid "Assets" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:48 hledger/Hledger/Cli/Commands/Balancesheetequity.hs:49 +msgid "Liabilities" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:29 +msgid "Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:30 +msgid "Daily Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:31 +msgid "Weekly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:32 +msgid "Biweekly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:33 +msgid "Monthly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:34 +msgid "Bimonthly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:35 +msgid "Quarterly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:36 +msgid "Half-yearly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:37 +msgid "Yearly Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:38 +msgid "Biennial Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:39 +msgid "Periodic Balance Sheet With Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:56 +msgid "Equity" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:32 +msgid "Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:33 +msgid "Daily Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:34 +msgid "Weekly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:35 +msgid "Biweekly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:36 +msgid "Monthly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:37 +msgid "Bimonthly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:38 +msgid "Quarterly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:39 +msgid "Half-yearly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:40 +msgid "Yearly Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:41 +msgid "Biennial Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:42 +msgid "Periodic Cashflow Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Cashflow.hs:45 +msgid "Cash flows" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:28 +msgid "Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:29 +msgid "Daily Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:30 +msgid "Weekly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:31 +msgid "Biweekly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:32 +msgid "Monthly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:33 +msgid "Bimonthly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:34 +msgid "Quarterly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:35 +msgid "Half-yearly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:36 +msgid "Yearly Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:37 +msgid "Biennial Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:38 +msgid "Periodic Income Statement" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:41 +msgid "Revenues" +msgstr "" + +#. the report title, with its reporting interval if any. Each is a whole phrase, so put the words in the order and form your language needs. +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:48 +msgid "Expenses" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Register.hs:115 +msgid "Register" +msgstr "" + +#: hledger-ui/Hledger/UI/AccountsScreen.hs:68 +msgid "account balances" +msgstr "" + +#: hledger-ui/Hledger/UI/AccountsScreen.hs:69 +msgid "account changes" +msgstr "" + +#: hledger-ui/Hledger/UI/AccountsScreen.hs:70 +msgid "cash balances" +msgstr "" + +#: hledger-ui/Hledger/UI/AccountsScreen.hs:71 +msgid "balance sheet balances" +msgstr "" + +#: hledger-ui/Hledger/UI/AccountsScreen.hs:72 +msgid "income statement changes" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:93 +msgid "Cash accounts" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:94 +msgid "Balance sheet accounts" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:95 +msgid "Income statement accounts" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:96 +msgid "All accounts" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/AddR.hs:65 +msgid "Transaction added." +msgstr "" + +#: hledger-web/Hledger/Web/Handler/BalanceR.hs:38 hledger-web/Hledger/Web/Handler/JournalR.hs:30 hledger-web/Hledger/Web/Handler/RegisterR.hs:39 +#, python-brace-format +msgid "{title}, filtered" +msgstr "" + +#. the browser tab title of this page. +#: hledger-web/Hledger/Web/Handler/BalanceR.hs:48 +msgid "balance - hledger-web" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/BalanceR.hs:54 hledger-web/Hledger/Web/Handler/BalanceR.hs:99 +msgid "Balance report" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/BalanceR.hs:56 +msgid "Could not parse the period expression:" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/EditR.hs:44 hledger-web/Hledger/Web/Handler/UploadR.hs:57 +#, python-brace-format +msgid "Failed to load journal: {error}" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/EditR.hs:47 +#, python-brace-format +msgid "Saved journal {file}" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/EditR.hs:52 hledger-web/Hledger/Web/Handler/MiscR.hs:56 +msgid "Edit journal" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/JournalR.hs:27 +msgid "General Journal" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/JournalR.hs:28 +#, python-brace-format +msgid "Transactions in {account}" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/JournalR.hs:29 +#, python-brace-format +msgid "Transactions in {account} (excluding subaccounts)" +msgstr "" + +#. the browser tab title of this page. +#: hledger-web/Hledger/Web/Handler/JournalR.hs:40 +msgid "journal - hledger-web" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/RegisterR.hs:36 +msgid "all accounts" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/RegisterR.hs:38 +#, python-brace-format +msgid "{account} (excluding subaccounts)" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/RegisterR.hs:56 +msgctxt "column heading" +msgid "Historical Total" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/RegisterR.hs:57 +msgctxt "column heading" +msgid "Period Total" +msgstr "" + +#. the browser tab title of this page. +#: hledger-web/Hledger/Web/Handler/RegisterR.hs:62 +msgid "register - hledger-web" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/UploadR.hs:52 +#, python-brace-format +msgid "Encoding error: '{error}'. If your file is not UTF-8 encoded, try the 'edit form', where the transcoding should be handled by the browser." +msgstr "" + +#: hledger-web/Hledger/Web/Handler/UploadR.hs:60 +#, python-brace-format +msgid "File {file} uploaded successfully" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/UploadR.hs:65 +msgid "Upload journal" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:39 hledger-web/templates/default-layout.hamlet:69 +msgid "Close" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:40 hledger-web/templates/journal.hamlet:7 +msgid "Add a transaction" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:53 +#, python-brace-format +msgid "Account {n}" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:54 +#, python-brace-format +msgid "Amount {n}" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:60 +msgid "Invalid date format" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:62 +msgctxt "placeholder" +msgid "Date" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:63 +msgctxt "placeholder" +msgid "Description" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:117 +msgid "Missing amount" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:118 +msgid "Missing account" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:148 +#, python-brace-format +msgid "Invalid value: {error}" +msgstr "" + +#: hledger-web/Hledger/Web/Widget/AddForm.hs:173 +msgid "Postings validation failed" +msgstr "" + +#. the label before the balance page's report links. +#: hledger-web/Hledger/Web/Widget/Common.hs:101 +msgid "Report:" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:108 +msgid "Balance" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:108 +msgid "Show the balance report" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:109 +msgid "Yearly" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:109 +msgid "Show the yearly balance report" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:110 +msgid "Quarterly" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:110 +msgid "Show the quarterly balance report" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:111 +msgid "Monthly" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:111 +msgid "Show the monthly balance report" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:112 +msgid "Weekly" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:112 +msgid "Show the weekly balance report" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:113 +msgid "Daily" +msgstr "" + +#. the balance page's report links: each link's text, and its tooltip. +#: hledger-web/Hledger/Web/Widget/Common.hs:113 +msgid "Show the daily balance report" +msgstr "" + +#: hledger-web/templates/add-form.hamlet:11 +msgid "Pick a date" +msgstr "" + +#: hledger-web/templates/add-form.hamlet:39 +msgid "Add to:" +msgstr "" + +#: hledger-web/templates/add-form.hamlet:46 hledger-web/templates/edit-form.hamlet:17 +msgid "Save" +msgstr "" + +#: hledger-web/templates/balance-report.hamlet:4 +msgid "Show general journal entries, most recent first" +msgstr "" + +#: hledger-web/templates/balance-report.hamlet:5 +msgid "Journal" +msgstr "" + +#: hledger-web/templates/balance-report.hamlet:15 +msgid "Show transactions affecting this account and subaccounts" +msgstr "" + +#: hledger-web/templates/balance-report.hamlet:19 +msgid "Show transactions affecting this account but not subaccounts" +msgstr "" + +#: hledger-web/templates/balance-report.hamlet:19 +msgid "only" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:49 +msgctxt "search box" +msgid "Search" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:50 +msgid "Enter hledger search patterns to filter the data below" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:55 +msgid "Clear search terms" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:57 +msgid "Apply search terms" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:60 +msgid "Manage journal files" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:63 +msgid "Show search and general help" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:71 +msgid "Help" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:77 +msgid "Keyboard shortcuts" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:79 +msgid "or maybe" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:79 +msgid "view this help (escape or click to exit)" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:80 +msgid "go to the Journal view (home)" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:81 +msgid "add a transaction (escape to cancel)" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:82 +msgid "toggle sidebar" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:83 +msgid "focus search form" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:84 +msgid "hide empty accounts in sidebar" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:86 +msgid "General" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:88 +msgid "The Journal view shows general journal entries, representing zero-sum movements of money (or other commodity) between hierarchical accounts" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:89 +msgid "The sidebar shows the resulting accounts and their final balances" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:90 +msgid "Parent account balances include subaccount balances" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:91 +msgid "Multiple currencies in balances are displayed one above the other" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:92 +msgid "Click account name links to see transactions affecting that account, with running balance" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:93 +msgid "Click date links to see journal entries on that date" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:96 +msgctxt "help heading" +msgid "Search" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:98 +msgid "Search patterns with spaces should be enclosed in quotes." +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:99 +msgid "match account names" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:100 +msgid "match account types" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:101 +msgid "match dates" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:102 +msgid "match status" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:103 +msgid "match transaction codes" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:104 +msgid "or" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:104 +msgid "match any visible field (account, amount, comment, description, code), or a date if REGEX is also a period expression" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:105 +msgid "match transaction descriptions" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:106 +msgid "match payee part of descriptions" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:107 +msgid "match note part of descriptions" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:108 +msgid "match unsigned magnitudes, or with signed N, signed amounts. For single-commodity amounts only." +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:109 +msgid "match currencies/commodities. Must match the whole symbol/name. To match dollar sign, write" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:110 +msgid "match tags, or tag and value" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:111 +msgid "match postings' realness/virtualness" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:112 +msgid "prepend not: to negate a search term" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:113 +msgid "match with a boolean query (and, or, not, (..))" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:114 +msgid "match transactions where any posting matches" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:115 +msgid "match transactions where all postings match" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:116 +msgid "clip account names at this depth" +msgstr "" + +#: hledger-web/templates/edit-form.hamlet:3 +msgid "Edit file" +msgstr "" + +#: hledger-web/templates/edit-form.hamlet:6 hledger-web/templates/upload-form.hamlet:5 +msgid "Are you sure? This will overwrite your journal!" +msgstr "" + +#: hledger-web/templates/edit-form.hamlet:14 +msgid "File format help" +msgstr "" + +#: hledger-web/templates/edit-form.hamlet:16 +msgid "Go back" +msgstr "" + +#: hledger-web/templates/journal.hamlet:6 +msgid "Add a new transaction to the journal" +msgstr "" + +#: hledger-web/templates/journal.hamlet:12 hledger-web/templates/register.hamlet:12 +msgctxt "column heading" +msgid "Date" +msgstr "" + +#: hledger-web/templates/journal.hamlet:13 hledger-web/templates/register.hamlet:14 +msgctxt "column heading" +msgid "Description" +msgstr "" + +#: hledger-web/templates/journal.hamlet:14 +msgctxt "column heading" +msgid "Account" +msgstr "" + +#: hledger-web/templates/journal.hamlet:15 +msgctxt "column heading" +msgid "Amount" +msgstr "" + +#: hledger-web/templates/manage.hamlet:2 +msgid "Your journal's files" +msgstr "" + +#: hledger-web/templates/manage.hamlet:9 +msgctxt "column heading" +msgid "File" +msgstr "" + +#: hledger-web/templates/manage.hamlet:18 +msgid "Edit" +msgstr "" + +#: hledger-web/templates/manage.hamlet:20 hledger-web/templates/upload-form.hamlet:12 +msgid "Upload" +msgstr "" + +#: hledger-web/templates/manage.hamlet:22 +msgid "Download" +msgstr "" + +#: hledger-web/templates/register.hamlet:15 +msgctxt "column heading" +msgid "To/From Account(s)" +msgstr "" + +#: hledger-web/templates/register.hamlet:16 +msgctxt "column heading" +msgid "Amount Out/In" +msgstr "" + +#: hledger-web/templates/upload-form.hamlet:2 +msgid "Upload to file" +msgstr "" + +#: hledger-web/templates/upload-form.hamlet:9 +msgid "Select file" +msgstr "" diff --git a/hledger-lib/package.yaml b/hledger-lib/package.yaml index 686f951c6f7..d788ee4a484 100644 --- a/hledger-lib/package.yaml +++ b/hledger-lib/package.yaml @@ -32,6 +32,7 @@ description: | extra-source-files: - CHANGES.md - README.md +- locale/de.po - test/unittest.hs - test/doctests.hs diff --git a/hledger-ui/Hledger/UI/AccountsScreen.hs b/hledger-ui/Hledger/UI/AccountsScreen.hs index 28c670f67b6..3209e1312a8 100644 --- a/hledger-ui/Hledger/UI/AccountsScreen.hs +++ b/hledger-ui/Hledger/UI/AccountsScreen.hs @@ -40,6 +40,7 @@ import System.Console.ANSI import System.FilePath (takeFileName) import Text.DocLayout (realLength) +import Hledger.Utils.I18n (tr) import Hledger import Hledger.Cli hiding (Mode, mode, progname, prognameandversion) import Hledger.UI.UIOptions @@ -63,11 +64,13 @@ asDraw ass@ASS{_assKind=kind} ui = dbgui "asDraw" $ asDrawHelper ass ui ropts' s -- | The display name shown in an accounts-like screen's header, for the given kind. accountsScreenName :: AccountsScreenKind -> ReportOpts -> String -accountsScreenName kind ropts = case kind of - AllAccounts -> "account " ++ if balanceaccum_ ropts == Historical then "balances" else "changes" - CashAccounts -> "cash balances" - BalancesheetAccounts -> "balance sheet balances" - IncomestatementAccounts -> "income statement changes" +accountsScreenName kind ropts = T.unpack $ case kind of + AllAccounts | balanceaccum_ ropts == Historical -> tr trs "account balances" + | otherwise -> tr trs "account changes" + CashAccounts -> tr trs "cash balances" + BalancesheetAccounts -> tr trs "balance sheet balances" + IncomestatementAccounts -> tr trs "income statement changes" + where trs = translations_ ropts -- | Help draw any accounts-like screen (all accounts, balance sheet, income statement..). -- The provided ReportOpts are used instead of the ones in the UIState. diff --git a/hledger-ui/Hledger/UI/MenuScreen.hs b/hledger-ui/Hledger/UI/MenuScreen.hs index 01f964bae29..a30b4d9d029 100644 --- a/hledger-ui/Hledger/UI/MenuScreen.hs +++ b/hledger-ui/Hledger/UI/MenuScreen.hs @@ -26,6 +26,7 @@ import Lens.Micro.Platform import System.Console.ANSI import System.FilePath (takeFileName) +import Hledger.Utils.I18n (Translations, tr) import Hledger import Hledger.Cli hiding (mode, progname, prognameandversion) import Hledger.UI.UIOptions @@ -49,7 +50,7 @@ msDraw sst UIState{aopts=_uopts@UIOpts{uoCliOpts=copts@CliOpts{reportspec_=_rspe _ -> [maincontent] where maincontent = Widget Greedy Greedy $ do - render $ defaultLayout toplabel bottomlabel $ renderList msDrawItem True (sst ^. mssList) + render $ defaultLayout toplabel bottomlabel $ renderList (msDrawItem (translations_ ropts)) True (sst ^. mssList) where toplabel = withAttr (attrName "border" <> attrName "filename") fs @@ -89,10 +90,10 @@ msDraw sst UIState{aopts=_uopts@UIOpts{uoCliOpts=copts@CliOpts{reportspec_=_rspe -- msDrawItem :: (Int,Int) -> Bool -> MenuScreenItem -> Widget Name -- msDrawItem (_acctwidth, _balwidth) _selected MenuScreenItem{..} = -msDrawItem :: Bool -> MenuScreenItem -> Widget Name -msDrawItem _selected MenuScreenItem{..} = +msDrawItem :: Translations -> Bool -> MenuScreenItem -> Widget Name +msDrawItem trs _selected MenuScreenItem{..} = Widget Greedy Fixed $ do - render $ txt msItemScreenName + render $ txt $ tr trs msItemScreenName -- XXX clean up like asHandle msHandle :: MenuScreenState -> BrickEvent Name AppEvent -> EventM Name UIState () diff --git a/hledger-ui/Hledger/UI/UIScreens.hs b/hledger-ui/Hledger/UI/UIScreens.hs index e7bcf14833f..a3b45ebf0c2 100644 --- a/hledger-ui/Hledger/UI/UIScreens.hs +++ b/hledger-ui/Hledger/UI/UIScreens.hs @@ -46,6 +46,7 @@ import Lens.Micro (over) import Safe import Data.Vector qualified as V +import Hledger.Utils.I18n (i18n) import Hledger.Cli hiding (mode, progname,prognameandversion) import Hledger.UI.UIOptions import Hledger.UI.UITypes @@ -87,11 +88,12 @@ msNew = MS MSS { _mssList = list MenuList (V.fromList items ) 1, _mssUnused = () } where -- keep synced with: indexes below, initial screen stack setup in UI.Main + -- TRANSLATORS: the menu screen's entries; translated when drawn. items = [ - MenuScreenItem "Cash accounts" CashAccounts - ,MenuScreenItem "Balance sheet accounts" BalancesheetAccounts - ,MenuScreenItem "Income statement accounts" IncomestatementAccounts - ,MenuScreenItem "All accounts" AllAccounts + MenuScreenItem (i18n "Cash accounts") CashAccounts + ,MenuScreenItem (i18n "Balance sheet accounts") BalancesheetAccounts + ,MenuScreenItem (i18n "Income statement accounts") IncomestatementAccounts + ,MenuScreenItem (i18n "All accounts") AllAccounts ] -- keep synced with items above. diff --git a/hledger-web/Hledger/Web/App.hs b/hledger-web/Hledger/Web/App.hs index fa359ed7c85..3022f5feb8e 100644 --- a/hledger-web/Hledger/Web/App.hs +++ b/hledger-web/Hledger/Web/App.hs @@ -22,9 +22,11 @@ import Control.Monad (join, when, unless) -- import Control.Monad.Except (runExceptT) -- now re-exported by Hledger import Data.ByteString.Base64 qualified as B64 import Data.ByteString.Char8 qualified as BC +import Data.Foldable (for_) +import Data.Map qualified as M import Data.Traversable (for) import Data.IORef (IORef, readIORef, writeIORef) -import Data.Maybe (fromMaybe) +import Data.Maybe (fromMaybe, listToMaybe) import Data.Text (Text) import Data.Text qualified as T import Data.Text.Encoding qualified as TE @@ -40,7 +42,9 @@ import Text.Blaze (Markup) import Text.Hamlet (hamletFile) import Yesod import Yesod.Default.Config +import Yesod.Form.I18n.German (germanFormMessage) +import Hledger.Utils.I18n (Translations(..), langTagCandidates, resolveLang, tr, trc) import Hledger import Hledger.Cli (CliOpts(..), journalReloadIfChanged) import Hledger.Web.Settings (Extra(..), widgetFile) @@ -62,6 +66,9 @@ data App = App , appJournal :: IORef Journal -- ^ the current journal, filtered by the initial command line query -- but ignoring any depth limit. + , appTranslations :: M.Map Text Translations + -- ^ the translation catalogs available to viewers, by language tag + -- (built-in and from the user's config directory), loaded at startup. } @@ -127,8 +134,17 @@ instance Yesod App where master <- getYesod here <- fromMaybe RootR <$> getCurrentRoute - VD{opts, j, qparam, q, qopts, perms} <- getViewData + VD{opts, j, qparam, q, qopts, perms, trs} <- getViewData msg <- getMessage + -- An explicit ?_LANG= choice is remembered in a cookie, which Yesod's + -- languages reads on later requests; only a tag naming an available + -- catalog is accepted. And since the page varies by language, say so + -- for any cache in front of a shared server. + mlangparam <- lookupGetParam "_LANG" + for_ (mlangparam >>= \l -> resolveLang (M.keys $ appTranslations master) [l]) $ \l -> + addHeader "Set-Cookie" $ "_LANG=" <> l <> "; Path=/; Max-Age=31536000; SameSite=Lax" + addHeader "Vary" "Cookie, Accept-Language" + let lang = trLang trs showSidebar <- shouldShowSidebar -- The policy is sent from here rather than from a middleware, so that -- the header and the page's script tags always carry the same nonce; @@ -158,7 +174,7 @@ instance Yesod App where else (== Just "1") . lookup "hideemptyaccts" . reqCookies <$> getRequest let accounts = - balanceReportAsHtml (JournalR, RegisterR) here hideEmptyAccts j qparam qopts $ + balanceReportAsHtml (JournalR, RegisterR) here hideEmptyAccts trs j qparam qopts $ styleAmounts (journalCommodityStylesWith HardRounding j) $ balanceReport rspec' j @@ -191,10 +207,42 @@ instance Yesod App where withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet") --- This instance is required to use forms. You can modify renderMessage to --- achieve customized and internationalized form validation messages. +---------------------------------------------------------------------- +-- translations + +-- | The translations for a viewer (as listed +-- by Yesod's 'languages': the _LANG query parameter, cookie and session +-- variable, then the Accept-Language header): the first available one, +-- trying each preference with its subtags dropped before moving on to the +-- next. Falls back to the server's --lang. +translationsFor :: App -> [Lang] -> Translations +translationsFor App{appOpts, appTranslations} langs = + fromMaybe serverdefault $ (`M.lookup` appTranslations) =<< resolveLang (M.keys appTranslations) langs + where serverdefault = translations_ $ _rsReportOpts $ reportspec_ $ cliopts_ appOpts + +-- | The translations for the current request's language. +requestTranslations :: Handler Translations +requestTranslations = translationsFor <$> getYesod <*> languages + +-- | A translatable text, for @_{HMsg "..."}@ in templates and 'setMessageI'. +newtype HMsg = HMsg Text + +instance RenderMessage App HMsg where + renderMessage app langs (HMsg s) = tr (translationsFor app langs) s + +-- | Like 'HMsg', with a context disambiguating a short text used in +-- more than one sense, eg @_{HMsgc "column heading" "Total"}@. +data HMsgc = HMsgc Text Text + +instance RenderMessage App HMsgc where + renderMessage app langs (HMsgc ctx s) = trc (translationsFor app langs) ctx s + +-- | yesod-form's validation messages, in the request's language when +-- yesod-form ships that language. instance RenderMessage App FormMessage where - renderMessage _ _ = defaultFormMessage + renderMessage app langs = fromMaybe defaultFormMessage $ listToMaybe + [ m | c <- langTagCandidates (trLang $ translationsFor app langs), Just m <- [lookup c formMessages] ] + where formMessages = [("de", germanFormMessage)] ---------------------------------------------------------------------- @@ -241,6 +289,7 @@ data ViewData = VD , q :: Query -- ^ a query parsed from the q parameter , qopts :: [QueryOpt] -- ^ query options parsed from the q parameter , perms :: [Permission] -- ^ permissions enabled for this request (by --allow and/or X-Sandstorm-Permissions) + , trs :: Translations -- ^ translations for this request's language } deriving (Show) instance Show Text.Blaze.Markup where show _ = "" @@ -249,10 +298,14 @@ instance Show Text.Blaze.Markup where show _ = "" getViewData :: Handler ViewData getViewData = do App{ - appOpts=opts@WebOpts{ cliopts_=copts@CliOpts{ reportspec_=rspec@ReportSpec{_rsReportOpts, _rsQuery} } }, + appOpts=opts0@WebOpts{ cliopts_=copts@CliOpts{ reportspec_=rspec@ReportSpec{_rsReportOpts, _rsQuery} } }, appJournal } <- getYesod let today = _rsDay rspec + -- the request's language, applied to the options too so that any + -- report text rendered by hledger-lib matches the page + trs <- requestTranslations + let opts = opts0{cliopts_ = copts{reportspec_ = rspec{_rsReportOpts = _rsReportOpts{translations_ = trs}}}} -- try to read the latest journal content, keeping the old content -- if there's an error @@ -290,7 +343,7 @@ getViewData = do -- otherwise take them from the access level specified by --allow's access level cliaccess -> pure $ accessLevelToPermissions cliaccess - return VD{opts, today, j, qparam, q, qopts, perms} + return VD{opts, today, j, qparam, q, qopts, perms, trs} checkServerSideUiEnabled :: Handler () checkServerSideUiEnabled = do diff --git a/hledger-web/Hledger/Web/Application.hs b/hledger-web/Hledger/Web/Application.hs index 8f17d01c4ec..75f0a8913ca 100644 --- a/hledger-web/Hledger/Web/Application.hs +++ b/hledger-web/Hledger/Web/Application.hs @@ -22,6 +22,7 @@ import Network.HTTP.Conduit (newManager) import Yesod.Default.Config import Hledger.Data (Journal, nulljournal) +import Hledger.Utils.I18n (loadAllTranslations) import Hledger.Web.Handler.AddR import Hledger.Web.Handler.MiscR @@ -74,10 +75,12 @@ makeAppWith j' aconf wopts = do s <- staticSite m <- newManager defaultManagerSettings jref <- newIORef j' + trs <- loadAllTranslations return App{ settings = aconf , getStatic = s , httpManager = m , appOpts = wopts , appJournal = jref + , appTranslations = trs } diff --git a/hledger-web/Hledger/Web/Handler/AddR.hs b/hledger-web/Hledger/Web/Handler/AddR.hs index d81922ed481..31946ccc330 100644 --- a/hledger-web/Hledger/Web/Handler/AddR.hs +++ b/hledger-web/Hledger/Web/Handler/AddR.hs @@ -62,7 +62,7 @@ postAddR = do liftIO $ do ensureJournalFileExists f appendToJournalFileOrStdout f (showTransaction t') - setMessage "Transaction added." + setMessageI (HMsg "Transaction added.") redirect JournalR FormMissing -> showForm view enctype FormFailure errs -> do diff --git a/hledger-web/Hledger/Web/Handler/BalanceR.hs b/hledger-web/Hledger/Web/Handler/BalanceR.hs index d48bd48fa40..d9979e06d40 100644 --- a/hledger-web/Hledger/Web/Handler/BalanceR.hs +++ b/hledger-web/Hledger/Web/Handler/BalanceR.hs @@ -16,6 +16,7 @@ import Hledger.Cli.CliOptions import Hledger.Cli.Commands.Balance qualified as Balance import Hledger.Query qualified as Query import Data.Text qualified as T +import Hledger.Utils.I18n (tr, trf) import Hledger.Web.Import import Hledger.Web.WebOptions @@ -28,13 +29,13 @@ import Hledger.Write.Spreadsheet (Cell, NumLines) getBalanceR :: Handler Html getBalanceR = do checkServerSideUiEnabled - VD{j, q, qopts, qparam, opts, today} <- getViewData + VD{j, q, qopts, qparam, opts, today, trs} <- getViewData require ViewPermission -- The period parameter is a period expression as for -p: an interval -- ("monthly"), a date span ("2024"), or both ("monthly in 2024"). -- An empty one is no period at all, as from a search form with nothing in it. mperiod <- (>>= \p -> if T.null p then Nothing else Just p) <$> lookupGetParam "period" - let filtered = if q /= Any then ", filtered" else "" :: Text + let withFilter t = if q /= Any then trf trs "{title}, filtered" [("title", t)] else t rspecOrig = reportspec_ $ cliopts_ opts roptsOrig = _rsReportOpts rspecOrig eperiod = case mperiod of @@ -43,14 +44,16 @@ getBalanceR = do Just p -> either (Left . errorBundlePretty) Right $ parsePeriodExpr today p defaultLayout $ do - setTitle "balance - hledger-web" + -- TRANSLATORS: the browser tab title of this page. + setTitleI (HMsg "balance - hledger-web") + case eperiod of -- No report links here: this page is a dead end until the navigation -- question (#2242) is settled, see the pull request. Left err -> Yesod.toWidget $ do - H.h2 $ H.toHtml $ reportTitle roptsOrig "Balance report" <> filtered + H.h2 $ H.toHtml $ withFilter $ reportTitle roptsOrig $ tr trs "Balance report" H.div ! A.class_ "alert alert-danger" $ do - "Could not parse the period expression:" + H.toHtml $ tr trs "Could not parse the period expression:" H.pre $ H.toHtml err Right (ivl, spn) -> do let -- A date: search term can carry an interval too (eg @@ -93,7 +96,7 @@ getBalanceR = do Balance.balanceReportAsSpreadsheetParts oneLineNoCostFmt ropts $ styleAmounts (journalCommodityStylesWith HardRounding j) $ balanceReport rspec j - in ( reportTitle ropts "Balance report" + in ( reportTitle ropts $ tr trs "Balance report" , ([toList header], map toList body, map toList totals)) _ -> let mbr = styleAmounts (journalCommodityStylesWith HardRounding j) $ @@ -102,8 +105,8 @@ getBalanceR = do , Balance.multiBalanceReportAsSpreadsheetParts oneLineNoCostFmt ropts (Balance.allCommoditiesFromPeriodicReport $ prRows mbr) mbr ) - Yesod.toWidget $ H.h2 $ H.toHtml $ title <> filtered - Yesod.toWidget $ balanceReportLinks BalanceR qparam spn reportinterval + Yesod.toWidget $ H.h2 $ H.toHtml $ withFilter title + Yesod.toWidget $ balanceReportLinks BalanceR trs qparam spn reportinterval Yesod.toWidget $ reportTable parts -- | The heading for a report: --title if one was given, otherwise the diff --git a/hledger-web/Hledger/Web/Handler/EditR.hs b/hledger-web/Hledger/Web/Handler/EditR.hs index caaedeab70b..7571c025e25 100644 --- a/hledger-web/Hledger/Web/Handler/EditR.hs +++ b/hledger-web/Hledger/Web/Handler/EditR.hs @@ -11,12 +11,14 @@ module Hledger.Web.Handler.EditR ) where import Control.Monad.Except (runExceptT) +import Data.Text qualified as T +import Hledger.Utils.I18n (Translations, tr, trf) import Hledger.Web.Import import Hledger.Web.Widget.Common (fromFormSuccess, helplink, journalFile404, writeJournalTextIfValidAndChanged) -editForm :: FilePath -> Text -> Form Text -editForm f txt = +editForm :: Translations -> FilePath -> Text -> Form Text +editForm trs f txt = identifyForm "edit" $ \extra -> do (tRes, tView) <- mreq textareaField fs (Just (Textarea txt)) pure (unTextarea <$> tRes, $(widgetFile "edit-form")) @@ -31,21 +33,21 @@ getEditR f = do postEditR :: FilePath -> Handler () postEditR f = do checkServerSideUiEnabled - VD {j} <- getViewData + VD {j, trs} <- getViewData require EditPermission (f', txt) <- journalFile404 f j - ((res, view), enctype) <- runFormPost (editForm f' txt) + ((res, view), enctype) <- runFormPost (editForm trs f' txt) newtxt <- fromFormSuccess (showForm view enctype) res runExceptT (writeJournalTextIfValidAndChanged f newtxt) >>= \case Left e -> do - setMessage $ "Failed to load journal: " <> toHtml e + setMessage $ toHtml $ trf trs "Failed to load journal: {error}" [("error", T.pack e)] showForm view enctype Right () -> do - setMessage $ "Saved journal " <> toHtml f <> "\n" + setMessage $ toHtml $ trf trs "Saved journal {file}" [("file", T.pack f)] <> "\n" redirect JournalR where showForm view enctype = sendResponse <=< defaultLayout $ do - setTitle "Edit journal" + setTitleI (HMsg "Edit journal") [whamlet|
^{view}|] diff --git a/hledger-web/Hledger/Web/Handler/JournalR.hs b/hledger-web/Hledger/Web/Handler/JournalR.hs index c1d63e1c48a..0198f790fcf 100644 --- a/hledger-web/Hledger/Web/Handler/JournalR.hs +++ b/hledger-web/Hledger/Web/Handler/JournalR.hs @@ -7,6 +7,7 @@ module Hledger.Web.Handler.JournalR where +import Hledger.Utils.I18n (tr, trf) import Hledger import Hledger.Cli.CliOptions import Hledger.Web.Import @@ -20,12 +21,13 @@ import Hledger.Web.Widget.Common getJournalR :: Handler Html getJournalR = do checkServerSideUiEnabled - VD{perms, j, q, opts, qparam, qopts, today} <- getViewData + VD{perms, j, q, opts, qparam, qopts, today, trs} <- getViewData require ViewPermission let title = case inAccount qopts of - Nothing -> "General Journal" - Just (a, inclsubs) -> "Transactions in " <> a <> if inclsubs then "" else " (excluding subaccounts)" - title' = title <> if q /= Any then ", filtered" else "" + Nothing -> tr trs "General Journal" + Just (a, True) -> trf trs "Transactions in {account}" [("account", a)] + Just (a, False) -> trf trs "Transactions in {account} (excluding subaccounts)" [("account", a)] + title' = if q /= Any then trf trs "{title}, filtered" [("title", title)] else title acctlink a = (RegisterR, [("q", replaceInacct qparam $ accountQuery a)]) rspec = (reportspec_ $ cliopts_ opts){_rsQuery = filterQuery (not . queryIsDepth) q} items = reverse $ @@ -34,5 +36,6 @@ getJournalR = do transactionFrag = transactionFragment j defaultLayout $ do - setTitle "journal - hledger-web" + -- TRANSLATORS: the browser tab title of this page. + setTitleI (HMsg "journal - hledger-web") $(widgetFile "journal") diff --git a/hledger-web/Hledger/Web/Handler/MiscR.hs b/hledger-web/Hledger/Web/Handler/MiscR.hs index b7197992b7d..a43cbf4004d 100644 --- a/hledger-web/Hledger/Web/Handler/MiscR.hs +++ b/hledger-web/Hledger/Web/Handler/MiscR.hs @@ -53,7 +53,7 @@ getManageR = do VD{j} <- getViewData require EditPermission defaultLayout $ do - setTitle "Edit journal" + setTitleI (HMsg "Edit journal") $(widgetFile "manage") getDownloadR :: FilePath -> Handler TypedContent diff --git a/hledger-web/Hledger/Web/Handler/RegisterR.hs b/hledger-web/Hledger/Web/Handler/RegisterR.hs index ecd2d6cfcbd..6e69ec4baa4 100644 --- a/hledger-web/Hledger/Web/Handler/RegisterR.hs +++ b/hledger-web/Hledger/Web/Handler/RegisterR.hs @@ -15,6 +15,7 @@ import Data.Text qualified as T import Safe (tailSafe) import Text.Hamlet (hamletFile) +import Hledger.Utils.I18n (tr, trc, trf) import Hledger import Hledger.Cli.CliOptions import Hledger.Web.Import @@ -28,13 +29,14 @@ import Hledger.Web.Widget.Common getRegisterR :: Handler Html getRegisterR = do checkServerSideUiEnabled - VD{perms, j, q, opts, qparam, qopts, today} <- getViewData + VD{perms, j, q, opts, qparam, qopts, today, trs} <- getViewData require ViewPermission - let (a,inclsubs) = fromMaybe ("all accounts",True) $ inAccount qopts - s1 = if inclsubs then "" else " (excluding subaccounts)" - s2 = if q /= Any then ", filtered" else "" - header = a <> s1 <> s2 + let title = case inAccount qopts of + Nothing -> tr trs "all accounts" + Just (a, True) -> a + Just (a, False) -> trf trs "{account} (excluding subaccounts)" [("account", a)] + header = if q /= Any then trf trs "{title}, filtered" [("title", title)] else title let rspec = reportspec_ (cliopts_ opts) acctQuery = fromMaybe Any (inAccountQuery qopts) @@ -51,12 +53,13 @@ getRegisterR = do styleAmounts (journalCommodityStylesWith HardRounding j) $ accountTransactionsReport rspec{_rsQuery=q} j acctQuery balancelabel - | isJust (inAccount qopts), balanceaccum_ (_rsReportOpts rspec) == Historical = "Historical Total" - | isJust (inAccount qopts) = "Period Total" - | otherwise = "Total" + | isJust (inAccount qopts), balanceaccum_ (_rsReportOpts rspec) == Historical = trc trs "column heading" "Historical Total" + | isJust (inAccount qopts) = trc trs "column heading" "Period Total" + | otherwise = trc trs "column heading" "Total" transactionFrag = transactionFragment j defaultLayout $ do - setTitle "register - hledger-web" + -- TRANSLATORS: the browser tab title of this page. + setTitleI (HMsg "register - hledger-web") $(widgetFile "register") -- cf. Hledger.Reports.AccountTransactionsReport.accountTransactionsReportItems diff --git a/hledger-web/Hledger/Web/Handler/UploadR.hs b/hledger-web/Hledger/Web/Handler/UploadR.hs index 629e4b46054..f9c439f7650 100644 --- a/hledger-web/Hledger/Web/Handler/UploadR.hs +++ b/hledger-web/Hledger/Web/Handler/UploadR.hs @@ -15,6 +15,8 @@ import Data.Conduit (connect) import Data.Conduit.Binary (sinkLbs) import Data.Text.Encoding qualified as TE +import Data.Text qualified as T +import Hledger.Utils.I18n (trf) import Hledger.Web.Import import Hledger.Web.Widget.Common (fromFormSuccess, journalFile404, writeJournalTextIfValidAndChanged) @@ -35,7 +37,7 @@ getUploadR f = do postUploadR :: FilePath -> Handler () postUploadR f = do checkServerSideUiEnabled - VD {j} <- getViewData + VD {j, trs} <- getViewData require EditPermission (f', _) <- journalFile404 f j @@ -47,21 +49,18 @@ postUploadR f = do -- XXX Unfortunate - how to parse as system locale? newtxt <- case TE.decodeUtf8' lbs of Left e -> do - setMessage $ - "Encoding error: '" <> toHtml (show e) <> "'. " <> - "If your file is not UTF-8 encoded, try the 'edit form', " <> - "where the transcoding should be handled by the browser." + setMessage $ toHtml $ trf trs "Encoding error: '{error}'. If your file is not UTF-8 encoded, try the 'edit form', where the transcoding should be handled by the browser." [("error", T.pack (show e))] showForm view enctype Right newtxt -> return newtxt runExceptT (writeJournalTextIfValidAndChanged f newtxt) >>= \case Left e -> do - setMessage $ "Failed to load journal: " <> toHtml e + setMessage $ toHtml $ trf trs "Failed to load journal: {error}" [("error", T.pack e)] showForm view enctype Right () -> do - setMessage $ "File " <> toHtml f <> " uploaded successfully" + setMessage $ toHtml $ trf trs "File {file} uploaded successfully" [("file", T.pack f)] redirect JournalR where showForm view enctype = sendResponse <=< defaultLayout $ do - setTitle "Upload journal" + setTitleI (HMsg "Upload journal") [whamlet|^{view}|] diff --git a/hledger-web/Hledger/Web/Test.hs b/hledger-web/Hledger/Web/Test.hs index c4a71c0c7d9..0ecf884fa02 100644 --- a/hledger-web/Hledger/Web/Test.hs +++ b/hledger-web/Hledger/Web/Test.hs @@ -41,7 +41,9 @@ module Hledger.Web.Test ( hledgerWebTest ) where +import Control.Exception (bracket_) import Data.Aeson (encode) +import Data.ByteString qualified as BS import Data.String (fromString) import Data.Function ((&)) import Data.Text qualified as T @@ -49,10 +51,14 @@ import Data.Text.Encoding qualified as TE import Data.Text.IO qualified as TIO import Data.Text.Lazy qualified as TL import Data.Text.Lazy.Encoding qualified as TLE +import Network.HTTP.Types (HeaderName) import Network.Wai.Test (SResponse(..)) -import System.Directory (getTemporaryDirectory) +import System.Directory (createDirectoryIfMissing, getTemporaryDirectory, removeDirectoryRecursive) +import System.Entropy (getEntropy) +import System.Environment (setEnv, unsetEnv) import System.FilePath (()) import Test.Hspec (expectationFailure, hspec) +import Text.Printf (printf) import Yesod.Default.Config import Yesod.Test @@ -120,10 +126,21 @@ editFieldName = do -- | The current response's Content-Security-Policy header, failing the test -- if there is none. cspHeaderValue :: YesodExample App T.Text -cspHeaderValue = withResponse $ \res -> - case lookup "Content-Security-Policy" (simpleHeaders res) of - Just h -> return $ TE.decodeUtf8 h - Nothing -> failing "the response has no Content-Security-Policy header" +cspHeaderValue = headerValue "Content-Security-Policy" + +-- | The values of all of the current response's headers with this name. +headerValues :: HeaderName -> YesodExample App [T.Text] +headerValues name = withResponse $ \res -> + return [TE.decodeUtf8 v | (n, v) <- simpleHeaders res, n == name] + +-- | The current response's headers with this name, joined; failing the +-- test if there are none. +headerValue :: HeaderName -> YesodExample App T.Text +headerValue name = do + vs <- headerValues name + if null vs + then failing ("the response has no " ++ show name ++ " header") + else return $ T.intercalate ", " vs -- | The nonce in the current response's Content-Security-Policy, failing the -- test if the header or the nonce is missing. @@ -135,6 +152,18 @@ cspNonce = do then failing "the Content-Security-Policy has no nonce" else return $ T.takeWhile (/= '\'') $ T.drop (T.length "'nonce-") fromnonce +-- | Run an action with XDG_CONFIG_HOME pointing at a fresh directory, removed +-- afterwards, so that catalogs written by a test never come from, or end up +-- in, the developer's real config directory, and concurrent runs do not share one. +withTempConfigDir :: (FilePath -> IO a) -> IO a +withTempConfigDir act = do + tmp <- getTemporaryDirectory + bytes <- BS.unpack <$> getEntropy 6 + let dir = tmp ("hledger-web-test-" ++ concatMap (printf "%02x") bytes) + bracket_ (createDirectoryIfMissing True dir >> setEnv "XDG_CONFIG_HOME" dir) + (unsetEnv "XDG_CONFIG_HOME" >> removeDirectoryRecursive dir) + (act dir) + -- | Fail the current test with a message. (yesod-test's own version of this -- is not exported.) failing :: String -> YesodExample App a @@ -288,6 +317,112 @@ hledgerWebTest = do bodyContains "a<img src=x onerror=alert(2)>" -- account, escaped bodyNotContains "Salden - hledger-web" + bodyContains "

Saldenbericht

" + bodyContains "Bericht:" + bodyContains "title=\"Monatlichen Saldenbericht anzeigen\">Monatlich" + + -- A translation is viewer-controlled text: it must be rendered as text + -- wherever it lands, including inside attributes. + withTempConfigDir $ \xdg -> do + createDirectoryIfMissing True (xdg "hledger" "locale") + TIO.writeFile (xdg "hledger" "locale" "xx.po") $ T.unlines + [ "msgid \"\"" + , "msgstr \"Content-Type: text/plain; charset=UTF-8\\n\"" + , "" + , "msgid \"Add a transaction\"" + , "msgstr \"\"" + , "" + , "msgid \"Show search and general help\"" + , "msgstr \"x\\\" onmouseover=\\\"alert(2)\"" + ] + runTests "hledger-web with a user translation catalog" [] nulljournal $ do + + yit "renders translations as text, in content and in attributes" $ do + request $ do + setMethod "GET" + setUrl JournalR + addRequestHeader ("Accept-Language", "xx") + statusIs 200 + bodyContains "lang=\"xx\"" + bodyContains "<img src=x onerror=alert(1)>" + bodyNotContains "Saldoänderungen in 2025-01-01..2025-02-28" + yit "keeps the period parameter off the other pages' search forms" $ do request $ do setMethod "GET" diff --git a/hledger-web/Hledger/Web/Widget/AddForm.hs b/hledger-web/Hledger/Web/Widget/AddForm.hs index 91497528864..cec38023f08 100644 --- a/hledger-web/Hledger/Web/Widget/AddForm.hs +++ b/hledger-web/Hledger/Web/Widget/AddForm.hs @@ -23,8 +23,9 @@ import Text.Blaze.Internal (Markup) import Text.Megaparsec (bundleErrors, eof, parseErrorTextPretty, runParser) import Yesod +import Hledger.Utils.I18n (Translations, substitutePlaceholders, tr, trc, trf) import Hledger -import Hledger.Web.App (App, Handler, Widget) +import Hledger.Web.App (App, Handler, Widget, HMsg(..), requestTranslations) import Hledger.Web.Settings (widgetFile) import Data.Function ((&)) import Control.Arrow (right) @@ -35,8 +36,8 @@ addModal addR j today = do [whamlet|
-