|
package main |
|
|
|
import ( |
|
"bufio" |
|
"bytes" |
|
"encoding/csv" |
|
"encoding/json" |
|
"flag" |
|
"os" |
|
"os/exec" |
|
"path" |
|
"strconv" |
|
"strings" |
|
"time" |
|
) |
|
|
|
func main() { |
|
gitRepo := flag.String("git-repo", "", "Path to git repository") |
|
flag.Parse() |
|
|
|
files := flag.Args() |
|
|
|
translationStrings := map[string]map[string]translationString{} |
|
variationKeys := map[string]map[string]struct{}{} |
|
blames := map[string]map[string]blame{} |
|
langs := []string{} |
|
|
|
for _, file := range files { |
|
f, _ := os.Open(file) |
|
defer f.Close() |
|
|
|
lang, _, _ := strings.Cut(path.Base(file), ".") |
|
langs = append(langs, lang) |
|
variationKeys[lang] = map[string]struct{}{} |
|
|
|
var v map[string]translationString |
|
json.NewDecoder(f).Decode(&v) |
|
|
|
for key, s := range v { |
|
if _, ok := translationStrings[key]; !ok { |
|
translationStrings[key] = map[string]translationString{} |
|
} |
|
translationStrings[key][lang] = s |
|
|
|
for k := range s.Variations { |
|
variationKeys[lang][k] = struct{}{} |
|
} |
|
} |
|
|
|
for key, b := range runBlame(*gitRepo, file) { |
|
if _, ok := blames[key]; !ok { |
|
blames[key] = map[string]blame{} |
|
} |
|
blames[key][lang] = b |
|
} |
|
} |
|
|
|
w := csv.NewWriter(os.Stdout) |
|
defer w.Flush() |
|
|
|
cols := []string{"key"} |
|
for _, lang := range langs { |
|
cols = append(cols, lang+"-changed", lang) |
|
for variation := range variationKeys[lang] { |
|
cols = append(cols, lang+"-"+variation) |
|
} |
|
} |
|
|
|
w.Write(cols) |
|
|
|
for key, ss := range translationStrings { |
|
row := []string{key} |
|
|
|
for _, lang := range langs { |
|
row = append(row, blames[key][lang].At.Format(time.RFC3339)) |
|
row = append(row, ss[lang].Value) |
|
for variation := range variationKeys[lang] { |
|
row = append(row, ss[lang].Variations[variation]) |
|
} |
|
} |
|
|
|
w.Write(row) |
|
} |
|
} |
|
|
|
type translationString struct { |
|
Value string |
|
Variations map[string]string |
|
} |
|
|
|
func (s *translationString) UnmarshalJSON(b []byte) error { |
|
var value string |
|
if err := json.Unmarshal(b, &value); err == nil { |
|
s.Value = value |
|
return nil |
|
} |
|
|
|
var variations map[string]string |
|
if err := json.Unmarshal(b, &variations); err == nil { |
|
s.Variations = variations |
|
return nil |
|
} |
|
|
|
return nil |
|
} |
|
|
|
type blame struct { |
|
Hash string |
|
Author string |
|
At time.Time |
|
} |
|
|
|
type blameRow struct { |
|
Hash string |
|
Author string |
|
At time.Time |
|
Keys []string |
|
} |
|
|
|
const ( |
|
authorPrefix = "author " |
|
authorTimePrefix = "author-time " |
|
keyPrefix = "\t " |
|
) |
|
|
|
func runBlame(gitRepo, file string) map[string]blame { |
|
cmd := exec.Command("git", "-C", gitRepo, "blame", "--line-porcelain", "--", file) |
|
data, _ := cmd.Output() |
|
|
|
r := bufio.NewScanner(bytes.NewReader(data)) |
|
|
|
rows := []blameRow{} |
|
currentRow := blameRow{} |
|
var nestedKey string |
|
blameInfos := map[string]blame{} |
|
|
|
for r.Scan() { |
|
line := r.Text() |
|
|
|
if strings.HasPrefix(line, authorPrefix) { |
|
rows = append(rows, currentRow) // start a new row |
|
currentRow = blameRow{} |
|
currentRow.Author = line[len(authorPrefix):] |
|
} else if strings.HasPrefix(line, authorTimePrefix) { |
|
t, _ := strconv.ParseInt(line[len(authorTimePrefix):], 10, 64) |
|
currentRow.At = time.Unix(t, 0) |
|
} else if strings.HasPrefix(line, keyPrefix) { |
|
if strings.TrimSpace(line) == "}," { |
|
nestedKey = "" |
|
continue |
|
} |
|
|
|
quotedKey, _, _ := strings.Cut(line[len(keyPrefix):], ":") |
|
key := quotedKey[1 : len(quotedKey)-1] |
|
if strings.HasPrefix(line[len(keyPrefix):], " ") { |
|
key = nestedKey |
|
} |
|
|
|
currentRow.Keys = append(currentRow.Keys, key) |
|
blameInfos[key] = blame{ |
|
At: currentRow.At, |
|
Hash: currentRow.Hash, |
|
Author: currentRow.Author, |
|
} |
|
|
|
if strings.HasSuffix(strings.TrimSpace(line), "{") { |
|
nestedKey = key |
|
} |
|
} |
|
} |
|
|
|
return blameInfos |
|
} |