message/pipeline: implement merge

- unique keys and identifiers
- detect collisions
- inject available translations in output files
- inject source language if applicable

Change-Id: I461a276c20b43161ee37499700e0feff82883e1b
Reviewed-on: https://go-review.googlesource.com/83819
Run-TryBot: Marcel van Lohuizen <mpvl@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Nigel Tao <nigeltao@golang.org>
diff --git a/cmd/gotext/examples/extract/locales/de/out.gotext.json b/cmd/gotext/examples/extract/locales/de/out.gotext.json
index 05b9330..696eeb7 100755
--- a/cmd/gotext/examples/extract/locales/de/out.gotext.json
+++ b/cmd/gotext/examples/extract/locales/de/out.gotext.json
@@ -3,16 +3,13 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:28:10"
+            "translation": "Hallo Welt!"
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
-            "translation": "",
+            "translation": "Hallo {City}!",
             "placeholders": [
                 {
                     "id": "City",
@@ -22,32 +19,12 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:32:10"
-        },
-        {
-            "id": "Hello {Town}!",
-            "key": "Hello %s!\n",
-            "message": "Hello {Town}!",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "Town",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "town",
-                    "comment": "Town"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:36:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
-            "translation": "",
+            "translation": "{Person} besucht {Place}!",
             "placeholders": [
                 {
                     "id": "Person",
@@ -67,47 +44,10 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:41:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Person visiting a place.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:56:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
             "translation": "",
             "placeholders": [
@@ -119,14 +59,12 @@
                     "argNum": 1,
                     "expr": "2"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:63:10"
+            ]
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
-            "translation": "",
+            "translation": "Noch {N} Bestände zu gehen!",
             "placeholders": [
                 {
                     "id": "N",
@@ -136,12 +74,10 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:68:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
             "translation": "",
             "placeholders": [
@@ -153,15 +89,13 @@
                     "argNum": 1,
                     "expr": "c"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:74:10"
+            ]
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
             "comment": "FOO\n",
@@ -174,12 +108,10 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:82:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
             "translation": "",
             "placeholders": [
@@ -199,8 +131,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:86:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/cmd/gotext/examples/extract/locales/en-US/out.gotext.json b/cmd/gotext/examples/extract/locales/en-US/out.gotext.json
index 69e8157..31785bf 100755
--- a/cmd/gotext/examples/extract/locales/en-US/out.gotext.json
+++ b/cmd/gotext/examples/extract/locales/en-US/out.gotext.json
@@ -3,16 +3,13 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:28:10"
+            "translation": "Hello world!"
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
-            "translation": "",
+            "translation": "Hello {City}n",
             "placeholders": [
                 {
                     "id": "City",
@@ -22,32 +19,12 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:32:10"
-        },
-        {
-            "id": "Hello {Town}!",
-            "key": "Hello %s!\n",
-            "message": "Hello {Town}!",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "Town",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "town",
-                    "comment": "Town"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:36:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
-            "translation": "",
+            "translation": "{Person} is visiting {Place}!",
             "placeholders": [
                 {
                     "id": "Person",
@@ -67,49 +44,13 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:41:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Person visiting a place.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:56:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
-            "translation": "",
+            "translation": "{2} files remaining!",
+            "translatorComment": "Copied from source.",
             "placeholders": [
                 {
                     "id": "2",
@@ -120,13 +61,25 @@
                     "expr": "2"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:63:10"
+            "fuzzy": true
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
-            "translation": "",
+            "translation": {
+                "select": {
+                    "feature": "plural",
+                    "arg": "N",
+                    "cases": {
+                        "one": {
+                            "msg": "One file remaining!"
+                        },
+                        "other": {
+                            "msg": "There are {N} more files remaining!"
+                        }
+                    }
+                }
+            },
             "placeholders": [
                 {
                     "id": "N",
@@ -136,14 +89,13 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:68:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
-            "translation": "",
+            "translation": "Use the following code for your discount: {ReferralCode}",
+            "translatorComment": "Copied from source.",
             "placeholders": [
                 {
                     "id": "ReferralCode",
@@ -154,16 +106,15 @@
                     "expr": "c"
                 }
             ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:74:10"
+            "fuzzy": true
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
-            "translation": "",
+            "translation": "{Device} is out of order!",
             "comment": "FOO\n",
             "placeholders": [
                 {
@@ -174,14 +125,12 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:82:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
-            "translation": "",
+            "translation": "{Miles} miles traveled ({Miles_1})",
             "placeholders": [
                 {
                     "id": "Miles",
@@ -199,8 +148,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:86:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/cmd/gotext/examples/extract/locales/zh/out.gotext.json b/cmd/gotext/examples/extract/locales/zh/out.gotext.json
index 6b53a34..946573e 100755
--- a/cmd/gotext/examples/extract/locales/zh/out.gotext.json
+++ b/cmd/gotext/examples/extract/locales/zh/out.gotext.json
@@ -3,14 +3,11 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:28:10"
+            "translation": ""
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
             "translation": "",
             "placeholders": [
@@ -22,30 +19,10 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:32:10"
-        },
-        {
-            "id": "Hello {Town}!",
-            "key": "Hello %s!\n",
-            "message": "Hello {Town}!",
-            "translation": "",
-            "placeholders": [
-                {
-                    "id": "Town",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "town",
-                    "comment": "Town"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:36:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
             "translation": "",
             "placeholders": [
@@ -67,47 +44,10 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:41:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Person visiting a place.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:56:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
             "translation": "",
             "placeholders": [
@@ -119,12 +59,10 @@
                     "argNum": 1,
                     "expr": "2"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:63:10"
+            ]
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
             "translation": "",
             "placeholders": [
@@ -136,12 +74,10 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:68:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
             "translation": "",
             "placeholders": [
@@ -153,15 +89,13 @@
                     "argNum": 1,
                     "expr": "c"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:74:10"
+            ]
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
             "comment": "FOO\n",
@@ -174,12 +108,10 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:82:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
             "translation": "",
             "placeholders": [
@@ -199,8 +131,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "golang.org/x/text/cmd/gotext/examples/extract/main.go:86:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/cmd/gotext/extract.go b/cmd/gotext/extract.go
index 8c80670..b59097b 100644
--- a/cmd/gotext/extract.go
+++ b/cmd/gotext/extract.go
@@ -34,5 +34,11 @@
 	if err != nil {
 		return wrap(err, "extract failed")
 	}
+	if err := state.Import(); err != nil {
+		return wrap(err, "import failed")
+	}
+	if err := state.Merge(); err != nil {
+		return wrap(err, "merge failed")
+	}
 	return wrap(state.Export(), "export failed")
 }
diff --git a/message/pipeline/generate.go b/message/pipeline/generate.go
index 8d6f07b..cb62c25 100644
--- a/message/pipeline/generate.go
+++ b/message/pipeline/generate.go
@@ -86,7 +86,6 @@
 	// Build up index of translations and original messages.
 	translations := map[language.Tag]map[string]Message{}
 	languages := []language.Tag{}
-	langVars := []string{}
 	usedKeys := map[string]int{}
 
 	for _, loc := range s.Translations {
@@ -110,6 +109,7 @@
 	// Verify completeness and register keys.
 	internal.SortTags(languages)
 
+	langVars := []string{}
 	for _, tag := range languages {
 		langVars = append(langVars, strings.Replace(tag.String(), "-", "_", -1))
 		dict := translations[tag]
diff --git a/message/pipeline/message.go b/message/pipeline/message.go
index b9b0b3a..c83a8fd 100644
--- a/message/pipeline/message.go
+++ b/message/pipeline/message.go
@@ -6,6 +6,7 @@
 
 import (
 	"encoding/json"
+	"errors"
 	"strings"
 
 	"golang.org/x/text/language"
@@ -35,7 +36,7 @@
 	// ID contains a list of identifiers for the message.
 	ID IDList `json:"id"`
 	// Key is the string that is used to look up the message at runtime.
-	Key         string `json:"key"`
+	Key         string `json:"key,omitempty"`
 	Meaning     string `json:"meaning,omitempty"`
 	Message     Text   `json:"message"`
 	Translation Text   `json:"translation"`
@@ -45,6 +46,11 @@
 
 	Placeholders []Placeholder `json:"placeholders,omitempty"`
 
+	// Fuzzy indicates that the provide translation needs review by a
+	// translator, for instance because it was derived from automated
+	// translation.
+	Fuzzy bool `json:"fuzzy,omitempty"`
+
 	// TODO: default placeholder syntax is {foo}. Allow alternative escaping
 	// like `foo`.
 
@@ -94,6 +100,20 @@
 	return sub, err
 }
 
+var errIncompatibleMessage = errors.New("messages incompatible")
+
+func checkEquivalence(a, b *Message) error {
+	for _, v := range a.ID {
+		for _, w := range b.ID {
+			if v == w {
+				return nil
+			}
+		}
+	}
+	// TODO: canonicalize placeholders and check for type equivalence.
+	return errIncompatibleMessage
+}
+
 // A Placeholder is a part of the message that should not be changed by a
 // translator. It can be used to hide or prettify format strings (e.g. %d or
 // {{.Count}}), hide HTML, or mark common names that should not be translated.
diff --git a/message/pipeline/pipeline.go b/message/pipeline/pipeline.go
index ff4505f..93d2602 100644
--- a/message/pipeline/pipeline.go
+++ b/message/pipeline/pipeline.go
@@ -243,31 +243,119 @@
 
 // Merge merges the extracted messages with the existing translations.
 func (s *State) Merge() error {
-	panic("unimplemented")
-	return nil
+	if s.Messages != nil {
+		panic("already merged")
+	}
+	// Create an index for each unique message.
+	// Duplicates are okay as long as the substitution arguments are okay as
+	// well.
+	// Top-level messages are okay to appear in multiple substitution points.
 
+	// Collect key equivalence.
+	msgs := []*Message{}
+	keyToIDs := map[string]*Message{}
+	for _, m := range s.Extracted.Messages {
+		m := m
+		if prev, ok := keyToIDs[m.Key]; ok {
+			if err := checkEquivalence(&m, prev); err != nil {
+				warnf("Key %q matches conflicting messages: %v and %v", m.Key, prev.ID, m.ID)
+				// TODO: track enough information so that the rewriter can
+				// suggest/disambiguate messages.
+			}
+			// TODO: add position to message.
+			continue
+		}
+		i := len(msgs)
+		msgs = append(msgs, &m)
+		keyToIDs[m.Key] = msgs[i]
+	}
+
+	// Messages with different keys may still refer to the same translated
+	// message (e.g. different whitespace). Filter these.
+	idMap := map[string]bool{}
+	filtered := []*Message{}
+	for _, m := range msgs {
+		found := false
+		for _, id := range m.ID {
+			found = found || idMap[id]
+		}
+		if !found {
+			filtered = append(filtered, m)
+		}
+		for _, id := range m.ID {
+			idMap[id] = true
+		}
+	}
+
+	// Build index of translations.
+	translations := map[language.Tag]map[string]Message{}
+	languages := []language.Tag{}
+
+	for _, t := range s.Translations {
+		tag := t.Language
+		if _, ok := translations[tag]; !ok {
+			translations[tag] = map[string]Message{}
+			languages = append(languages, tag)
+		}
+		for _, m := range t.Messages {
+			if !m.Translation.IsEmpty() {
+				for _, id := range m.ID {
+					if _, ok := translations[tag][id]; ok {
+						warnf("Duplicate translation in locale %q for message %q", tag, id)
+					}
+					translations[tag][id] = m
+				}
+			}
+		}
+	}
+	internal.SortTags(languages)
+
+	for _, tag := range languages {
+		ms := Messages{Language: tag}
+		for _, orig := range filtered {
+			m := *orig
+			m.Key = ""
+			m.Position = ""
+
+			for _, id := range m.ID {
+				if t, ok := translations[tag][id]; ok {
+					m.Translation = t.Translation
+					if t.TranslatorComment != "" {
+						m.TranslatorComment = t.TranslatorComment
+						m.Fuzzy = t.Fuzzy
+					}
+					break
+				}
+			}
+			if tag == s.Config.SourceLanguage && m.Translation.IsEmpty() {
+				m.Translation = m.Message
+				if m.TranslatorComment == "" {
+					m.TranslatorComment = "Copied from source."
+					m.Fuzzy = true
+				}
+			}
+			// TODO: if translation is empty: pre-expand based on available
+			// linguistic features. This may also be done as a plugin.
+			ms.Messages = append(ms.Messages, m)
+		}
+		s.Messages = append(s.Messages, ms)
+	}
+	return nil
 }
 
 // Export writes out the messages to translation out files.
 func (s *State) Export() error {
-	langs := []language.Tag{s.Config.SourceLanguage}
-	for _, t := range s.Translations {
-		langs = append(langs, t.Language)
-	}
-	langs = internal.UniqueTags(langs)
-	out := s.Extracted
 	path, err := outPattern(s)
 	if err != nil {
 		return wrap(err, "export failed")
 	}
-	for _, tag := range langs {
+	for _, out := range s.Messages {
 		// TODO: inject translations from existing files to avoid retranslation.
-		out.Language = tag
 		data, err := json.MarshalIndent(out, "", "    ")
 		if err != nil {
 			return wrap(err, "JSON marshal failed")
 		}
-		file := fmt.Sprintf(path, tag)
+		file := fmt.Sprintf(path, out.Language)
 		if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
 			return wrap(err, "dir create failed")
 		}
diff --git a/message/pipeline/pipeline_test.go b/message/pipeline/pipeline_test.go
index 78815f1..293101b 100644
--- a/message/pipeline/pipeline_test.go
+++ b/message/pipeline/pipeline_test.go
@@ -52,7 +52,7 @@
 			s, err := Extract(&config)
 			chk(t, err)
 			chk(t, s.Import())
-			// chk(t, s.Merge()) // TODO
+			chk(t, s.Merge())
 			// TODO:
 			//  for range s.Config.Actions {
 			//  	//  TODO: do the actions.
diff --git a/message/pipeline/testdata/test1/catalog_gen.go b/message/pipeline/testdata/test1/catalog_gen.go
index 8195287..7d93f48 100644
--- a/message/pipeline/testdata/test1/catalog_gen.go
+++ b/message/pipeline/testdata/test1/catalog_gen.go
@@ -37,21 +37,22 @@
 }
 
 var messageKeyToIndex = map[string]int{
-	"%.2[1]f miles traveled (%[1]f)": 7,
-	"%[1]s is visiting %[3]s!\n":     3,
-	"%d files remaining!":            4,
-	"%d more files remaining!":       5,
-	"%s is out of order!":            6,
-	"%s is visiting %s!\n":           2,
-	"Hello %s!\n":                    1,
-	"Hello world!\n":                 0,
+	"%.2[1]f miles traveled (%[1]f)":                 8,
+	"%[1]s is visiting %[3]s!\n":                     3,
+	"%d files remaining!":                            4,
+	"%d more files remaining!":                       5,
+	"%s is out of order!":                            7,
+	"%s is visiting %s!\n":                           2,
+	"Hello %s!\n":                                    1,
+	"Hello world!\n":                                 0,
+	"Use the following code for your discount: %d\n": 6,
 }
 
-var deIndex = []uint32{ // 9 elements
+var deIndex = []uint32{ // 10 elements
 	0x00000000, 0x00000011, 0x00000023, 0x0000003d,
 	0x00000057, 0x00000075, 0x00000094, 0x00000094,
-	0x00000094,
-} // Size: 60 bytes
+	0x00000094, 0x00000094,
+} // Size: 64 bytes
 
 const deData string = "" + // Size: 148 bytes
 	"\x04\x00\x01\x0a\x0c\x02Hallo Welt!\x04\x00\x01\x0a\x0d\x02Hallo %[1]s!" +
@@ -59,25 +60,26 @@
 	"s besucht %[3]s!\x02Noch zwei Bestände zu gehen!\x02Noch %[1]d Bestände " +
 	"zu gehen!"
 
-var en_USIndex = []uint32{ // 9 elements
+var en_USIndex = []uint32{ // 10 elements
 	0x00000000, 0x00000012, 0x00000024, 0x00000042,
-	0x00000060, 0x00000060, 0x000000a3, 0x000000ba,
-	0x000000d9,
-} // Size: 60 bytes
+	0x00000060, 0x00000077, 0x000000ba, 0x000000ef,
+	0x00000106, 0x00000125,
+} // Size: 64 bytes
 
-const en_USData string = "" + // Size: 217 bytes
+const en_USData string = "" + // Size: 293 bytes
 	"\x04\x00\x01\x0a\x0d\x02Hello world!\x04\x00\x01\x0a\x0d\x02Hello %[1]s!" +
 	"\x04\x00\x01\x0a\x19\x02%[1]s is visiting %[2]s!\x04\x00\x01\x0a\x19\x02" +
-	"%[1]s is visiting %[3]s!\x14\x01\x81\x01\x00\x02\x14\x02One file remaini" +
-	"ng!\x00&\x02There are %[1]d more files remaining!\x02%[1]s is out of ord" +
-	"er!\x02%.2[1]f miles traveled (%[1]f)"
+	"%[1]s is visiting %[3]s!\x02%[1]d files remaining!\x14\x01\x81\x01\x00" +
+	"\x02\x14\x02One file remaining!\x00&\x02There are %[1]d more files remai" +
+	"ning!\x04\x00\x01\x0a0\x02Use the following code for your discount: %[1]" +
+	"d\x02%[1]s is out of order!\x02%.2[1]f miles traveled (%[1]f)"
 
-var zhIndex = []uint32{ // 9 elements
+var zhIndex = []uint32{ // 10 elements
 	0x00000000, 0x00000000, 0x00000000, 0x00000000,
 	0x00000000, 0x00000000, 0x00000000, 0x00000000,
-	0x00000000,
-} // Size: 60 bytes
+	0x00000000, 0x00000000,
+} // Size: 64 bytes
 
 const zhData string = ""
 
-// Total table size 545 bytes (0KiB); checksum: 343E0210
+// Total table size 633 bytes (0KiB); checksum: 74B32E70
diff --git a/message/pipeline/testdata/test1/catalog_gen.go.want b/message/pipeline/testdata/test1/catalog_gen.go.want
index 8195287..7d93f48 100644
--- a/message/pipeline/testdata/test1/catalog_gen.go.want
+++ b/message/pipeline/testdata/test1/catalog_gen.go.want
@@ -37,21 +37,22 @@
 }
 
 var messageKeyToIndex = map[string]int{
-	"%.2[1]f miles traveled (%[1]f)": 7,
-	"%[1]s is visiting %[3]s!\n":     3,
-	"%d files remaining!":            4,
-	"%d more files remaining!":       5,
-	"%s is out of order!":            6,
-	"%s is visiting %s!\n":           2,
-	"Hello %s!\n":                    1,
-	"Hello world!\n":                 0,
+	"%.2[1]f miles traveled (%[1]f)":                 8,
+	"%[1]s is visiting %[3]s!\n":                     3,
+	"%d files remaining!":                            4,
+	"%d more files remaining!":                       5,
+	"%s is out of order!":                            7,
+	"%s is visiting %s!\n":                           2,
+	"Hello %s!\n":                                    1,
+	"Hello world!\n":                                 0,
+	"Use the following code for your discount: %d\n": 6,
 }
 
-var deIndex = []uint32{ // 9 elements
+var deIndex = []uint32{ // 10 elements
 	0x00000000, 0x00000011, 0x00000023, 0x0000003d,
 	0x00000057, 0x00000075, 0x00000094, 0x00000094,
-	0x00000094,
-} // Size: 60 bytes
+	0x00000094, 0x00000094,
+} // Size: 64 bytes
 
 const deData string = "" + // Size: 148 bytes
 	"\x04\x00\x01\x0a\x0c\x02Hallo Welt!\x04\x00\x01\x0a\x0d\x02Hallo %[1]s!" +
@@ -59,25 +60,26 @@
 	"s besucht %[3]s!\x02Noch zwei Bestände zu gehen!\x02Noch %[1]d Bestände " +
 	"zu gehen!"
 
-var en_USIndex = []uint32{ // 9 elements
+var en_USIndex = []uint32{ // 10 elements
 	0x00000000, 0x00000012, 0x00000024, 0x00000042,
-	0x00000060, 0x00000060, 0x000000a3, 0x000000ba,
-	0x000000d9,
-} // Size: 60 bytes
+	0x00000060, 0x00000077, 0x000000ba, 0x000000ef,
+	0x00000106, 0x00000125,
+} // Size: 64 bytes
 
-const en_USData string = "" + // Size: 217 bytes
+const en_USData string = "" + // Size: 293 bytes
 	"\x04\x00\x01\x0a\x0d\x02Hello world!\x04\x00\x01\x0a\x0d\x02Hello %[1]s!" +
 	"\x04\x00\x01\x0a\x19\x02%[1]s is visiting %[2]s!\x04\x00\x01\x0a\x19\x02" +
-	"%[1]s is visiting %[3]s!\x14\x01\x81\x01\x00\x02\x14\x02One file remaini" +
-	"ng!\x00&\x02There are %[1]d more files remaining!\x02%[1]s is out of ord" +
-	"er!\x02%.2[1]f miles traveled (%[1]f)"
+	"%[1]s is visiting %[3]s!\x02%[1]d files remaining!\x14\x01\x81\x01\x00" +
+	"\x02\x14\x02One file remaining!\x00&\x02There are %[1]d more files remai" +
+	"ning!\x04\x00\x01\x0a0\x02Use the following code for your discount: %[1]" +
+	"d\x02%[1]s is out of order!\x02%.2[1]f miles traveled (%[1]f)"
 
-var zhIndex = []uint32{ // 9 elements
+var zhIndex = []uint32{ // 10 elements
 	0x00000000, 0x00000000, 0x00000000, 0x00000000,
 	0x00000000, 0x00000000, 0x00000000, 0x00000000,
-	0x00000000,
-} // Size: 60 bytes
+	0x00000000, 0x00000000,
+} // Size: 64 bytes
 
 const zhData string = ""
 
-// Total table size 545 bytes (0KiB); checksum: 343E0210
+// Total table size 633 bytes (0KiB); checksum: 74B32E70
diff --git a/message/pipeline/testdata/test1/locales/de/out.gotext.json b/message/pipeline/testdata/test1/locales/de/out.gotext.json
index 9c7e594..f19e21d 100755
--- a/message/pipeline/testdata/test1/locales/de/out.gotext.json
+++ b/message/pipeline/testdata/test1/locales/de/out.gotext.json
@@ -3,16 +3,13 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "testdata/test1/test1.go:19:10"
+            "translation": "Hallo Welt!"
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
-            "translation": "",
+            "translation": "Hallo {City}!",
             "placeholders": [
                 {
                     "id": "City",
@@ -22,14 +19,12 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "testdata/test1/test1.go:24:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
-            "translation": "",
+            "translation": "{Person} besucht {Place}!",
             "placeholders": [
                 {
                     "id": "Person",
@@ -49,49 +44,12 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "testdata/test1/test1.go:30:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Field names are placeholders.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "testdata/test1/test1.go:44:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
-            "translation": "",
+            "translation": "Noch zwei Bestände zu gehen!",
             "placeholders": [
                 {
                     "id": "2",
@@ -101,14 +59,12 @@
                     "argNum": 1,
                     "expr": "2"
                 }
-            ],
-            "position": "testdata/test1/test1.go:51:10"
+            ]
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
-            "translation": "",
+            "translation": "Noch {N} Bestände zu gehen!",
             "placeholders": [
                 {
                     "id": "N",
@@ -118,12 +74,10 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "testdata/test1/test1.go:56:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
             "translation": "",
             "placeholders": [
@@ -135,15 +89,13 @@
                     "argNum": 1,
                     "expr": "c"
                 }
-            ],
-            "position": "testdata/test1/test1.go:64:10"
+            ]
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
             "comment": "This comment wins.\n",
@@ -156,12 +108,10 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "testdata/test1/test1.go:70:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
             "translation": "",
             "placeholders": [
@@ -181,8 +131,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "testdata/test1/test1.go:74:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/de/out.gotext.json.want b/message/pipeline/testdata/test1/locales/de/out.gotext.json.want
index 9c7e594..f19e21d 100755
--- a/message/pipeline/testdata/test1/locales/de/out.gotext.json.want
+++ b/message/pipeline/testdata/test1/locales/de/out.gotext.json.want
@@ -3,16 +3,13 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "testdata/test1/test1.go:19:10"
+            "translation": "Hallo Welt!"
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
-            "translation": "",
+            "translation": "Hallo {City}!",
             "placeholders": [
                 {
                     "id": "City",
@@ -22,14 +19,12 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "testdata/test1/test1.go:24:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
-            "translation": "",
+            "translation": "{Person} besucht {Place}!",
             "placeholders": [
                 {
                     "id": "Person",
@@ -49,49 +44,12 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "testdata/test1/test1.go:30:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Field names are placeholders.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "testdata/test1/test1.go:44:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
-            "translation": "",
+            "translation": "Noch zwei Bestände zu gehen!",
             "placeholders": [
                 {
                     "id": "2",
@@ -101,14 +59,12 @@
                     "argNum": 1,
                     "expr": "2"
                 }
-            ],
-            "position": "testdata/test1/test1.go:51:10"
+            ]
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
-            "translation": "",
+            "translation": "Noch {N} Bestände zu gehen!",
             "placeholders": [
                 {
                     "id": "N",
@@ -118,12 +74,10 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "testdata/test1/test1.go:56:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
             "translation": "",
             "placeholders": [
@@ -135,15 +89,13 @@
                     "argNum": 1,
                     "expr": "c"
                 }
-            ],
-            "position": "testdata/test1/test1.go:64:10"
+            ]
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
             "comment": "This comment wins.\n",
@@ -156,12 +108,10 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "testdata/test1/test1.go:70:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
             "translation": "",
             "placeholders": [
@@ -181,8 +131,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "testdata/test1/test1.go:74:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/en-US/out.gotext.json b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json
index 4d317af..59f92a5 100755
--- a/message/pipeline/testdata/test1/locales/en-US/out.gotext.json
+++ b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json
@@ -3,16 +3,13 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "testdata/test1/test1.go:19:10"
+            "translation": "Hello world!"
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
-            "translation": "",
+            "translation": "Hello {City}!",
             "placeholders": [
                 {
                     "id": "City",
@@ -22,14 +19,12 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "testdata/test1/test1.go:24:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
-            "translation": "",
+            "translation": "{Person} is visiting {Place}!",
             "placeholders": [
                 {
                     "id": "Person",
@@ -49,49 +44,13 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "testdata/test1/test1.go:30:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Field names are placeholders.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "testdata/test1/test1.go:44:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
-            "translation": "",
+            "translation": "{2} files remaining!",
+            "translatorComment": "Copied from source.",
             "placeholders": [
                 {
                     "id": "2",
@@ -102,13 +61,25 @@
                     "expr": "2"
                 }
             ],
-            "position": "testdata/test1/test1.go:51:10"
+            "fuzzy": true
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
-            "translation": "",
+            "translation": {
+                "select": {
+                    "feature": "plural",
+                    "arg": "N",
+                    "cases": {
+                        "one": {
+                            "msg": "One file remaining!"
+                        },
+                        "other": {
+                            "msg": "There are {N} more files remaining!"
+                        }
+                    }
+                }
+            },
             "placeholders": [
                 {
                     "id": "N",
@@ -118,14 +89,13 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "testdata/test1/test1.go:56:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
-            "translation": "",
+            "translation": "Use the following code for your discount: {ReferralCode}",
+            "translatorComment": "Copied from source.",
             "placeholders": [
                 {
                     "id": "ReferralCode",
@@ -136,16 +106,15 @@
                     "expr": "c"
                 }
             ],
-            "position": "testdata/test1/test1.go:64:10"
+            "fuzzy": true
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
-            "translation": "",
+            "translation": "{Device} is out of order!",
             "comment": "This comment wins.\n",
             "placeholders": [
                 {
@@ -156,14 +125,12 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "testdata/test1/test1.go:70:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
-            "translation": "",
+            "translation": "{Miles} miles traveled ({Miles_1})",
             "placeholders": [
                 {
                     "id": "Miles",
@@ -181,8 +148,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "testdata/test1/test1.go:74:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want
index 4d317af..59f92a5 100755
--- a/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want
+++ b/message/pipeline/testdata/test1/locales/en-US/out.gotext.json.want
@@ -3,16 +3,13 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "testdata/test1/test1.go:19:10"
+            "translation": "Hello world!"
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
-            "translation": "",
+            "translation": "Hello {City}!",
             "placeholders": [
                 {
                     "id": "City",
@@ -22,14 +19,12 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "testdata/test1/test1.go:24:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
-            "translation": "",
+            "translation": "{Person} is visiting {Place}!",
             "placeholders": [
                 {
                     "id": "Person",
@@ -49,49 +44,13 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "testdata/test1/test1.go:30:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Field names are placeholders.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "testdata/test1/test1.go:44:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
-            "translation": "",
+            "translation": "{2} files remaining!",
+            "translatorComment": "Copied from source.",
             "placeholders": [
                 {
                     "id": "2",
@@ -102,13 +61,25 @@
                     "expr": "2"
                 }
             ],
-            "position": "testdata/test1/test1.go:51:10"
+            "fuzzy": true
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
-            "translation": "",
+            "translation": {
+                "select": {
+                    "feature": "plural",
+                    "arg": "N",
+                    "cases": {
+                        "one": {
+                            "msg": "One file remaining!"
+                        },
+                        "other": {
+                            "msg": "There are {N} more files remaining!"
+                        }
+                    }
+                }
+            },
             "placeholders": [
                 {
                     "id": "N",
@@ -118,14 +89,13 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "testdata/test1/test1.go:56:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
-            "translation": "",
+            "translation": "Use the following code for your discount: {ReferralCode}",
+            "translatorComment": "Copied from source.",
             "placeholders": [
                 {
                     "id": "ReferralCode",
@@ -136,16 +106,15 @@
                     "expr": "c"
                 }
             ],
-            "position": "testdata/test1/test1.go:64:10"
+            "fuzzy": true
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
-            "translation": "",
+            "translation": "{Device} is out of order!",
             "comment": "This comment wins.\n",
             "placeholders": [
                 {
@@ -156,14 +125,12 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "testdata/test1/test1.go:70:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
-            "translation": "",
+            "translation": "{Miles} miles traveled ({Miles_1})",
             "placeholders": [
                 {
                     "id": "Miles",
@@ -181,8 +148,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "testdata/test1/test1.go:74:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/zh/out.gotext.json b/message/pipeline/testdata/test1/locales/zh/out.gotext.json
index 6f8aa67..9bede65 100755
--- a/message/pipeline/testdata/test1/locales/zh/out.gotext.json
+++ b/message/pipeline/testdata/test1/locales/zh/out.gotext.json
@@ -3,14 +3,11 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "testdata/test1/test1.go:19:10"
+            "translation": ""
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
             "translation": "",
             "placeholders": [
@@ -22,12 +19,10 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "testdata/test1/test1.go:24:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
             "translation": "",
             "placeholders": [
@@ -49,47 +44,10 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "testdata/test1/test1.go:30:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Field names are placeholders.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "testdata/test1/test1.go:44:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
             "translation": "",
             "placeholders": [
@@ -101,12 +59,10 @@
                     "argNum": 1,
                     "expr": "2"
                 }
-            ],
-            "position": "testdata/test1/test1.go:51:10"
+            ]
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
             "translation": "",
             "placeholders": [
@@ -118,12 +74,10 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "testdata/test1/test1.go:56:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
             "translation": "",
             "placeholders": [
@@ -135,15 +89,13 @@
                     "argNum": 1,
                     "expr": "c"
                 }
-            ],
-            "position": "testdata/test1/test1.go:64:10"
+            ]
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
             "comment": "This comment wins.\n",
@@ -156,12 +108,10 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "testdata/test1/test1.go:70:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
             "translation": "",
             "placeholders": [
@@ -181,8 +131,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "testdata/test1/test1.go:74:10"
+            ]
         }
     ]
 }
\ No newline at end of file
diff --git a/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want b/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want
index 6f8aa67..9bede65 100755
--- a/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want
+++ b/message/pipeline/testdata/test1/locales/zh/out.gotext.json.want
@@ -3,14 +3,11 @@
     "messages": [
         {
             "id": "Hello world!",
-            "key": "Hello world!\n",
             "message": "Hello world!",
-            "translation": "",
-            "position": "testdata/test1/test1.go:19:10"
+            "translation": ""
         },
         {
             "id": "Hello {City}!",
-            "key": "Hello %s!\n",
             "message": "Hello {City}!",
             "translation": "",
             "placeholders": [
@@ -22,12 +19,10 @@
                     "argNum": 1,
                     "expr": "city"
                 }
-            ],
-            "position": "testdata/test1/test1.go:24:10"
+            ]
         },
         {
             "id": "{Person} is visiting {Place}!",
-            "key": "%s is visiting %s!\n",
             "message": "{Person} is visiting {Place}!",
             "translation": "",
             "placeholders": [
@@ -49,47 +44,10 @@
                     "expr": "place",
                     "comment": "Place the person is visiting."
                 }
-            ],
-            "position": "testdata/test1/test1.go:30:10"
-        },
-        {
-            "id": "{Person} is visiting {Place}!",
-            "key": "%[1]s is visiting %[3]s!\n",
-            "message": "{Person} is visiting {Place}!",
-            "translation": "",
-            "comment": "Field names are placeholders.",
-            "placeholders": [
-                {
-                    "id": "Person",
-                    "string": "%[1]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 1,
-                    "expr": "pp.Person"
-                },
-                {
-                    "id": "Place",
-                    "string": "%[3]s",
-                    "type": "string",
-                    "underlyingType": "string",
-                    "argNum": 3,
-                    "expr": "pp.Place",
-                    "comment": "Place the person is visiting."
-                },
-                {
-                    "id": "Extra",
-                    "string": "%[2]v",
-                    "type": "int",
-                    "underlyingType": "int",
-                    "argNum": 2,
-                    "expr": "pp.extra"
-                }
-            ],
-            "position": "testdata/test1/test1.go:44:10"
+            ]
         },
         {
             "id": "{2} files remaining!",
-            "key": "%d files remaining!",
             "message": "{2} files remaining!",
             "translation": "",
             "placeholders": [
@@ -101,12 +59,10 @@
                     "argNum": 1,
                     "expr": "2"
                 }
-            ],
-            "position": "testdata/test1/test1.go:51:10"
+            ]
         },
         {
             "id": "{N} more files remaining!",
-            "key": "%d more files remaining!",
             "message": "{N} more files remaining!",
             "translation": "",
             "placeholders": [
@@ -118,12 +74,10 @@
                     "argNum": 1,
                     "expr": "n"
                 }
-            ],
-            "position": "testdata/test1/test1.go:56:10"
+            ]
         },
         {
             "id": "Use the following code for your discount: {ReferralCode}",
-            "key": "Use the following code for your discount: %d\n",
             "message": "Use the following code for your discount: {ReferralCode}",
             "translation": "",
             "placeholders": [
@@ -135,15 +89,13 @@
                     "argNum": 1,
                     "expr": "c"
                 }
-            ],
-            "position": "testdata/test1/test1.go:64:10"
+            ]
         },
         {
             "id": [
                 "msgOutOfOrder",
                 "{Device} is out of order!"
             ],
-            "key": "%s is out of order!",
             "message": "{Device} is out of order!",
             "translation": "",
             "comment": "This comment wins.\n",
@@ -156,12 +108,10 @@
                     "argNum": 1,
                     "expr": "device"
                 }
-            ],
-            "position": "testdata/test1/test1.go:70:10"
+            ]
         },
         {
             "id": "{Miles} miles traveled ({Miles_1})",
-            "key": "%.2[1]f miles traveled (%[1]f)",
             "message": "{Miles} miles traveled ({Miles_1})",
             "translation": "",
             "placeholders": [
@@ -181,8 +131,7 @@
                     "argNum": 1,
                     "expr": "miles"
                 }
-            ],
-            "position": "testdata/test1/test1.go:74:10"
+            ]
         }
     ]
 }
\ No newline at end of file