diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index 4d2bd6bf1b1d..66114aa4d9a8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -50,7 +50,6 @@ import com.ichi2.anki.reviewreminders.ScheduleReminders import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.toSentenceCase -import com.ichi2.anki.utils.ext.description import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.utils.HtmlUtils.convertNewlinesToHtml import kotlinx.coroutines.Job @@ -608,11 +607,17 @@ class StudyOptionsFragment : } // Set deck description + @Language("HTML") val desc: String = if (isDynamic) { resources.getString(R.string.dyn_deck_desc) } else { - col.decks.current().description + val deck = col.decks.current() + if (deck.descriptionAsMarkdown) { + col.renderMarkdown(deck.description, sanitize = true) + } else { + deck.description + } } if (desc.isNotEmpty()) { textDeckDescription.text = formatDescription(desc) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt index fad6d2fd0a94..dbc8c911e1cc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialog.kt @@ -16,123 +16,175 @@ package com.ichi2.anki.dialogs +import android.app.Dialog import android.os.Bundle -import android.view.LayoutInflater import android.view.View -import android.view.ViewGroup +import android.widget.CheckBox +import android.widget.ImageButton +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf +import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText -import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.R import com.ichi2.anki.StudyOptionsFragment -import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.utils.ext.description -import com.ichi2.anki.utils.ext.update -import com.ichi2.themes.Themes +import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.AndroidUiUtils.setFocusAndOpenKeyboard +import com.ichi2.utils.create +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton +import com.ichi2.utils.show +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch import timber.log.Timber /** - * Allows a user to edit the [deck description][description] + * Allows a user to edit the [deck description][com.ichi2.anki.libanki.Deck.description] * * This is visible on [StudyOptionsFragment] */ class EditDeckDescriptionDialog : DialogFragment() { - private val deckId: DeckId - get() = requireArguments().getLong(ARG_DECK_ID) + private val viewModel: EditDeckDescriptionDialogViewModel by viewModels() - private lateinit var deckDescriptionInput: TextInputEditText + private lateinit var alertDialog: AlertDialog + private lateinit var dialogView: View - private var currentDescription - get() = deckDescriptionInput.text.toString() - set(value) { - deckDescriptionInput.setText(value) - } + private val deckDescriptionInput: TextInputEditText + get() = dialogView.findViewById(R.id.deck_description_input) + + private val formatAsMarkdownInput: CheckBox + get() = dialogView.findViewById(R.id.format_as_markdown) - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - Themes.setTheme(requireContext()) - return inflater.inflate(R.layout.dialog_deck_description, null).apply { - deckDescriptionInput = this.findViewById(R.id.deck_description_input) - launchCatchingTask { - currentDescription = getDescription() + private val toolbar: MaterialToolbar + get() = dialogView.findViewById(R.id.topAppBar) + + private val onUnsavedChangesBackCallback = + object : OnBackPressedCallback(enabled = false) { + override fun handleOnBackPressed() { + showDiscardChangesDialog() } - findViewById(R.id.topAppBar) - .apply { - setNavigationOnClickListener { - onBack() - } + } - setOnMenuItemClickListener { menuItem -> - if (menuItem.itemId == R.id.action_save) { - saveAndExit() - true - } else { - false - } - } - }.also { toolbar -> - launchCatchingTask { toolbar.title = withCol { decks.getLegacy(deckId)!!.name } } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + this.dialogView = layoutInflater.inflate(R.layout.dialog_deck_description, null) + return MaterialAlertDialogBuilder(requireContext()) + .create { + setView(dialogView) + positiveButton(R.string.save) + negativeButton(R.string.close) + }.apply { + alertDialog = this + setOnShowListener { + positiveButton.setOnClickListener { viewModel.saveAndExit() } + negativeButton.setOnClickListener { viewModel.onBackRequested() } } - setFocusAndOpenKeyboard(deckDescriptionInput) { deckDescriptionInput.setSelection(deckDescriptionInput.text!!.length) } - } + setCanceledOnTouchOutside(false) + setCancelable(false) + onBackPressedDispatcher.addCallback(this, onUnsavedChangesBackCallback) + show() + setupDialogView(dialogView) + } } - override fun onStart() { - super.onStart() + private fun setupDialogView(view: View) { + deckDescriptionInput.apply { + doOnTextChanged { text, _, _, _ -> + viewModel.description = text?.toString() ?: "" + } + } + + formatAsMarkdownInput.apply { + setOnCheckedChangeListener { _, value -> viewModel.formatAsMarkdown = value } + } + + // setup 'Format as Markdown' help + view.findViewById(R.id.markdown_formatting_help).apply { + contentDescription = + getString(R.string.help_button_content_description, getString(R.string.format_deck_description_as_markdown)) + setOnClickListener { + MaterialAlertDialogBuilder(requireContext()).show { + setTitle(formatAsMarkdownInput.text) + setIcon(R.drawable.ic_help_black_24dp) + // FIXME: the upstream string unexpectedly contains newlines + setMessage(TR.deckConfigDescriptionNewHandlingHint().replace("\n", " ").replace(" ", " ")) + } + } + } - dialog!!.window!!.setLayout( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) + setupFlows() } - private fun saveAndExit() = - launchCatchingTask { - setDescription(currentDescription) - Timber.i("closing deck description dialog") - dismiss() + private fun setupFlows() { + lifecycleScope.launch { + viewModel.flowOfDismissDialog + .filterNotNull() + .collect { dismissType -> + when (dismissType) { + DismissType.ClosedWithoutSaving -> dismiss() + DismissType.Saved -> { + dismiss() + showSnackbar(R.string.deck_description_saved) + } + } + } } - private fun onBack() = - launchCatchingTask { - fun closeWithoutSaving() { - Timber.i("Closing dialog without saving") - dismiss() + lifecycleScope.launch { + viewModel.flowOfDescription.collect { desc -> + if (desc == deckDescriptionInput.text.toString()) return@collect + deckDescriptionInput.setText(desc) } + } - if (getDescription() == currentDescription) { - closeWithoutSaving() - return@launchCatchingTask + lifecycleScope.launch { + viewModel.flowOfFormatAsMarkdown.collect { + formatAsMarkdownInput.isChecked = it } + } - Timber.i("asking if user should discard changes") - DiscardChangesDialog.showDialog(requireContext()) { - closeWithoutSaving() + lifecycleScope.launch { + viewModel.flowOfInitCompleted.collect { + if (!it) return@collect + toolbar.title = viewModel.windowTitle + setFocusAndOpenKeyboard(deckDescriptionInput) { deckDescriptionInput.setSelection(deckDescriptionInput.text!!.length) } } } - private suspend fun getDescription() = withCol { decks.getLegacy(deckId)!!.description } + lifecycleScope.launch { + viewModel.flowOfShowDiscardChanges.collect { + showDiscardChangesDialog() + } + } - private suspend fun setDescription(value: String) { - Timber.i("updating deck description") - withCol { decks.update(deckId) { description = value } } + lifecycleScope.launch { + viewModel.flowOfHasChanges.collect { + alertDialog.positiveButton.isEnabled = it + onUnsavedChangesBackCallback.isEnabled = it + } + } } - companion object { - private const val ARG_DECK_ID = "deckId" + fun showDiscardChangesDialog() { + Timber.i("asking if user should discard changes") + DiscardChangesDialog.showDialog(requireContext()) { + viewModel.closeWithoutSaving() + } + } + companion object { fun newInstance(deckId: DeckId): EditDeckDescriptionDialog = EditDeckDescriptionDialog().apply { arguments = bundleOf( - ARG_DECK_ID to deckId, + EditDeckDescriptionDialogViewModel.ARG_DECK_ID to deckId, ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt new file mode 100644 index 000000000000..438bfaed7b2b --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModel.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 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.dialogs + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.utils.ext.update +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.launch +import timber.log.Timber + +class EditDeckDescriptionDialogViewModel( + private val stateHandle: SavedStateHandle, +) : ViewModel() { + val deckId: DeckId + get() = requireNotNull(stateHandle.get(ARG_DECK_ID)) + + @VisibleForTesting + var userHasMadeChanges: Boolean + get() = stateHandle.get(STATE_USER_MADE_CHANGES) ?: false + set(value) { + stateHandle[STATE_USER_MADE_CHANGES] = value + } + + private lateinit var initialDialogState: DeckDescriptionState + + lateinit var windowTitle: String + + val flowOfDescription = MutableStateFlow(stateHandle.get(STATE_DESCRIPTION) ?: "") + + val flowOfFormatAsMarkdown = MutableStateFlow(stateHandle.get(STATE_FORMAT_AS_MARKDOWN) ?: false) + + var description + get() = flowOfDescription.value + set(value) { + userHasMadeChanges = true + stateHandle[STATE_DESCRIPTION] = value + flowOfDescription.value = value + } + + var formatAsMarkdown: Boolean + get() = flowOfFormatAsMarkdown.value + set(value) { + userHasMadeChanges = true + stateHandle[STATE_FORMAT_AS_MARKDOWN] = value + flowOfFormatAsMarkdown.value = value + } + + private val dialogState: DeckDescriptionState + get() = + DeckDescriptionState( + description = this.description, + formatAsMarkdown = this.formatAsMarkdown, + ) + + val flowOfDismissDialog = MutableStateFlow(null) + + val flowOfShowDiscardChanges = MutableSharedFlow() + + val flowOfInitCompleted = MutableStateFlow(false) + + val flowOfHasChanges: Flow = + combineTransform(flowOfDescription, flowOfFormatAsMarkdown, flowOfInitCompleted) { + emit(hasChanges()) + } + + init { + viewModelScope.launch { + windowTitle = withCol { decks.getLegacy(deckId)!!.name } + initialDialogState = queryDescriptionState() + if (!userHasMadeChanges) { + description = initialDialogState.description + formatAsMarkdown = initialDialogState.formatAsMarkdown + userHasMadeChanges = false + } + flowOfInitCompleted.emit(true) + } + } + + fun onBackRequested() = + viewModelScope.launch { + if (!hasChanges()) { + closeWithoutSaving() + return@launch + } + + Timber.i("asking if user should discard changes") + flowOfShowDiscardChanges.emit(Unit) + } + + fun closeWithoutSaving() = + viewModelScope.launch { + Timber.i("Closing dialog without saving") + flowOfDismissDialog.emit(DismissType.ClosedWithoutSaving) + } + + fun saveAndExit() = + viewModelScope.launch { + save() + Timber.i("closing deck description dialog") + flowOfDismissDialog.emit(DismissType.Saved) + } + + private suspend fun queryDescriptionState() = + withCol { + decks.getLegacy(deckId)!!.let { + DeckDescriptionState( + description = it.description, + formatAsMarkdown = it.descriptionAsMarkdown, + ) + } + } + + private suspend fun save() { + Timber.i("updating deck description") + withCol { + decks.update(deckId) { + this.description = dialogState.description + this.descriptionAsMarkdown = dialogState.formatAsMarkdown + } + } + } + + private fun hasChanges(): Boolean { + // this can be triggered via the back dispatcher + if (!::initialDialogState.isInitialized) return false + return initialDialogState != dialogState + } + + /** + * State for [EditDeckDescriptionDialog]. + * + * Simplifies detecting user changes + * + * @param description see [com.ichi2.anki.libanki.Deck.description] + * @param formatAsMarkdown see [com.ichi2.anki.libanki.Deck.markdownDescription] + */ + private data class DeckDescriptionState( + val description: String, + val formatAsMarkdown: Boolean, + ) + + /** How the Dialog was dismissed */ + sealed class DismissType { + data object Saved : DismissType() + + data object ClosedWithoutSaving : DismissType() + } + + companion object { + const val ARG_DECK_ID = "deckId" + const val STATE_DESCRIPTION = "description" + const val STATE_FORMAT_AS_MARKDOWN = "format_as_markdown" + const val STATE_USER_MADE_CHANGES = "user_made_changes" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index 7c16d908238c..86d0cf6bcee0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -57,7 +57,6 @@ import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.libanki.exception.EmptyMediaException import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.libanki.sched.Ease -import com.ichi2.anki.utils.ext.description import com.ichi2.utils.FileUtil import com.ichi2.utils.FileUtil.internalizeUri import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt index 70b21666dcc5..b5323ec972db 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Deck.kt @@ -20,12 +20,6 @@ import com.ichi2.anki.libanki.Deck import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks -var Deck.description: String - get() = optString("desc", "") - set(value) { - put("desc", value) - } - fun Decks.update( did: DeckId, block: Deck.() -> Unit, diff --git a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml index 82c14ce43ad3..099de0c8c531 100644 --- a/AnkiDroid/src/main/res/layout/dialog_deck_description.xml +++ b/AnkiDroid/src/main/res/layout/dialog_deck_description.xml @@ -19,43 +19,72 @@ xmlns:tools="http://schemas.android.com/tools" android:orientation="vertical" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="wrap_content" + android:layout_marginHorizontal="8dp" + > + + + + + + + diff --git a/AnkiDroid/src/main/res/menu/menu_deck_description.xml b/AnkiDroid/src/main/res/menu/menu_deck_description.xml deleted file mode 100644 index e83aa95a2a4d..000000000000 --- a/AnkiDroid/src/main/res/menu/menu_deck_description.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 0a868f715218..97b76953ea5c 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -377,6 +377,9 @@ opening the system text to speech settings fails">Failed to open text to speech Please log in to download more decks Description + Description updated + Format as Markdown + Open help for ā€˜%s’ Failed to copy diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt new file mode 100644 index 000000000000..479e57d66f4c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EditDeckDescriptionDialogViewModelTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 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.dialogs + +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.ARG_DECK_ID +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_DESCRIPTION +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_FORMAT_AS_MARKDOWN +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.Companion.STATE_USER_MADE_CHANGES +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType.ClosedWithoutSaving +import com.ichi2.anki.dialogs.EditDeckDescriptionDialogViewModel.DismissType.Saved +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import com.ichi2.anki.libanki.testutils.AnkiTest +import com.ichi2.testutils.JvmTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests [EditDeckDescriptionDialogViewModel] + */ +@RunWith(AndroidJUnit4::class) +class EditDeckDescriptionDialogViewModelTest : JvmTest() { + @Test + fun `title is set`() = + runViewModelTest { + assertThat(windowTitle, equalTo("Default")) + } + + @Test + fun `default description is empty`() = + runViewModelTest { + assertThat(description, equalTo("")) + } + + @Test + fun `description is updated in database`() = + runViewModelTest { + description = "foo" + assertThat("description not updated before save", defaultDeck.description, equalTo("")) + + saveAndExit() + + assertThat("description updated after save", defaultDeck.description, equalTo("foo")) + } + + @Test + fun `format as markdown is updated in database`() = + runViewModelTest { + formatAsMarkdown = true + assertThat("format as markdown not updated before save", defaultDeck.descriptionAsMarkdown, equalTo(false)) + + saveAndExit() + + assertThat("format as markdown updated after save", defaultDeck.descriptionAsMarkdown, equalTo(true)) + } + + @Test + fun `dialog dismissed if no changes`() = + runViewModelTest { + flowOfDismissDialog.test { + onBackRequested() + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(ClosedWithoutSaving)) + } + } + + @Test + fun `dialog not immediately dismissed if going back with changes`() = + runViewModelTest { + flowOfDismissDialog.test { + description = "foo" + onBackRequested() + assertThat("dialog should not be dismissed", expectMostRecentItem(), equalTo(null)) + } + } + + @Test + fun `'discard changes' shown if going back with changes`() = + runViewModelTest { + flowOfShowDiscardChanges.test { + description = "foo" + onBackRequested() + assertThat("discard should be shown", expectMostRecentItem(), equalTo(Unit)) + } + } + + @Test + fun `'close without saving' closes even if changes are made`() = + runViewModelTest { + flowOfDismissDialog.test { + description = "foo" + closeWithoutSaving() + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(ClosedWithoutSaving)) + } + } + + @Test + fun `dialog is dismissed as saved`() = + runViewModelTest { + flowOfDismissDialog.test { + description = "foo" + saveAndExit() + assertThat("dialog should be dismissed", expectMostRecentItem(), equalTo(Saved)) + } + } + + @Test + fun `'format as markdown' also triggers changes`() = + runViewModelTest { + flowOfShowDiscardChanges.test { + formatAsMarkdown = true + onBackRequested() + + expectMostRecentItem() + } + } + + @Test + fun `test state restoration`() = + runViewModelTest(updatedDescription = "foo", updatedFormatAsMarkdown = true) { + assertThat("database is unchanged", defaultDeck.description, equalTo("")) + assertThat("dialog state is maintained", description, equalTo("foo")) + assertThat("dialog state is maintained", formatAsMarkdown, equalTo(true)) + } + + @Test + fun `no changes initially`() = + runViewModelTest { + assertThat(userHasMadeChanges, equalTo(false)) + } + + @Test + fun `description is a change`() = + runViewModelTest { + description = "foo" + assertThat(userHasMadeChanges, equalTo(true)) + } + + @Test + fun `format as markdown is a change`() = + runViewModelTest { + formatAsMarkdown = false + assertThat(userHasMadeChanges, equalTo(true)) + } + + @Test + fun `saved changes update`() = + runViewModelTest { + flowOfHasChanges.test { + assertThat("initial state", expectMostRecentItem(), equalTo(false)) + description = "foo" + assertThat("has changes after change", expectMostRecentItem(), equalTo(true)) + description = "" + assertThat("no changes if all reverted", expectMostRecentItem(), equalTo(false)) + } + } + + val AnkiTest.defaultDeck + get() = col.decks.getLegacy(DEFAULT_DECK_ID)!! + + private fun runViewModelTest( + updatedDescription: String? = null, + updatedFormatAsMarkdown: Boolean? = null, + testBody: suspend EditDeckDescriptionDialogViewModel.() -> Unit, + ) = runTest { + val viewModel = + EditDeckDescriptionDialogViewModel( + savedStateHandleOf( + ARG_DECK_ID to DEFAULT_DECK_ID, + STATE_DESCRIPTION to updatedDescription, + STATE_FORMAT_AS_MARKDOWN to updatedFormatAsMarkdown, + STATE_USER_MADE_CHANGES to (updatedDescription != null || updatedFormatAsMarkdown != null), + ), + ) + testBody(viewModel) + } +} + +fun savedStateHandleOf(vararg pairs: Pair): SavedStateHandle = SavedStateHandle(mapOf(*pairs)) diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt index 58906c567ddf..0efb78844fc8 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -67,6 +67,7 @@ import com.ichi2.anki.libanki.utils.NotInLibAnki import net.ankiweb.rsdroid.Backend import net.ankiweb.rsdroid.RustCleanup import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException +import org.intellij.lang.annotations.Language import timber.log.Timber import java.io.File @@ -1148,6 +1149,20 @@ class Collection( fun evaluateParamsLegacyRaw(input: ByteArray): ByteArray = backend.evaluateParamsLegacyRaw(input = input) + /** + * Converts Markdown ([text]) to HTML + * + * @param text Markdown to format as HTML + * @param sanitize whether to sanitize the HTML using + * [ammonia](https://docs.rs/ammonia/latest/ammonia/). `img` tags are also stripped + */ + @Language("HTML") + @LibAnkiAlias("render_markdown") + fun renderMarkdown( + text: String, + sanitize: Boolean, + ): String = backend.renderMarkdown(markdown = text, sanitize = sanitize) + fun compareAnswer( expected: String, provided: String, diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt index f8d5cf0bb791..b40b4ced1733 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Deck.kt @@ -69,4 +69,35 @@ class Deck : JSONObject { set(value) { put("conf", value) } + + /** + * The description, shown on the deck overview and optionally the congratulations screen. + * + * May be HTML or Markdown, depending on [descriptionAsMarkdown]. + */ + var description: String + get() = optString("desc", "") + set(value) { + put("desc", value) + } + + /** + * Treats [description] as Markdown, cleaning HTML input and stripping images. + * + * If disabled, the description is only shown on the deck overview. + * If enabled, it is also shown on the congratulations screen. + * + * Markdown will appear as text on Anki 2.1.40 and below. + * + * Anki names this feature 'md': Markdown description + * + * @see anki.backend.GeneratedBackend.renderMarkdown + * @see anki.i18n.GeneratedTranslations.deckConfigDescriptionNewHandling + * @see anki.i18n.GeneratedTranslations.deckConfigDescriptionNewHandlingHint + */ + var descriptionAsMarkdown: Boolean + get() = optBoolean("md", false) + set(value) { + put("md", value) + } }