From a97cc3049d038bf30a794bcdccdb29d940078e98 Mon Sep 17 00:00:00 2001 From: Arthur Cinader <700572+acinader@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:37:29 -0700 Subject: [PATCH 01/12] imp: two wording fixes: "Biennial" for a two-year interval, and "1 day ago" "Biannual" means twice a year; the title of a report with a two-year interval now says "Biennial". And stats no longer says "1 days ago". The interval word is about to become a translation key, and every later change to an English string invalidates its translations, so it is fixed first; the stats wording is fixed while looking at it. AI usage: drafted with Claude Code, reviewed and edited by the author. Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz --- hledger/Hledger/Cli/Commands/Stats.hs | 5 +++-- hledger/Hledger/Cli/CompoundBalanceCommand.hs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/hledger/Hledger/Cli/Commands/Stats.hs b/hledger/Hledger/Cli/Commands/Stats.hs index 624030b3132..2f0f125b2bd 100644 --- a/hledger/Hledger/Cli/Commands/Stats.hs +++ b/hledger/Hledger/Cli/Commands/Stats.hs @@ -172,8 +172,9 @@ showLedgerStats verbose l today spn = showelapsed Nothing = "" showelapsed (Just dys) = printf " (%d %s)" dys' direction where dys' = abs dys - direction | dys >= 0 = "days ago" :: String - | otherwise = "days from now" + unit = if dys' == 1 then "day" else "days" :: String + direction | dys >= 0 = unit ++ " ago" + | otherwise = unit ++ " from now" tnum1 = length ts -- Integer would be better showstart (DateSpan (Just efd) _) = show $ fromEFDay efd showstart _ = "" diff --git a/hledger/Hledger/Cli/CompoundBalanceCommand.hs b/hledger/Hledger/Cli/CompoundBalanceCommand.hs index c941b8faeab..52ac3a936d7 100644 --- a/hledger/Hledger/Cli/CompoundBalanceCommand.hs +++ b/hledger/Hledger/Cli/CompoundBalanceCommand.hs @@ -248,7 +248,7 @@ showInterval = \case Quarters 1 -> Just "Quarterly" Quarters 2 -> Just "Half-yearly" Years 1 -> Just "Yearly" - Years 2 -> Just "Biannual" + Years 2 -> Just "Biennial" _ -> Just "Periodic" -- | Summarise one or more (inclusive) end dates, in a way that's From 6ea24b0314122e80ce22486d27288be672fc7fd0 Mon Sep 17 00:00:00 2001 From: Arthur Cinader <700572+acinader@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:46:28 -0700 Subject: [PATCH 02/12] lib: add Hledger.Utils.I18n: translations from gettext PO catalogs A Translations value maps English text to a translation; tr, trc, trf and trn look text up, with contexts, {name} placeholders and plural forms. Catalogs are gettext PO files, so the usual translator tools can edit them; a German catalog is built in (hledger-lib/locale/de.po, embedded at build time), and a user can override or add a language with $XDG_CONFIG_HOME/hledger/locale/LANG.po. The parser is ours, since no Haskell gettext library is in the snapshot, and handles what Poedit, Weblate and msgmerge write, including the Plural-Forms expression. Lookups are pure functions of the Translations value: there is no process-global locale, so hledger-web can serve each request in its own language. With the default (English) value every lookup is the identity, so existing output is unchanged byte for byte. hledger-lib/locale/README.md explains the catalogs to translators, with the German terminology choices. ReportOpts gains translations_ (English by default), and showPeriodAbbrevWith and showDateSpanAbbrevWith render month names from a given TimeLocale; the --lang option and the report renderers that use these follow in the next commit. Language tags are normalized and matched with RFC 4647 truncation (de-CH falls back to de), and the "auto" setting follows LANGUAGE, LC_ALL, LC_MESSAGES and LANG with gettext's rules, so LC_ALL=C still means English. AI usage: drafted with Claude Code, reviewed and edited by the author. Note: I made no real effort to read or understand hledger-lib/Hledger/Utils/I18n.hs (yet?). I anticipate that it will be maintained by agent and covered with appropriate tests. Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz --- hledger-lib/Hledger/Data/Dates.hs | 5 + hledger-lib/Hledger/Data/Period.hs | 13 +- hledger-lib/Hledger/Reports/ReportOptions.hs | 5 + hledger-lib/Hledger/Utils.hs | 2 + hledger-lib/Hledger/Utils/I18n.hs | 799 ++++++++++++++++++ hledger-lib/Hledger/Utils/IO.hs | 8 +- hledger-lib/hledger-lib.cabal | 2 + hledger-lib/locale/README.md | 62 ++ hledger-lib/locale/de.po | 618 ++++++++++++++ hledger-lib/locale/hledger.pot | 826 +++++++++++++++++++ hledger-lib/package.yaml | 1 + 11 files changed, 2336 insertions(+), 5 deletions(-) create mode 100644 hledger-lib/Hledger/Utils/I18n.hs create mode 100644 hledger-lib/locale/README.md create mode 100644 hledger-lib/locale/de.po create mode 100644 hledger-lib/locale/hledger.pot 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..b36b8d250c9 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) 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. 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..01803eb92b3 --- /dev/null +++ b/hledger-lib/Hledger/Utils/I18n.hs @@ -0,0 +1,799 @@ +{-| +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) +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, 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.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.Read (readMaybe) + +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. Problems are reported as warnings and the catalog ignored. +readOverrideCatalog :: Text -> IO (Maybe 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 c) + +-- | The language tags for which a catalog exists, built-in or in the +-- user's override directory. Always includes "en". +availableLanguages :: IO [Text] +availableLanguages = do + 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. +loadTranslations :: Text -> IO Translations +loadTranslations lang = do + let builtin = fromMaybe noTranslations{trLang = lang} $ M.lookup lang builtinTranslations + moverride <- readOverrideCatalog lang + return $ maybe builtin (mergeTranslations builtin) moverride + +-- | 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) + +quotedString :: PoParser Text +quotedString = T.pack <$> (char '"' *> manyTill strChar (char '"')) + where + strChar = (char '\\' *> escape) <|> satisfy (\c -> c /= '"' && c /= '\n') + 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..d46c53b5afc --- /dev/null +++ b/hledger-lib/locale/de.po @@ -0,0 +1,618 @@ +# 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" + +#. Report titles. +msgid "Balance Sheet" +msgstr "Bilanz" + +msgid "Balance Sheet With Equity" +msgstr "Bilanz mit Eigenkapital" + +msgid "Income Statement" +msgstr "Einnahmenüberschussrechnung" + +msgid "Cashflow Statement" +msgstr "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 "{interval}{report} {dates}{clarification}{valuation}" +msgstr "{interval}{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}:" + +#. Interval words, preceding a report title: "Monatliche Bilanz". +msgid "Daily" +msgstr "Tägliche" + +msgid "Weekly" +msgstr "Wöchentliche" + +msgid "Biweekly" +msgstr "Zweiwöchentliche" + +msgid "Monthly" +msgstr "Monatliche" + +msgid "Bimonthly" +msgstr "Zweimonatliche" + +msgid "Quarterly" +msgstr "Vierteljährliche" + +msgid "Half-yearly" +msgstr "Halbjährliche" + +msgid "Yearly" +msgstr "Jährliche" + +msgid "Biennial" +msgstr "Zweijährliche" + +msgid "Periodic" +msgstr "Periodische" + +#. 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 "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" + +#, 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)" + +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..2f569191207 --- /dev/null +++ b/hledger-lib/locale/hledger.pot @@ -0,0 +1,826 @@ +# 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:171 +msgctxt "month" +msgid "January" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:171 +msgctxt "month" +msgid "February" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:171 +msgctxt "month" +msgid "March" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:172 +msgctxt "month" +msgid "April" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:172 +msgctxt "month" +msgid "May" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:172 +msgctxt "month" +msgid "June" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:173 +msgctxt "month" +msgid "July" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:173 +msgctxt "month" +msgid "August" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:173 +msgctxt "month" +msgid "September" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:174 +msgctxt "month" +msgid "October" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:174 +msgctxt "month" +msgid "November" +msgstr "" + +#. stand-alone (nominative) month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:174 +msgctxt "month" +msgid "December" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:178 +msgctxt "month abbrev" +msgid "Jan" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:178 +msgctxt "month abbrev" +msgid "Feb" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:178 +msgctxt "month abbrev" +msgid "Mar" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:179 +msgctxt "month abbrev" +msgid "Apr" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:179 +msgctxt "month abbrev" +msgid "May" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:179 +msgctxt "month abbrev" +msgid "Jun" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:180 +msgctxt "month abbrev" +msgid "Jul" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:180 +msgctxt "month abbrev" +msgid "Aug" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:180 +msgctxt "month abbrev" +msgid "Sep" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:181 +msgctxt "month abbrev" +msgid "Oct" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:181 +msgctxt "month abbrev" +msgid "Nov" +msgstr "" + +#. short stand-alone month names, as used in a column heading. +#: hledger-lib/Hledger/Utils/I18n.hs:181 +msgctxt "month abbrev" +msgid "Dec" +msgstr "" + +#. the report title, eg "Monthly Balance Sheet 2024 (Historical Ending Balances), valued at period ends". {interval} and {clarification} bring their own surrounding space when present. +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:151 +#, python-brace-format +msgid "{interval}{report} {dates}{clarification}{valuation}" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:177 +msgid "(Period-End Value Changes)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:178 +msgid "(Cumulative Period-End Value Changes)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:179 +msgid "(Incremental Gain)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:180 +msgid "(Cumulative Gain)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:181 +msgid "(Historical Gain)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:182 +msgid "(Balance Changes)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:183 +msgid "(Cumulative Ending Balances)" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:184 +msgid "(Historical Ending Balances)" +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:895 hledger/Hledger/Cli/Commands/Balance.hs:1100 +msgid ", converted to cost" +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:898 hledger/Hledger/Cli/Commands/Balance.hs:1103 +msgid ", valued at posting date" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:194 hledger/Hledger/Cli/Commands/Balance.hs:900 hledger/Hledger/Cli/Commands/Balance.hs:1104 +msgid ", valued at period ends" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:195 hledger/Hledger/Cli/Commands/Balance.hs:901 hledger/Hledger/Cli/Commands/Balance.hs:1105 +msgid ", current value" +msgstr "" + +#. the budget report title, eg "Budget performance in 2024, valued at period ends:". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:196 hledger/Hledger/Cli/Commands/Balance.hs:902 hledger/Hledger/Cli/Commands/Balance.hs:1106 +#, python-brace-format +msgid ", valued at {date}" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:247 +msgid "Daily" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:248 +msgid "Weekly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:249 +msgid "Biweekly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:250 +msgid "Monthly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:251 +msgid "Bimonthly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:252 hledger/Hledger/Cli/CompoundBalanceCommand.hs:255 +msgid "Quarterly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:253 hledger/Hledger/Cli/CompoundBalanceCommand.hs:256 +msgid "Half-yearly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:254 hledger/Hledger/Cli/CompoundBalanceCommand.hs:257 +msgid "Yearly" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:258 +msgid "Biennial" +msgstr "" + +#. these precede a report title, as in "Monthly Balance Sheet". If your language inflects adjectives, use a form that fits every report title, or a stand-alone form such as "per month". +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:259 +msgid "Periodic" +msgstr "" + +#: hledger/Hledger/Cli/CompoundBalanceCommand.hs:323 +msgid "Net:" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:410 +msgid "Budget Report" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:423 +msgid "Multi-period Balance Report" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:435 +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:881 +#, python-brace-format +msgid "{report} in {dates}{valuation}:" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:885 +msgid "Period-end value changes" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:886 +msgid "Cumulative period-end value changes" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:887 +msgid "Incremental gain" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:888 +msgid "Cumulative gain" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:889 +msgid "Historical gain" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:890 +msgid "Balance changes" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:891 +msgid "Ending balances (cumulative)" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:892 +msgid "Ending balances (historical)" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:953 hledger/Hledger/Cli/Commands/Balance.hs:1135 +msgctxt "column heading" +msgid "Commodity" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:963 hledger/Hledger/Cli/Commands/Balance.hs:1137 hledger-web/Hledger/Web/Handler/RegisterR.hs:58 +msgctxt "column heading" +msgid "Total" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balance.hs:964 hledger/Hledger/Cli/Commands/Balance.hs:1138 +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:1096 +#, python-brace-format +msgid "Budget performance in {dates}{valuation}:" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:24 +msgid "Balance Sheet" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:27 hledger/Hledger/Cli/Commands/Balancesheetequity.hs:28 +msgid "Assets" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balancesheet.hs:34 hledger/Hledger/Cli/Commands/Balancesheetequity.hs:35 +msgid "Liabilities" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:25 +msgid "Balance Sheet With Equity" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Balancesheetequity.hs:42 +msgid "Equity" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Cashflow.hs:28 +msgid "Cashflow Statement" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Cashflow.hs:31 +msgid "Cash flows" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:24 +msgid "Income Statement" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:27 +msgid "Revenues" +msgstr "" + +#: hledger/Hledger/Cli/Commands/Incomestatement.hs:34 +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:91 +msgid "Cash accounts" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:92 +msgid "Balance sheet accounts" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:93 +msgid "Income statement accounts" +msgstr "" + +#. the menu screen's entries; translated when drawn. +#: hledger-ui/Hledger/UI/UIScreens.hs:94 +msgid "All accounts" +msgstr "" + +#: hledger-web/Hledger/Web/Handler/AddR.hs:65 +msgid "Transaction added." +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 "" + +#: hledger-web/Hledger/Web/Handler/JournalR.hs:30 hledger-web/Hledger/Web/Handler/RegisterR.hs:39 +#, python-brace-format +msgid "{title}, filtered" +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 "" + +#: 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:67 +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 "" + +#: 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:53 +msgid "Clear search terms" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:55 +msgid "Apply search terms" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:58 +msgid "Manage journal files" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:61 +msgid "Show search and general help" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:69 +msgid "Help" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:75 +msgid "Keyboard shortcuts" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:77 +msgid "or maybe" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:77 +msgid "view this help (escape or click to exit)" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:78 +msgid "go to the Journal view (home)" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:79 +msgid "add a transaction (escape to cancel)" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:80 +msgid "toggle sidebar" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:81 +msgid "focus search form" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:82 +msgid "hide empty accounts in sidebar" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:84 +msgid "General" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:86 +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:87 +msgid "The sidebar shows the resulting accounts and their final balances" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:88 +msgid "Parent account balances include subaccount balances" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:89 +msgid "Multiple currencies in balances are displayed one above the other" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:90 +msgid "Click account name links to see transactions affecting that account, with running balance" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:91 +msgid "Click date links to see journal entries on that date" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:94 +msgctxt "help heading" +msgid "Search" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:96 +msgid "Search patterns with spaces should be enclosed in quotes." +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:97 +msgid "match account names" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:98 +msgid "match account types" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:99 +msgid "match dates" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:100 +msgid "match status" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:101 +msgid "match transaction codes" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:102 +msgid "match transaction descriptions" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:103 +msgid "match payee part of descriptions" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:104 +msgid "match note part of descriptions" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:105 +msgid "match unsigned magnitudes, or with signed N, signed amounts. For single-commodity amounts only." +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:106 +msgid "match currencies/commodities. Must match the whole symbol/name. To match dollar sign, write" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:107 +msgid "match tags, or tag and value" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:108 +msgid "match postings' realness/virtualness" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:109 +msgid "prepend not: to negate a search term" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:110 +msgid "match with a boolean query (and, or, not, (..))" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:111 +msgid "match transactions where any posting matches" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:112 +msgid "match transactions where all postings match" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:113 +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 From bc4eee64b210fdf051392f87ae226b4de65b8095 Mon Sep 17 00:00:00 2001 From: Arthur Cinader <700572+acinader@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:06:23 -0700 Subject: [PATCH 03/12] feat: cli: --lang translates report titles, headings and month names A new general option, --lang=LANG (also usable in config files), selects a translation catalog: a language tag like de, auto (from the environment), or en, the default. It translates the compound reports' titles, section titles and interval words, the balance and budget report titles and their valuation descriptions, the Total/Average column headings and the Net: row in text output, month names in period headings (reportPeriodName now takes the ReportOpts and uses the translated TimeLocale), and the sheet names of FODS output. Titles are built once and shared by every output format, so like --title they are translated in CSV, TSV and JSON output too; column headings in those formats stay English, and an explicit --title or --subreport-titles is used as given. The composed titles are now single templates with placeholders, so a translator can reorder them. Docs: a Languages section, the environment variables, and the general options list. AI usage: drafted with Claude Code, reviewed and edited by the author. Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz --- doc/common.m4 | 3 + hledger-lib/Hledger/Reports/ReportOptions.hs | 14 +-- hledger/Hledger/Cli/CliOptions.hs | 7 +- hledger/Hledger/Cli/Commands/Balance.hs | 81 +++++++------- hledger/Hledger/Cli/Commands/Balancesheet.hs | 7 +- .../Cli/Commands/Balancesheetequity.hs | 9 +- hledger/Hledger/Cli/Commands/Cashflow.hs | 5 +- .../Hledger/Cli/Commands/Incomestatement.hs | 7 +- hledger/Hledger/Cli/Commands/Register.hs | 3 +- hledger/Hledger/Cli/CompoundBalanceCommand.hs | 91 ++++++++-------- hledger/hledger.m4.md | 51 ++++++++- hledger/test/i18n.test | 102 ++++++++++++++++++ 12 files changed, 277 insertions(+), 103 deletions(-) create mode 100644 hledger/test/i18n.test 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/hledger-lib/Hledger/Reports/ReportOptions.hs b/hledger-lib/Hledger/Reports/ReportOptions.hs index b36b8d250c9..d8326f88af4 100644 --- a/hledger-lib/Hledger/Reports/ReportOptions.hs +++ b/hledger-lib/Hledger/Reports/ReportOptions.hs @@ -89,7 +89,7 @@ import Data.Time.Calendar (Day, addDays) import Data.Default (Default(..)) import Safe (lastDef, lastMay, maximumMay, readMay) -import Hledger.Utils.I18n (Translations, noTranslations) +import Hledger.Utils.I18n (Translations, noTranslations, trTimeLocale) import Hledger.Data import Hledger.Query import Hledger.Utils @@ -943,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/Hledger/Cli/CliOptions.hs b/hledger/Hledger/Cli/CliOptions.hs index 9a91f8c42ba..f8c17f26294 100644 --- a/hledger/Hledger/Cli/CliOptions.hs +++ b/hledger/Hledger/Cli/CliOptions.hs @@ -123,6 +123,7 @@ import System.Info (os) import Text.Megaparsec import Text.Megaparsec.Char +import Hledger.Utils.I18n (translationsForLangOption) import Hledger import Hledger.Cli.DocFiles import Hledger.Cli.Version @@ -299,6 +300,8 @@ terminalflags = [ -- keep synced with hledger-lib:colorOption: ,flagReq ["color","colour"] (\s opts -> Right $ setopt "color" s opts) "YNA" "use ANSI color ? y/yes, n/no, or auto (default)" + ,flagReq ["lang"] (\s opts -> Right $ setopt "lang" s opts) "LANG" + "language for report titles and headings: a language tag like de, auto (from the environment), or en (default)" ] -- | Flags for selecting flat/tree mode, used for reports organised by account. @@ -664,7 +667,9 @@ rawOptsToCliOpts rawopts = do command = stringopt "command" rawopts usecolor <- useColorOnStdout let iopts = rawOptsToInputOpts day usecolor rawopts - rspec <- either error' pure $ rawOptsToReportSpec day usecolor rawopts -- PARTIAL: + rspec0 <- either error' pure $ rawOptsToReportSpec day usecolor rawopts -- PARTIAL: + trs <- translationsForLangOption $ maybestringopt "lang" rawopts + let rspec = rspec0{_rsReportOpts = (_rsReportOpts rspec0){translations_ = trs}} mtermwidth <- getTerminalWidth let availablewidth = fromMaybe defaultWidth mtermwidth return defcliopts { diff --git a/hledger/Hledger/Cli/Commands/Balance.hs b/hledger/Hledger/Cli/Commands/Balance.hs index d55bff0f9fd..bc12d64b5d8 100644 --- a/hledger/Hledger/Cli/Commands/Balance.hs +++ b/hledger/Hledger/Cli/Commands/Balance.hs @@ -309,6 +309,7 @@ import Text.Tabular.AsciiWide import System.IO qualified as IO +import Hledger.Utils.I18n (tr, trc, trf) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.Utils @@ -409,7 +410,7 @@ balance opts@CliOpts{reportspec_=rspec} j = case balancecalc_ ropts of "tsv" -> printTSV . budgetReportAsCsv ropts "html" -> (<>"\n") . htmlAsLazyText . budgetReportAsHtml ropts "fods" -> printFods IO.localeEncoding . - Map.singleton "Budget Report" . budgetReportAsSpreadsheet oneLineNoCostFmt ropts + Map.singleton (tr (translations_ ropts) "Budget Report") . budgetReportAsSpreadsheet oneLineNoCostFmt ropts _ -> error' $ unsupportedOutputFormatError fmt writeOutputLazyText opts $ render budgetreport @@ -422,7 +423,7 @@ balance opts@CliOpts{reportspec_=rspec} j = case balancecalc_ ropts of "html" -> (<>"\n") . htmlAsLazyText . multiBalanceReportAsHtml ropts "json" -> (<>"\n") . toJsonText "fods" -> printFods IO.localeEncoding . - Map.singleton "Multi-period Balance Report" . multiBalanceReportAsSpreadsheet ropts + Map.singleton (tr (translations_ ropts) "Multi-period Balance Report") . multiBalanceReportAsSpreadsheet ropts _ -> const $ error' $ unsupportedOutputFormatError fmt -- PARTIAL: writeOutputLazyText opts $ render report @@ -434,7 +435,7 @@ balance opts@CliOpts{reportspec_=rspec} j = case balancecalc_ ropts of "tsv" -> printTSV . balanceReportAsCsv ropts "html" -> (<>"\n") . htmlAsLazyText . balanceReportAsHtml ropts "json" -> (<>"\n") . toJsonText - "fods" -> printFods IO.localeEncoding . Map.singleton "Balance Report" . (,) (1,0) . balanceReportAsSpreadsheet oneLineNoCostFmt ropts + "fods" -> printFods IO.localeEncoding . Map.singleton (tr (translations_ ropts) "Balance Report") . (,) (1,0) . balanceReportAsSpreadsheet oneLineNoCostFmt ropts _ -> error' $ unsupportedOutputFormatError fmt -- PARTIAL: writeOutputLazyText opts $ render report where @@ -796,7 +797,7 @@ multiBalanceReportAsSpreadsheetParts :: [[Ods.Cell Ods.NumLines Text]], [[Ods.Cell Ods.NumLines Text]]) multiBalanceReportAsSpreadsheetParts fmt opts@ReportOpts{..} - allCommodities (PeriodicReport colspans items tr) = + allCommodities (PeriodicReport colspans items totrow) = (allHeaders, concatMap fullRowAsTexts items, addTotalBorders totalrows) where accountCell label = @@ -836,7 +837,7 @@ multiBalanceReportAsSpreadsheetParts fmt opts@ReportOpts{..} if no_total_ then [] else addRowSpanHeader (accountCell totalRowHeadingSpreadsheet) $ - rowAsText Total (simpleDateSpanCell period_titles_) tr + rowAsText Total (simpleDateSpanCell period_titles_) totrow rowAsText rc dsCell = map (map (fmap wbToText)) . multiBalanceRowAsCellBuilders fmt opts colspans allCommodities rc dsCell @@ -884,27 +885,29 @@ multiBalanceReportAsText ropts r = TB.toLazyText $ multiBalanceReportTitle :: ReportOpts -> MultiBalanceReport -> Text multiBalanceReportTitle ropts@ReportOpts{..} r = effectiveTitle ropts defaultTitle where - defaultTitle = mtitle <> " in " <> showDateSpan (periodicReportSpan r) <> valuationdesc <> ":" + -- TRANSLATORS: the multi-period balance report title, eg "Balance changes in 2024, valued at period ends:". + defaultTitle = trf translations_ "{report} in {dates}{valuation}:" + [ ("report", mtitle), ("dates", showDateSpan (periodicReportSpan r)), ("valuation", valuationdesc) ] mtitle = case (balancecalc_, balanceaccum_) of - (CalcValueChange, PerPeriod ) -> "Period-end value changes" - (CalcValueChange, Cumulative ) -> "Cumulative period-end value changes" - (CalcGain, PerPeriod ) -> "Incremental gain" - (CalcGain, Cumulative ) -> "Cumulative gain" - (CalcGain, Historical ) -> "Historical gain" - (_, PerPeriod ) -> "Balance changes" - (_, Cumulative ) -> "Ending balances (cumulative)" - (_, Historical) -> "Ending balances (historical)" + (CalcValueChange, PerPeriod ) -> tr translations_ "Period-end value changes" + (CalcValueChange, Cumulative ) -> tr translations_ "Cumulative period-end value changes" + (CalcGain, PerPeriod ) -> tr translations_ "Incremental gain" + (CalcGain, Cumulative ) -> tr translations_ "Cumulative gain" + (CalcGain, Historical ) -> tr translations_ "Historical gain" + (_, PerPeriod ) -> tr translations_ "Balance changes" + (_, Cumulative ) -> tr translations_ "Ending balances (cumulative)" + (_, Historical) -> tr translations_ "Ending balances (historical)" valuationdesc = (case conversionop_ of - Just ToCost -> ", converted to cost" + Just ToCost -> tr translations_ ", converted to cost" _ -> "") <> (case value_ of - Just (AtThen _mc) -> ", valued at posting date" + Just (AtThen _mc) -> tr translations_ ", valued at posting date" Just (AtEnd _mc) | changingValuation -> "" - Just (AtEnd _mc) -> ", valued at period ends" - Just (AtNow _mc) -> ", current value" - Just (AtDate d _mc) -> ", valued at " <> showDate d + Just (AtEnd _mc) -> tr translations_ ", valued at period ends" + Just (AtNow _mc) -> tr translations_ ", current value" + Just (AtDate d _mc) -> trf translations_ ", valued at {date}" [("date", showDate d)] Nothing -> "") changingValuation = case (balancecalc_, balanceaccum_) of @@ -944,9 +947,9 @@ multiBalanceReportAsPartTable :: ReportOpts -> [CommoditySymbol] -> MultiBalanceReport -> Table T.Text T.Text WideBuilder multiBalanceReportAsPartTable - opts@ReportOpts{summary_only_, average_, balanceaccum_} + opts@ReportOpts{summary_only_, average_} allCommodities - (PeriodicReport spans items tr) = + (PeriodicReport spans items totrow) = maybetranspose $ addtotalrow $ Table @@ -955,7 +958,7 @@ multiBalanceReportAsPartTable (concat rows) where colheadings = - ["Commodity" | layout_ opts == LayoutBare] + [trc (translations_ opts) "column heading" "Commodity" | layout_ opts == LayoutBare] ++ case layout_ opts of LayoutBareWide -> @@ -964,9 +967,9 @@ multiBalanceReportAsPartTable _ -> spanNames spanNames = (guard (not summary_only_) >> - map (reportPeriodName (period_titles_ opts) balanceaccum_ spans) spans) - ++ [" Total" | multiBalanceHasTotalsColumn opts] - ++ ["Average" | average_] + map (reportPeriodName opts spans) spans) + ++ [" " <> trc (translations_ opts) "column heading" "Total" | multiBalanceHasTotalsColumn opts] + ++ [trc (translations_ opts) "column heading" "Average" | average_] (accts, rows) = unzip $ fmap fullRowAsTexts items' where isLeaf rs row = not $ any (\r -> T.isPrefixOf (displayFull (prrName row) <> ":") (displayFull (prrName r))) rs @@ -980,7 +983,7 @@ multiBalanceReportAsPartTable addtotalrow | no_total_ opts = id | otherwise = - let totalrows = multiBalanceRowAsText opts allCommodities tr + let totalrows = multiBalanceRowAsText opts allCommodities totrow rowhdrs = Group NoLine $ map Header $ totalRowHeadingText : replicate (length totalrows - 1) "" colhdrs = Header [] -- unused, concatTables will discard in (flip (concatTables SingleLine) $ Table rowhdrs colhdrs totalrows) @@ -1100,17 +1103,19 @@ budgetReportAsText ropts budgetr = TB.toLazyText $ budgetReportTitle :: ReportOpts -> BudgetReport -> Text budgetReportTitle ropts@ReportOpts{..} budgetr = effectiveTitle ropts defaultTitle where - defaultTitle = "Budget performance in " <> showDateSpan (periodicReportSpan budgetr) - <> (case conversionop_ of - Just ToCost -> ", converted to cost" + -- TRANSLATORS: the budget report title, eg "Budget performance in 2024, valued at period ends:". + defaultTitle = trf translations_ "Budget performance in {dates}{valuation}:" + [ ("dates", showDateSpan (periodicReportSpan budgetr)), ("valuation", valuationdesc) ] + valuationdesc = + (case conversionop_ of + Just ToCost -> tr translations_ ", converted to cost" _ -> "") <> (case value_ of - Just (AtThen _mc) -> ", valued at posting date" - Just (AtEnd _mc) -> ", valued at period ends" - Just (AtNow _mc) -> ", current value" - Just (AtDate d _mc) -> ", valued at " <> showDate d + Just (AtThen _mc) -> tr translations_ ", valued at posting date" + Just (AtEnd _mc) -> tr translations_ ", valued at period ends" + Just (AtNow _mc) -> tr translations_ ", current value" + Just (AtDate d _mc) -> trf translations_ ", valued at {date}" [("date", showDate d)] Nothing -> "") - <> ":" -- | Build a 'Table' from a multi-column balance report. budgetReportAsTable :: ReportOpts -> BudgetReport -> Table Text Text WideBuilder @@ -1143,10 +1148,10 @@ budgetReportAsTable ropts@ReportOpts{..} (PeriodicReport spans items totrow) = in (flip (concatTables SingleLine) $ Table rowhdrs colhdrs totalrows) -- XXX ? - colheadings = ["Commodity" | layout_ == LayoutBare] - ++ (if not summary_only_ then map (reportPeriodName period_titles_ balanceaccum_ spans) spans else []) - ++ [" Total" | row_total_] - ++ ["Average" | average_] + colheadings = [trc translations_ "column heading" "Commodity" | layout_ == LayoutBare] + ++ (if not summary_only_ then map (reportPeriodName ropts spans) spans else []) + ++ [" " <> trc translations_ "column heading" "Total" | row_total_] + ++ [trc translations_ "column heading" "Average" | average_] (accts, rows, totalrows) = (accts' diff --git a/hledger/Hledger/Cli/Commands/Balancesheet.hs b/hledger/Hledger/Cli/Commands/Balancesheet.hs index c2bca00685f..2b7d31ed8f0 100644 --- a/hledger/Hledger/Cli/Commands/Balancesheet.hs +++ b/hledger/Hledger/Cli/Commands/Balancesheet.hs @@ -14,23 +14,24 @@ module Hledger.Cli.Commands.Balancesheet ( import System.Console.CmdArgs.Explicit +import Hledger.Utils.I18n (i18n) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.CompoundBalanceCommand balancesheetSpec = CompoundBalanceCommandSpec { cbcdoc = $(embedFileRelative "Hledger/Cli/Commands/Balancesheet.txt"), - cbctitle = "Balance Sheet", + cbctitle = i18n "Balance Sheet", cbcqueries = [ CBCSubreportSpec{ - cbcsubreporttitle="Assets" + cbcsubreporttitle=i18n "Assets" ,cbcsubreportquery=Type [Asset] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyPositive}) ,cbcsubreporttransform=id ,cbcsubreportincreasestotal=True } ,CBCSubreportSpec{ - cbcsubreporttitle="Liabilities" + cbcsubreporttitle=i18n "Liabilities" ,cbcsubreportquery=Type [Liability] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyNegative}) ,cbcsubreporttransform=fmap maNegate diff --git a/hledger/Hledger/Cli/Commands/Balancesheetequity.hs b/hledger/Hledger/Cli/Commands/Balancesheetequity.hs index 00a66430712..556e70a47d1 100644 --- a/hledger/Hledger/Cli/Commands/Balancesheetequity.hs +++ b/hledger/Hledger/Cli/Commands/Balancesheetequity.hs @@ -15,30 +15,31 @@ module Hledger.Cli.Commands.Balancesheetequity ( import System.Console.CmdArgs.Explicit +import Hledger.Utils.I18n (i18n) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.CompoundBalanceCommand balancesheetequitySpec = CompoundBalanceCommandSpec { cbcdoc = $(embedFileRelative "Hledger/Cli/Commands/Balancesheetequity.txt"), - cbctitle = "Balance Sheet With Equity", + cbctitle = i18n "Balance Sheet With Equity", cbcqueries = [ CBCSubreportSpec{ - cbcsubreporttitle="Assets" + cbcsubreporttitle=i18n "Assets" ,cbcsubreportquery=Type [Asset] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyPositive}) ,cbcsubreporttransform=id ,cbcsubreportincreasestotal=True } ,CBCSubreportSpec{ - cbcsubreporttitle="Liabilities" + cbcsubreporttitle=i18n "Liabilities" ,cbcsubreportquery=Type [Liability] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyNegative}) ,cbcsubreporttransform=fmap maNegate ,cbcsubreportincreasestotal=False } ,CBCSubreportSpec{ - cbcsubreporttitle="Equity" + cbcsubreporttitle=i18n "Equity" ,cbcsubreportquery=Type [Equity] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyNegative}) ,cbcsubreporttransform=fmap maNegate diff --git a/hledger/Hledger/Cli/Commands/Cashflow.hs b/hledger/Hledger/Cli/Commands/Cashflow.hs index 7f9630f1ec0..ba9eb2c71f6 100644 --- a/hledger/Hledger/Cli/Commands/Cashflow.hs +++ b/hledger/Hledger/Cli/Commands/Cashflow.hs @@ -18,16 +18,17 @@ module Hledger.Cli.Commands.Cashflow ( import System.Console.CmdArgs.Explicit +import Hledger.Utils.I18n (i18n) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.CompoundBalanceCommand cashflowSpec = CompoundBalanceCommandSpec { cbcdoc = $(embedFileRelative "Hledger/Cli/Commands/Cashflow.txt"), - cbctitle = "Cashflow Statement", + cbctitle = i18n "Cashflow Statement", cbcqueries = [ CBCSubreportSpec{ - cbcsubreporttitle="Cash flows" + cbcsubreporttitle=i18n "Cash flows" ,cbcsubreportquery=Type [Cash] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_= Just NormallyPositive}) ,cbcsubreporttransform=id diff --git a/hledger/Hledger/Cli/Commands/Incomestatement.hs b/hledger/Hledger/Cli/Commands/Incomestatement.hs index 59cb4e032c6..6ffab04dba9 100644 --- a/hledger/Hledger/Cli/Commands/Incomestatement.hs +++ b/hledger/Hledger/Cli/Commands/Incomestatement.hs @@ -14,23 +14,24 @@ module Hledger.Cli.Commands.Incomestatement ( import System.Console.CmdArgs.Explicit +import Hledger.Utils.I18n (i18n) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.CompoundBalanceCommand incomestatementSpec = CompoundBalanceCommandSpec { cbcdoc = $(embedFileRelative "Hledger/Cli/Commands/Incomestatement.txt"), - cbctitle = "Income Statement", + cbctitle = i18n "Income Statement", cbcqueries = [ CBCSubreportSpec{ - cbcsubreporttitle="Revenues" + cbcsubreporttitle=i18n "Revenues" ,cbcsubreportquery=Type [Revenue] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyNegative}) ,cbcsubreporttransform=fmap maNegate ,cbcsubreportincreasestotal=True } ,CBCSubreportSpec{ - cbcsubreporttitle="Expenses" + cbcsubreporttitle=i18n "Expenses" ,cbcsubreportquery=Type [Expense] ,cbcsubreportoptions=(\ropts -> ropts{normalbalance_=Just NormallyPositive}) ,cbcsubreporttransform=id diff --git a/hledger/Hledger/Cli/Commands/Register.hs b/hledger/Hledger/Cli/Commands/Register.hs index a3e9072394c..538226786f6 100644 --- a/hledger/Hledger/Cli/Commands/Register.hs +++ b/hledger/Hledger/Cli/Commands/Register.hs @@ -29,6 +29,7 @@ import Data.Text.Lazy.Builder qualified as TB import Safe (readMay) import System.Console.CmdArgs.Explicit (flagNone, flagReq) +import Hledger.Utils.I18n (tr) import Hledger hiding (per) import Hledger.Write.Csv (CSV, printCSV, printTSV) import Hledger.Write.Ods (printFods) @@ -111,7 +112,7 @@ register opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j map (map (fmap Lucid.toHtml)) . postingsReportAsSpreadsheet opts oneLineNoCostFmt baseUrl query | fmt=="fods" = - printFods IO.localeEncoding . Map.singleton "Register" . + printFods IO.localeEncoding . Map.singleton (tr (translations_ (_rsReportOpts rspec)) "Register") . (,) (1,0) . postingsReportAsSpreadsheet opts oneLineNoCostFmt baseUrl query | otherwise = error' $ unsupportedOutputFormatError fmt -- PARTIAL: diff --git a/hledger/Hledger/Cli/CompoundBalanceCommand.hs b/hledger/Hledger/Cli/CompoundBalanceCommand.hs index 52ac3a936d7..97a2d271a10 100644 --- a/hledger/Hledger/Cli/CompoundBalanceCommand.hs +++ b/hledger/Hledger/Cli/CompoundBalanceCommand.hs @@ -34,6 +34,7 @@ import System.Console.CmdArgs.Explicit as C (Mode, flagNone, flagReq) import System.IO qualified as IO import Text.Tabular.AsciiWide as Tabular hiding (render) +import Hledger.Utils.I18n (Translations, noTranslations, tr, trf) import Hledger import Hledger.Cli.Commands.Balance import Hledger.Cli.CliOptions @@ -59,7 +60,7 @@ import Hledger.Write.Spreadsheet qualified as Spr -- data CompoundBalanceCommandSpec = CompoundBalanceCommandSpec { cbcdoc :: CommandHelpStr, -- ^ the command's name(s) and documentation - cbctitle :: String, -- ^ overall report title + cbctitle :: T.Text, -- ^ overall report title cbcqueries :: [CBCSubreportSpec DisplayName], -- ^ subreport details cbcaccum :: BalanceAccumulation -- ^ how to accumulate balances (per-period, cumulative, historical) -- (overrides command line flags) @@ -145,13 +146,15 @@ compoundBalanceCommand CompoundBalanceCommandSpec{..} opts@CliOpts{reportspec_=r -- Set balance type in the report options. ropts' = ropts{balanceaccum_=balanceaccumulation} - title = - maybe "" (<>" ") mintervalstr - <> T.pack cbctitle - <> " " - <> titledatestr - <> maybe "" (" "<>) mtitleclarification - <> valuationdesc + -- TRANSLATORS: the report title, eg "Monthly Balance Sheet 2024 (Historical Ending Balances), valued at period ends". + -- {interval} and {clarification} bring their own surrounding space when present. + title = trf translations_ "{interval}{report} {dates}{clarification}{valuation}" + [ ("interval", maybe "" (<> " ") mintervalstr) + , ("report", tr translations_ cbctitle) + , ("dates", titledatestr) + , ("clarification", maybe "" (" " <>) mtitleclarification) + , ("valuation", valuationdesc) + ] where -- XXX #1078 the title of ending balance reports @@ -166,31 +169,31 @@ compoundBalanceCommand CompoundBalanceCommandSpec{..} opts@CliOpts{reportspec_=r enddates = map (addDays (-1)) . mapMaybe spanEnd $ cbrDates cbr -- these spans will always have a definite end date requestedspan = fst $ reportSpan j rspec - mintervalstr = showInterval interval_ + mintervalstr = showInterval translations_ interval_ -- when user overrides, add an indication to the report title -- Do we need to deal with overridden BalanceCalculation? mtitleclarification = case (balancecalc_, balanceaccumulation, mbalanceAccumulationOverride) of - (CalcValueChange, PerPeriod, _ ) -> Just "(Period-End Value Changes)" - (CalcValueChange, Cumulative, _ ) -> Just "(Cumulative Period-End Value Changes)" - (CalcGain, PerPeriod, _ ) -> Just "(Incremental Gain)" - (CalcGain, Cumulative, _ ) -> Just "(Cumulative Gain)" - (CalcGain, Historical, _ ) -> Just "(Historical Gain)" - (_, _, Just PerPeriod ) -> Just "(Balance Changes)" - (_, _, Just Cumulative) -> Just "(Cumulative Ending Balances)" - (_, _, Just Historical) -> Just "(Historical Ending Balances)" + (CalcValueChange, PerPeriod, _ ) -> Just $ tr translations_ "(Period-End Value Changes)" + (CalcValueChange, Cumulative, _ ) -> Just $ tr translations_ "(Cumulative Period-End Value Changes)" + (CalcGain, PerPeriod, _ ) -> Just $ tr translations_ "(Incremental Gain)" + (CalcGain, Cumulative, _ ) -> Just $ tr translations_ "(Cumulative Gain)" + (CalcGain, Historical, _ ) -> Just $ tr translations_ "(Historical Gain)" + (_, _, Just PerPeriod ) -> Just $ tr translations_ "(Balance Changes)" + (_, _, Just Cumulative) -> Just $ tr translations_ "(Cumulative Ending Balances)" + (_, _, Just Historical) -> Just $ tr translations_ "(Historical Ending Balances)" _ -> Nothing valuationdesc = (case conversionop_ of - Just ToCost -> ", converted to cost" + Just ToCost -> tr translations_ ", converted to cost" _ -> "") <> (case value_ of - Just (AtThen _mc) -> ", valued at posting date" + Just (AtThen _mc) -> tr translations_ ", valued at posting date" Just (AtEnd _mc) | changingValuation -> "" - Just (AtEnd _mc) -> ", valued at period ends" - Just (AtNow _mc) -> ", current value" - Just (AtDate today _mc) -> ", valued at " <> showDate today + Just (AtEnd _mc) -> tr translations_ ", valued at period ends" + Just (AtNow _mc) -> tr translations_ ", current value" + Just (AtDate today _mc) -> trf translations_ ", valued at {date}" [("date", showDate today)] Nothing -> "") changingValuation = case (balancecalc_, balanceaccum_) of @@ -203,7 +206,8 @@ compoundBalanceCommand CompoundBalanceCommandSpec{..} opts@CliOpts{reportspec_=r -- --subreport-titles=A|B|... overrides per-subreport titles. cbr' = compoundBalanceReport rspec{_rsReportOpts=ropts'} j cbcqueries cbr = applySubreportTitles ropts' $ - cbr'{cbrTitle = effectiveTitle ropts' title} + cbr'{cbrTitle = effectiveTitle ropts' title + ,cbrSubreports = [ (tr translations_ t, r, b) | (t, r, b) <- cbrSubreports cbr' ]} -- render appropriately render = case outputFormatFromOpts opts of @@ -233,23 +237,26 @@ applySubreportTitles ropts cbr@CompoundPeriodicReport{cbrSubreports=subs} = replace i (old,r,b) = (fromMaybe old (atMay custom i), r, b) in cbr{cbrSubreports = zipWith replace [0..] subs} --- | Show a simplified description of an Interval. -showInterval :: Interval -> Maybe T.Text -showInterval = \case +-- | Show a simplified description of an Interval, translated. +-- TRANSLATORS: these precede a report title, as in "Monthly Balance Sheet". If your +-- language inflects adjectives, use a form that fits every report title, or a +-- stand-alone form such as "per month". +showInterval :: Translations -> Interval -> Maybe T.Text +showInterval t = \case NoInterval -> Nothing - Days 1 -> Just "Daily" - Weeks 1 -> Just "Weekly" - Weeks 2 -> Just "Biweekly" - Months 1 -> Just "Monthly" - Months 2 -> Just "Bimonthly" - Months 3 -> Just "Quarterly" - Months 6 -> Just "Half-yearly" - Months 12 -> Just "Yearly" - Quarters 1 -> Just "Quarterly" - Quarters 2 -> Just "Half-yearly" - Years 1 -> Just "Yearly" - Years 2 -> Just "Biennial" - _ -> Just "Periodic" + Days 1 -> Just $ tr t "Daily" + Weeks 1 -> Just $ tr t "Weekly" + Weeks 2 -> Just $ tr t "Biweekly" + Months 1 -> Just $ tr t "Monthly" + Months 2 -> Just $ tr t "Bimonthly" + Months 3 -> Just $ tr t "Quarterly" + Months 6 -> Just $ tr t "Half-yearly" + Months 12 -> Just $ tr t "Yearly" + Quarters 1 -> Just $ tr t "Quarterly" + Quarters 2 -> Just $ tr t "Half-yearly" + Years 1 -> Just $ tr t "Yearly" + Years 2 -> Just $ tr t "Biennial" + _ -> Just $ tr t "Periodic" -- | Summarise one or more (inclusive) end dates, in a way that's -- visually different from showDateSpan, suggesting discrete end dates @@ -313,7 +320,7 @@ compoundBalanceReportAsText ropts (CompoundPeriodicReport title _colspans subrep -- ] coltotalslines = multiBalanceRowAsText ropts allCommodities totalsrow totalstable = Table - (Group NoLine $ map Header $ "Net:" : replicate (length coltotalslines - 1) "") -- row headers + (Group NoLine $ map Header $ tr (translations_ ropts) "Net:" : replicate (length coltotalslines - 1) "") -- row headers (Header []) -- column headers, concatTables will discard these coltotalslines -- cell values @@ -392,8 +399,8 @@ compoundBalanceReportAsSpreadsheet fmt accountLabel maybeBlank ropts cbr = dataHeaders = (guard (layout_ ropts /= LayoutTidy) >>) $ map - (reportPeriodName - (period_titles_ ropts) (balanceaccum_ ropts) colspans) + -- column headings stay English in these formats, month names included + (reportPeriodName ropts{translations_ = noTranslations} colspans) (if not (summary_only_ ropts) then colspans else []) ++ (guard (multiBalanceHasTotalsColumn ropts) >> ["Total"]) ++ (guard (average_ ropts) >> ["Average"]) diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index e8ba689be9e..404b8a347d8 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -429,6 +429,13 @@ If this environment variable exists (with any value, including empty), hledger will not use ANSI color codes in terminal output, unless overridden by an explicit `--color=y` or `--colour=y` option. +**LANGUAGE**, **LC_ALL**, **LC_MESSAGES**, **LANG** +When the `--lang=auto` option is used, these select the language of report titles and headings, +following the usual gettext rules: the effective locale is `LC_ALL`, else `LC_MESSAGES`, else `LANG`; +if that is `C`, `POSIX` or unset, English is used; otherwise the languages listed in `LANGUAGE` +(colon-separated) are tried first, then the effective locale. +See [Languages](#languages). + # PART 2: COMMANDS @@ -5256,11 +5263,51 @@ In both `--title` and `--subreport-titles`, you can use `\n` to generate a newli In [multi-period reports](#report-intervals) each period has a heading describing its date range or end date. When date ranges correspond to natural period boundaries, -they are described compactly by default (month names are in english, currently). -Eg: `2026`, `Q1`, `Jan`, `W02`. +they are described compactly by default. +Eg: `2026`, `Q1`, `Jan`, `W02`. (Month names follow the [`--lang` option](#languages).) You can disable these compact descriptions by using `--period-titles=dates`; then periods will always be described as `STARTDATE..ENDDATE`. +# Languages + +hledger's output is in English by default. +Report titles, section headings, column headings, month names and similar structural text +can be shown in another language with the `--lang` option (which can also be set in a [config file](#config-files)), +when a translation catalog for that language is available. +(This is hledger's localization support; it covers the language of hledger's own text, +not number or date formats, which come from the journal, as described below.) + +- `--lang=LANG` selects a language by its tag, like `de` or `pt-BR`. An unavailable language is an error. +- `--lang=auto` selects the language from the environment + (the LANGUAGE, LC_ALL, LC_MESSAGES and LANG variables, see [Environment](#environment)), falling back to English. +- `--lang=en`, or no `--lang` option, selects English. + +To always get German output, for example, put it in your config file's general options: + +``` +# ~/.config/hledger/hledger.conf +--lang de +``` + +Currently a German catalog is built in. +Catalogs are [gettext PO files](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html), +which the usual translation tools can edit. +hledger also looks for catalogs in the `locale` subdirectory of its config directory +(`~/.config/hledger/locale/LANG.po` on unix, `%APPDATA%\hledger\locale\LANG.po` on windows), +and merges a catalog found there over the built-in catalog for the same language, if any. +So a translator can work on a new language with a released hledger, +and a user can adjust terminology to taste. +Contributed catalogs are welcome, and no programming is needed: +see [Translating hledger](https://hledger.org/TRANSLATING.html) in the developer docs. + +Only hledger's own structural text is translated. +Your data (account names, descriptions, amounts) is never changed; +dates keep their ISO format, and numbers keep the display styles declared for each commodity. +Error messages, journal-format output, and the column headings of CSV, TSV and JSON output stay in English, +since they are commonly read by other programs. +Report titles, however, are translated wherever they appear, as they would be with `--title`; +and an explicit `--title` or `--subreport-titles` is always used as given, untranslated. + # Amount formatting diff --git a/hledger/test/i18n.test b/hledger/test/i18n.test new file mode 100644 index 00000000000..93ef02e7438 --- /dev/null +++ b/hledger/test/i18n.test @@ -0,0 +1,102 @@ +# * --lang: translations for report structure text + +# ** 1. --lang translates a compound report's title, subreport titles and totals row. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal bs --lang de +Bilanz 2008-12-31 + + || 2008-12-31 +====================++============ + Vermögen || +--------------------++------------ + assets:bank:saving || $1 + assets:cash || $-2 +--------------------++------------ + || $-1 +====================++============ + Verbindlichkeiten || +--------------------++------------ + liabilities:debts || $-1 +--------------------++------------ + || $-1 +====================++============ + Überschuss: || 0 + +# ** 2. Month names in column headings, and the balance report title. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal bal -M --lang de +Saldoänderungen in 2008: + + || Jan Feb Mär Apr Mai Jun Jul Aug Sep Okt Nov Dez +======================++============================================================ + assets:bank:checking || $1 0 0 0 0 0 0 0 0 0 0 $-1 + assets:bank:saving || 0 0 0 0 0 $1 0 0 0 0 0 0 + assets:cash || 0 0 0 0 0 $-2 0 0 0 0 0 0 + expenses:food || 0 0 0 0 0 $1 0 0 0 0 0 0 + expenses:supplies || 0 0 0 0 0 $1 0 0 0 0 0 0 + income:gifts || 0 0 0 0 0 $-1 0 0 0 0 0 0 + income:salary || $-1 0 0 0 0 0 0 0 0 0 0 0 + liabilities:debts || 0 0 0 0 0 0 0 0 0 0 0 $1 +----------------------++------------------------------------------------------------ + || 0 0 0 0 0 0 0 0 0 0 0 0 + +# ** 3. The interval word, the valuation description and the totals column heading. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal is -Q -T --lang de --value=then +Vierteljährliche Einnahmenüberschussrechnung 2008, bewertet zum Buchungsdatum + + || 2008Q1 2008Q2 2008Q3 2008Q4 Gesamt +===================++========================================== + Einnahmen || +-------------------++------------------------------------------ + income:gifts || 0 $1 0 0 $1 + income:salary || $1 0 0 0 $1 +-------------------++------------------------------------------ + || $1 $1 0 0 $2 +===================++========================================== + Ausgaben || +-------------------++------------------------------------------ + expenses:food || 0 $1 0 0 $1 + expenses:supplies || 0 $1 0 0 $1 +-------------------++------------------------------------------ + || 0 $2 0 0 $2 +===================++========================================== + Überschuss: || $1 $-1 0 0 0 + +# ** 4. Titles are report data, so they are translated wherever --title applies, +# including CSV; column headings and row labels in CSV stay English. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal bs --lang de -O csv +"Bilanz 2008-12-31","" +"Account","2008-12-31" +"Vermögen","" +"assets:bank:saving","$1" +"assets:cash","$-2" +"Total:","$-1" +"Verbindlichkeiten","" +"liabilities:debts","$-1" +"Total:","$-1" +"Net:","0" + +# ** 5. An explicit --title is used as is, untranslated. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal bs --lang de --title "Meine Bilanz" | head -1 +Meine Bilanz + +# ** 6. A language with no catalog is a usage error. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal bs --lang xx +>2 /no translations are available for "xx"/ +>= 1 + +# ** 7. --lang auto follows the environment, like gettext. (LC_CTYPE pins the output +# encoding to a locale every CI machine has; the de_DE locale need not be installed.) +$ XDG_CONFIG_HOME=/nonexistent LANGUAGE= LC_ALL= LC_MESSAGES= LC_CTYPE=en_US.UTF-8 LANG=de_DE.UTF-8 hledger -f sample.journal bs --lang auto | head -1 +Bilanz 2008-12-31 + +# ** 8. LC_ALL=C forces English, even with LANGUAGE set. +$ XDG_CONFIG_HOME=/nonexistent LANGUAGE=de LC_ALL=C hledger -f sample.journal bs --lang auto | head -1 +Balance Sheet 2008-12-31 + +# ** 9. Without --lang, the environment is ignored and output is English. +$ XDG_CONFIG_HOME=/nonexistent LANGUAGE=de LC_ALL=de_DE.UTF-8 hledger -f sample.journal bs | head -1 +Balance Sheet 2008-12-31 + +# ** 10. CSV column headings stay English, month names included; only the title is translated. +$ XDG_CONFIG_HOME=/nonexistent hledger -f sample.journal is -M --lang de -O csv | head -2 +"Monatliche Einnahmenüberschussrechnung 2008","","","","","","","","","","","","" +"Account","Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec" From 0aee437775244d1b9cca3468cdda450dc453d10e Mon Sep 17 00:00:00 2001 From: Arthur Cinader <700572+acinader@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:14:38 -0700 Subject: [PATCH 04/12] feat: ui: --lang translates the menu and accounts screen names The help dialog and the register screen's composed labels are left for a follow-up; they need a layout rework to fit longer text. AI usage: drafted with Claude Code, reviewed and edited by the author. Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz --- hledger-ui/Hledger/UI/AccountsScreen.hs | 13 ++++++++----- hledger-ui/Hledger/UI/MenuScreen.hs | 9 +++++---- hledger-ui/Hledger/UI/UIScreens.hs | 10 ++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) 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. From 3db34a64d35e31257d2530d8bf2318adb46da995 Mon Sep 17 00:00:00 2001 From: Arthur Cinader <700572+acinader@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:36:17 -0700 Subject: [PATCH 05/12] feat: web: pages in the viewer's language hledger-web now loads every available translation catalog at startup and chooses one per request: a _LANG query parameter (remembered in a SameSite cookie, only when it names an available catalog), the _LANG cookie, the browser's Accept-Language header (each preference tried with its subtags dropped, so de-CH gets de), then the server's --lang. Pages say which language they are in () and that they vary by it (Vary), for caches in front of a shared server. Templates use _{HMsg "..."} and _{HMsgc "context" "..."}, which look up the request's catalog through Yesod's RenderMessage; handlers get the same Translations from getViewData. The add form's validation messages and yesod-form's own messages follow too, and the placeholders that hledger.js writes on added rows come from the page. Translations are viewer-controlled text and are rendered as text, in content and in attributes; a test with a hostile catalog checks it. AI usage: drafted with Claude Code, reviewed and edited by the author. Claude-Session: https://claude.ai/code/session_01G2VXnprjHmXZR8tgWPV3vz --- hledger-lib/locale/de.po | 6 + hledger-lib/locale/hledger.pot | 38 +++-- hledger-web/Hledger/Web/App.hs | 69 +++++++-- hledger-web/Hledger/Web/Application.hs | 3 + hledger-web/Hledger/Web/Handler/AddR.hs | 2 +- hledger-web/Hledger/Web/Handler/EditR.hs | 16 ++- hledger-web/Hledger/Web/Handler/JournalR.hs | 10 +- hledger-web/Hledger/Web/Handler/MiscR.hs | 2 +- hledger-web/Hledger/Web/Handler/RegisterR.hs | 18 +-- hledger-web/Hledger/Web/Handler/UploadR.hs | 15 +- hledger-web/Hledger/Web/Test.hs | 134 +++++++++++++++++- hledger-web/Hledger/Web/Widget/AddForm.hs | 52 ++++--- hledger-web/Hledger/Web/Widget/Common.hs | 5 +- hledger-web/hledger-web.m4.md | 15 ++ hledger-web/static/hledger.js | 6 +- hledger-web/templates/add-form.hamlet | 13 +- hledger-web/templates/balance-report.hamlet | 8 +- .../templates/default-layout-wrapper.hamlet | 2 +- hledger-web/templates/default-layout.hamlet | 85 +++++------ hledger-web/templates/edit-form.hamlet | 10 +- hledger-web/templates/journal.hamlet | 12 +- hledger-web/templates/manage.hamlet | 10 +- hledger-web/templates/register.hamlet | 10 +- hledger-web/templates/upload-form.hamlet | 8 +- hledger-web/test/browser/i18n.spec.js | 48 +++++++ 25 files changed, 437 insertions(+), 160 deletions(-) create mode 100644 hledger-web/test/browser/i18n.spec.js diff --git a/hledger-lib/locale/de.po b/hledger-lib/locale/de.po index d46c53b5afc..708bc6dfc8a 100644 --- a/hledger-lib/locale/de.po +++ b/hledger-lib/locale/de.po @@ -394,6 +394,12 @@ 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" diff --git a/hledger-lib/locale/hledger.pot b/hledger-lib/locale/hledger.pot index 2f569191207..cc6c5b6d105 100644 --- a/hledger-lib/locale/hledger.pot +++ b/hledger-lib/locale/hledger.pot @@ -410,22 +410,22 @@ msgid "income statement changes" msgstr "" #. the menu screen's entries; translated when drawn. -#: hledger-ui/Hledger/UI/UIScreens.hs:91 +#: 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:92 +#: 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:93 +#: 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:94 +#: hledger-ui/Hledger/UI/UIScreens.hs:96 msgid "All accounts" msgstr "" @@ -699,50 +699,58 @@ msgid "match transaction codes" msgstr "" #: hledger-web/templates/default-layout.hamlet:102 -msgid "match transaction descriptions" +msgid "or" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:102 +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:103 -msgid "match payee part of descriptions" +msgid "match transaction descriptions" msgstr "" #: hledger-web/templates/default-layout.hamlet:104 -msgid "match note part of descriptions" +msgid "match payee part of descriptions" msgstr "" #: hledger-web/templates/default-layout.hamlet:105 -msgid "match unsigned magnitudes, or with signed N, signed amounts. For single-commodity amounts only." +msgid "match note part of descriptions" msgstr "" #: hledger-web/templates/default-layout.hamlet:106 -msgid "match currencies/commodities. Must match the whole symbol/name. To match dollar sign, write" +msgid "match unsigned magnitudes, or with signed N, signed amounts. For single-commodity amounts only." msgstr "" #: hledger-web/templates/default-layout.hamlet:107 -msgid "match tags, or tag and value" +msgid "match currencies/commodities. Must match the whole symbol/name. To match dollar sign, write" msgstr "" #: hledger-web/templates/default-layout.hamlet:108 -msgid "match postings' realness/virtualness" +msgid "match tags, or tag and value" msgstr "" #: hledger-web/templates/default-layout.hamlet:109 -msgid "prepend not: to negate a search term" +msgid "match postings' realness/virtualness" msgstr "" #: hledger-web/templates/default-layout.hamlet:110 -msgid "match with a boolean query (and, or, not, (..))" +msgid "prepend not: to negate a search term" msgstr "" #: hledger-web/templates/default-layout.hamlet:111 -msgid "match transactions where any posting matches" +msgid "match with a boolean query (and, or, not, (..))" msgstr "" #: hledger-web/templates/default-layout.hamlet:112 -msgid "match transactions where all postings match" +msgid "match transactions where any posting matches" msgstr "" #: hledger-web/templates/default-layout.hamlet:113 +msgid "match transactions where all postings match" +msgstr "" + +#: hledger-web/templates/default-layout.hamlet:114 msgid "clip account names at this depth" msgstr "" 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/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..309829fc823 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 $ 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..748a8efe43c 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,9 +53,9 @@ 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" 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..e0d0b13311a 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,101 @@ hledgerWebTest = do bodyContains "a<img src=x onerror=alert(2)>" -- account, escaped bodyNotContains " 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 "
-