Extension detail updates

Add readme/changelog support (readme icon is filled in for sources that have readmes)

Also added the icon when loading extensions
This commit is contained in:
Jays2Kings 2022-05-04 02:16:45 -04:00
parent c811d16c90
commit ffb7b44f76
10 changed files with 178 additions and 54 deletions

View file

@ -68,6 +68,8 @@ class ExtensionManager(
*/ */
private val installedExtensionsRelay = BehaviorRelay.create<List<Extension.Installed>>() private val installedExtensionsRelay = BehaviorRelay.create<List<Extension.Installed>>()
private val iconMap = mutableMapOf<String, Drawable>()
/** /**
* List of the currently installed extensions. * List of the currently installed extensions.
*/ */
@ -79,12 +81,19 @@ class ExtensionManager(
} }
fun getAppIconForSource(source: Source): Drawable? { fun getAppIconForSource(source: Source): Drawable? {
return getAppIconForSource(source.id)
}
fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = val pkgName =
installedExtensions.find { ext -> ext.sources.any { it.id == source.id } }?.pkgName installedExtensions.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
return if (pkgName != null) try { return if (pkgName != null) {
context.packageManager.getApplicationIcon(pkgName) try {
} catch (e: Exception) { return iconMap[pkgName]
null ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
} catch (e: Exception) {
null
}
} else null } else null
} }
@ -105,7 +114,7 @@ class ExtensionManager(
setupAvailableSourcesMap() setupAvailableSourcesMap()
} }
private var availableSources = hashMapOf<String, Extension.AvailableSource>() private var availableSources = hashMapOf<Long, Extension.AvailableSource>()
/** /**
* Relay used to notify the untrusted extensions. * Relay used to notify the untrusted extensions.
@ -200,7 +209,7 @@ class ExtensionManager(
} }
} }
fun getStubSource(id: Long) = availableSources[id.toString()] fun getStubSource(id: Long) = availableSources[id]
/** /**
* Finds the available extensions in the [api] and updates [availableExtensions]. * Finds the available extensions in the [api] and updates [availableExtensions].

View file

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.api package eu.kanade.tachiyomi.extension.api
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
@ -10,25 +11,29 @@ import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.Serializable
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
internal class ExtensionGithubApi { internal class ExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy() private val networkService: NetworkHelper by injectLazy()
private val preferences: PreferencesHelper by injectLazy()
suspend fun findExtensions(): List<Extension.Available> { suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { return withIOContext {
networkService.client val extensions = networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json")) .newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await() .await()
.parseAs<JsonArray>() .parseAs<List<ExtensionJsonObject>>()
.let { parseResponse(it) } .toExtensions()
// Sanity check - a small number of extensions probably means something broke
// with the repo generator
if (extensions.size < 100) {
throw Exception()
}
extensions
} }
} }
@ -55,31 +60,26 @@ internal class ExtensionGithubApi {
} }
} }
private fun parseResponse(json: JsonArray): List<Extension.Available> { private fun List<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
return json return this
.filter { element -> .filter {
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content val libVersion = it.version.substringBeforeLast('.').toDouble()
val libVersion = versionName.substringBeforeLast('.').toDouble()
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
} }
.map { element -> .map {
val name = element.jsonObject["name"]!!.jsonPrimitive.content.substringAfter("Tachiyomi: ") Extension.Available(
val pkgName = element.jsonObject["pkg"]!!.jsonPrimitive.content name = it.name.substringAfter("Tachiyomi: "),
val apkName = element.jsonObject["apk"]!!.jsonPrimitive.content pkgName = it.pkg,
val versionName = element.jsonObject["version"]!!.jsonPrimitive.content versionName = it.version,
val versionCode = element.jsonObject["code"]!!.jsonPrimitive.long versionCode = it.code,
val lang = element.jsonObject["lang"]!!.jsonPrimitive.content lang = it.lang,
val nsfw = element.jsonObject["nsfw"]!!.jsonPrimitive.int == 1 isNsfw = it.nsfw == 1,
val sources = element.jsonObject["sources"]?.jsonArray?.map f@{ hasReadme = it.hasReadme == 1,
val sName = it.jsonObject["name"]?.jsonPrimitive?.content ?: return@f null hasChangelog = it.hasChangelog == 1,
val sId = it.jsonObject["id"]?.jsonPrimitive?.content ?: return@f null sources = it.sources,
val sLang = it.jsonObject["lang"]?.jsonPrimitive?.content ?: "" apkName = it.apk,
val sBaseUrl = it.jsonObject["baseUrl"]?.jsonPrimitive?.content ?: "" iconUrl = "${REPO_URL_PREFIX}icon/${it.apk.replace(".apk", ".png")}",
Extension.AvailableSource(sName, sId, sLang, sBaseUrl) )
}?.filterNotNull()
val icon = "${REPO_URL_PREFIX}icon/${apkName.replace(".apk", ".png")}"
Extension.Available(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon, sources)
} }
} }
@ -89,3 +89,17 @@ internal class ExtensionGithubApi {
} }
private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/"
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Long,
val version: String,
val nsfw: Int,
val hasReadme: Int = 0,
val hasChangelog: Int = 0,
val sources: List<Extension.AvailableSource>?,
)

View file

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension.model package eu.kanade.tachiyomi.extension.model
import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import kotlinx.serialization.Serializable
sealed class Extension { sealed class Extension {
@ -10,6 +12,8 @@ sealed class Extension {
abstract val versionCode: Long abstract val versionCode: Long
abstract val lang: String? abstract val lang: String?
abstract val isNsfw: Boolean abstract val isNsfw: Boolean
abstract val hasReadme: Boolean
abstract val hasChangelog: Boolean
data class Installed( data class Installed(
override val name: String, override val name: String,
@ -18,8 +22,11 @@ sealed class Extension {
override val versionCode: Long, override val versionCode: Long,
override val lang: String, override val lang: String,
override val isNsfw: Boolean, override val isNsfw: Boolean,
override val hasReadme: Boolean,
override val hasChangelog: Boolean,
val pkgFactory: String?, val pkgFactory: String?,
val sources: List<Source>, val sources: List<Source>,
val icon: Drawable?,
val hasUpdate: Boolean = false, val hasUpdate: Boolean = false,
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isUnofficial: Boolean = false, val isUnofficial: Boolean = false,
@ -32,14 +39,17 @@ sealed class Extension {
override val versionCode: Long, override val versionCode: Long,
override val lang: String, override val lang: String,
override val isNsfw: Boolean, override val isNsfw: Boolean,
override val hasReadme: Boolean,
override val hasChangelog: Boolean,
val apkName: String, val apkName: String,
val iconUrl: String, val iconUrl: String,
val sources: List<AvailableSource>? = null, val sources: List<AvailableSource>? = null,
) : Extension() ) : Extension()
@Serializable
data class AvailableSource( data class AvailableSource(
val name: String, val name: String,
val id: String, val id: Long,
val lang: String, val lang: String,
val baseUrl: String, val baseUrl: String,
) )
@ -52,5 +62,7 @@ sealed class Extension {
val signatureHash: String, val signatureHash: String,
override val lang: String? = null, override val lang: String? = null,
override val isNsfw: Boolean = false, override val isNsfw: Boolean = false,
override val hasReadme: Boolean = false,
override val hasChangelog: Boolean = false,
) : Extension() ) : Extension()
} }

View file

@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.util.lang.Hash import eu.kanade.tachiyomi.util.lang.Hash
import eu.kanade.tachiyomi.util.system.getApplicationIcon
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber import timber.log.Timber
@ -35,6 +36,8 @@ internal object ExtensionLoader {
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class" private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory" private const val METADATA_SOURCE_FACTORY = "tachiyomi.extension.factory"
private const val METADATA_NSFW = "tachiyomi.extension.nsfw" private const val METADATA_NSFW = "tachiyomi.extension.nsfw"
private const val METADATA_HAS_README = "tachiyomi.extension.hasReadme"
private const val METADATA_HAS_CHANGELOG = "tachiyomi.extension.hasChangelog"
const val LIB_VERSION_MIN = 1.2 const val LIB_VERSION_MIN = 1.2
const val LIB_VERSION_MAX = 1.3 const val LIB_VERSION_MAX = 1.3
@ -151,6 +154,9 @@ internal object ExtensionLoader {
return LoadResult.Error("NSFW extension $pkgName not allowed") return LoadResult.Error("NSFW extension $pkgName not allowed")
} }
val hasReadme = appInfo.metaData.getInt(METADATA_HAS_README, 0) == 1
val hasChangelog = appInfo.metaData.getInt(METADATA_HAS_CHANGELOG, 0) == 1
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader) val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
@ -179,7 +185,6 @@ internal object ExtensionLoader {
val langs = sources.filterIsInstance<CatalogueSource>() val langs = sources.filterIsInstance<CatalogueSource>()
.map { it.lang } .map { it.lang }
.toSet() .toSet()
val lang = when (langs.size) { val lang = when (langs.size) {
0 -> "" 0 -> ""
1 -> langs.first() 1 -> langs.first()
@ -193,9 +198,12 @@ internal object ExtensionLoader {
versionCode, versionCode,
lang, lang,
isNsfw, isNsfw,
hasReadme,
hasChangelog,
sources = sources, sources = sources,
pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY),
isUnofficial = signatureHash != officialSignature, isUnofficial = signatureHash != officialSignature,
icon = context.getApplicationIcon(pkgName),
) )
return LoadResult.Success(extension) return LoadResult.Success(extension)
} }

View file

@ -29,9 +29,9 @@ class AndroidCookieJar : CookieJar {
} }
} }
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1) { fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1): Int {
val urlString = url.toString() val urlString = url.toString()
val cookies = manager.getCookie(urlString) ?: return val cookies = manager.getCookie(urlString) ?: return 0
fun List<String>.filterNames(): List<String> { fun List<String>.filterNames(): List<String> {
return if (cookieNames != null) { return if (cookieNames != null) {
@ -41,10 +41,11 @@ class AndroidCookieJar : CookieJar {
} }
} }
cookies.split(";") return cookies.split(";")
.map { it.substringBefore("=") } .map { it.substringBefore("=") }
.filterNames() .filterNames()
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") } .onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
.count()
} }
fun removeAll() { fun removeAll() {

View file

@ -105,8 +105,8 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
binding.sourceImage.load(extension.iconUrl) { binding.sourceImage.load(extension.iconUrl) {
target(CoverViewTarget(binding.sourceImage)) target(CoverViewTarget(binding.sourceImage))
} }
} else { } else if (extension is Extension.Installed) {
extension.getApplicationIcon(itemView.context)?.let { binding.sourceImage.setImageDrawable(it) } binding.sourceImage.load(extension.icon)
} }
bindButton(item) bindButton(item)
} }

View file

@ -31,14 +31,17 @@ import eu.kanade.tachiyomi.data.preference.minusAssign
import eu.kanade.tachiyomi.data.preference.plusAssign import eu.kanade.tachiyomi.data.preference.plusAssign
import eu.kanade.tachiyomi.databinding.ExtensionDetailControllerBinding import eu.kanade.tachiyomi.databinding.ExtensionDetailControllerBinding
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.getPreferenceKey import eu.kanade.tachiyomi.source.getPreferenceKey
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.setting.DSL import eu.kanade.tachiyomi.ui.setting.DSL
import eu.kanade.tachiyomi.ui.setting.onChange import eu.kanade.tachiyomi.ui.setting.onChange
import eu.kanade.tachiyomi.ui.setting.switchPreference import eu.kanade.tachiyomi.ui.setting.switchPreference
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.contextCompatDrawable
import eu.kanade.tachiyomi.util.view.openInBrowser import eu.kanade.tachiyomi.util.view.openInBrowser
import eu.kanade.tachiyomi.util.view.scrollViewWith import eu.kanade.tachiyomi.util.view.scrollViewWith
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
@ -46,8 +49,11 @@ import eu.kanade.tachiyomi.widget.LinearLayoutManagerAccurateOffset
import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito import eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText.Companion.setIncognito
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import okhttp3.HttpUrl.Companion.toHttpUrl
import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
class ExtensionDetailsController(bundle: Bundle? = null) : class ExtensionDetailsController(bundle: Bundle? = null) :
@ -60,6 +66,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
private var preferenceScreen: PreferenceScreen? = null private var preferenceScreen: PreferenceScreen? = null
private val preferences: PreferencesHelper = Injekt.get() private val preferences: PreferencesHelper = Injekt.get()
private val network: NetworkHelper by injectLazy()
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -147,26 +154,76 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.extension_details, menu) inflater.inflate(R.menu.extension_details, menu)
menu.findItem(R.id.action_history).isVisible = presenter.extension?.isUnofficial == false presenter.extension?.let { extension ->
menu.findItem(R.id.action_history).isVisible = !extension.isUnofficial
menu.findItem(R.id.action_readme).isVisible = !extension.isUnofficial
if (extension.hasReadme) {
menu.findItem(R.id.action_readme).icon = view?.context?.contextCompatDrawable(R.drawable.ic_help_24dp)
}
}
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
R.id.action_history -> openCommitHistory() R.id.action_history -> openChangelog()
R.id.action_readme -> openReadme()
R.id.action_clear_cookies -> clearCookies()
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
private fun openCommitHistory() { private fun openChangelog() {
val pkgName = presenter.extension!!.pkgName.substringAfter("eu.kanade.tachiyomi.extension.") val extension = presenter.extension!!
val pkgFactory = presenter.extension!!.pkgFactory val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val url = when { val pkgFactory = extension.pkgFactory
!pkgFactory.isNullOrEmpty() -> "$URL_EXTENSION_COMMITS/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory" if (extension.hasChangelog) {
else -> "$URL_EXTENSION_COMMITS/src/${pkgName.replace(".", "/")}" val url = createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/CHANGELOG.md")
openInBrowser(url)
return
} }
// Falling back on GitHub commit history because there is no explicit changelog in extension
val url = createUrl(URL_EXTENSION_COMMITS, pkgName, pkgFactory)
openInBrowser(url) openInBrowser(url)
} }
private fun createUrl(url: String, pkgName: String, pkgFactory: String?, path: String = ""): String {
return when {
!pkgFactory.isNullOrEmpty() -> "$url/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/$pkgFactory$path"
else -> "$url/src/${pkgName.replace(".", "/")}$path"
}
}
private fun openReadme() {
val extension = presenter.extension!!
if (!extension.hasReadme) {
openInBrowser("https://tachiyomi.org/help/faq/#extensions")
return
}
val pkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.extension.")
val pkgFactory = extension.pkgFactory
val url = createUrl(URL_EXTENSION_BLOB, pkgName, pkgFactory, "/README.md")
openInBrowser(url)
return
}
private fun clearCookies() {
val urls = presenter.extension?.sources
?.filterIsInstance<HttpSource>()
?.map { it.baseUrl }
?.distinct() ?: emptyList()
val cleared = urls.sumOf {
network.cookieManager.remove(it.toHttpUrl())
}
Timber.d("Cleared $cleared cookies for: ${urls.joinToString()}")
val context = view?.context ?: return
binding.coordinator.snack(context.getString(R.string.cookies_cleared))
}
private fun addPreferencesForSource(screen: PreferenceScreen, source: Source, isMultiSource: Boolean, isMultiLangSingleSource: Boolean) { private fun addPreferencesForSource(screen: PreferenceScreen, source: Source, isMultiSource: Boolean, isMultiLangSingleSource: Boolean) {
val context = screen.context val context = screen.context
@ -307,6 +364,8 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
private companion object { private companion object {
const val PKGNAME_KEY = "pkg_name" const val PKGNAME_KEY = "pkg_name"
const val LASTOPENPREFERENCE_KEY = "last_open_preference" const val LASTOPENPREFERENCE_KEY = "last_open_preference"
private const val URL_EXTENSION_BLOB =
"https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master"
private const val URL_EXTENSION_COMMITS = private const val URL_EXTENSION_COMMITS =
"https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master" "https://github.com/tachiyomiorg/tachiyomi-extensions/commits/master"
} }

View file

@ -455,3 +455,11 @@ fun Context.createFileInCacheDir(name: String): File {
file.createNewFile() file.createNewFile()
return file return file
} }
fun Context.getApplicationIcon(pkgName: String): Drawable? {
return try {
packageManager.getApplicationIcon(pkgName)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}

View file

@ -7,4 +7,16 @@
android:title="@string/whats_new" android:title="@string/whats_new"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/action_readme"
android:icon="@drawable/ic_help_outline_24dp"
android:title="@string/faq_and_guides"
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_clear_cookies"
android:icon="@drawable/ic_delete_24dp"
android:title="@string/clear_cookies"
app:showAsAction="never" />
</menu> </menu>

View file

@ -873,6 +873,7 @@
<string name="send_crash_report">Send crash reports</string> <string name="send_crash_report">Send crash reports</string>
<string name="helps_fix_bugs">Helps fix any bugs. No sensitive data will be sent</string> <string name="helps_fix_bugs">Helps fix any bugs. No sensitive data will be sent</string>
<string name="whats_new">What\'s new</string> <string name="whats_new">What\'s new</string>
<string name="faq_and_guides">FAQ and Guides</string>
<string name="whats_new_this_release">What\'s new in this release</string> <string name="whats_new_this_release">What\'s new in this release</string>
<string name="help_translate">Help translate</string> <string name="help_translate">Help translate</string>
<string name="helpful_translation_links">Helpful translation links</string> <string name="helpful_translation_links">Helpful translation links</string>