Repository: lingui/js-lingui
Pull request #677 (2020-04-23 13:42:15)

feat: Implement gettext plurals for PO files
Hi & thanks for your work on this project!
While looking for a new localization solutions for our React frontend, we found this project and liked the approach. Our goal is to use po files to leverage comments for providing context for translators (and our future selves…).
We started with the @next branch since that's the natual way to go forward. Despite some rough edges regarding the documentation and typescript typings, we were very happy with the functionality of this solution.

## Problem

Our only major pain point (besides the removal of the ``/* i18n: Comment */t("id")`Translated string`;`` syntax from version 2.x ;)) is that the po file format does not support the "native" plurals with `msgstr[0]`, `msgstr[1]` etc.
Unfortunately, we were not able to find an online translation service that was able to handle ICU plural strings when embedded in po files. The situation may be different with JSON documents, but then we'd loose comment functionality.

We noticed that other user have already encountered the same problem, unfortunately without an obvious solution: #595 & #82 (where it is mentioned that there are services that understand ICU, but as I said, we weren't able to find any that support ICU in PO)

## Approach

After some experimenting, we decided to give a try at implementing rudimentary ICU -> PO -> ICU transformation for the po format. This PR shows the modifications that were required.
In short, the new code transforms the following ICU into the corresponding PO plurals (and back):

```json
{
    "message_id": {
        "message": "{count, plural, one {Singular Case} two {Case for two} other {Case for {count} other}}"
    }
}
```

```po
msgctxt "pluralize_on=count"
msgid "message_id"
msgid_plural "message_id"
msgstr[0] "Singular Case"
msgstr[1] "Case for two"
msgstr[2] "Case for {count} other"
```

## To be discussed

#### 1. What to use as `msgid_plural`?

Currently, the code uses `msgid_plural` to tell the world that a message is pluralized.
When a custom ID is used, it uses the same ID as `msgid_plural`.

As of right now, for automatic IDs (where the message is also its ID), the last plural case taken from the ICU message format is used as `msgid_plural`, but I don't know if that's the right decision as it reads somewhat strange when the regular `msgid` is the full ICU message and the `msgid_plural` is only an excerpt from that.

#### 2. Is `msgctxt` the right place to store pluralization key?

