Add translators to the credits dialog

- Add tooling at //tools/i18n to generate the file with the information
  about the translators: //src/json/i18n-credits.json.
- Change credits.json to remove an entry from a translator, who is now
  mentioned in the i18n credits.
- Change Makefile and release.bash to incorporate the i18n credit
  generation in the process of building the extension.
- Change options page to accommodate the translators section.

Change-Id: I7f3991f9c456c381832f4a7bebdfc5581ef9e4be
diff --git a/.gitignore b/.gitignore
index 125c39b..a509c3e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 out/
 src/manifest.json
+src/json/i18n-credits.json
 crowdin.yaml
diff --git a/Makefile b/Makefile
index 3f174f1..98aec15 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,15 @@
-.PHONY: all chromium-stable chromium-beta
+.PHONY: all i18n-credits chromium-stable chromium-beta
 
 all: chromium-stable chromium-beta
 
-chromium-stable:
-	bash release.bash -c stable -b chromium
+i18n-credits:
+	bash generatei18nCredits.bash
 
-chromium-beta:
-	bash release.bash -c beta -b chromium
+chromium-stable: i18n-credits
+	bash release.bash -c stable -b chromium -f
+
+chromium-beta: i18n-credits
+	bash release.bash -c beta -b chromium -f
 
 clean:
 	rm -rf out
diff --git a/generatei18nCredits.bash b/generatei18nCredits.bash
new file mode 100644
index 0000000..38da15b
--- /dev/null
+++ b/generatei18nCredits.bash
@@ -0,0 +1,2 @@
+#!/bin/bash
+(cd tools/i18n && go run generate-i18n-credits.go)
diff --git a/release.bash b/release.bash
index 94ab75a..b29162c 100644
--- a/release.bash
+++ b/release.bash
@@ -15,6 +15,8 @@
     -b, --browser  indicates the target browser for the release. As of
                    now it can only be "chromium", which is also the
                    default value.
+    -f, --fast     indicates that the release shouldn't generate the
+                   i18n credits JSON file.
 
 END
 }
@@ -25,11 +27,12 @@
 }
 
 # Get options
-opts=$(getopt -l "help,channel:,browser:" -o "hc:b:" -n "$progname" -- "$@")
+opts=$(getopt -l "help,channel:,browser:,fast" -o "hc:b:f" -n "$progname" -- "$@")
 eval set -- "$opts"
 
 channel=stable
 browser=chromium
+fast=0
 
 while true; do
   case "$1" in
@@ -45,6 +48,10 @@
       browser="$2"
       shift 2
       ;;
+    -f | --fast)
+      fast=1
+      shift
+      ;;
     *) break ;;
   esac
 done
@@ -69,6 +76,11 @@
 
 bash generateManifest.bash "${dependencies[@]}"
 
+# Also, generate the credits for the translators
+if [[ $fast == 0 ]]; then
+  bash generatei18nCredits.bash
+fi
+
 # This is the version name which git gives us
 version=$(git describe --always --tags --dirty)
 
diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json
index 3ba9d52..433fd17 100644
--- a/src/_locales/en/messages.json
+++ b/src/_locales/en/messages.json
@@ -85,6 +85,14 @@
 		"message": "by",
 		"description": "Fragment of the author statement in an item of the credits. NOTE: put in in lowercase letters. EXAMPLE: '{{options_credits_by}} Adrià Vilanova Martínez'"
 	},
+  "options_credits_translations": {
+    "message": "Translations",
+    "description": "Header for the section in the credits dialog which recognizes translators."
+  },
+  "options_credits_translations_paragraph": {
+    "message": "I would like to give a very special thank you to the following contributors, who have selflessly translated the extension interface to many languages:",
+    "description": "Paragraph in the 'Translations' section of the credits dialog, which recognizes translators. Following this paragraph there's a list with all the translators' names (if you've translated a string in Crowdin, you'll automatically be added to the list in the following extension update)."
+  },
 	"options_ok": {
 		"message": "OK",
 		"description": "OK button in the dialogs"
diff --git a/src/css/options.css b/src/css/options.css
index 7808e30..9aa7b75 100644
--- a/src/css/options.css
+++ b/src/css/options.css
@@ -104,7 +104,7 @@
   width: 100%;
 }
 
