From f7d4a33ea91c0ad858a620e2994979286773c8db Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 30 Sep 2024 04:13:49 +0100 Subject: [PATCH] improvement(lint): 'TranslationTypo' - link to Crowdin Aims to save time when maintainers are performing translation syncs Sample link: https://crowdin.com/editor/ankidroid/7290/en-af#q=create_subdeck --- .../ichi2/anki/lint/rules/TranslationTypo.kt | 7 +- .../java/com/ichi2/anki/lint/utils/Crowdin.kt | 131 +++++++++++++++ .../anki/lint/rules/TranslationTypoTest.kt | 63 ++++++++ .../ichi2/anki/lint/testutils/LintAssert.kt | 18 ++- .../com/ichi2/anki/lint/utils/CrowdinTest.kt | 150 ++++++++++++++++++ tools/localization/src/update.ts | 1 + 6 files changed, 366 insertions(+), 4 deletions(-) create mode 100644 lint-rules/src/main/java/com/ichi2/anki/lint/utils/Crowdin.kt create mode 100644 lint-rules/src/test/java/com/ichi2/anki/lint/utils/CrowdinTest.kt diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/TranslationTypo.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/TranslationTypo.kt index 4c627672745e..0c96b6a1ea46 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/TranslationTypo.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/TranslationTypo.kt @@ -27,6 +27,7 @@ import com.android.tools.lint.detector.api.XmlContext import com.android.tools.lint.detector.api.XmlScanner import com.google.common.annotations.VisibleForTesting import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.CrowdinContext.Companion.toCrowdinContext import com.ichi2.anki.lint.utils.ext.isRightToLeftLanguage import org.w3c.dom.Element @@ -94,13 +95,17 @@ class TranslationTypo : ResourceXmlDetector(), XmlScanner { return } + val crowdinContext = context.toCrowdinContext() + /** Helper function to report 'TranslationTypo' issues */ fun Element.reportIssue(message: String) { val elementToReport = this + val crowdinEditUrl = crowdinContext?.getEditUrl(elementToReport) + ?.let { url -> "; $url" } ?: "" context.report( issue = ISSUE, location = context.getElementLocation(elementToReport), - message = message + message = message + crowdinEditUrl ) } diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/utils/Crowdin.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/Crowdin.kt new file mode 100644 index 000000000000..fb91f9321eee --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/Crowdin.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2024 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.lint.utils + +import com.android.tools.lint.detector.api.ResourceContext +import org.w3c.dom.Element +import java.io.File +import java.util.Locale + +/** + * Identifier of an XML file in a Crowdin URL. + * 8236 -> `20-search-preference.xml` in the URL https://crowdin.com/editor/ankidroid/8236/en-yu + */ +@JvmInline +value class CrowdinFileIdentifier(private val value: Long) { + override fun toString(): String = value.toString() + + companion object { + private val fileNameToIdentifier = mapOf( + "01-core" to 7290, + "02-strings" to 7291, + "03-dialogs" to 7303, + "04-network" to 8167, + "05-feedback" to 8168, + "06-statistics" to 8169, + "07-cardbrowser" to 8170, + "08-widget" to 8171, + "09-backup" to 8172, + "10-preferences" to 8173, + "11-arrays" to 8174, + "16-multimedia-editor" to 8229, + "17-model-manager" to 8230, + "19-standard-models" to 8232, + "20-search-preference" to 8236 + ).mapValues { CrowdinFileIdentifier(it.value.toLong()) } + + fun fromFile(file: File): CrowdinFileIdentifier? = + fileNameToIdentifier[file.nameWithoutExtension] + } +} + +/** + * The language key which Crowdin uses to represent the language: `yu`, NOT `yue` + */ +@JvmInline +value class CrowdinLanguageTag(private val tag: String) { + override fun toString() = tag + + companion object { + private val customMappings = mapOf( + /* from tools/localization/src/update.ts */ + "yue" to "yu", + "heb" to "he", + "iw" to "he", + "ind" to "id", + "tgl" to "tl", + + /* Other weirdness */ + + /* Malayalam */ + "ml" to "mlin", + /* Punjabi */ + "pa" to "pain", + // Norwegian Nynorsk + "nn" to "nnno", + + /* Tatar (Russia) */ + "tt" to "ttru", + + /* Urdu (Pakistan) */ + "ur" to "urpk", + + // Crowdin does not handle 'Spanish (Spain)' as 'eses', it needs 'es' + "eses" to "es", + "ptpt" to "pt" + ) + + fun fromFolderName(folderName: String): CrowdinLanguageTag { + val language = folderName.substring("values-".length) + .replace("-r", "") // es-rAR -> esAR + .lowercase(Locale.ROOT) // esAR -> esar + + val crowdinLanguage = customMappings[language] ?: language + + return CrowdinLanguageTag(crowdinLanguage) + } + + fun fromFolder(folder: File): CrowdinLanguageTag = fromFolderName(folder.name) + } +} + +/** + * How Crowdin represents the Android path of `values-yue/01-core.xml` + * @param languageTag How 'values-zh-rCN' is represented. See [CrowdinLanguageTag] + * @param fileIdentifier How `01-core` is represented. See [CrowdinFileIdentifier] + */ +data class CrowdinContext(val languageTag: CrowdinLanguageTag, val fileIdentifier: CrowdinFileIdentifier) { + private fun getStringName(element: Element): String? = + if (element.hasAttribute("name")) element.getAttribute("name") else null + + fun getEditUrl(element: Element): String? { + val stringName = getStringName(element) ?: return null + return getEditUrl(stringName) + } + + /** Example: [https://crowdin.com/editor/ankidroid/7290/en-af#q=create_subdeck](https://crowdin.com/editor/ankidroid/7290/en-af#q=create_subdeck) */ + fun getEditUrl(string: String): String = + "https://crowdin.com/editor/ankidroid/$fileIdentifier/en-$languageTag#q=$string" + + companion object { + fun ResourceContext.toCrowdinContext(): CrowdinContext? { + val androidLanguage = CrowdinLanguageTag.fromFolder(file.parentFile) + val identifier = CrowdinFileIdentifier.fromFile(file) ?: return null + return CrowdinContext(androidLanguage, identifier) + } + } +} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/TranslationTypoTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/TranslationTypoTest.kt index 815a8f183d6d..19a3c244e27a 100644 --- a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/TranslationTypoTest.kt +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/TranslationTypoTest.kt @@ -69,4 +69,67 @@ class TranslationTypoTest { TranslationTypo.ISSUE.assertXmlStringsNoIssues(stringRemoved) } + + /** A link to the string on Crowdin should be provided */ + @Test + fun crowdinEditLinkIsProvided() { + // Use links in the form: https://crowdin.com/editor/ankidroid/7290/en-af#q=create_subdeck + // where 7290 is 01-core.xml, `en-af` is Afrikaans, and `create_subdeck` is the key + + // The actual link is https://crowdin.com/editor/ankidroid/7290/en-af#6534818, but + // we don't have context to map from `create_subdeck` to `6534818` + + // We do not use '...', as this is not checked for RTL languages + val error = """ + javascript + """ + + // 'standard' test + TranslationTypo.ISSUE.assertXmlStringsHasError( + error, + expectedError = "https://crowdin.com/editor/ankidroid/7290/en-af#q=create_subdeck", + fileName = "01-core", + androidLanguageFolder = "af" + ) + + // 02-strings -> 7291 + TranslationTypo.ISSUE.assertXmlStringsHasError( + error, + expectedError = "https://crowdin.com/editor/ankidroid/7291/en-af#q=create_subdeck", + fileName = "02-strings", + androidLanguageFolder = "af" + ) + + // custom mapping: yue -> yu + TranslationTypo.ISSUE.assertXmlStringsHasError( + error, + expectedError = "https://crowdin.com/editor/ankidroid/7290/en-yu#q=create_subdeck", + fileName = "01-core", + androidLanguageFolder = "yue" + ) + + // Used region specifier: Chinese + TranslationTypo.ISSUE.assertXmlStringsHasError( + error, + expectedError = "https://crowdin.com/editor/ankidroid/7290/en-zhcn#q=create_subdeck", + fileName = "01-core", + androidLanguageFolder = "zh-rCN" + ) + + // no -> nnno + TranslationTypo.ISSUE.assertXmlStringsHasError( + error, + expectedError = "https://crowdin.com/editor/ankidroid/7290/en-nnno#q=create_subdeck", + fileName = "01-core", + androidLanguageFolder = "nn" + ) + + // ur -> urpa + TranslationTypo.ISSUE.assertXmlStringsHasError( + error, + expectedError = "https://crowdin.com/editor/ankidroid/7290/en-urpk#q=create_subdeck", + fileName = "01-core", + androidLanguageFolder = "ur" + ) + } } diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/testutils/LintAssert.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/testutils/LintAssert.kt index 7717882f2321..bdf153e3a95d 100644 --- a/lint-rules/src/test/java/com/ichi2/anki/lint/testutils/LintAssert.kt +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/testutils/LintAssert.kt @@ -43,17 +43,29 @@ fun Issue.assertXmlStringsHasErrorCount(@Language("XML") xmlFile: String, expect .expectErrorCount(expectedErrorCount) } -fun Issue.assertXmlStringsHasError(@Language("XML") xmlFile: String, expectedError: String) { +/** + * @param androidLanguageFolder the code used in the Android `values-XX` folder. + * Cantonese: `yue`, not `yu` + * @param fileName The name of the xml file without extension: `01-core` etc... + */ +fun Issue.assertXmlStringsHasError( + @Language("XML") xmlFile: String, + expectedError: String, + androidLanguageFolder: String? = null, + fileName: String? = null +) { + val languageQualifier = if (androidLanguageFolder != null) "-$androidLanguageFolder" else "" + val resourceFileName = fileName ?: "constants" TestLintTask.lint() .allowMissingSdk() .allowCompilationErrors() - .files(TestFiles.xml("res/values/constants.xml", xmlFile)) + .files(TestFiles.xml("res/values$languageQualifier/$resourceFileName.xml", xmlFile)) .issues(this) .run() .expectErrorCount(1) .check({ output: String -> assertTrue( - "check should fail wth '$expectedError', but was '$output'", + "check should fail with '$expectedError', but was '$output'", output.contains(expectedError) ) }) diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/utils/CrowdinTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/utils/CrowdinTest.kt new file mode 100644 index 000000000000..05b9f19ab3c5 --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/utils/CrowdinTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2024 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.lint.utils + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasSize +import org.junit.Test + +typealias ManuallyVerifiedString = String? + +class CrowdinTest { + + @Test + fun manuallyTestLinks() { + // manually verified by David (2024-09-30) + val folderNames = mapOf( + "values-af" to "af", + "values-am" to "am", + "values-ar" to "ar", + "values-az" to "az", + "values-be" to "be", + "values-bg" to "bg", + "values-bn" to "bn", + "values-ca" to "ca", + "values-ckb" to "ckb", + "values-cs" to "cs", + "values-da" to "da", + "values-de" to "de", + "values-el" to "el", + "values-en" to "en", + "values-eo" to "eo", + "values-es-rAR" to "esar", + "values-es-rES" to "es", + "values-et" to "et", + "values-eu" to "eu", + "values-fa" to "fa", + "values-fi" to "fi", + "values-fil" to "fil", + "values-fr" to "fr", + "values-fy" to "fy", + "values-ga" to "ga", + "values-gl" to "gl", + "values-got" to "got", + "values-gu" to "gu", + "values-heb" to "he", + "values-hi" to "hi", + "values-hr" to "hr", + "values-hu" to "hu", + "values-hy" to "hy", + "values-ind" to "id", + "values-is" to "is", + "values-it" to "it", + "values-iw" to "he", + "values-ja" to "ja", + "values-jv" to "jv", + "values-ka" to "ka", + "values-kk" to "kk", + "values-km" to "km", + "values-kn" to "kn", + "values-ko" to "ko", + "values-ku" to "ku", + "values-ky" to "ky", + "values-lt" to "lt", + "values-lv" to "lv", + "values-mk" to "mk", + "values-ml" to "mlin", + "values-mn" to "mn", + "values-mr" to "mr", + "values-ms" to "ms", + "values-my" to "my", + "values-nl" to "nl", + "values-nn" to "nnno", + "values-no" to "no", + "values-or" to "or", + "values-pa" to "pain", + "values-pl" to "pl", + "values-pt-rBR" to "ptbr", + "values-pt-rPT" to "pt", + "values-ro" to "ro", + "values-ru" to "ru", + "values-sat" to "sat", + "values-sc" to "sc", + "values-sk" to "sk", + "values-sl" to "sl", + "values-sq" to "sq", + "values-sr" to "sr", + "values-ss" to "ss", + "values-sv" to "sv", + "values-ta" to "ta", + "values-te" to "te", + "values-tg" to "tg", + "values-tgl" to "tl", + "values-th" to "th", + "values-ti" to "ti", + "values-tn" to "tn", + "values-tr" to "tr", + "values-ts" to "ts", + "values-tt" to "ttru", + "values-uk" to "uk", + "values-ur" to "urpk", + "values-uz" to "uz", + "values-ve" to "ve", + "values-vi" to "vi", + "values-wo" to "wo", + "values-xh" to "xh", + "values-yue" to "yu", + "values-zh-rCN" to "zhcn", + "values-zh-rTW" to "zhtw", + "values-zu" to "zu" + ) + + val fileIdentifier = CrowdinFileIdentifier(7290) + + val stringsToTest = mutableListOf() + for ((folderName, expected) in folderNames) { + val languageTag = CrowdinLanguageTag.fromFolderName(folderName) + + if (expected != null) { + assertThat(languageTag.toString(), equalTo(expected)) + continue + } + + val context = CrowdinContext(languageTag, fileIdentifier) + + // the '_' in the name ensures the strings only match names, and not the English + val actual = context.getEditUrl("send_feedback") + + stringsToTest.add(actual) + } + + // display a list of the languages to test (if any) + println(stringsToTest.joinToString(separator = "\n")) + assertThat("all values should be manually tested", stringsToTest, hasSize(0)) + } +} diff --git a/tools/localization/src/update.ts b/tools/localization/src/update.ts index d10da9910a0e..e186ae78e74c 100644 --- a/tools/localization/src/update.ts +++ b/tools/localization/src/update.ts @@ -171,6 +171,7 @@ export async function updateI18nFiles() { androidLanguages = [language.split("-", 1)[0]]; // Example: es-ES becomes es } + // Also update lint-rules/src/main/java/com/ichi2/anki/lint/utils/Crowdin.kt switch (language) { case "yu": androidLanguages = ["yue"];