Since the ICU format is more powerful than PO plurals, we need to somehow remember the placeholder used to pluralize the message to transform the message back to the ICU format.
It might be possible to parse it from the translations, but not in all cases. (Might not be used in all/any translation, multiple placeholders might be used but we can't know which is the one for localization...)

As a workaround, I've chosen to store the pluralization key in the PO item's `msgctxt`. What do you think about that? Should we rather use a special comment line for that purpose?

#### 3. How to opt in or out of this process?

While we think that this new way of using PO plurals is "more correct" than embedding ICU messages in PO, it might not be suitable for everyone and brake the workflow for people with functioning translation tools that understand ICU within PO (whatever tools that are).

What do you think would be good way to integrate this? As a new format (`po_with_plurals`?)
 As the default and the old po parsers is then referred to as `po_icu`?

## Summary

If you decide that we can go forward with this I'll gladly add tests for the new warning outputs that have not been automatically tested yet. The transforms themselves are covered in two added test cases as you can see in the diff.

We'd like to hear what you think about these changes and whether you could see them land in lingui.

now[bot]: 
This pull request is being automatically deployed with Vercel ([learn more](https://zeit.ink/github-learn-more)).
To see the status of your deployment, click on the icon next to each commit.
	
codecov[bot]: # [Codecov](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=h1) Report
> Merging [#677](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=desc) into [next](https://codecov.io/gh/lingui/js-lingui/commit/b3c10f2452a0efc085c1bf4289241b3e7cebd943&el=desc) will **decrease** coverage by `0.33%`.
> The diff coverage is `79.59%`.

[![Impacted file tree graph](https://codecov.io/gh/lingui/js-lingui/pull/677/graphs/tree.svg?width=650&height=150&src=pr&token=CuJ1VD5BY5)](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##             next     #677      +/-   ##
==========================================
- Coverage   83.97%   83.63%   -0.34%     
==========================================
  Files          37       37              
  Lines        1173     1216      +43     
  Branches      311      321      +10     
==========================================
+ Hits          985     1017      +32     
- Misses        113      121       +8     
- Partials       75       78       +3     
```


| [Impacted Files](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [packages/cli/src/api/formats/po.ts](https://codecov.io/gh/lingui/js-lingui/pull/677/diff?src=pr&el=tree#diff-cGFja2FnZXMvY2xpL3NyYy9hcGkvZm9ybWF0cy9wby50cw==) | `86.41% <79.59%> (-13.59%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=footer). Last update [b3c10f2...3cab207](https://codecov.io/gh/lingui/js-lingui/pull/677?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

tricoder42: This is good 👍 I understand you problem, although I don't have capacity to answer any questions right now. I'll get back to you in few days, hopefully after v3 is released.
iStefo: Thanks. Only issue I see is that (depending on how developers would opt-in or -out of this change) it could be a breaking change that should be considered before releasing v3, right?
Otherwise we're bound to enabling this in a backwards compatible way, which would also work, of course...
tricoder42: I would definitely make this feature opt-in. I always considered gettext as a message format. PO file format comes definitely hand in hand with gettext, but when you omit plural rules, you can use it as a file format with any other message format.

The problem is that gettext is just a subset of ICU message format. Plurals are simplified - there can be only one plural per message and source locale must use two plural forms (e.g. in Czech we have 3 plural forms and therefore I can't use it as a source locale). Other formatters are missing - `select`, `selectOrdinal`. So, the conversion from ICU to gettext isn't "lossless" in general case. On the other hand I completely understand that for lot users the gettext features are good enough. After all, gettext was and is still used a lot.

Now question is how far we want to get. I believe the simplest solution would be a different catalog format, e.g. `po-gettext`. That format would convert ICU to gettext on `lingui extract` and compile gettext on `lingui compile`. That way the core stays the same and the hard work happens in CLI. We just need to take care of edge cases and show an error, e.g. if you use any feature which isn't supported by gettext.

What do you think?
iStefo: Thanks for your input. I really haven't considered the difference between po and gettext, but I think you're right with what you say.

Regarding Plurals: I haven't used a language as source language with more than two plurals. As long as you use messages-as-keys, you're probably right (since there only is`msgid` and `msgid_plural`). With custom IDs one should be fine I guess...
I'm not sure what you mean regarding "there can only be one plural per message"? Do you mean the dependent variable that is used for pluralization? How would an ICU mesasge look that depends on two variables?

Select, selectOrdinal: I haven't had a case where I needed one of those (and gettext as well as our editor of choice, poeditor.com, don't support them) and I guess it would be problematic to implement anyways. Maybe one could generate additional messages with the values appended ("mykey_valueX"...), but that would need deep integration into catalog handling – much more than simply converting between message formats.

I'm fine with using the suggested `po-gettext` format and completely decouple the formatter from the current `po` formatter. I will adjust my implementation in this regard.
Raising errors for unsupported features is fine for now until we happen to find a way to work around the limitations.
Thanks again for your time!
tricoder42: > I'm not sure what you mean regarding "there can only be one plural per message"? Do you mean the dependent variable that is used for pluralization? How would an ICU mesasge look that depends on two variables?

`I have {countBooks, plural, one {# book} other {# books}} and {countBeer, plural, one {# beer} other {# beers}}`

I'm not saying this is very common usecase, but something we need to consider when adding support for gettext.

The same applies for `select` and `selectOrdinal` - simply throw an error explaining that you can't use them with gettext. We can't cover all usecases anyway.

As for message ids: With custom ids I would simply use `_plural`  suffix. With generated ids it would be great if we could "explode" icu message into singular/plural forms:

```diff
- msgid "{count, plural, one {I have # book} other {I have # books}}"
+ msgid "I have # book"
+ msgid_plural "I have # books"
``` 

Not sure if `msgctxt "pluralize_on=count"` is the best place to keep information about placeholders, but we definitely need to put it somewhere. I'm open to any suggestion. Feel free to use `msgctx` if we don't figure out anything better. `msgctx` is usually used to distinguish messages with same `msgid` but different meaning.
tricoder42: And thank you for tacking this issue 🙏  I understand that most tools work with gettext and that ICU might be overpowered for most cases.
ruscoder: Hello! Any updates on this PR?
tricoder42: Hey @iStefo, sorry for long delay. Are you still available to work on this issue? Meanwhile, the v3.0 was release so you would need to resolve conflicts. Let me know if I can help somehow
iStefo: Hi, I'm sorry, too, as I've not been responding or making progress on this.
Internally, we do use our fork of over at https://github.com/box-id/js-lingui/tree/next to export gettext plurals (and upload/download them to/from poeditor.com for localization) in production and it's working very well within its limits (which need to be clarified, documented and, ideally, safeguarded in code).

I'll try hard to allocate some time this week to get this thing rolling again as I'm sure my manager would like to see us using the upstream version of lingui again at some time...
vercel[bot]: @iStefo is attempting to deploy a commit to the **LinguiJS** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=LinguiJS&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22893d99d56b68e316d7a9514d6533b2e89dca0f9d%22%7D%2C%22id%22%3A%22QmYPB4AMiXyRQ71KzSLSTtTfZzpqZyYsV7fRzyxj3WQNZF%22%2C%22org%22%3A%22lingui%22%2C%22prId%22%3A677%2C%22repo%22%3A%22js-lingui%22%7D).


iStefo: Alright. I've pushed the new commits to the origin branch of this PR (still named `next`, but should be equal to `main` + my changes).
I have added additional warnings (regarding multiple/plurals and a warning about select and selectOrdinal that can be disabled). In the deployed version you can see the added documentation to the format description and a short section to the available format options, adding `po-gettext`.

I have successfully used this new version in our react app without noticing any issues. Regarding using a development language with more than 2 plural forms: I have not done any testing in this direction (but mentioned it in the docs). Theoretically, everything should be fine, but I can't say that for sure.

Please have a look @tricoder42 and get back to me if we should change anything.
semoal: Looks good to me :) Thanks for your work mate @iStefo 
iStefo: Thanks for your feedback.

While reviewing the code (again), I noticed that `msgid`s where not set in the way described in the docs, and that there should be a better way to solve the issues I originally had with using the extracted cases (plural/singular).
By storing the original ICU message on `msgctxt` I think I have achieved better and "more native" gettext plurals than before.

I have updated the docs and merged the current main branch again to resolve the conflicts that have emerged over the weekend.
iStefo: (I have fixed the lint warning regarding the type of PR this is)
iStefo: Hi @semoal @tricoder42, please see if you can free up some time to take another look at this so we can get it merged :)
semoal: Except this minor changes, for me looks pretty good, probably Tomas will review it today or tomorrow, so we can release this week.
iStefo: I've updated the implementation to:

- Use extracted comments to store the required ICU context. The comment is prefixed with `js-lingui:` to allow reidentification when parsing
- Use the `extractedComments` property when mapping from lingui messages to PO items, even though TypeScript is currently not happy with that...
- Only include `references` if `options.origins` is set (although it seems like leaving this option unset, for existing catalogs, it will not remove references if already present, is that correct?)
vonovak: hey there, just a word of warning, I was dealing with plurals on my previous project and I believe there might a misalignment in this PR:

you're using the CLDR data to get the plurals for a given language and CLDR data supports plurals for decimals (eg 1.5) as well as integers (2).

BUT

po gettext only works for integers - compare the number of plurals for gettext: cs - Czech has 3: find it in http://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html or https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html#Plural-forms but in CLDR it has 4 - see https://github.com/unicode-org/cldr/blob/master/common/supplemental/plurals.xml#L154

to give an example, in Czech we have

0.5 dne / 0.5 days
1 den / 1 day
2 dny / 2 days
5 dní / 5 days

and CLDR supports all forms but gettext does not support 0.5

What this means is that the plurals, essentially, won't work. I believe you'll need to find some other source than CLDR to find the number of plurals for a given language, I remember finding some repo on github that did that. I might be wrong here but please double-check this. Nice work btw!
iStefo: If I understand @vonovak correctly, this could be an issue even if no decimal numbers are used, because of the different number of plural cases reported by CLDR vs. gettext.

For example, the gettext-enabled localization tool we currently use offers the plural cases "one, few, other" for the Czech language (which seems correct when I try out cldr-plurals for integer numbers), so the PO files would contain the cases in this order with indexes from 0 to 2.
However, plurals-cldr (https://github.com/nodeca/plurals-cldr/blob/master/dist/plurals-cldr.js#L235) outputs the plural cases as "one, few, many, other" which means that gettext messages 0-2 get converted back to the cases "one, few, many" instead of "one, few, other" as probably expected by ICU, right?

I have reproduced the issue in a test case and will commit the solution later today.
vonovak: @iStefo 

> If I understand @vonovak correctly, this could be an issue even if no decimal numbers are used, because of the different number of plural cases reported by CLDR vs. gettext.

> which means that gettext messages 0-2 get converted back to the cases "one, few, many" instead of "one, few, other" as probably expected by ICU, right?

exactly!
iStefo: Thanks @vonovak for broadening my knowledge of foreign languages 😀

I have implemented a solution that was heavily influenced by https://github.com/LLK/po2icu/blob/9eb97f81f72b2fee02b77f1424702e019647e9b9/lib/po2icu.js#L148 which achieves a very similar goal to my code.

@tricoder42 I still updated the docs to include gettext's limitation regarding fractional numbers. I've also updated the docs to reflect the new way of storing the context data in a comment instead of `msgctx`. From my point of view, this is ready to be merged.

Patch:
diff --git a/docs/ref/catalog-formats.rst b/docs/ref/catalog-formats.rst
index 824ac802..1cce1084 100644
--- a/docs/ref/catalog-formats.rst
+++ b/docs/ref/catalog-formats.rst
@@ -36,6 +36,63 @@ The advantages of this format are:
 
 .. _gettext: https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html
 
+.. _po-gettext:
+
+PO File with gettext Plurals
+============================
+
+When using localization backends that don't understand the ICU plural syntax exported by the default `po` formatter,
+**po-gettext** can be used to read and write to PO files using gettext-native plurals.
+
+This is how the regular PO format exports plurals:
+
+.. code-block:: po
+
+   msgid "{count, plural, one {Message} other {Messages}}"
+   msgstr "{count, plural, one {Message} other {Messages}}"
+
+With `po-gettext`, plural messages are exported in the following way, depending on wheter an explicit ID is set:
+
+.. code-block:: po
+
+   # Message with custom ID "my_message" that is pluralized on property "someCount".
+   #
+   # Notice that 'msgid_plural' was generad by appending a '_plural' suffix.
+   #. js-lingui:pluralize_on=someCount
+   msgid "my_message"
+   msgid_plural "my_message_plural"
+   msgstr[0] "Singular case"
+   msgstr[1] "Case number {someCount}"
+
+   # Message without custom ID that is pluralized on property "anotherCount".
+   #
+   # Notice how 'msgid' and 'msgid_plural' were extracted from original message.
+   #
+   # To allow matching this PO item to the appropriate catalog entry when deserializing,
+   # the original ICU message is also stored in the generated comment.
+   #. js-lingui:icu=%7BanotherCount%2C+plural%2C+one+%7BSingular+case%7D+other+%7BCase+number+%7BanotherCount%7D%7D%7D&pluralize_on=anotherCount
+   msgid "Singular case"
+   msgid_plural "Case number {anotherCount}"
+   msgstr[0] "Singular case"
+   msgstr[1] "Case number {anotherCount}"
+
+Note that this format comes with several caveats and should therefore only be used if using ICU plurals in PO files is
+not an option:
+
+  - Nested/multiple plurals in one message as shown in :jsmacro:`plural` are not supported as they cannot be expressed 
+    with gettext plurals. Messages containing nested/multiple formats will not be output correctly.
+  
+  - :jsmacro:`select` and :jsmacro:`selectOrdinal` cannot be expressed with gettext plurals, but the original ICU format
+    is still saved to the `msgid`/`msgstr` properties. To disable the warning that this might not be the expected
+    behavior, include :code:`{ disableSelectWarning: true }` in the :conf:`formatOptions`.
+
+  - Source/development languages with more than two plurals could experience difficulties when no custom IDs are used,
+    as gettext cannot have more than two plurals cases identifying an item (:code:`msgid` and :code:`msgid_plural`).
+
+  - Gettext doesn't support plurals for negative and fractional numbers even though some languages have special rules
+    for these cases.
+
+
 JSON
 ====
 
diff --git a/docs/ref/conf.rst b/docs/ref/conf.rst
index de8b628d..a027a74e 100644
--- a/docs/ref/conf.rst
+++ b/docs/ref/conf.rst
@@ -295,6 +295,11 @@ Gettext PO file:
    msgid "MessageID"
    msgstr "Translated Message"
 
+po-gettext
+^^^^^^^^^^
+
+Uses PO files but with gettext-style plurals, see :ref:`po-gettext`.
+
 minimal
 ^^^^^^^
 
diff --git a/packages/cli/package.json b/packages/cli/package.json
index c1faba5a..ce50f5a2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -54,10 +54,12 @@
     "messageformat-parser": "^4.1.3",
     "micromatch": "4.0.2",
     "mkdirp": "^1.0.4",
+    "node-gettext": "^3.0.0",
     "normalize-path": "^3.0.0",
     "ora": "^5.1.0",
     "papaparse": "^5.3.0",
-    "pofile": "^1.0.11",
+    "plurals-cldr": "^1.0.4",
+    "pofile": "^1.1.0",
     "pseudolocale": "^1.1.0",
     "ramda": "^0.27.1"
   },
@@ -65,6 +67,7 @@
     "@types/micromatch": "^4.0.1",
     "@types/normalize-path": "^3.0.0",
     "@types/papaparse": "^5.2.3",
+    "@types/plurals-cldr": "^1.0.1",
     "mockdate": "^3.0.2",
     "typescript": "^4.0.3"
   },
diff --git a/packages/cli/src/api/formats/fixtures/messages_plural.po b/packages/cli/src/api/formats/fixtures/messages_plural.po
new file mode 100644
index 00000000..4431962c
--- /dev/null
+++ b/packages/cli/src/api/formats/fixtures/messages_plural.po
@@ -0,0 +1,33 @@
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2018-08-27 10:00+0000\n"
+"Mime-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: @lingui/cli\n"
+"Language: en\n"
+
+#. js-lingui:pluralize_on=someCount
+msgid "message_with_id"
+msgid_plural "message_with_id_plural"
+msgstr[0] "Singular case"
+msgstr[1] "Case number {someCount}"
+
+#. js-lingui:icu=%7BanotherCount%2C+plural%2C+one+%7BSingular+case%7D+other+%7BCase+number+%7BanotherCount%7D%7D%7D&pluralize_on=anotherCount
+msgid "Singular case"
+msgid_plural "Case number {anotherCount}"
+msgstr[0] "Singular case"
+msgstr[1] "Case number {anotherCount}"
+
+#. Comment made by the developers.
+#. js-lingui:pluralize_on=count
+msgid "message_with_id_but_without_translation"
+msgid_plural "message_with_id_but_without_translation_plural"
+msgstr[0] ""
+msgstr[1] ""
+
+#. js-lingui:icu=%7Bcount%2C+plural%2C+one+%7BSingular%7D+other+%7BPlural%7D%7D&pluralize_on=count
+msgid "Singular"
+msgid_plural "Plural"
+msgstr[0] ""
+msgstr[1] ""
\ No newline at end of file
diff --git a/packages/cli/src/api/formats/index.ts b/packages/cli/src/api/formats/index.ts
index 5e71d207..380668ac 100644
--- a/packages/cli/src/api/formats/index.ts
+++ b/packages/cli/src/api/formats/index.ts
@@ -1,17 +1,18 @@
-import { CatalogFormatOptions, CatalogFormat } from "@lingui/conf"
+import { CatalogFormat, CatalogFormatOptions } from "@lingui/conf"
 
 import { CatalogType } from "../catalog"
-
+import csv from "./csv"
 import lingui from "./lingui"
 import minimal from "./minimal"
 import po from "./po"
-import csv from "./csv"
+import poGettext from "./po-gettext"
 
 const formats: Record<CatalogFormat, CatalogFormatter> = {
   lingui,
   minimal,
   po,
   csv,
+  "po-gettext": poGettext,
 }
 
 type CatalogFormatOptionsInternal = {
diff --git a/packages/cli/src/api/formats/po-gettext.ts b/packages/cli/src/api/formats/po-gettext.ts
new file mode 100644
index 00000000..327393bb
--- /dev/null
+++ b/packages/cli/src/api/formats/po-gettext.ts
@@ -0,0 +1,338 @@
+import { format as formatDate } from "date-fns"
+import fs from "fs"
+import ICUParser from "messageformat-parser"
+import pluralsCldr from "plurals-cldr"
+import PO from "pofile"
+import * as R from "ramda"
+import gettextPlurals from "node-gettext/lib/plurals"
+
+import { CatalogType, MessageType } from "../catalog"
+import { joinOrigin, splitOrigin, writeFileIfChanged } from "../utils"
+import { CatalogFormatter } from "."
+
+// Workaround because pofile doesn't support es6 modules, see https://github.com/rubenv/pofile/pull/38#issuecomment-623119284
+type POItemType = InstanceType<typeof PO.Item>
+
+function getCreateHeaders(language = "no") {
+  return {
+    "POT-Creation-Date": formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx"),
+    "Mime-Version": "1.0",
+    "Content-Type": "text/plain; charset=utf-8",
+    "Content-Transfer-Encoding": "8bit",
+    "X-Generator": "@lingui/cli",
+    Language: language,
+  }
+}
+
+// Attempts to turn a single tokenized ICU plural case back into a string.
+function stringifyICUCase(icuCase) {
+  return icuCase.tokens
+    .map((token) => {
+      if (typeof token === "string") {
+        return token
+      } else if (token.type === "octothorpe") {
+        return "#"
+      } else if (token.type === "argument") {
+        return "{" + token.arg + "}"
+      } else {
+        console.warn(
+          `Unexpected token "${token}" while stringifying plural case "${icuCase}". Token will be ignored.`
+        )
+        return ""
+      }
+    })
+    .join("")
+}
+
+const ICU_PLURAL_REGEX = /^{.*, plural, .*}$/
+const ICU_SELECT_REGEX = /^{.*, select(Ordinal)?, .*}$/
+const LINE_ENDINGS = /\r?\n/g
+
+// Prefix that is used to identitify context information used by this module in PO's "extracted comments".
+const CTX_PREFIX = "js-lingui:"
+
+const serialize = (items: CatalogType, options) =>
+  R.compose(
+    R.values,
+    R.mapObjIndexed((message: MessageType, key) => {
+      const item = new PO.Item()
+      item.msgid = key
+      item.comments = message.comments || []
+
+      // The extractedComments array may be modified in this method, so create a new array with the message's elements.
+      // Destructuring `undefined` is forbidden, so fallback to `[]` if the message has no extracted comments.
+      item.extractedComments = [...(message.extractedComments ?? [])]
+
+      if (options.origins) {
+        item.references = message.origin ? message.origin.map(joinOrigin) : []
+      }
+
+      // @ts-ignore: Figure out how to set this flag
+      item.obsolete = message.obsolete
+      item.flags = message.flags
+        ? R.fromPairs(message.flags.map((flag) => [flag, true]))
+        : {}
+
+      // Depending on whether custom ids are used by the developer, the (potential plural) "original", untranslated ICU
+      // message can be found in `message.message` or in the item's `key` itself.
+      const icuMessage = message.message || key
+
+      const _simplifiedMessage = icuMessage.replace(LINE_ENDINGS, " ")
+
+      // Quick check to see if original message is a plural localization.
+      if (ICU_PLURAL_REGEX.test(_simplifiedMessage)) {
+        try {
+          const [messageAst] = ICUParser.parse(icuMessage)
+
+          // Check if any of the plural cases contain plurals themselves.
+          if (
+            messageAst.cases.some((icuCase) =>
+              icuCase.tokens.some((token) => token.type === "plural")
+            )
+          ) {
+            console.warn(
+              `Nested plurals cannot be expressed with gettext plurals. ` +
+                `Message with key "%s" will not be saved correctly.`,
+              key
+            )
+          }
+
+          // Store placeholder that is pluralized upon to allow restoring ICU format later.
+          const ctx = new URLSearchParams({
+            pluralize_on: messageAst.arg,
+          })
+
+          if (message.message == null) {
+            // For messages without developer-set ID, use first case as `msgid` and the last case as `msgid_plural`.
+            // This does not necessarily make sense for development languages with more than two numbers, but gettext
+            // only supports exactly two plural forms.
+            item.msgid = stringifyICUCase(messageAst.cases[0])
+            item.msgid_plural = stringifyICUCase(
+              messageAst.cases[messageAst.cases.length - 1]
+            )
+
+            // Since the original msgid is overwritten, store ICU message to allow restoring that ID later.
+            ctx.set("icu", key)
+          } else {
+            // For messages with developer-set ID, append `_plural` to the key to generate `msgid_plural`.
+            item.msgid_plural = key + "_plural"
+          }
+
+          ctx.sort()
+          item.extractedComments.push(CTX_PREFIX + ctx.toString())
+
+          // If there is a translated value, parse that instead of the original message to prevent overriding localized
+          // content with the original message. If there is no translated value, don't touch msgstr, since marking item as
+          // plural (above) already causes `pofile` to automatically generate `msgstr[0]` and `msgstr[1]`.
+          if (message.translation?.length > 0) {
+            try {
+              const [ast] = ICUParser.parse(message.translation)
+              if (ast.cases == null) {
+                console.warn(
+                  `Found translation without plural cases for key "${key}". ` +
+                    `This likely means that a translated .po file misses multiple msgstr[] entries for the key. ` +
+                    `Translation found: "${message.translation}"`
+                )
+                item.msgstr = [message.translation]
+              } else {
+                item.msgstr = ast.cases.map(stringifyICUCase)
+              }
+            } catch (e) {
+              console.error(`Error parsing translation ICU for key "${key}"`, e)
+            }
+          }
+        } catch (e) {
+          console.error(`Error parsing message ICU for key "${key}":`, e)
+        }
+      } else {
+        if (
+          !options.disableSelectWarning &&
+          ICU_SELECT_REGEX.test(_simplifiedMessage)
+        ) {
+          console.warn(
+            `ICU 'select' and 'selectOrdinal' formats cannot be expressed natively in gettext format. ` +
+              `Item with key "%s" will be included in the catalog as raw ICU message. ` +
+              `To disable this warning, include '{ disableSelectWarning: true }' in the config's 'formatOptions'`,
+            key
+          )
+        }
+        item.msgstr = [message.translation]
+      }
+
+      return item
+    })
+  )(items)
+
+const getMessageKey = R.prop<"msgid", string>("msgid")
+const getTranslations = R.prop<"msgstr", string[]>("msgstr")
+const getExtractedComments = R.prop("extractedComments")
+const getTranslatorComments = R.prop("comments")
+const getOrigins = R.prop("references")
+const getFlags = R.compose(
+  R.map(R.trim),
+  R.keys,
+  R.dissoc("obsolete"), // backward-compatibility, remove in 3.x
+  R.prop("flags")
+)
+const isObsolete = R.either(R.path(["flags", "obsolete"]), R.prop("obsolete"))
+
+const getTranslationCount = R.compose(R.length, getTranslations)
+
+const deserialize: (Object) => Object = R.map(
+  R.applySpec({
+    translation: R.compose(R.head, R.defaultTo([]), getTranslations),
+    extractedComments: R.compose(R.defaultTo([]), getExtractedComments),
+    comments: R.compose(R.defaultTo([]), getTranslatorComments),
+    obsolete: isObsolete,
+    origin: R.compose(R.map(splitOrigin), R.defaultTo([]), getOrigins),
+    flags: getFlags,
+  })
+)
+
+/**
+ * Returns ICU case labels in the order that gettext lists localized messages, e.g. 0,1,2 => `["one", "two", "other"]`.
+ *
+ * Obtaining the ICU case labels for gettext-supported inputs (gettext doesn't support fractions, even though some
+ * languages have a separate case for fractional numbers) works by applying the CLDR selector to the example values
+ * listed in the node-gettext plurals module.
+ *
+ * This approach is heavily influenced by
+ * https://github.com/LLK/po2icu/blob/9eb97f81f72b2fee02b77f1424702e019647e9b9/lib/po2icu.js#L148.
+ */
+const getPluralCases = (lang: string): string[] | undefined => {
+  const gettextPluralsInfo = gettextPlurals[lang]
+
+  return gettextPluralsInfo?.examples.map((pluralCase) =>
+    pluralsCldr(lang, pluralCase.sample)
+  )
+}
+
+const convertPluralsToICU = (items: POItemType[], lang?: string) => {
+  // .po plurals are numbered 0-N and need to be mapped to ICU plural classes ("one", "few", "many"...). Different
+  // languages can have different plural classes (some start with "zero", some with "one"), so read that data from CLDR.
+  // `pluralForms` may be `null` if lang is not found. As long as no plural is used, don't report an error.
+  let pluralForms = getPluralCases(lang)
+
+  items.forEach((item) => {
+    const translationCount = getTranslationCount(item)
+    const messageKey = getMessageKey(item)
+
+    // Messages without multiple translations (= plural cases) need no further processing.
+    if (translationCount <= 1) {
+      return
+    }
+
+    // msgid_plural must be set, but its actual value is not important.
+    if (!item.msgid_plural) {
+      console.warn(
+        `Multiple translations for item with key "%s" but missing 'msgid_plural' in catalog "${lang}". This is not supported and the plural cases will be ignored.`,
+        messageKey
+      )
+      return
+    }
+
+    const contextComment = item.extractedComments
+      .find((comment) => comment.startsWith(CTX_PREFIX))
+      ?.substr(CTX_PREFIX.length)
+    const ctx = new URLSearchParams(contextComment)
+
+    if (contextComment != null) {
+      item.extractedComments = item.extractedComments.filter(
+        (comment) => !comment.startsWith(CTX_PREFIX)
+      )
+    }
+
+    // If an original ICU was stored, use that as `msgid` to match the catalog that was originally exported.
+    const storedICU = ctx.get("icu")
+    if (storedICU != null) {
+      item.msgid = storedICU
+    }
+
+    // If all translations are empty, ignore item.
+    if (item.msgstr.every((str) => str.length === 0)) {
+      return
+    }
+
+    if (pluralForms == null) {
+      console.warn(
+        `Multiple translations for item with key "%s" in language "${lang}", but no plural cases were found. ` +
+          `This prohibits the translation of .po plurals into ICU plurals. Pluralization will not work for this key.`,
+        messageKey
+      )
+      return
+    }
+
+    const pluralCount = pluralForms.length
+    if (translationCount > pluralCount) {
+      console.warn(
+        `More translations provided (${translationCount}) for item with key "%s" in language "${lang}" than there are plural cases available (${pluralCount}). ` +
+          `This will result in not all translations getting picked up.`,
+        messageKey
+      )
+    }
+
+    // Map each msgstr to a "<pluralform> {<translated_string>}" entry, joined by one space.
+    const pluralClauses = item.msgstr
+      .map((str, index) => pluralForms[index] + " {" + str + "}")
+      .join(" ")
+
+    // Find out placeholder name from item's message context, defaulting to "count".
+    let pluralizeOn = ctx.get("pluralize_on")
+    if (!pluralizeOn) {
+      console.warn(
+        `Unable to determine plural placeholder name for item with key "%s" in language "${lang}" (should be stored in a comment starting with "#. ${CTX_PREFIX}"), assuming "count".`,
+        messageKey
+      )
+      pluralizeOn = "count"
+    }
+
+    item.msgstr = ["{" + pluralizeOn + ", plural, " + pluralClauses + "}"]
+  })
+}
+
+const indexItems = R.indexBy(getMessageKey)
+
+type PoFormatter = {
+  parse: (raw: string) => Object
+  serialize: (
+    catalog: CatalogType,
+    options: Record<string, any>
+  ) => POItemType[]
+}
+
+const poGettext: CatalogFormatter & PoFormatter = {
+  catalogExtension: ".po",
+
+  write(filename, catalog: CatalogType, options) {
+    let po
+    if (fs.existsSync(filename)) {
+      const raw = fs.readFileSync(filename).toString()
+      po = PO.parse(raw)
+    } else {
+      po = new PO()
+      po.headers = getCreateHeaders(options.locale)
+      if (options.locale === undefined) {
+        delete po.headers.Language
+      }
+      po.headerOrder = Object.keys(po.headers)
+    }
+    po.items = this.serialize(catalog, options)
+    writeFileIfChanged(filename, po.toString())
+  },
+
+  // Mainly exported for easier testing
+  serialize,
+
+  read(filename) {
+    const raw = fs.readFileSync(filename).toString()
+    return this.parse(raw)
+  },
+
+  parse(raw) {
+    const po = PO.parse(raw)
+    convertPluralsToICU(po.items, po.headers.Language)
+    return deserialize(indexItems(po.items))
+  },
+}
+
+export default poGettext
diff --git a/packages/cli/src/api/pseudoLocalize.ts b/packages/cli/src/api/pseudoLocalize.ts
index 098bc429..1bfe15f7 100644
--- a/packages/cli/src/api/pseudoLocalize.ts
+++ b/packages/cli/src/api/pseudoLocalize.ts
@@ -16,9 +16,9 @@ Example: https://regex101.com/r/bDHD9z/3
 const HTMLRegex = /<\/?\w+((\s+\w+(\s*=\s*(?:".*?"|'.*?'|[^'">\s]+))?)+\s*|\s*)\/?>/g
 /*
 Regex should match js-lingui plurals
-Example: https://regex101.com/r/utnbQw/3
+Example: https://regex101.com/r/utnbQw/4
 */
-const PluralRegex = /({\w*,\s*plural,(.|\n)*?{)|(}\s*(offset|zero|one|two|few|many|other)\s*{)/g
+const PluralRegex = /({\w*,\s*(plural|selectordinal),(.|\n)*?{)|(}\s*(offset|zero|one|two|few|many|other)\s*{)/g
 /*
 Regex should match js-lingui variables
 Example: https://regex101.com/r/dw1QHb/2
diff --git a/packages/conf/index.d.ts b/packages/conf/index.d.ts
index c1669e3b..aed99cd6 100644
--- a/packages/conf/index.d.ts
+++ b/packages/conf/index.d.ts
@@ -1,4 +1,4 @@
-export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv";
+export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext";
 export type CatalogFormatOptions = {
     origins?: boolean;
 }
diff --git a/yarn.lock b/yarn.lock
index 16c55a80..d2efb0ef 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2728,6 +2728,11 @@
   resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
   integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
 
+"@types/plurals-cldr@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@types/plurals-cldr/-/plurals-cldr-1.0.1.tgz#3b165778d8310c9f456a4f627f49921337c49152"
+  integrity sha512-enSHTQeNTPmC3D4BOyAltzHSt7HraFXG5ONwWIsG8g6VUvJB9XiOEu56Y5LoNRj9ppq4mYqCmNfELPAJOsu21A==
+
 "@types/prettier@^2.0.0":
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.0.2.tgz#5bb52ee68d0f8efa9cc0099920e56be6cc4e37f3"
@@ -8964,6 +8969,13 @@ node-fetch@^2.2.0, node-fetch@^2.5.0, node-fetch@^2.6.1:
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
   integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
 
+node-gettext@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/node-gettext/-/node-gettext-3.0.0.tgz#6b3a253309aa1e53164646c6c644fcddd0d45c58"
+  integrity sha512-/VRYibXmVoN6tnSAY2JWhNRhWYJ8Cd844jrZU/DwLVoI4vBI6ceYbd8i42sYZ9uOgDH3S7vslIKOWV/ZrT2YBA==
+  dependencies:
+    lodash.get "^4.4.2"
+
 node-gyp@^5.0.2:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-5.1.1.tgz#eb915f7b631c937d282e33aed44cb7a025f62a3e"
@@ -9847,7 +9859,12 @@ pluralize@^7.0.0:
   resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
   integrity sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==
 
-pofile@^1.0.11:
+plurals-cldr@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/plurals-cldr/-/plurals-cldr-1.0.4.tgz#534e4784f80679d3b0b39b0fb6cc46645fcf5de8"
+  integrity sha512-4nLXqtel7fsCgzi8dvRZvUjfL8SXpP982sKg7b2TgpnR8rDnes06iuQ83trQ/+XdtyMIQkBBbKzX6x97eLfsJQ==
+
+pofile@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.0.tgz#9ce84bbef5043ceb4f19bdc3520d85778fad4f94"
   integrity sha512-6XYcNkXWGiJ2CVXogTP7uJ6ZXQCldYLZc16wgRp8tqRaBTTyIfF+TUT3EQJPXTLAT7OTPpTAoaFdoXKfaTRU1w==


Test patch:
diff --git a/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap b/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap
new file mode 100644
index 00000000..0a813b56
--- /dev/null
+++ b/packages/cli/src/api/formats/__snapshots__/po-gettext.test.ts.snap
@@ -0,0 +1,260 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`po-gettext format should convert ICU plural messages to gettext plurals 1`] = `
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2018-08-27 10:00+0000\\n"
+"Mime-Version: 1.0\\n"
+"Content-Type: text/plain; charset=utf-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"X-Generator: @lingui/cli\\n"
+"Language: en\\n"
+
+#. js-lingui:pluralize_on=count
+msgid "message_with_id_and_octothorpe"
+msgid_plural "message_with_id_and_octothorpe_plural"
+msgstr[0] "Singular"
+msgstr[1] "Number is #"
+
+#. This is a comment by the developers about how the content must be localized.
+#. js-lingui:pluralize_on=someCount
+msgid "message_with_id"
+msgid_plural "message_with_id_plural"
+msgstr[0] "Singular case with id"
+msgstr[1] "Case number {someCount} with id"
+
+#. js-lingui:icu=%7BanotherCount%2C+plural%2C+one+%7BSingular+case%7D+other+%7BCase+number+%7BanotherCount%7D%7D%7D&pluralize_on=anotherCount
+msgid "Singular case"
+msgid_plural "Case number {anotherCount}"
+msgstr[0] "Singular case"
+msgstr[1] "Case number {anotherCount}"
+
+#. js-lingui:pluralize_on=count
+msgid "message_with_id_but_without_translation"
+msgid_plural "message_with_id_but_without_translation_plural"
+msgstr[0] ""
+msgstr[1] ""
+
+#. js-lingui:icu=%7Bcount%2C+plural%2C+one+%7BSingular+automatic+id+no+translation%7D+other+%7BPlural+%7Bcount%7D+automatic+id+no+translation%7D%7D&pluralize_on=count
+msgid "Singular automatic id no translation"
+msgid_plural "Plural {count} automatic id no translation"
+msgstr[0] ""
+msgstr[1] ""
+
+`;
+
+exports[`po-gettext format should convert gettext plurals to ICU plural messages 1`] = `
+Object {
+  message_with_id: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: {someCount, plural, one {Singular case} other {Case number {someCount}}},
+  },
+  message_with_id_but_without_translation: Object {
+    comments: Array [],
+    extractedComments: Array [
+      Comment made by the developers.,
+    ],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: ,
+  },
+  {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: {anotherCount, plural, one {Singular case} other {Case number {anotherCount}}},
+  },
+  {count, plural, one {Singular} other {Plural}}: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: ,
+  },
+}
+`;
+
+exports[`po-gettext format should correct badly used comments 1`] = `
+Object {
+  withDescriptionAndComments: Object {
+    comments: Array [
+      Translator comment,
+    ],
+    extractedComments: Array [
+      Single description only,
+      Second description?,
+    ],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: Second description joins translator comments,
+  },
+  withMultipleDescriptions: Object {
+    comments: Array [],
+    extractedComments: Array [
+      First description,
+      Second comment,
+      Third comment,
+    ],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: Extra comments are separated from the first description line,
+  },
+}
+`;
+
+exports[`po-gettext format should read catalog in pofile format 1`] = `
+Object {
+  obsolete: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: true,
+    origin: Array [],
+    translation: Is marked as obsolete,
+  },
+  static: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: Static message,
+  },
+  veryLongString: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. "What's happened to me?" he thought. It wasn't a dream. His room, a proper human,
+  },
+  withComments: Object {
+    comments: Array [
+      Translator comment,
+      This one might come from developer,
+    ],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: Support translator comments separately,
+  },
+  withDescription: Object {
+    comments: Array [],
+    extractedComments: Array [
+      Description is comment from developers to translators,
+    ],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: Message with description,
+  },
+  withFlags: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [
+      fuzzy,
+      otherFlag,
+    ],
+    obsolete: false,
+    origin: Array [],
+    translation: Keeps any flags that are defined,
+  },
+  withMultipleOrigins: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [
+      Array [
+        src/App.js,
+        4,
+      ],
+      Array [
+        src/Component.js,
+        2,
+      ],
+    ],
+    translation: Message with multiple origin,
+  },
+  withOrigin: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [
+      Array [
+        src/App.js,
+        4,
+      ],
+    ],
+    translation: Message with origin,
+  },
+}
+`;
+
+exports[`po-gettext format should throw away additional msgstr if present 1`] = `
+Object {
+  withMultipleTranslations: Object {
+    comments: Array [],
+    extractedComments: Array [],
+    flags: Array [],
+    obsolete: false,
+    origin: Array [],
+    translation: This is just fine,
+  },
+}
+`;
+
+exports[`po-gettext format should write catalog in pofile format 1`] = `
+msgid ""
+msgstr ""
+"POT-Creation-Date: 2018-08-27 10:00+0000\\n"
+"Mime-Version: 1.0\\n"
+"Content-Type: text/plain; charset=utf-8\\n"
+"Content-Transfer-Encoding: 8bit\\n"
+"X-Generator: @lingui/cli\\n"
+"Language: en\\n"
+
+msgid "static"
+msgstr "Static message"
+
+#: src/App.js:4
+msgid "withOrigin"
+msgstr "Message with origin"
+
+#: src/App.js:4
+#: src/Component.js:2
+msgid "withMultipleOrigins"
+msgstr "Message with multiple origin"
+
+#. Description is comment from developers to translators
+msgid "withDescription"
+msgstr "Message with description"
+
+# Translator comment
+# This one might come from developer
+msgid "withComments"
+msgstr "Support translator comments separately"
+
+#~ msgid "obsolete"
+#~ msgstr "Obsolete message"
+
+#, fuzzy,otherFlag
+msgid "withFlags"
+msgstr "Keeps any flags that are defined"
+
+msgid "veryLongString"
+msgstr "One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections. The bedding was hardly able to cover it and seemed ready to slide off any moment. His many legs, pitifully thin compared with the size of the rest of him, waved about helplessly as he looked. \\"What's happened to me?\\" he thought. It wasn't a dream. His room, a proper human"
+
+`;
diff --git a/packages/cli/src/api/formats/po-gettext.test.ts b/packages/cli/src/api/formats/po-gettext.test.ts
new file mode 100644
index 00000000..1489b9e3
--- /dev/null
+++ b/packages/cli/src/api/formats/po-gettext.test.ts
@@ -0,0 +1,369 @@
+import { mockConsole } from "@lingui/jest-mocks"
+import fs from "fs"
+import mockFs from "mock-fs"
+import mockDate from "mockdate"
+import path from "path"
+import PO from "pofile"
+
+import { CatalogType } from "../catalog"
+import format from "./po-gettext"
+
+describe("po-gettext format", () => {
+  const dateHeaders = {
+    "pot-creation-date": "2018-08-09",
+    "po-revision-date": "2018-08-09",
+  }
+
+  afterEach(() => {
+    mockFs.restore()
+    mockDate.reset()
+  })
+
+  it("should write catalog in pofile format", () => {
+    mockFs({
+      locale: {
+        en: mockFs.directory(),
+      },
+    })
+    mockDate.set("2018-08-27T10:00Z")
+
+    const filename = path.join("locale", "en", "messages.po")
+    const catalog: CatalogType = {
+      static: {
+        translation: "Static message",
+      },
+      withOrigin: {
+        translation: "Message with origin",
+        origin: [["src/App.js", 4]],
+      },
+      withMultipleOrigins: {
+        translation: "Message with multiple origin",
+        origin: [
+          ["src/App.js", 4],
+          ["src/Component.js", 2],
+        ],
+      },
+      withDescription: {
+        translation: "Message with description",
+        extractedComments: [
+          "Description is comment from developers to translators",
+        ],
+      },
+      withComments: {
+        comments: ["Translator comment", "This one might come from developer"],
+        translation: "Support translator comments separately",
+      },
+      obsolete: {
+        translation: "Obsolete message",
+        obsolete: true,
+      },
+      withFlags: {
+        flags: ["fuzzy", "otherFlag"],
+        translation: "Keeps any flags that are defined",
+      },
+      veryLongString: {
+        translation:
+          "One morning, when Gregor Samsa woke from troubled dreams, he found himself" +
+          " transformed in his bed into a horrible vermin. He lay on his armour-like" +
+          " back, and if he lifted his head a little he could see his brown belly," +
+          " slightly domed and divided by arches into stiff sections. The bedding was" +
+          " hardly able to cover it and seemed ready to slide off any moment. His many" +
+          " legs, pitifully thin compared with the size of the rest of him, waved about" +
+          " helplessly as he looked. \"What's happened to me?\" he thought. It wasn't" +
+          " a dream. His room, a proper human",
+      },
+    }
+
+    format.write(filename, catalog, {
+      origins: true,
+      locale: "en",
+      ...dateHeaders,
+    })
+    const pofile = fs.readFileSync(filename).toString()
+    mockFs.restore()
+    expect(pofile).toMatchSnapshot()
+  })
+
+  it("should read catalog in pofile format", () => {
+    const filename = path.join(
+      path.resolve(__dirname),
+      "fixtures",
+      "messages.po"
+    )
+    const actual = format.read(filename)
+    expect(actual).toMatchSnapshot()
+  })
+
+  it("should correct badly used comments", () => {
+    const po = PO.parse(`
+      #. First description
+      #. Second comment
+      #. Third comment
+      msgid "withMultipleDescriptions"
+      msgstr "Extra comments are separated from the first description line"
+
+      # Translator comment
+      #. Single description only
+      #. Second description?
+      msgid "withDescriptionAndComments"
+      msgstr "Second description joins translator comments"
+    `)
+
+    mockFs({
+      locale: {
+        en: {
+          "messages.po": po.toString(),
+        },
+      },
+    })
+
+    const filename = path.join("locale", "en", "messages.po")
+    const actual = format.read(filename)
+    mockFs.restore()
+    expect(actual).toMatchSnapshot()
+  })
+
+  it("should throw away additional msgstr if present", () => {
+    const po = PO.parse(`
+      msgid "withMultipleTranslations"
+      msgstr[0] "This is just fine"
+      msgstr[1] "Throw away that one"
+    `)
+
+    mockFs({
+      locale: {
+        en: {
+          "messages.po": po.toString(),
+        },
+      },
+    })
+
+    const filename = path.join("locale", "en", "messages.po")
+    mockConsole((console) => {
+      const file = fs.readFileSync(filename).toString()
+      const actual = format.parse(file)
+      expect(console.warn).toHaveBeenCalledWith(
+        expect.stringContaining("Multiple translations"),
+        "withMultipleTranslations"
+      )
+      mockFs.restore()
+      expect(actual).toMatchSnapshot()
+    })
+  })
+
+  it("should write the same catalog as it was read", () => {
+    const pofile = fs
+      .readFileSync(
+        path.join(path.resolve(__dirname), "fixtures", "messages.po")
+      )
+      .toString()
+
+    const filename = path.join(
+      path.resolve(__dirname),
+      "fixtures",
+      "messages.po"
+    )
+    const catalog = format.read(filename)
+
+    mockFs({
+      locale: {
+        en: {
+          "messages.po": pofile,
+        },
+      },
+    })
+
+    const mock_filename = path.join("locale", "en", "messages.po")
+    format.write(mock_filename, catalog, { origins: true, locale: "en" })
+    const actual = fs.readFileSync(mock_filename).toString()
+
+    mockFs.restore()
+
+    // on windows mockFs adds ··· to multiline string, so this strictly equal comparison can't be done
+    // we test that the content if the same inlined...
+    expect(actual.replace(/(\r\n|\n|\r)/gm, "")).toEqual(
+      pofile.replace(/(\r\n|\n|\r)/gm, "")
+    )
+  })
+
+  it("should convert ICU plural messages to gettext plurals", function () {
+    mockFs({
+      locale: {
+        en: mockFs.directory(),
+      },
+    })
+    mockDate.set("2018-08-27T10:00Z")
+
+    const filename = path.join("locale", "en", "messages.po")
+    const catalog: CatalogType = {
+      message_with_id_and_octothorpe: {
+        message: "{count, plural, one {Singular} other {Number is #}}",
+        translation: "{count, plural, one {Singular} other {Number is #}}",
+      },
+      message_with_id: {
+        message:
+          "{someCount, plural, one {Singular case with id\
+          and linebreak} other {Case number {someCount} with id}}",
+        translation:
+          "{someCount, plural, one {Singular case with id} other {Case number {someCount} with id}}",
+        extractedComments: [
+          "This is a comment by the developers about how the content must be localized.",
+        ],
+      },
+      "{anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}": {
+        translation:
+          "{anotherCount, plural, one {Singular case} other {Case number {anotherCount}}}",
+      },
+      // Entry with developer-defined ID that generates empty msgstr[] lines
+      message_with_id_but_without_translation: {
+        message:
+          "{count, plural, one {Singular with id but no translation} other {Plural {count} with empty id but no translation}}",
+        translation: "",
+      },
+      // Entry with automatic ID that generates empty msgstr[] lines
+      "{count, plural, one {Singular automatic id no translation} other {Plural {count} automatic id no translation}}": {
+        translation: "",
+      },
+    }
+
+    format.write(filename, catalog, {
+      locale: "en",
+    })
+    const pofile = fs.readFileSync(filename).toString()
+    mockFs.restore()
+    expect(pofile).toMatchSnapshot()
+  })
+
+  it("should convert gettext plurals to ICU plural messages", function () {
+    const pofile = fs
+      .readFileSync(
+        path.join(path.resolve(__dirname), "fixtures", "messages_plural.po")
+      )
+      .toString()
+
+    const catalog = format.parse(pofile)
+    expect(catalog).toMatchSnapshot()
+  })
+
+  it("should warn when using nested plurals that cannot be represented with gettext plurals", () => {
+    const catalog = {
+      nested_plural_message: {
+        message: `{count, plural,
+          one {{numArticles, plural,
+            one {1 book and 1 article}
+            other {1 book and {numArticles} articles}
+          }}
+          other {{numArticles, plural,
+            one {{numBooks} books and 1 article}
+            other {{numBooks} books and {numArticles} articles}
+          }}
+        }`,
+        translation: `{count, plural,
+          one {{numArticles, plural,
+            one {1 book and 1 article}
+            other {1 book and {numArticles} articles}
+          }}
+          other {{numArticles, plural,
+            one {{numBooks} books and 1 article}
+            other {{numBooks} books and {numArticles} articles}
+          }}
+        }`,
+      },
+    }
+
+    mockConsole((console) => {
+      format.serialize(catalog, {})
+
+      expect(console.warn).toHaveBeenCalledWith(
+        expect.stringContaining("Nested plurals"),
+        "nested_plural_message"
+      )
+    })
+  })
+
+  it("should use correct ICU plural cases for languages having an additional plural case for fractions", () => {
+    // This tests the edge case described in https://github.com/lingui/js-lingui/pull/677#issuecomment-737152022
+    const po = `
+msgid ""
+msgstr ""
+"Language: cs\n"
+
+#. js-lingui:icu=%7B#%2C+plural%2C+one+%7Bday%7D+other+%7Bdays%7D%7D&pluralize_on=#
+msgid "# day"
+msgid_plural "# days"
+msgstr[0] "# den"
+msgstr[1] "# dny"
+msgstr[2] "# dní"
+`
+
+    const parsed = format.parse(po)
+
+    expect(parsed).toEqual({
+      "{#, plural, one {day} other {days}}": {
+        // Note that the last case must be `other` (the 4th CLDR case name) instead of `many` (the 3rd CLDR case name).
+        translation: "{#, plural, one {# den} few {# dny} other {# dní}}",
+        extractedComments: [],
+        comments: [],
+        obsolete: false,
+        origin: [],
+        flags: [],
+      },
+    })
+  })
+
+  describe("when using 'select' format", () => {
+    const catalog = {
+      select_message: {
+        message: `{gender, select, male {he} female {she} other {they}`,
+        translation: "",
+      },
+    }
+
+    it("should warn", () => {
+      mockConsole((console) => {
+        format.serialize(catalog, {})
+
+        expect(console.warn).toHaveBeenCalledWith(
+          expect.stringContaining("select"),
+          "select_message"
+        )
+      })
+    })
+
+    it("should not warn when disabling the warning in config", () => {
+      mockConsole((console) => {
+        format.serialize(catalog, { disableSelectWarning: true })
+
+        expect(console.warn).not.toHaveBeenCalled()
+      })
+    })
+  })
+
+  describe("when using 'selectOrdinal' format", () => {
+    const catalog = {
+      select_ordinal_message: {
+        message: `{count, selectOrdinal, one {1st} two {2nd} few {3rd} other {#th}}`,
+        translation: "",
+      },
+    }
+
+    it("should warn", () => {
+      mockConsole((console) => {
+        format.serialize(catalog, {})
+
+        expect(console.warn).toHaveBeenCalledWith(
+          expect.stringContaining("selectOrdinal"),
+          "select_ordinal_message"
+        )
+      })
+    })
+
+    it("should not warn when disabling the warning in config", () => {
+      mockConsole((console) => {
+        format.serialize(catalog, { disableSelectWarning: true })
+
+        expect(console.warn).not.toHaveBeenCalled()
+      })
+    })
+  })
+})
diff --git a/packages/cli/src/api/pseudoLocalize.test.ts b/packages/cli/src/api/pseudoLocalize.test.ts
index 6108606f..2c9bf99c 100644
--- a/packages/cli/src/api/pseudoLocalize.test.ts
+++ b/packages/cli/src/api/pseudoLocalize.test.ts
@@ -68,6 +68,16 @@ describe("PseudoLocalization", () => {
     })
   })
 
+  it("SelectOrdinal", () => {
+    expect(
+      pseudoLocalize(
+        "{count, selectordinal, offset:1 one {1st} two {2nd} few {3rd} =4 {4th} many {testMany} other {#th}}"
+      )
+    ).toEqual(
+      "{count, selectordinal, offset:1 one {1śţ} two {2ńď} few {3ŕď} =4 {4ţĥ} many {ţēśţMàńŷ} other {#ţĥ}}"
+    )
+  })
+
   it("should not pseudolocalize variables", () => {
     expect(pseudoLocalize("replace {count}")).toEqual("ŕēƥĺàćē {count}")
     expect(pseudoLocalize("replace { count }")).toEqual("ŕēƥĺàćē { count }")