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!")
+}