diff --git a/.github/workflows/pull_strings.yml b/.github/workflows/pull_strings.yml
index 2ef9104579..241745e151 100644
--- a/.github/workflows/pull_strings.yml
+++ b/.github/workflows/pull_strings.yml
@@ -1,8 +1,6 @@
name: Pull strings
on:
- schedule:
- - cron: "0 0 * * 0"
workflow_dispatch:
jobs:
@@ -17,7 +15,7 @@ jobs:
uses: actions/checkout@v5
with:
ref: dev
- clean: true
+ persist-credentials: true
- name: Pull strings
uses: crowdin/github-action@v2
@@ -25,16 +23,28 @@ jobs:
config: crowdin.yml
upload_sources: false
download_translations: true
+ push_translations: false
skip_ref_checkout: true
- localization_branch_name: feat/translations
- create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+ - name: Process strings
+ run: |
+ gradlew processStringsFromCrowdin
+ env:
+ ORG_GRADLE_PROJECT_githubPackagesUsername: ${{ github.actor }}
+ ORG_GRADLE_PROJECT_githubPackagesPassword: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Commit changes
+ uses: stefanzweifel/git-auto-commit-action@v7
+ with:
+ commit_message: "chore: Sync translations from Crowdin"
+ push_options: '--force'
+ branch: feat/translations
+
- name: Open pull request
- if: github.event_name == 'workflow_dispatch'
uses: repo-sync/pull-request@v2
with:
source_branch: feat/translations
diff --git a/patches/build.gradle.kts b/patches/build.gradle.kts
index 49a9a8198e..4bddb28ac6 100644
--- a/patches/build.gradle.kts
+++ b/patches/build.gradle.kts
@@ -1,10 +1,3 @@
-import org.w3c.dom.*
-import javax.xml.parsers.DocumentBuilderFactory
-import javax.xml.transform.OutputKeys
-import javax.xml.transform.TransformerFactory
-import javax.xml.transform.dom.DOMSource
-import javax.xml.transform.stream.StreamResult
-
group = "app.revanced"
patches {
@@ -54,89 +47,4 @@ publishing {
}
}
-tasks.register("processStringsForCrowdin") {
- description = "Process strings file for Crowdin by commenting out non-standard tags."
-
- doLast {
- // Comment out the non-standard tags. Otherwise, Crowdin interprets the file
- // not as Android but instead a generic xml file where strings are
- // identified by xml position and not key
- val stringsXmlFile = project.projectDir.resolve("src/main/resources/addresources/values/strings.xml")
-
- val builder = DocumentBuilderFactory.newInstance().apply {
- isIgnoringComments = false
- isCoalescing = false
- isNamespaceAware = false
- }.newDocumentBuilder()
-
- val document = builder.newDocument()
- val root = document.createElement("resources").also(document::appendChild)
-
- fun walk(node: Node, appId: String? = null, patchId: String? = null, insideResources: Boolean = false) {
- fun walkChildren(el: Element, appId: String?, patchId: String?, insideResources: Boolean) {
- val children = el.childNodes
- for (i in 0 until children.length) {
- walk(children.item(i), appId, patchId, insideResources)
- }
- }
- when (node.nodeType) {
- Node.COMMENT_NODE -> {
- val comment = document.createComment(node.nodeValue)
- if (insideResources) root.appendChild(comment) else document.insertBefore(comment, root)
- }
-
- Node.ELEMENT_NODE -> {
- val element = node as Element
-
- when (element.tagName) {
- "resources" -> walkChildren(element, appId, patchId, insideResources = true)
-
- "app" -> {
- val newAppId = element.getAttribute("id")
-
- root.appendChild(document.createComment(" "))
- walkChildren(element, newAppId, patchId, insideResources)
- root.appendChild(document.createComment(" "))
- }
-
- "patch" -> {
- val newPatchId = element.getAttribute("id")
-
- root.appendChild(document.createComment(" "))
- walkChildren(element, appId, newPatchId, insideResources)
- root.appendChild(document.createComment(" "))
- }
-
- "string" -> {
- val name = element.getAttribute("name")
- val value = element.textContent
- val fullName = "$appId.$patchId.$name"
-
- val stringElement = document.createElement("string")
- stringElement.setAttribute("name", fullName)
- stringElement.appendChild(document.createTextNode(value))
- root.appendChild(stringElement)
- }
-
- else -> walkChildren(element, appId, patchId, insideResources)
- }
- }
- }
- }
-
- builder.parse(stringsXmlFile).let {
- val topLevel = it.childNodes
- for (i in 0 until topLevel.length) {
- val node = topLevel.item(i)
- if (node != it.documentElement) walk(node)
- }
-
- walk(it.documentElement)
- }
-
- TransformerFactory.newInstance().newTransformer().apply {
- setOutputProperty(OutputKeys.INDENT, "yes")
- setOutputProperty(OutputKeys.ENCODING, "utf-8")
- }.transform(DOMSource(document), StreamResult(stringsXmlFile))
- }
-}
+apply(from = "strings-processing.gradle.kts")
diff --git a/patches/strings-processing.gradle.kts b/patches/strings-processing.gradle.kts
new file mode 100644
index 0000000000..47fd1dce91
--- /dev/null
+++ b/patches/strings-processing.gradle.kts
@@ -0,0 +1,160 @@
+import org.w3c.dom.*
+import javax.xml.parsers.DocumentBuilder
+import javax.xml.parsers.DocumentBuilderFactory
+import javax.xml.transform.OutputKeys
+import javax.xml.transform.TransformerFactory
+import javax.xml.transform.dom.DOMSource
+import javax.xml.transform.stream.StreamResult
+
+fun parseXmlDocument(file: File): Pair {
+ val builder = DocumentBuilderFactory.newInstance().apply {
+ isIgnoringComments = false
+ isCoalescing = false
+ isNamespaceAware = false
+ }.newDocumentBuilder()
+
+ return builder to builder.parse(file)
+}
+
+fun createXmlDocument(): Pair {
+ val builder = DocumentBuilderFactory.newInstance().apply {
+ isIgnoringComments = false
+ isCoalescing = false
+ isNamespaceAware = false
+ }.newDocumentBuilder()
+
+ return builder to builder.newDocument()
+}
+
+fun writeXmlDocument(document: Document, file: File) {
+ TransformerFactory.newInstance().newTransformer().apply {
+ setOutputProperty(OutputKeys.INDENT, "yes")
+ setOutputProperty(OutputKeys.ENCODING, "utf-8")
+ setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4")
+ }.transform(DOMSource(document), StreamResult(file))
+}
+
+fun NodeList.forEach(action: (Node) -> Unit) {
+ for (i in 0 until length) {
+ action(item(i))
+ }
+}
+
+fun Element.getAppOrPatchId(): String? = if (tagName in setOf("app", "patch")) getAttribute("id") else null
+
+fun buildFullName(appId: String?, patchId: String?, name: String): String = listOfNotNull(appId, patchId, name).joinToString(".")
+
+fun buildPrefix(appId: String?, patchId: String?): String = listOfNotNull(appId, patchId).joinToString(".")
+
+fun processStringElements(
+ inputDoc: Document,
+ outputDoc: Document,
+ shouldCommentOut: Boolean = true,
+ transform: (Element, String?, String?) -> Node?,
+) {
+ val root = outputDoc.createElement("resources").also(outputDoc::appendChild)
+
+ fun walk(node: Node, appId: String? = null, patchId: String? = null, insideResources: Boolean = false, parentElement: Element = root) {
+ when (node.nodeType) {
+ Node.COMMENT_NODE -> {
+ val comment = outputDoc.createComment(node.nodeValue)
+ if (insideResources) parentElement.appendChild(comment) else outputDoc.insertBefore(comment, root)
+ }
+
+ Node.ELEMENT_NODE -> {
+ val element = node as Element
+
+ when (element.tagName) {
+ "resources" -> element.childNodes.forEach { walk(it, appId, patchId, true, parentElement) }
+
+ "app", "patch" -> {
+ val id = element.getAttribute("id")
+ val (newAppId, newPatchId) = if (element.tagName == "app") {
+ id to patchId
+ } else {
+ appId to id
+ }
+
+ if (shouldCommentOut) {
+ parentElement.appendChild(outputDoc.createComment(" <${element.tagName} id=\"$id\"> "))
+ element.childNodes.forEach { walk(it, newAppId, newPatchId, true, parentElement) }
+ parentElement.appendChild(outputDoc.createComment(" ${element.tagName}> "))
+ } else {
+ val newElement = outputDoc.createElement(element.tagName).apply {
+ setAttribute("id", id)
+ }
+ parentElement.appendChild(newElement)
+ element.childNodes.forEach { walk(it, newAppId, newPatchId, true, newElement) }
+ }
+ }
+
+ "string" -> transform(element, appId, patchId)?.let { parentElement.appendChild(it) }
+
+ else -> element.childNodes.forEach { walk(it, appId, patchId, true, parentElement) }
+ }
+ }
+ }
+ }
+
+ inputDoc.childNodes.forEach {
+ if (it != inputDoc.documentElement) walk(it)
+ }
+ walk(inputDoc.documentElement)
+}
+
+tasks.register("processStringsForCrowdin") {
+ description = "Process strings file for Crowdin by flattening app/patch structure into string names."
+
+ doLast {
+ val stringsXmlFile = project.projectDir.resolve("src/main/resources/addresources/values/strings.xml")
+ val (_, inputDoc) = parseXmlDocument(stringsXmlFile)
+ val (_, outputDoc) = createXmlDocument()
+
+ processStringElements(inputDoc, outputDoc, shouldCommentOut = true) { element, appId, patchId ->
+ val name = element.getAttribute("name")
+ val fullName = buildFullName(appId, patchId, name)
+
+ outputDoc.createElement("string").apply {
+ setAttribute("name", fullName)
+ appendChild(outputDoc.createTextNode(element.textContent))
+ }
+ }
+
+ writeXmlDocument(outputDoc, stringsXmlFile)
+ }
+}
+
+tasks.register("processStringsFromCrowdin") {
+ description = "Strip app/patch prefix from string names in localized files."
+
+ doLast {
+ val resDir = project.projectDir.resolve("src/main/resources/addresources")
+
+ resDir.listFiles()?.filter {
+ it.isDirectory && it.name.startsWith("values-")
+ }?.forEach { valuesDir ->
+ val stringsXmlFile = valuesDir.resolve("strings.xml")
+ if (!stringsXmlFile.exists()) return@forEach
+
+ val (_, inputDoc) = parseXmlDocument(stringsXmlFile)
+ val (_, outputDoc) = createXmlDocument()
+
+ processStringElements(inputDoc, outputDoc, shouldCommentOut = false) { element, appId, patchId ->
+ val name = element.getAttribute("name")
+ val prefix = buildPrefix(appId, patchId)
+ val strippedName = if (prefix.isNotEmpty() && name.startsWith("$prefix.")) {
+ name.removePrefix("$prefix.")
+ } else {
+ name
+ }
+
+ outputDoc.createElement("string").apply {
+ setAttribute("name", strippedName)
+ appendChild(outputDoc.createTextNode(element.textContent))
+ }
+ }
+
+ writeXmlDocument(outputDoc, stringsXmlFile)
+ }
+ }
+}