-dialog .action_buttons {
+dialog#languages_add_dialog .action_buttons {
   margin-top: 10px;
   float: right;
 }
@@ -140,6 +140,7 @@
   left: 50%;
   margin-left: -216px;
   margin-top: -231px;
+  padding: 0;
   height: 430px;
   width: 400px;
   border: 1px solid rgba(0, 0, 0, 0.3);
@@ -147,25 +148,49 @@
   box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
 }
 
+dialog#credits_dialog[open] {
+  display: flex;
+  flex-direction: column;
+}
+
+dialog#credits_dialog .scrollable {
+  padding: 1em;
+  overflow-y: auto;
+}
+
 dialog#credits_dialog .content_area h4 {
   margin-bottom: 0px;
 }
 
-dialog#credits_dialog .content_area a.homepage {
+dialog#credits_dialog .entry {
+  position: relative;
+}
+
+dialog#credits_dialog .entry a.homepage {
   position: absolute;
   right: 16px;
   font-size: 14px;
 }
 
-dialog#credits_dialog .content_area p,
-dialog#credits_dialog .content_area span {
+dialog#credits_dialog p,
+dialog#credits_dialog span {
   font-size: 14px;
 }
 
-dialog#credits_dialog .content_area p.author {
+dialog#credits_dialog p.author {
   margin-top: 7px;
 }
 
+dialog#credits_dialog #translators .name {
+  font-weight: bold;
+}
+
+dialog .action_buttons {
+  border-top: 1px solid #ccc;
+  padding: 1em;
+  text-align: right;
+}
+
 #otheroptions p {
   margin-top: 0;
   margin-bottom: 0;
diff --git a/src/js/options.js b/src/js/options.js
index a639c56..ca66004 100644
--- a/src/js/options.js
+++ b/src/js/options.js
@@ -129,12 +129,17 @@
     });
 
     // About credits...
