0
0
mirror of https://github.com/florisboard/florisboard.git synced 2024-09-19 19:42:20 +02:00

Add user dictionary manager UI for system and internal

This commit is contained in:
Patrick Goldinger 2021-05-07 03:51:40 +02:00
parent df6b08024f
commit 084c2abfc2
5 changed files with 441 additions and 17 deletions

View File

@ -40,8 +40,9 @@ import java.util.*
private const val WORDS_TABLE = "words"
private const val FREQUENCY_MIN = 1
private const val FREQENCY_MAX = 255
const val FREQUENCY_MIN = 1
const val FREQUENCY_MAX = 255
const val FREQUENCY_DEFAULT = 128
private const val SORT_BY_WORD_ASC = "${UserDictionary.Words.WORD} ASC"
private const val SORT_BY_WORD_DESC = "${UserDictionary.Words.WORD} DESC"
@ -56,6 +57,10 @@ private val PROJECTIONS: Array<String> = arrayOf(
UserDictionary.Words.SHORTCUT,
)
private val PROJECTIONS_LANGUAGE: Array<String> = arrayOf(
UserDictionary.Words.LOCALE,
)
@Entity(tableName = WORDS_TABLE)
data class UserDictionaryEntry(
@PrimaryKey(autoGenerate = true)
@ -89,12 +94,18 @@ interface UserDictionaryDao {
@Query(SELECT_ALL_FROM_WORDS)
fun queryAll(): List<UserDictionaryEntry>
@Query("$SELECT_ALL_FROM_WORDS WHERE (${UserDictionary.Words.LOCALE} = :locale AND :locale IS NOT NULL) OR (${UserDictionary.Words.LOCALE} IS NULL AND :locale IS NULL)")
fun queryAll(locale: Locale?): List<UserDictionaryEntry>
@Query("$SELECT_ALL_FROM_WORDS WHERE ${UserDictionary.Words.WORD} = :word")
fun queryExact(word: String): List<UserDictionaryEntry>
@Query("$SELECT_ALL_FROM_WORDS WHERE ${UserDictionary.Words.WORD} = :word AND $LOCALE_MATCHES")
@Query("$SELECT_ALL_FROM_WORDS WHERE ${UserDictionary.Words.WORD} = :word AND (${UserDictionary.Words.LOCALE} = :locale OR (${UserDictionary.Words.LOCALE} IS NULL AND :locale IS NULL))")
fun queryExact(word: String, locale: Locale?): List<UserDictionaryEntry>
@Query("SELECT DISTINCT ${UserDictionary.Words.LOCALE} FROM $WORDS_TABLE")
fun queryLanguageList(): List<Locale?>
@Insert
fun insert(entry: UserDictionaryEntry)
@ -130,7 +141,7 @@ interface UserDictionaryDatabase {
when (key) {
"w", "word" -> word = value.ifBlank { null }
"f", "freq" -> runCatching { value.toInt(10) }.onSuccess {
freq = it.coerceIn(FREQUENCY_MIN, FREQENCY_MAX)
freq = it.coerceIn(FREQUENCY_MIN, FREQUENCY_MAX)
}
"l", "locale" -> locale = when (value) {
"all", "null", "" -> null
@ -203,15 +214,18 @@ abstract class FlorisUserDictionaryDatabase : RoomDatabase(), UserDictionaryData
class Converters {
@TypeConverter
fun localeToString(locale: Locale?): String {
return locale.toString()
fun localeToString(locale: Locale?): String? {
return when (locale) {
null -> null
else -> locale.toString()
}
}
@TypeConverter
fun stringToLocale(string: String): Locale? {
fun stringToLocale(string: String?): Locale? {
return when (string) {
"all", "null", "" -> null
else ->LocaleUtils.stringToLocale(string)
null, "all", "null", "" -> null
else -> LocaleUtils.stringToLocale(string)
}
}
}
@ -228,7 +242,7 @@ class SystemUserDictionaryDatabase(context: Context) : UserDictionaryDatabase {
override fun query(word: String, locale: Locale?): List<UserDictionaryEntry> {
return queryResolver(
selection = "${UserDictionary.Words.WORD} LIKE ? AND (${UserDictionary.Words.LOCALE} = ? OR ${UserDictionary.Words.LOCALE} = ? OR ${UserDictionary.Words.LOCALE} IS NULL)",
selectionArgs = arrayOf("%$word%", locale.toString(), locale?.language.toString()),
selectionArgs = arrayOf("%$word%", locale?.toString() ?: "", locale?.language?.toString() ?: ""),
sortOrder = SORT_BY_FREQ_DESC,
)
}
@ -241,6 +255,22 @@ class SystemUserDictionaryDatabase(context: Context) : UserDictionaryDatabase {
)
}
override fun queryAll(locale: Locale?): List<UserDictionaryEntry> {
return if (locale == null) {
queryResolver(
selection = "${UserDictionary.Words.LOCALE} IS NULL",
selectionArgs = null,
sortOrder = SORT_BY_FREQ_DESC,
)
} else {
queryResolver(
selection = "${UserDictionary.Words.LOCALE} = ?",
selectionArgs = arrayOf(locale.toString()),
sortOrder = SORT_BY_FREQ_DESC,
)
}
}
override fun queryExact(word: String): List<UserDictionaryEntry> {
return queryResolver(
selection = "${UserDictionary.Words.WORD} = ?",
@ -252,11 +282,37 @@ class SystemUserDictionaryDatabase(context: Context) : UserDictionaryDatabase {
override fun queryExact(word: String, locale: Locale?): List<UserDictionaryEntry> {
return queryResolver(
selection = "${UserDictionary.Words.WORD} = ? AND (${UserDictionary.Words.LOCALE} = ? OR ${UserDictionary.Words.LOCALE} = ? OR ${UserDictionary.Words.LOCALE} IS NULL)",
selectionArgs = arrayOf(word, locale.toString(), locale?.language.toString()),
selectionArgs = arrayOf(word, locale?.toString() ?: "", locale?.language?.toString() ?: ""),
sortOrder = null,
)
}
override fun queryLanguageList(): List<Locale?> {
val resolver = applicationContext.get()?.contentResolver ?: return listOf()
val cursor = resolver.query(
UserDictionary.Words.CONTENT_URI,
PROJECTIONS_LANGUAGE,
null,
null,
null
) ?: return listOf()
if (cursor.count <= 0) {
return listOf()
}
val localeIndex = cursor.getColumnIndex(UserDictionary.Words.LOCALE)
val retList = mutableSetOf<Locale?>()
while (cursor.moveToNext()) {
val localeStr = cursor.getString(localeIndex)
if (localeStr == null) {
retList.add(null)
} else {
retList.add(LocaleUtils.stringToLocale(localeStr))
}
}
cursor.close()
return retList.toList()
}
private fun queryResolver(selection: String?, selectionArgs: Array<out String>?, sortOrder: String?): List<UserDictionaryEntry> {
val resolver = applicationContext.get()?.contentResolver ?: return listOf()
val cursor = resolver.query(

View File

@ -16,28 +16,146 @@
package dev.patrickgold.florisboard.settings
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.Toolbar
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.databinding.UdmActivityBinding
import dev.patrickgold.florisboard.databinding.UdmEntryDialogBinding
import dev.patrickgold.florisboard.ime.core.FlorisActivity
import dev.patrickgold.florisboard.ime.dictionary.DictionaryManager
import dev.patrickgold.florisboard.ime.dictionary.FREQUENCY_DEFAULT
import dev.patrickgold.florisboard.ime.dictionary.FREQUENCY_MAX
import dev.patrickgold.florisboard.ime.dictionary.FREQUENCY_MIN
import dev.patrickgold.florisboard.ime.dictionary.UserDictionaryDao
import dev.patrickgold.florisboard.ime.dictionary.UserDictionaryDatabase
import dev.patrickgold.florisboard.ime.extension.AssetManager
import dev.patrickgold.florisboard.ime.dictionary.UserDictionaryEntry
import dev.patrickgold.florisboard.ime.text.keyboard.*
import java.util.*
interface OnListItemCLickListener {
fun onListItemClick(pos: Int)
}
class LanguageEntryAdapter(
private val data: List<String>,
private val onListItemCLickListener: OnListItemCLickListener
) : RecyclerView.Adapter<LanguageEntryAdapter.ViewHolder>() {
class ViewHolder(view: View, private val onListItemCLickListener: OnListItemCLickListener) :
RecyclerView.ViewHolder(view) {
val titleView: TextView = view.findViewById(android.R.id.title)
val summaryView: TextView = view.findViewById(android.R.id.summary)
init {
view.setOnClickListener {
onListItemCLickListener.onListItemClick(adapterPosition)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val listItemView = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item, parent, false)
return ViewHolder(listItemView, onListItemCLickListener)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.titleView.text = data[position]
}
override fun getItemCount(): Int {
return data.size
}
}
class UserDictionaryEntryAdapter(
private val data: List<UserDictionaryEntry>,
private val onListItemCLickListener: OnListItemCLickListener
) : RecyclerView.Adapter<UserDictionaryEntryAdapter.ViewHolder>() {
class ViewHolder(view: View, private val onListItemCLickListener: OnListItemCLickListener) :
RecyclerView.ViewHolder(view) {
val titleView: TextView = view.findViewById(android.R.id.title)
val summaryView: TextView = view.findViewById(android.R.id.summary)
init {
view.setOnClickListener {
onListItemCLickListener.onListItemClick(adapterPosition)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val listItemView = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item, parent, false)
return ViewHolder(listItemView, onListItemCLickListener)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.titleView.text = data[position].word
val shortcut = data[position].shortcut
holder.summaryView.text = if (shortcut == null) {
String.format(
holder.summaryView.context.resources.getString(R.string.settings__udm__word_summary_freq),
data[position].freq
)
} else {
String.format(
holder.summaryView.context.resources.getString(R.string.settings__udm__word_summary_freq_shortcut),
data[position].freq,
shortcut
)
}
}
override fun getItemCount(): Int {
return data.size
}
}
class UdmActivity : FlorisActivity<UdmActivityBinding>() {
private val dictionaryManager: DictionaryManager get() = DictionaryManager.default()
private val assetManager: AssetManager get() = AssetManager.default()
private var userDictionaryType: Int = -1
private var currentLevel: Int = LEVEL_LANGUAGES
private var locale: Locale? = null
private var currentLocale: Locale? = null
private var activeDialogWindow: AlertDialog? = null
private var languageList: List<Locale?> = listOf()
private var wordList: List<UserDictionaryEntry> = listOf()
private val languageListItemClickListener = object : OnListItemCLickListener {
override fun onListItemClick(pos: Int) {
if (currentLevel == LEVEL_LANGUAGES) {
currentLocale = languageList[pos]
currentLevel = LEVEL_WORDS
buildUi()
}
}
}
private val wordListItemClickListener = object : OnListItemCLickListener {
override fun onListItemClick(pos: Int) {
if (currentLevel == LEVEL_WORDS) {
val entry = wordList[pos]
showEditWordDialog(entry)
}
}
}
private val importUserDictionary = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
// If uri is null it indicates that the selection activity was cancelled (mostly by pressing the back button),
@ -100,7 +218,7 @@ class UdmActivity : FlorisActivity<UdmActivityBinding>() {
private const val LEVEL_LANGUAGES: Int = 1
private const val LEVEL_WORDS: Int = 2
private const val USER_DICTIONARY_SETTINGS_INTENT_ACTION: String =
private const val SYSTEM_USER_DICTIONARY_SETTINGS_INTENT_ACTION: String =
"android.settings.USER_DICTIONARY_SETTINGS"
}
@ -123,7 +241,10 @@ class UdmActivity : FlorisActivity<UdmActivityBinding>() {
dictionaryManager.loadUserDictionariesIfNecessary()
binding.fabAddWord.setOnClickListener { }
binding.fabAddWord.setOnClickListener { showAddWordDialog() }
binding.recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
buildUi()
}
override fun onCreateBinding(): UdmActivityBinding {
@ -132,9 +253,24 @@ class UdmActivity : FlorisActivity<UdmActivityBinding>() {
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.udm_extra_menu, menu)
if (userDictionaryType == USER_DICTIONARY_TYPE_FLORIS) {
menu?.findItem(R.id.udm__open_system_manager_ui)?.isVisible = false
}
return true
}
override fun onDestroy() {
super.onDestroy()
activeDialogWindow?.dismiss()
activeDialogWindow = null
currentLocale = null
}
override fun onResume() {
super.onResume()
buildUi()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
@ -149,11 +285,134 @@ class UdmActivity : FlorisActivity<UdmActivityBinding>() {
exportUserDictionary.launch("my-personal-dictionary.clb")
true
}
R.id.udm__open_system_manager_ui -> {
startActivity(Intent(SYSTEM_USER_DICTIONARY_SETTINGS_INTENT_ACTION))
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun buildUi() {
override fun onBackPressed() {
if (currentLevel == LEVEL_WORDS) {
currentLevel = LEVEL_LANGUAGES
currentLocale = null
buildUi()
} else {
super.onBackPressed()
}
}
private fun userDictionaryDao(): UserDictionaryDao? {
return when (userDictionaryType) {
USER_DICTIONARY_TYPE_FLORIS -> dictionaryManager.florisUserDictionaryDao()
USER_DICTIONARY_TYPE_SYSTEM -> dictionaryManager.systemUserDictionaryDao()
else -> null
}
}
private fun buildUi() {
when (currentLevel) {
LEVEL_LANGUAGES -> {
languageList = userDictionaryDao()?.queryLanguageList()?.sortedBy { it?.displayLanguage } ?: listOf()
binding.recyclerView.adapter = LanguageEntryAdapter(
languageList.map { it?.displayName ?: resources.getString(R.string.settings__udm__all_languages) },
languageListItemClickListener
)
}
LEVEL_WORDS -> {
wordList = userDictionaryDao()?.queryAll(currentLocale) ?: listOf()
binding.recyclerView.adapter = UserDictionaryEntryAdapter(
wordList,
wordListItemClickListener
)
}
}
}
private fun showAddWordDialog() {
val dialogBinding = UdmEntryDialogBinding.inflate(layoutInflater)
dialogBinding.freq.setText(FREQUENCY_DEFAULT.toString())
dialogBinding.freqLabel.hint = String.format(
resources.getString(R.string.settings__udm__dialog__freq_label),
FREQUENCY_MIN,
FREQUENCY_MAX
)
if (currentLevel == LEVEL_WORDS) {
currentLocale?.let {
dialogBinding.locale.setText(it.toString())
}
}
AlertDialog.Builder(this).apply {
setTitle(R.string.settings__udm__dialog__title_add)
setCancelable(true)
setView(dialogBinding.root)
setPositiveButton(R.string.assets__action__add, null)
setNegativeButton(R.string.assets__action__cancel, null)
setOnDismissListener { activeDialogWindow = null }
create()
activeDialogWindow = show()
activeDialogWindow?.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
val word = dialogBinding.word.text.toString()
val freq = dialogBinding.freq.text.toString()
val shortcut = dialogBinding.shortcut.text?.toString()?.ifBlank { null }
val locale = dialogBinding.locale.text?.toString()?.ifBlank { null }
if (word.isBlank() || freq.isBlank()) {
// ERROR
} else {
userDictionaryDao()?.insert(
UserDictionaryEntry(0, word, freq.toInt(), locale, shortcut)
)
activeDialogWindow?.dismiss()
activeDialogWindow = null
buildUi()
}
}
}
}
private fun showEditWordDialog(entry: UserDictionaryEntry) {
val dialogBinding = UdmEntryDialogBinding.inflate(layoutInflater)
dialogBinding.word.setText(entry.word)
dialogBinding.freq.setText(entry.freq.toString())
dialogBinding.freqLabel.hint = String.format(
resources.getString(R.string.settings__udm__dialog__freq_label),
FREQUENCY_MIN,
FREQUENCY_MAX
)
dialogBinding.shortcut.setText(entry.shortcut ?: "")
dialogBinding.locale.setText(entry.locale ?: "")
AlertDialog.Builder(this).apply {
setTitle(R.string.settings__udm__dialog__title_edit)
setCancelable(true)
setView(dialogBinding.root)
setPositiveButton(R.string.assets__action__apply, null)
setNegativeButton(R.string.assets__action__cancel, null)
setNeutralButton(R.string.assets__action__delete) { _, _ ->
userDictionaryDao()?.delete(entry)
buildUi()
}
setOnDismissListener { activeDialogWindow = null }
create()
activeDialogWindow = show()
activeDialogWindow?.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
val word = dialogBinding.word.text.toString()
val freq = dialogBinding.freq.text.toString()
val shortcut = dialogBinding.shortcut.text?.toString()?.ifBlank { null }
val locale = dialogBinding.locale.text?.toString()?.ifBlank { null }
if (word.isBlank() || freq.isBlank()) {
// ERROR
} else {
userDictionaryDao()?.update(
UserDictionaryEntry(entry.id, word, freq.toInt(), locale, shortcut)
)
activeDialogWindow?.dismiss()
activeDialogWindow = null
buildUi()
}
}
}
}
}

View File

@ -0,0 +1,94 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:paddingTop="16dp"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/word_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/settings__udm__dialog__word_label"
app:boxBackgroundMode="outline"
app:boxBackgroundColor="?android:windowBackground"
app:boxStrokeColor="?colorAccent"
app:boxStrokeErrorColor="?colorError"
app:boxStrokeWidth="1dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/word"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:inputType="textFilter"
android:imeOptions="flagNoExtractUi"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/freq_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/settings__udm__dialog__freq_label"
app:boxBackgroundMode="outline"
app:boxBackgroundColor="?android:windowBackground"
app:boxStrokeColor="?colorAccent"
app:boxStrokeErrorColor="?colorError"
app:boxStrokeWidth="1dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/freq"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:inputType="textFilter"
android:imeOptions="flagNoExtractUi"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/shortcut_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/settings__udm__dialog__shortcut_label"
app:boxBackgroundMode="outline"
app:boxBackgroundColor="?android:windowBackground"
app:boxStrokeColor="?colorAccent"
app:boxStrokeErrorColor="?colorError"
app:boxStrokeWidth="1dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/shortcut"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:inputType="textFilter"
android:imeOptions="flagNoExtractUi"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/locale_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/settings__udm__dialog__locale_label"
app:boxBackgroundMode="outline"
app:boxBackgroundColor="?android:windowBackground"
app:boxStrokeColor="?colorAccent"
app:boxStrokeErrorColor="?colorError"
app:boxStrokeWidth="1dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/locale"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:inputType="textFilter"
android:imeOptions="flagNoExtractUi"/>
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View File

@ -17,6 +17,11 @@
android:id="@+id/udm__export"
android:orderInCategory="2"
android:title="@string/assets__action__export"/>
<item
android:id="@+id/udm__open_system_manager_ui"
android:orderInCategory="3"
android:title="@string/settings__udm__open_system_manager_ui"/>
</menu>
</item>

View File

@ -259,9 +259,18 @@
<string name="settings__udm__title_floris" comment="Title of the User Dictionary Manager activity for internal">Internal User Dictionary</string>
<string name="settings__udm__title_system" comment="Title of the User Dictionary Manager activity for system">System User Dictionary</string>
<string name="settings__udm__word_summary_freq" comment="Summary label for a word entry. The decimal placeholder inserts the frequency for the word it summarizes.">Frequency: %d</string>
<string name="settings__udm__word_summary_freq_shortcut" comment="Summary label for a word entry. The first placeholder inserts the frequency for the word it summarizes, the second placeholder the shortcut defined.">Frequency: %d | Shortcut: %s</string>
<string name="settings__udm__all_languages" comment="Label of the For all languages entry in the language list">For all languages</string>
<string name="settings__udm__open_system_manager_ui" comment="Label of the Open system manager UI menu option">Open system manager UI</string>
<string name="settings__udm__dictionary_import_success" comment="Message for dictionary import success">User dictionary imported successfully!</string>
<string name="settings__udm__dictionary_export_success" comment="Message for dictionary export success">User dictionary exported successfully!</string>
<string name="settings__udm__dialog__title_add" comment="Label for the title (when in adding mode) in the user dictionary add/edit dialog">Add word entry</string>
<string name="settings__udm__dialog__title_edit" comment="Label for the title (when in editing mode) in the user dictionary add/edit dialog">Edit word entry</string>
<string name="settings__udm__dialog__word_label" comment="Label for the word in the user dictionary add/edit dialog">Word</string>
<string name="settings__udm__dialog__freq_label" comment="Label for the frequency in the user dictionary add/edit dialog. The two decimal placeholders are the minimum and maximum frequency, both inclusive.">Frequency (between %d and %d)</string>
<string name="settings__udm__dialog__shortcut_label" comment="Label for the shortcut in the user dictionary add/edit dialog">Shortcut (optional)</string>
<string name="settings__udm__dialog__locale_label" comment="Label for the language code in the user dictionary add/edit dialog">Language code (optional)</string>
<string name="settings__gestures__title" comment="Title of Gestures fragment">Gestures &amp; Glide typing</string>
<string name="pref__glide__title" comment="Preference group title">Glide typing</string>
@ -347,6 +356,7 @@
<string name="assets__file__name">Name</string>
<string name="assets__file__source">Source</string>
<string name="assets__action__add">Add</string>
<string name="assets__action__apply">Apply</string>
<string name="assets__action__cancel">Cancel</string>
<string name="assets__action__cancel_confirm_title">Confirm cancel</string>
<string name="assets__action__cancel_confirm_message">Are you sure you want to discard any unsaved changes? This action can not be undone once executed.</string>