Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

### Describe the bug
<-- A clear and concise description of what the bug is. --!>

### To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error

### Expected behavior
A clear and concise description of what you expected to happen.

### Screenshots
If applicable, add screenshots to help explain your problem.

### Environment (please complete the following information):
- **Device:** [e.g. Pixel 7]
- **OS:** [e.g. Android 14]
- **App Version:** [e.g. 1.0.1]

### Additional context
Add any other context about the problem here.
14 changes: 14 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@


### Is your feature request related to a problem?
<-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] --!>

### Describe the solution you'd like
<-- A clear and concise description of what you want to happen. --!>

### Describe alternatives you've considered
<-- A clear and concise description of any alternative solutions or features you've considered. --!>

### Additional context
<-- Add any other context or screenshots about the feature request here. --!>

5 changes: 0 additions & 5 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
---
name: Pull Request
about: Create a new pull request
labels: ''
---

## Task summery
<!-- Briefly describe the changes in this PR. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ internal fun Project.configureApplication() {
}

androidResources {
localeFilters += listOf("en", "ko")
localeFilters += listOf("en", "ko", "ja", "zh-rCN", "vi", "th")
}

buildTypes {
Expand Down
7 changes: 7 additions & 0 deletions config/scripts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Service Account credentials
*.json
service-account*.json

# Translation config files (except example)
translations/*
!translations/example.properties
36 changes: 36 additions & 0 deletions config/scripts/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
plugins {
id("winter.kotlin.library")
}

dependencies {
// Google Sheets API v4
implementation("com.google.apis:google-api-services-sheets:v4-rev20220927-2.0.0")

// Google Auth Library for Service Account
implementation("com.google.auth:google-auth-library-oauth2-http:1.40.0")

// Required for JSON parsing by Google's libraries
implementation("com.google.code.gson:gson:2.13.2")

// HTTP transport for Google API
implementation("com.google.http-client:google-http-client-jackson2:2.0.2")
}

/**
* ./gradlew :config:scripts:updateTranslations -Pargs="example"
* ./gradlew :config:scripts:updateTranslations -Pargs="sample-app"
* ./gradlew :config:scripts:updateTranslations -Pargs="app"
*/
tasks.register<JavaExec>("updateTranslations") {
group = "translation"
description = "Update translations from Google Sheets to Android strings.xml"
mainClass.set("dev.love.winter.scripts.UpdateTranslationsKt")
classpath = sourceSets["main"].runtimeClasspath
workingDir = rootProject.projectDir

// Pass command line arguments to the script
val argsProperty = providers.gradleProperty("args")
if (argsProperty.isPresent) {
args(argsProperty.get().split(" "))
}
}
2 changes: 2 additions & 0 deletions config/scripts/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
@file:Suppress("MatchingDeclarationName")

package dev.love.winter.scripts

import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
import com.google.api.client.json.gson.GsonFactory
import com.google.api.services.sheets.v4.Sheets
import com.google.api.services.sheets.v4.SheetsScopes
import com.google.auth.http.HttpCredentialsAdapter
import com.google.auth.oauth2.ServiceAccountCredentials
import java.io.File
import java.io.FileInputStream
import java.io.FileWriter
import java.util.Properties
import kotlin.system.exitProcess

data class ScriptConfig(
val spreadsheetId: String,
val sheetName: String,
val serviceAccountJsonPath: String,
val appModulePath: String,
)

/**
* ./gradlew :config:scripts:updateTranslations -Pargs="example"
* ./gradlew :config:scripts:updateTranslations -Pargs="sample-app"
* ./gradlew :config:scripts:updateTranslations -Pargs="app"
*/
fun main(args: Array<String>) {
if (args.isEmpty()) {
printUsage()
exitProcess(1)
}

val config = loadConfigFromFile(args[0])
runTranslationUpdate(config)
}

fun loadConfigFromFile(configPathOrAppName: String): ScriptConfig {
val configFile = if (configPathOrAppName.endsWith(".properties")) {
File(configPathOrAppName)
} else {
File("config/scripts/translations/$configPathOrAppName.properties")
}

check(configFile.exists()) {
"Config file not found: ${configFile.absolutePath}\n" +
"Available configs: ${File("config/scripts/translations").listFiles()?.joinToString { it.name } ?: "none"}"
}

val properties = Properties()
FileInputStream(configFile).use { properties.load(it) }

val configDir = configFile.parentFile

return ScriptConfig(
spreadsheetId = properties.getProperty("spreadsheet.id") ?: error("Missing required property: spreadsheet.id"),
sheetName = properties.getProperty("sheet.name") ?: error("Missing required property: sheet.name"),
serviceAccountJsonPath = resolveFilePath(
path = properties.getProperty("service.account.json.path") ?: error("Missing required property: service.account.json.path"),
baseDir = configDir,
),
appModulePath = properties.getProperty("app.module.path")
?: error("Missing required property: app.module.path"),
)
}

fun resolveFilePath(path: String, baseDir: File): String {
val file = File(path)
return if (file.isAbsolute) {
path
} else {
File(baseDir, path).absolutePath
}
}

fun runTranslationUpdate(config: ScriptConfig) {
try {
println("Starting translation update...")
println(" Spreadsheet ID: ${config.spreadsheetId}")
println(" Sheet Name: ${config.sheetName}")
println(" App Module: ${config.appModulePath}")

val translationData = fetchTranslationsFromSheet(config)
val parsedData = parseTranslationData(translationData)
saveTranslationsAsXml(parsedData, config.appModulePath)

println("✓ Translations successfully saved!")
println(" Languages: ${parsedData.languages.joinToString()}")
println(" Total keys: ${parsedData.translations.size}")
} catch (e: Exception) {
System.err.println("✗ Error occurred: ${e.message}")
exitProcess(1)
}
}

fun printUsage() {
val availableConfigs = File("config/scripts/translations").listFiles()
?.filter { it.extension == "properties" }
?.map { it.nameWithoutExtension }
?: emptyList()

println(
"""
Usage: ./gradlew :config:scripts:updateTranslations --args="<appName>"

Available configurations in translations/:
${availableConfigs.joinToString("\n ") { "- $it" }}

Examples:
# Use app config from translations/example.properties
./gradlew :config:scripts:updateTranslations --args="example"

# Use custom config file path
./gradlew :config:scripts:updateTranslations --args="path/to/custom.properties"

To add a new app configuration:
1. Copy translations/example.properties to translations/your-app.properties
2. Update the properties file with your app's settings
3. Run: ./gradlew :config:scripts:updateTranslations --args="your-app"
""".trimIndent()
)
}

fun fetchTranslationsFromSheet(config: ScriptConfig): List<List<Any>> {
val credentials = ServiceAccountCredentials.fromStream(
FileInputStream(config.serviceAccountJsonPath)
).createScoped(listOf(SheetsScopes.SPREADSHEETS_READONLY))

val httpTransport = GoogleNetHttpTransport.newTrustedTransport()
val jsonFactory = GsonFactory.getDefaultInstance()

val sheetsService = Sheets.Builder(
httpTransport,
jsonFactory,
HttpCredentialsAdapter(credentials)
)
.setApplicationName("Translation Update Script")
.build()

val response = sheetsService.spreadsheets().values()
.get(config.spreadsheetId, config.sheetName)
.execute()

val values = response.getValues() ?: emptyList()
check(values.isNotEmpty()) { "No data found in sheet: ${config.sheetName}" }

return values
}

data class TranslationData(
val languages: List<String>,
val translations: List<TranslationEntry>,
)

data class TranslationEntry(
val key: String,
val values: Map<String, String>,
val context: String?,
)

fun parseTranslationData(data: List<List<Any>>): TranslationData {
check(data.isNotEmpty()) { "Translation data is empty" }

val headers = data[0].map { it.toString() }
val keyIndex = headers.indexOf("key")
check(keyIndex >= 0) { "Missing 'key' column in spreadsheet" }

val contextIndex = headers.indexOf("context").takeIf { it >= 0 }

val languageColumns = headers.mapIndexedNotNull { index, header ->
if (header != "key" && header != "context" && header != "screen" && header.length <= 5) {
index to header
} else {
null
}
}

check(languageColumns.isNotEmpty()) { "No language columns found in spreadsheet" }

val languages = languageColumns.map { it.second }
val translations = data.drop(1).mapNotNull { row ->
if (row.size <= keyIndex) return@mapNotNull null

val key = row[keyIndex].toString().trim()
if (key.isEmpty()) return@mapNotNull null

val values = languageColumns.associate { (index, lang) ->
lang to (row.getOrNull(index)?.toString()?.trim() ?: "")
}

val context = contextIndex?.let { row.getOrNull(it)?.toString()?.trim() }

TranslationEntry(
key = key,
values = values,
context = context,
)
}

return TranslationData(
languages = languages,
translations = translations,
)
}

fun convertToAndroidResourceQualifier(languageCode: String): String {
val parts = languageCode.split("-")
return when (parts.size) {
1 -> {
parts[0].lowercase()
}
2 -> {
val language = parts[0].lowercase()
val region = parts[1].uppercase()
"$language-r$region"
}
else -> {
languageCode.lowercase()
}
}
}

fun saveTranslationsAsXml(data: TranslationData, appModulePath: String) {
data.languages.forEach { language ->
val androidLanguageCode = convertToAndroidResourceQualifier(language)
val valuesDir = if (language == "en") {
File(appModulePath, "src/main/res/values")
} else {
File(appModulePath, "src/main/res/values-$androidLanguageCode")
}

valuesDir.mkdirs()
val stringsFile = File(valuesDir, "strings.xml")

FileWriter(stringsFile).use { writer ->
writer.write("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n")
writer.write("<resources>\n")

data.translations.forEach { entry ->
val value = entry.values[language] ?: ""
if (value.isNotEmpty()) {
if (!entry.context.isNullOrBlank()) {
writer.write(" <!-- ${entry.context} -->\n")
}
val escapedValue = escapeXml(value)
writer.write(" <string name=\"${entry.key}\">$escapedValue</string>\n")
}
}

writer.write("</resources>\n")
}

println(" ✓ ${stringsFile.relativeTo(File(appModulePath)).path}")
}
}

fun escapeXml(text: String): String {
var result = text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "\\'")
.replace("\n", "\\n")

// Escape @ symbol at the beginning of the string (Android resource reference)
if (result.startsWith("@")) {
result = "\\@${result.substring(1)}"
}

return result
}
17 changes: 17 additions & 0 deletions config/scripts/translations/example.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Example Translation Configuration
# Copy this file and rename it for each app (e.g., design-system-catalog.properties)

# Google Spreadsheet ID (from URL)
# https://docs.google.com/spreadsheets/d/[THIS_PART]/edit
spreadsheet.id=YOUR_SPREADSHEET_ID_HERE

# Sheet name (will fetch all data from this sheet)
sheet.name=Translations

# Service Account JSON key file path (relative to this properties file or absolute path)
service.account.json.path=service-account.json

# App module path (relative to project root)
# Example: sample/design-system-catalog
# Example: app
app.module.path=sample/design-system-catalog
Loading
Loading