-    fetch('json/credits.json')
-        .then(res => res.json())
-        .then(json => {
+    var normalCredits = fetch('json/credits.json').then(res => res.json());
+    var i18nCredits = fetch('json/i18n-credits.json').then(res => res.json());
+
+    Promise.all([normalCredits, i18nCredits])
+        .then(values => {
+          var credits = values[0];
+          var i18nCredits = values[1];
           var content = $('dialog#credits_dialog .content_area');
-          json.forEach(item => {
+          credits.forEach(item => {
             var div = document.createElement('div');
+            div.classList.add('entry');
             if (item.url) {
               var a = document.createElement('a');
               a.classList.add('homepage');
@@ -160,9 +165,35 @@
             content.append(div);
           });
 
+          var cList = document.getElementById('translators');
+          i18nCredits.forEach(contributor => {
+            var li = document.createElement('li');
+            var languages = [];
+            if (contributor.languages) {
+              contributor.languages.forEach(lang => {
+                languages.push(lang.name || 'undefined');
+              });
+            }
+
+            var name = document.createElement('span');
+            name.classList.add('name');
+            name.textContent = contributor.name || 'undefined';
+            li.append(name);
+
+            if (languages.length > 0) {
+              var languages =
+                  document.createTextNode(': ' + languages.join(', '));
+              li.append(languages);
+            }
+
+            cList.append(li);
+          });
+
           window.onhashchange = function() {
             if (location.hash == '#credits') {
-              $('dialog#credits_dialog').showModal();
+              var credits = document.getElementById('credits_dialog');
+              credits.showModal();
+              credits.querySelector('.scrollable').scrollTo(0, 0);
               $('#credits_ok').focus();
             }
           };
diff --git a/src/json/credits.json b/src/json/credits.json
index 9e8243e..690e2a6 100644
--- a/src/json/credits.json
+++ b/src/json/credits.json
@@ -5,11 +5,6 @@
     "author": "dAKirby309"
   },
   {
-    "name": "Russian Translation",
-    "url": "https://code.google.com/r/sashasimkin-translateselectedtext/source/detail?r=fc4e58ee0d69929d610a84a7600338e99b9d3d83",
-    "author": "Alexander Simkin"
-  },
-  {
     "name": "Sortable",
     "url": "https://github.com/RubaXa/Sortable",
     "author": "Lebedev Konstantin",
diff --git a/src/options.html b/src/options.html
index 2765d1a..ade625c 100644
--- a/src/options.html
+++ b/src/options.html
@@ -46,8 +46,18 @@
     </div>
   </dialog>
   <dialog id="credits_dialog">
-    <h3 data-i18n="credits"></h3>
-    <div class="content_area">
+    <div class="scrollable">
+      <h3 data-i18n="credits"></h3>
+      <div class="entry">
+        <a href="https://gtranslate.avm99963.com/" class="homepage" target="_blank" data-i18n="credits_homepage"></a>
+        <h4 data-i18n="credits_translations"></h4>
+        <div data-i18n="credits_translations_paragraph">
+        </div>
+        <ul id="translators">
+        </ul>
+      </div>
+      <div class="content_area">
+      </div>
     </div>
     <div class="action_buttons">
       <button id="credits_ok" data-i18n="ok"></button>
diff --git a/tools/i18n/blocked-users.txt b/tools/i18n/blocked-users.txt
new file mode 100644
index 0000000..52ad0a7
--- /dev/null
+++ b/tools/i18n/blocked-users.txt
@@ -0,0 +1,21 @@
+# These users were blocked because they are spammers, and therefore shouldn't be
+# included in the credits section.
+watabas
+p.karlsen
+Julpetter
+viki19822
+jjroot19
+mattiasxxpersson
+
+# These users joined the project but didn't make any translation, so they
+# shouldn't be credited neither.
+# NOTE: This list should be revisited in the future in case they actually
+# contribute.
+yolandarobinson3838
+ema168rutter
+bsiwanon
+nlmyst-35
+
+# This is the extension creator, so he shouldn't be credited as a translator
+# neither.
+avm99963
diff --git a/tools/i18n/generate-i18n-credits.go b/tools/i18n/generate-i18n-credits.go
new file mode 100644
index 0000000..93fa702
--- /dev/null
+++ b/tools/i18n/generate-i18n-credits.go
@@ -0,0 +1,319 @@
+package main
+
+import (
+	"bufio"
+	"encoding/json"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"os"
+	"strings"
+	"time"
+)
+
+const blockFileName = "blocked-users.txt"
+const projectId = "191707"
+const checkAttempts = 5
+const checkWaitTime = 2 * time.Second
+const baseApiURL = "https://api.crowdin.com/api/v2/"
+const i18nCreditsFile = "../../src/json/i18n-credits.json"
+
+// Contributors who have sent translations before the Crowdin instance
+// was set up:
+var additionalContributors = []Contributor{
+	Contributor{
+		Name: "Alexander Simkin",
+		Languages: []Language{
+			Language{
+				Id:   "ru",
+				Name: "Russian",
+			}},
+	}}
+
+type User struct {
+	Id        string `json:"id"`
+	Username  string `json:"username"`
+	FullName  string `json:"fullName"`
+	AvatarUrl string `json:"avatarUrl"`
+}
+
+type Language struct {
+	Id   string `json:"id"`
+	Name string `json:"name"`
+}
+
+type TopUser struct {
+	User          User       `json:"user"`
+	Languages     []Language `json:"languages"`
+	Translated    int        `json:"translated"`
+	Target        int        `json:"target"`
+	Approved      int        `json:"approved"`
+	Voted         int        `json:"voted"`
+	PositiveVotes int        `json:"positiveVotes"`
+	NegativeVotes int        `json:"negativeVotes"`
+	Winning       int        `json:"winning"`
+}
+
+type DateRange struct {
+	from string `json:"from"`
+	to   string `json:"to"`
+}
+
+type Report struct {
+	Name      string    `json:"name"`
+	Url       string    `json:"url"`
+	Unit      string    `json:"unit"`
+	DateRange DateRange `json:"dateRange"`
+	Language  string    `json:"language"`
+	TopUsers  []TopUser `json:"data"`
+}
+
+type Contributor struct {
+	Name      string     `json:"name"`
+	Languages []Language `json:"languages"`
+}
+
+func isBlocked(blockedUsers []string, user string) bool {
+	for _, u := range blockedUsers {
+		if user == u {
+			return true
+		}
+	}
+	return false
+}
+
+func getBlockedUsers() []string {
+	blockFile, err := os.Open(blockFileName)
+	if err != nil {
+		log.Fatalf("Couldn't open blockfile, error: %v", err)
+	}
+	defer blockFile.Close()
+
+	blocked := make([]string, 0)
+
+	scanner := bufio.NewScanner(blockFile)
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if len(line) == 0 || line[0] == '#' {
+			continue
+		}
+		blocked = append(blocked, line)
+	}
+
+	return blocked
+}
+
+func getJSONFromResponseBody(body *io.ReadCloser) (map[string]interface{}, error) {
+	var responseJSON map[string]interface{}
+
+	responseRawBody, err := ioutil.ReadAll(*body)
+	if err != nil {
+		return responseJSON, err
+	}
+	if err := json.Unmarshal(responseRawBody, &responseJSON); err != nil {
+		return responseJSON, err
+	}
+
+	return responseJSON, nil
+}
+
+func apiCall(path string, method string, body string) (*http.Response, error) {
+	token, isTokenSet := os.LookupEnv("GTRANSLATE_CROWDIN_API_KEY")
+	if !isTokenSet {
+		return nil, fmt.Errorf("Environmental variable GTRANSLATE_CROWDIN_API_KEY is not set.")
+	}
+
+	if body == "" {
+		body = "{}"
+	}
+
+	client := &http.Client{
+		Timeout: 10 * time.Second,
+	}
+
+	url := baseApiURL + path
+	bodyReader := strings.NewReader(body)
+	request, err := http.NewRequest(method, url, bodyReader)
+	if err != nil {
+		return nil, err
+	}
+
+	request.Header.Add("Authorization", "Bearer "+token)
+	request.Header.Add("Content-Type", "application/json")
+
+	return client.Do(request)
+}
+
+func generateReport(projectId string) (string, error) {
+	response, err := apiCall("projects/"+projectId+"/reports", "POST", `
+    {
+      "name": "top-members",
+      "schema": {
+        "unit": "words",
+        "format": "json"
+      }
+    }
+  `)
+	if err != nil {
+		return "", fmt.Errorf("Error while requesting top users report: %v", err)
+	}
+
+	if response.StatusCode != 201 {
+		return "", fmt.Errorf("Error while requesting top users report (status code %d)", response.StatusCode)
+	}
+
+	responseJSON, err := getJSONFromResponseBody(&response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	data := responseJSON["data"].(map[string]interface{})
+	return data["identifier"].(string), nil
+}
+
+func isReportGenerated(projectId string, reportId string) (bool, error) {
+	response, err := apiCall("projects/"+projectId+"/reports/"+reportId, "GET", "{}")
+	if err != nil {
+		return false, fmt.Errorf("Error while checking report generation: %v", err)
+	}
+
+	if response.StatusCode != 200 {
+		return false, fmt.Errorf("Error while checking report generation (status code %d)", response.StatusCode)
+	}
+
+	responseJSON, err := getJSONFromResponseBody(&response.Body)
+	if err != nil {
+		return false, err
+	}
+
+	data := responseJSON["data"].(map[string]interface{})
+	return data["status"].(string) == "finished", nil
+}
+
+func getReportUrl(projectId string, reportId string) (string, error) {
+	response, err := apiCall("projects/"+projectId+"/reports/"+reportId+"/download", "GET", "{}")
+	if err != nil {
+		return "", fmt.Errorf("Error while retrieving top users report download URL: %v", err)
+	}
+
+	if response.StatusCode != 200 {
+		return "", fmt.Errorf("Error while retrieving top users report download URL (status code %d)", response.StatusCode)
+	}
+
+	responseJSON, err := getJSONFromResponseBody(&response.Body)
+	if err != nil {
+		return "", err
+	}
+
+	data := responseJSON["data"].(map[string]interface{})
+	return data["url"].(string), nil
+}
+
+func getReport(projectId string, reportId string) (Report, error) {
+	reportUrl, err := getReportUrl(projectId, reportId)
+	if err != nil {
+		return Report{}, err
+	}
+
+	response, err := http.Get(reportUrl)
+	if err != nil {
+		return Report{}, fmt.Errorf("An error occurred while downloading the report: %v", err)
+	}
+
+	var report Report
+
+	responseRawBody, err := ioutil.ReadAll(response.Body)
+	if err != nil {
+		return Report{}, err
+	}
+	if err := json.Unmarshal(responseRawBody, &report); err != nil {
+		return Report{}, err
+	}
+
+	return report, nil
+}
+
+func getContributorsFromReport(report Report) []Contributor {
+	blockedUsers := getBlockedUsers()
+
+	contributors := make([]Contributor, 0)
+	for _, c := range additionalContributors {
+		contributors = append(contributors, c)
+	}
+	for _, u := range report.TopUsers {
+		if u.Translated <= 0 || isBlocked(blockedUsers, u.User.Username) {
+			continue
+		}
+		contributors = append(contributors, Contributor{
+			Name:      u.User.FullName,
+			Languages: u.Languages,
+		})
+	}
+	return contributors
+}
+
+func GetContributors(projectId string) ([]Contributor, error) {
+	id, err := generateReport(projectId)
+	if err != nil {
+		return nil, err
+	}
+
+	log.Printf("Top users report requested successfully (assigned id: %v)", id)
+
+	reportGenerated := false
+	for i := 0; i < checkAttempts; i++ {
+		currReportGenerated, err := isReportGenerated(projectId, id)
+		if err != nil {
+			log.Printf("[Try %d] Couldn't check whether the top users report has been generated, error: %v", i+1, err)
+		} else if currReportGenerated {
+			log.Printf("[Try %d] The top users report has been generated.", i+1)
+			reportGenerated = true
+			break
+		} else {
+			log.Printf("[Try %d] Top users report hasn't still been generated.", i+1)
+		}
+		time.Sleep(checkWaitTime)
+	}
+
+	if !reportGenerated {
+		return nil, fmt.Errorf("After %d checks, the top users report hasn't still been generated. Aborting.", checkAttempts)
+	}
+
+	report, err := getReport(projectId, id)
+	if err != nil {
+		return nil, fmt.Errorf("Couldn't retrieve top users report, error: %v", err)
+	}
+
+	return getContributorsFromReport(report), nil
+}
+
+func main() {
+	log.SetPrefix("[generate-i18n-credits] ")
+	log.SetFlags(0)
+
+	log.Println("Starting to generate i18n credits")
+
+	contributors, err := GetContributors(projectId)
+	if err != nil {
+		log.Fatalf("%v", err)
+	}
+
+	creditsFile, err := os.Create(i18nCreditsFile)
+	if err != nil {
+		log.Fatalf("Couldn't create i18n credits file, error: %v", err)
+	}
+	defer creditsFile.Close()
+
+	JSONBytes, err := json.MarshalIndent(contributors, "", "  ")
+	if err != nil {
+		log.Fatalf("Couldn't marshal Contributors interface, error: %v", err)
+	}
+
+	if _, err := creditsFile.Write(JSONBytes); err != nil {
+		log.Fatalf("Couldn't write to i18n credits file, error: %v", err)
+	}
+
+	log.Println("Done!")
+}