diff --git a/app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt b/app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt new file mode 100644 index 000000000..7807bba04 --- /dev/null +++ b/app/src/androidTest/java/com/orgzly/android/data/DataRepositoryTest.kt @@ -0,0 +1,50 @@ +package com.orgzly.android.data + +import com.orgzly.android.OrgzlyTest +import com.orgzly.android.prefs.AppPreferences +import junit.framework.TestCase +import org.junit.Assert +import org.junit.Test +import java.io.IOException + +class DataRepositoryTest : OrgzlyTest() { + + /** + * If the user attempts to export app settings to a note with a non-unique "ID" value, then + * - no export should happen + * - a runtime exception should be thrown + */ + @Test(expected = RuntimeException::class) + fun testExportSettingsAndSearchesToSelectedNote() { + testUtils.setupBook( + "book1", + """ + * Note 1 + :PROPERTIES: + :ID: not-unique-value + :END: + + content + + * Note 2 + :PROPERTIES: + :ID: not-unique-value + :END: + + content + + """.trimIndent() + ) + TestCase.assertEquals(2, dataRepository.getNotes("book1").size) + AppPreferences.settingsExportAndImportNoteId(context, "not-unique-value") + try { + dataRepository.exportSettingsAndSearchesToSelectedNote() + } catch (e: IOException) { + Assert.assertTrue(e.message!!.contains("Found multiple")) + throw e + } finally { + TestCase.assertEquals("content", dataRepository.getNotes("book1")[0].note.content) + TestCase.assertEquals("content", dataRepository.getNotes("book1")[1].note.content) + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java b/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java index 999b4d77a..7217862a0 100644 --- a/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java +++ b/app/src/androidTest/java/com/orgzly/android/misc/BookParsingTest.java @@ -14,10 +14,10 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.junit.Assume.assumeTrue; @@ -176,7 +176,8 @@ public void testBookSinglePropertyIsParsed() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", "bar")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("foo", "bar")); } /** @@ -197,8 +198,8 @@ public void testBookMultiplePropertiesAreParsed() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", "bar")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("bar", "foo")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), dataRepository.findNotesOrBooksHavingProperty("foo", "bar")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), dataRepository.findNotesOrBooksHavingProperty("bar", "foo")); } /** @@ -220,12 +221,12 @@ public void testBookDuplicateProperties() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("bar", - "secondvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("foo", "firstvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("bar", "firstvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("foo", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("bar", "secondvalue")); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("foo", "firstvalue").size()); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("bar", "firstvalue").size()); } @Test @@ -243,16 +244,16 @@ public void testBookDuplicatePropertiesDifferentCase() { """; TestedBook testedBook = onBook(content); testedBook.onLoad().isWhenSaved(content); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("foo", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("bar", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("FOO", - "secondvalue")); - assertEquals(testedBook.book, dataRepository.findNoteOrBookHavingProperty("BAR", - "secondvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("foo", "firstvalue")); - assertNull(dataRepository.findNoteOrBookHavingProperty("bar", "firstvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("foo", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("bar", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("FOO", "secondvalue")); + assertEquals(new ArrayList<>(Collections.singleton(testedBook.book)), + dataRepository.findNotesOrBooksHavingProperty("BAR", "secondvalue")); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("foo", "firstvalue").size()); + assertEquals(0, dataRepository.findNotesOrBooksHavingProperty("bar", "firstvalue").size()); } /* diff --git a/app/src/main/java/com/orgzly/android/data/DataRepository.kt b/app/src/main/java/com/orgzly/android/data/DataRepository.kt index f384fc8ea..d47f568fd 100644 --- a/app/src/main/java/com/orgzly/android/data/DataRepository.kt +++ b/app/src/main/java/com/orgzly/android/data/DataRepository.kt @@ -13,8 +13,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.preference.PreferenceManager import androidx.sqlite.db.SupportSQLiteQuery import androidx.sqlite.db.SupportSQLiteQueryBuilder +import com.google.gson.Gson +import com.google.gson.JsonObject import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.* @@ -22,6 +25,7 @@ import com.orgzly.android.data.mappers.OrgMapper import com.orgzly.android.db.NotesClipboard import com.orgzly.android.db.OrgzlyDatabase import com.orgzly.android.db.dao.NoteDao +import com.orgzly.android.db.dao.NoteDao.NoteIdBookId import com.orgzly.android.db.dao.NoteViewDao import com.orgzly.android.db.dao.ReminderTimeDao import com.orgzly.android.db.entity.* @@ -1987,15 +1991,63 @@ class DataRepository @Inject constructor( NotesOrgExporter(this).exportBook(book, writer) } - fun findNoteHavingProperty(name: String, value: String): NoteDao.NoteIdBookId? { - return db.note().firstNoteHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + fun findNotesHavingProperty(name: String, value: String): List { + return db.note().allNotesHavingPropertyLowerCase(name.lowercase(), value.lowercase()) } - fun findNoteOrBookHavingProperty(name: String, value: String): Any? { - val foundNote = findNoteHavingProperty(name, value) - if (foundNote != null) + fun findNotesOrBooksHavingProperty(name: String, value: String): List { + val foundNote = findNotesHavingProperty(name, value) + if (foundNote.isNotEmpty()) return foundNote - return db.book().firstBookHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + return db.book().allBooksHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + } + + fun findUniqueNoteHavingProperty(name: String, value: String): NoteIdBookId? { + val foundNotes = db.note().allNotesHavingPropertyLowerCase(name.lowercase(), value.lowercase()) + return if (foundNotes.isEmpty()) null + else if (foundNotes.size == 1) foundNotes[0] + else { + val msg = App.getAppContext().getString(R.string.error_multiple_notes_with_matching_property_value, name, value) + throw RuntimeException(msg) + } + } + + fun exportSettingsAndSearchesToSelectedNote() { + val targetNote = findUniqueNoteHavingProperty("ID", AppPreferences.settingsExportAndImportNoteId(context)) + if (targetNote != null) { + val gson = Gson() + // Get settings as JSON + val settingsJsonObject = gson.toJsonTree(PreferenceManager.getDefaultSharedPreferences(context).all) + // Get saved searches as JSON + val savedSearchesJsonObject = gson.fromJson("{}", JsonObject::class.java) + getSavedSearches().forEach { + savedSearchesJsonObject.addProperty(it.name, it.query) + } + // Put them together + val finalMap = mapOf("settings" to settingsJsonObject, "saved_searches" to savedSearchesJsonObject) + val finalJsonString = gson.toJson(finalMap) + updateNoteContent(targetNote.bookId, targetNote.noteId, finalJsonString) + } + } + + fun importSettingsAndSearchesFromSelectedNote() { + val sourceNote = findUniqueNoteHavingProperty("ID", AppPreferences.settingsExportAndImportNoteId(context)) + if (sourceNote != null) { + val notePayload = getNotePayload(sourceNote.noteId) + if (notePayload != null) { + val gson = Gson().fromJson(notePayload.content, Map::class.java) + val settings = gson["settings"] as Map + if (settings.isNotEmpty()) + AppPreferences.setDefaultPrefsFromJsonMap(context, settings) + val savedSearches: List = (gson["saved_searches"] as Map) + .entries + .mapIndexed { index, entry -> + SavedSearch(0, entry.key, entry.value, index + 1) + } + if (savedSearches.isNotEmpty()) + replaceSavedSearches(savedSearches) + } + } } /* diff --git a/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt b/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt index 899e0c30f..27bc1921f 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/BookDao.kt @@ -58,9 +58,8 @@ abstract class BookDao : BaseDao { FROM book_properties LEFT JOIN books ON (books.id = book_properties.book_id) WHERE LOWER(book_properties.name) = :name AND LOWER(book_properties.value) = :value AND books.id IS NOT NULL - LIMIT 1 """) - abstract fun firstBookHavingPropertyLowerCase(name: String, value: String): Book? + abstract fun allBooksHavingPropertyLowerCase(name: String, value: String): List fun getOrInsert(name: String): Long = get(name).let { diff --git a/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt b/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt index 9be88ef1c..655c18225 100644 --- a/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt +++ b/app/src/main/java/com/orgzly/android/db/dao/NoteDao.kt @@ -306,9 +306,8 @@ abstract class NoteDao : BaseDao { LEFT JOIN notes ON (notes.id = note_properties.note_id) WHERE LOWER(note_properties.name) = :name AND LOWER(note_properties.value) = :value AND notes.id IS NOT NULL ORDER BY notes.lft - LIMIT 1 """) - abstract fun firstNoteHavingPropertyLowerCase(name: String, value: String): NoteIdBookId? + abstract fun allNotesHavingPropertyLowerCase(name: String, value: String): List @Query(""" UPDATE notes diff --git a/app/src/main/java/com/orgzly/android/di/AppComponent.kt b/app/src/main/java/com/orgzly/android/di/AppComponent.kt index d89a48fa4..4cbcc7b94 100644 --- a/app/src/main/java/com/orgzly/android/di/AppComponent.kt +++ b/app/src/main/java/com/orgzly/android/di/AppComponent.kt @@ -12,11 +12,10 @@ import com.orgzly.android.reminders.NoteReminders import com.orgzly.android.reminders.RemindersBroadcastReceiver import com.orgzly.android.sync.SyncWorker import com.orgzly.android.ui.BookChooserActivity -import com.orgzly.android.ui.logs.AppLogsActivity import com.orgzly.android.ui.TemplateChooserActivity import com.orgzly.android.ui.books.BooksFragment +import com.orgzly.android.ui.logs.AppLogsActivity import com.orgzly.android.ui.main.MainActivity -import com.orgzly.android.ui.sync.SyncFragment import com.orgzly.android.ui.note.NoteFragment import com.orgzly.android.ui.notes.NotesFragment import com.orgzly.android.ui.notes.book.BookFragment @@ -33,7 +32,10 @@ import com.orgzly.android.ui.repos.ReposActivity import com.orgzly.android.ui.savedsearch.SavedSearchFragment import com.orgzly.android.ui.savedsearches.SavedSearchesFragment import com.orgzly.android.ui.settings.SettingsActivity +import com.orgzly.android.ui.settings.exporting.SettingsExportFragment +import com.orgzly.android.ui.settings.importing.SettingsImportFragment import com.orgzly.android.ui.share.ShareActivity +import com.orgzly.android.ui.sync.SyncFragment import com.orgzly.android.usecase.UseCaseRunner import com.orgzly.android.usecase.UseCaseWorker import com.orgzly.android.widgets.ListWidgetProvider @@ -74,6 +76,8 @@ interface AppComponent { fun inject(arg: SavedSearchesFragment) fun inject(arg: SavedSearchFragment) fun inject(arg: RefileFragment) + fun inject(arg: SettingsExportFragment) + fun inject(arg: SettingsImportFragment) fun inject(arg: SyncFragment) fun inject(arg: SyncWorker) diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index 861e2abfe..170c4cb48 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -20,7 +20,9 @@ import java.io.File; import java.net.URISyntaxException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; @@ -60,6 +62,11 @@ public static AppPreferencesValues getAllValues(Context context) { return values; } + public static void setDefaultPrefsFromJsonMap(Context context, Map parsedMap) { + SharedPreferences prefs = getDefaultSharedPreferences(context); + setPrefsFromValues(prefs, parsedMap); + } + public static void setAllFromValues(Context context, AppPreferencesValues values) { AppPreferences.clearAllSharedPreferences(context); @@ -92,6 +99,10 @@ private static void setPrefsFromValues(SharedPreferences prefs, Map v } else if (value instanceof Set) { edit.putStringSet(key, (Set) value); + + } else if (value instanceof ArrayList) { + HashSet set = new HashSet<>((Collection) value); + edit.putStringSet(key, set); } } @@ -1143,6 +1154,20 @@ public static void subfolderSupport(Context context, boolean value) { getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply(); } + /* + * Export and import of user settings + */ + + public static String settingsExportAndImportNoteId(Context context) { + return getDefaultSharedPreferences(context).getString( + context.getResources().getString(R.string.pref_key_note_id_for_settings_export_and_import), ""); + } + + public static void settingsExportAndImportNoteId(Context context, String value) { + String key = context.getResources().getString(R.string.pref_key_note_id_for_settings_export_and_import); + getDefaultSharedPreferences(context).edit().putString(key, value).apply(); + } + /* * Repository properties map */ diff --git a/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt b/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt index 2ead4f345..404e3654f 100644 --- a/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt +++ b/app/src/main/java/com/orgzly/android/ui/main/MainActivityViewModel.kt @@ -81,15 +81,19 @@ class MainActivityViewModel(private val dataRepository: DataRepository) : Common catchAndPostError { val result = UseCaseRunner.run(useCase) + val data = result.userData as List<*> - if (result.userData == null) { + if (data.isEmpty()) { val msg = App.getAppContext().getString(R.string.no_such_link_target, name, value) errorEvent.postValue(Throwable(msg)) - } else { - when (result.userData) { + if (data.size > 1) { + val msg = App.getAppContext().getString(R.string.error_multiple_notes_with_matching_property_value, name, value) + errorEvent.postValue(Throwable(msg)) + } + when (data[0]) { is NoteDao.NoteIdBookId -> { - val noteIdBookId = result.userData + val noteIdBookId = data[0] as NoteDao.NoteIdBookId when (AppPreferences.linkTarget(App.getAppContext())) { "note_details" -> @@ -104,7 +108,8 @@ class MainActivityViewModel(private val dataRepository: DataRepository) : Common } } is Book -> { - navigationActions.postValue(MainNavigationAction.OpenBook(result.userData.id)) + val book = data[0] as Book + navigationActions.postValue(MainNavigationAction.OpenBook(book.id)) } } } diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt index 346dbd691..3dcf7c4bb 100644 --- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsActivity.kt @@ -7,7 +7,11 @@ import com.orgzly.android.App import com.orgzly.android.ui.CommonActivity import com.orgzly.android.ui.settings.SettingsFragment.Listener import com.orgzly.android.ui.showSnackbar -import com.orgzly.android.usecase.* +import com.orgzly.android.usecase.BookImportGettingStarted +import com.orgzly.android.usecase.DatabaseClear +import com.orgzly.android.usecase.UseCase +import com.orgzly.android.usecase.UseCaseRunner +import com.orgzly.android.usecase.UseCaseWorker import com.orgzly.databinding.ActivitySettingsBinding class SettingsActivity : CommonActivity(), Listener { diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt index 96ce0a1d4..2f3810ec8 100644 --- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt @@ -26,6 +26,8 @@ import com.orgzly.android.usecase.UseCase import com.orgzly.android.util.AppPermissions import com.orgzly.android.util.LogUtils import com.orgzly.android.widgets.ListWidgetProvider +import com.orgzly.android.ui.settings.exporting.SettingsExportFragment +import com.orgzly.android.ui.settings.importing.SettingsImportFragment /** * Displays settings. @@ -68,6 +70,20 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } } + preference(R.string.pref_key_export_settings)?.let { + it.setOnPreferenceClickListener { + SettingsExportFragment().show(childFragmentManager, SettingsExportFragment.FRAGMENT_TAG) + true + } + } + + preference(R.string.pref_key_import_settings)?.let { + it.setOnPreferenceClickListener { + SettingsImportFragment().show(childFragmentManager, SettingsImportFragment.FRAGMENT_TAG) + true + } + } + preference(R.string.pref_key_reload_getting_started)?.let { it.setOnPreferenceClickListener { listener?.onGettingStartedNotebookReloadRequest() diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt new file mode 100644 index 000000000..3eee0b717 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportAdapter.kt @@ -0,0 +1,103 @@ +package com.orgzly.android.ui.settings.exporting + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.orgzly.R +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.db.entity.NoteView +import com.orgzly.android.ui.notes.NoteItemViewBinder +import com.orgzly.databinding.ItemSettingsExportBinding + +class SettingsExportAdapter(val context: Context, val listener: OnClickListener) : + ListAdapter( + DIFF_CALLBACK + ) { + + data class Icons(@DrawableRes val up: Int, @DrawableRes val book: Int) + + private var icons: Icons? = null + + private val noteItemViewBinder = NoteItemViewBinder(context, true) + + interface OnClickListener { + fun onItem(item: SettingsExportViewModel.Item) + fun onButton(item: SettingsExportViewModel.Item) + } + + class SettingsExportViewHolder(val binding: ItemSettingsExportBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingsExportViewHolder { + val holder = SettingsExportViewHolder(ItemSettingsExportBinding.inflate( + LayoutInflater.from(parent.context), parent, false)) + + holder.binding.itemSettingsExportPayload.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onItem(getItem(holder.bindingAdapterPosition)) + } + } + + holder.binding.itemSettingsExportButton.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onButton(getItem(holder.bindingAdapterPosition)) + } + } + + return holder + } + + override fun onBindViewHolder(holder: SettingsExportViewHolder, position: Int) { + + if (icons == null) { + icons = Icons(R.drawable.ic_keyboard_arrow_up, R.drawable.ic_library_books) + } + + val item = getItem(position) + + when (val payload = item.payload) { + is Book -> { + holder.binding.itemSettingsExportName.text = payload.title ?: payload.name + + holder.binding.itemSettingsExportButton.visibility = View.GONE + + holder.binding.itemSettingsExportIcon.visibility = View.VISIBLE + } + + is Note -> { + holder.binding.itemSettingsExportName.text = noteItemViewBinder.generateTitle( + NoteView(note = payload, bookName = "")) + icons?.let { + if (payload.position.descendantsCount > 0) { + holder.binding.itemSettingsExportIcon.setImageResource(R.drawable.bullet_folded) + holder.binding.itemSettingsExportButton.visibility = View.GONE + } else { + holder.binding.itemSettingsExportIcon.setImageResource(R.drawable.bullet) + holder.binding.itemSettingsExportButton.visibility = View.VISIBLE + } + holder.binding.itemSettingsExportIcon.visibility = View.VISIBLE + } + } + } + } + + companion object { + private val DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SettingsExportViewModel.Item, newItem: SettingsExportViewModel.Item): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: SettingsExportViewModel.Item, newItem: SettingsExportViewModel.Item): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt new file mode 100644 index 000000000..7ac5312f3 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportFragment.kt @@ -0,0 +1,210 @@ +package com.orgzly.android.ui.settings.exporting + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.ui.Breadcrumbs +import com.orgzly.android.ui.note.NotePayload +import com.orgzly.android.ui.showSnackbar +import com.orgzly.android.ui.util.styledAttributes +import com.orgzly.android.util.LogUtils +import com.orgzly.databinding.DialogExportSettingsBinding +import javax.inject.Inject + +class SettingsExportFragment : DialogFragment() { + + private lateinit var binding: DialogExportSettingsBinding + + @Inject + lateinit var dataRepository: DataRepository + + lateinit var viewModel: SettingsExportViewModel + + override fun onAttach(context: Context) { + super.onAttach(context) + + App.appComponent.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val noteIds = arguments?.getLongArray(ARG_NOTE_IDS)?.toSet() ?: emptySet() + val count = arguments?.getInt(ARG_COUNT) ?: 0 + + val factory = SettingsExportViewModelFactory.forNotes(dataRepository, noteIds, count) + + viewModel = ViewModelProvider(this, factory)[SettingsExportViewModel::class.java] + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val dialog = MaterialAlertDialogBuilder(requireContext(), theme) + + return dialog.show() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, savedInstanceState) + + binding = DialogExportSettingsBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.dialogExportSettingsToolbar.apply { + title = resources.getString(R.string.export_settings_to_note) + + setNavigationOnClickListener { + dismiss() + } + } + + binding.dialogExportSettingsWarning.apply { + /* Get error color attribute. */ + setTextColor(context.styledAttributes(intArrayOf(R.attr.colorError)) { typedArray -> + typedArray.getColor(0, 0) + }) + text = context.getString(R.string.export_settings_warning_text) + } + + val adapter = SettingsExportAdapter(binding.root.context, object: SettingsExportAdapter.OnClickListener { + override fun onItem(item: SettingsExportViewModel.Item) { + viewModel.open(item) + } + + override fun onButton(item: SettingsExportViewModel.Item) { + viewModel.export(item) + } + }) + + binding.dialogExportSettingsTargets.let { + it.layoutManager = LinearLayoutManager(context) + it.adapter = adapter + } + + binding.dialogExportSettingsBreadcrumbs.movementMethod = LinkMovementMethod.getInstance() + + viewModel.data.observe(viewLifecycleOwner) { data -> + val breadcrumbs = data.first + val list = data.second + + adapter.submitList(list) + + // Update and scroll breadcrumbs to the end + binding.dialogExportSettingsBreadcrumbs.text = generateBreadcrumbs(breadcrumbs) + binding.dialogExportSettingsBreadcrumbsScrollView.apply { + post { + fullScroll(View.FOCUS_RIGHT) + } + } + } + + viewModel.exportedEvent.observeSingle(viewLifecycleOwner) { result -> + if (result.userData is RuntimeException) { + (result.userData).let { + activity?.showSnackbar(it.localizedMessage) + } + } else { + dismiss() + + (result.userData as NotePayload).let { + activity?.showSnackbar( + getString( + R.string.settings_exported_to, it.title + ) + ) + } + } + } + + viewModel.errorEvent.observeSingle(viewLifecycleOwner) { error -> + binding.dialogExportSettingsToolbar.subtitle = (error.cause ?: error).localizedMessage + } + + viewModel.openForTheFirstTime() + } + + private fun generateBreadcrumbs(path: List): CharSequence { + val breadcrumbs = Breadcrumbs() + + path.forEachIndexed { index, item -> + val onClick = if (index != path.size - 1) { // Not last + fun() { + viewModel.onBreadcrumbClick(item) + } + } else { + null + } + + when (val payload = item.payload) { + is SettingsExportViewModel.Home -> + breadcrumbs.add(getString(R.string.notebooks), 0, onClick = onClick) + is Book -> + breadcrumbs.add(payload.title ?: payload.name, 0, onClick = onClick) + is Note -> + breadcrumbs.add(payload.title, onClick = onClick) + } + } + + return breadcrumbs.toCharSequence() + } + + override fun onResume() { + super.onResume() + + dialog?.apply { + + val w = resources.displayMetrics.widthPixels + val h = resources.displayMetrics.heightPixels + + requireDialog().window?.apply { + if (h > w) { // Portrait + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, (h * 0.90).toInt()) + } else { + setLayout((w * 0.90).toInt(), ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + } + + } + + companion object { + fun getInstance(noteIds: Set, count: Int): SettingsExportFragment { + return SettingsExportFragment().also { fragment -> + fragment.arguments = Bundle().apply { + putLongArray(ARG_NOTE_IDS, noteIds.toLongArray()) + putInt(ARG_COUNT, count) + } + } + } + + private const val ARG_NOTE_IDS = "note_ids" + private const val ARG_COUNT = "count" + + private val TAG = SettingsExportFragment::class.java.name + + val FRAGMENT_TAG: String = SettingsExportFragment::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt new file mode 100644 index 000000000..ebb27a442 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModel.kt @@ -0,0 +1,178 @@ +package com.orgzly.android.ui.settings.exporting + +import androidx.lifecycle.MutableLiveData +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.dao.NoteDao.NoteIdBookId +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.CommonViewModel +import com.orgzly.android.ui.SingleLiveEvent +import com.orgzly.android.usecase.UseCase.Companion.SYNC_DATA_MODIFIED +import com.orgzly.android.usecase.UseCaseResult +import com.orgzly.android.util.LogUtils +import java.util.Stack +import java.util.UUID + +class SettingsExportViewModel( + val dataRepository: DataRepository, + val noteIds: Set, + val count: Int) : CommonViewModel() { + + class Home + class Parent + + data class Item(val payload: Any? = null, val name: String? = null) + + private val breadcrumbs = Stack() + + val data = MutableLiveData, List>>() + + val exportedEvent: SingleLiveEvent = SingleLiveEvent() + + fun openForTheFirstTime() { + val targetNote: NoteIdBookId? = dataRepository.findUniqueNoteHavingProperty( + "ID", AppPreferences.settingsExportAndImportNoteId(App.getAppContext())) + var item: Item? = null + if (targetNote != null) { + val note = dataRepository.getNote(targetNote.noteId) + if (note != null) { + val parentNote = dataRepository.getNoteAncestors(note.id).last() + item = replayUntilNoteId(parentNote.id) + } + } + if (item == null) + item = HOME + open(item) + } + + fun open(item: Item) { + val payload = item.payload + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, payload) + + when (payload) { + is Parent -> { + breadcrumbs.pop() + + open(breadcrumbs.pop()) + } + + is Home -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getBooks().map { book -> + Item(book.book, book.book.name) + } + + breadcrumbs.clear() + breadcrumbs.push(HOME) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Book -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getTopLevelNotes(payload.id).map { note -> + Item(note, note.title) + } + + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Note -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getNoteChildren(payload.id).map { note -> + Item(note, note.title) + } + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Items for $payload: $items") + + if (items.isNotEmpty()) { + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + } + } + } + + fun export(item: Item) { + val payload = item.payload as Note + val notePayload = dataRepository.getNotePayload(payload.id) ?: throw RuntimeException( + App.getAppContext().getString(R.string.failed_to_get_note_payload)) + if (notePayload.properties.get("ID") == null) { + // Note has no "ID" property - let's add one + notePayload.properties.put("ID", UUID.randomUUID().toString()) + dataRepository.updateNote(payload.id, notePayload) + } else { + // Check that the note's "ID" property value is unique + if (dataRepository.findNotesHavingProperty("ID", notePayload.properties.get("ID")).size > 1) { + exportedEvent.postValue( + UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = RuntimeException(App.getAppContext().getString(R.string.selected_notes_id_property_value_is_not_unique)) + ) + ) + return + } + } + AppPreferences.settingsExportAndImportNoteId(App.getAppContext(), notePayload.properties.get("ID")) + dataRepository.exportSettingsAndSearchesToSelectedNote() + exportedEvent.postValue(UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = notePayload + )) + } + + private fun replayUntilNoteId(noteId: Long): Item? { + val notes = dataRepository.getNoteAndAncestors(noteId) + + if (notes.isNotEmpty()) { + val lastNote = notes.last() + + val book = dataRepository.getBook(lastNote.position.bookId) + + if (book != null) { + breadcrumbs.clear() + breadcrumbs.add(HOME) + breadcrumbs.add(Item(book, book.name)) + + for (i in 0 until notes.count() - 1) { + val note = notes[i] + + val item = Item(note, note.title) + + breadcrumbs.push(item) + } + + + return Item(lastNote, lastNote.title) + } + } + + return null + } + + fun onBreadcrumbClick(item: Item) { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, item) + while (breadcrumbs.pop() != item) { + // Pop up to and including clicked item + } + open(item) + } + + companion object { + val HOME = Item(Home()) + private val TAG = SettingsExportViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt new file mode 100644 index 000000000..8accf249d --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/exporting/SettingsExportViewModelFactory.kt @@ -0,0 +1,26 @@ +package com.orgzly.android.ui.settings.exporting + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.orgzly.android.data.DataRepository + +class SettingsExportViewModelFactory( + private val dataRepository: DataRepository, + private val noteIds: Set, + private val count: Int) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return SettingsExportViewModel(dataRepository, noteIds, count) as T + } + + companion object { + fun forNotes( + dataRepository: DataRepository, + noteIds: Set, + count: Int): ViewModelProvider.Factory { + + return SettingsExportViewModelFactory(dataRepository, noteIds, count) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt new file mode 100644 index 000000000..8a5dc4205 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportAdapter.kt @@ -0,0 +1,103 @@ +package com.orgzly.android.ui.settings.importing + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import com.orgzly.R +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.db.entity.NoteView +import com.orgzly.android.ui.notes.NoteItemViewBinder +import com.orgzly.databinding.ItemSettingsImportBinding + +class SettingsImportAdapter(val context: Context, val listener: OnClickListener) : + ListAdapter( + DIFF_CALLBACK + ) { + + data class Icons(@DrawableRes val up: Int, @DrawableRes val book: Int) + + private var icons: Icons? = null + + private val noteItemViewBinder = NoteItemViewBinder(context, true) + + interface OnClickListener { + fun onItem(item: SettingsImportViewModel.Item) + fun onButton(item: SettingsImportViewModel.Item) + } + + class SettingsImportViewHolder(val binding: ItemSettingsImportBinding) : RecyclerView.ViewHolder(binding.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SettingsImportViewHolder { + val holder = SettingsImportViewHolder(ItemSettingsImportBinding.inflate( + LayoutInflater.from(parent.context), parent, false)) + + holder.binding.itemSettingsImportPayload.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onItem(getItem(holder.bindingAdapterPosition)) + } + } + + holder.binding.itemSettingsImportButton.setOnClickListener { + val position = holder.bindingAdapterPosition + if (position != RecyclerView.NO_POSITION) { + listener.onButton(getItem(holder.bindingAdapterPosition)) + } + } + + return holder + } + + override fun onBindViewHolder(holder: SettingsImportViewHolder, position: Int) { + + if (icons == null) { + icons = Icons(R.drawable.ic_keyboard_arrow_up, R.drawable.ic_library_books) + } + + val item = getItem(position) + + when (val payload = item.payload) { + is Book -> { + holder.binding.itemSettingsImportName.text = payload.title ?: payload.name + + holder.binding.itemSettingsImportButton.visibility = View.GONE + + holder.binding.itemSettingsImportIcon.visibility = View.VISIBLE + } + + is Note -> { + holder.binding.itemSettingsImportName.text = noteItemViewBinder.generateTitle( + NoteView(note = payload, bookName = "")) + icons?.let { + if (payload.position.descendantsCount > 0) { + holder.binding.itemSettingsImportIcon.setImageResource(R.drawable.bullet_folded) + holder.binding.itemSettingsImportButton.visibility = View.GONE + } else { + holder.binding.itemSettingsImportIcon.setImageResource(R.drawable.bullet) + holder.binding.itemSettingsImportButton.visibility = View.VISIBLE + } + holder.binding.itemSettingsImportIcon.visibility = View.VISIBLE + } + } + } + } + + companion object { + private val DIFF_CALLBACK: DiffUtil.ItemCallback = + object : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: SettingsImportViewModel.Item, newItem: SettingsImportViewModel.Item): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: SettingsImportViewModel.Item, newItem: SettingsImportViewModel.Item): Boolean { + return oldItem == newItem + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt new file mode 100644 index 000000000..514aeff47 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportFragment.kt @@ -0,0 +1,201 @@ +package com.orgzly.android.ui.settings.importing + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.ui.Breadcrumbs +import com.orgzly.android.ui.note.NotePayload +import com.orgzly.android.ui.showSnackbar +import com.orgzly.android.util.LogUtils +import com.orgzly.databinding.DialogImportSettingsBinding +import javax.inject.Inject + +class SettingsImportFragment : DialogFragment() { + + private lateinit var binding: DialogImportSettingsBinding + + @Inject + lateinit var dataRepository: DataRepository + + lateinit var viewModel: SettingsImportViewModel + + override fun onAttach(context: Context) { + super.onAttach(context) + + App.appComponent.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val noteIds = arguments?.getLongArray(ARG_NOTE_IDS)?.toSet() ?: emptySet() + val count = arguments?.getInt(ARG_COUNT) ?: 0 + + val factory = SettingsImportViewModelFactory.forNotes(dataRepository, noteIds, count) + + viewModel = ViewModelProvider(this, factory)[SettingsImportViewModel::class.java] + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG) + + val dialog = MaterialAlertDialogBuilder(requireContext(), theme) + + return dialog.show() + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, savedInstanceState) + + binding = DialogImportSettingsBinding.inflate(inflater, container, false) + + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.dialogImportSettingsToolbar.apply { + title = resources.getString(R.string.import_settings_from_note) + + setNavigationOnClickListener { + dismiss() + } + } + + val adapter = SettingsImportAdapter(binding.root.context, object: SettingsImportAdapter.OnClickListener { + override fun onItem(item: SettingsImportViewModel.Item) { + viewModel.open(item) + } + + override fun onButton(item: SettingsImportViewModel.Item) { + viewModel.import(item) + } + }) + + binding.dialogImportSettingsTargets.let { + it.layoutManager = LinearLayoutManager(context) + it.adapter = adapter + } + + binding.dialogImportSettingsBreadcrumbs.movementMethod = LinkMovementMethod.getInstance() + + viewModel.data.observe(viewLifecycleOwner) { data -> + val breadcrumbs = data.first + val list = data.second + + adapter.submitList(list) + + // Update and scroll breadcrumbs to the end + binding.dialogImportSettingsBreadcrumbs.text = generateBreadcrumbs(breadcrumbs) + binding.dialogImportSettingsBreadcrumbsScrollView.apply { + post { + fullScroll(View.FOCUS_RIGHT) + } + } + } + + viewModel.importedEvent.observeSingle(viewLifecycleOwner) { result -> + if (result.userData is RuntimeException) { + (result.userData).let { + activity?.showSnackbar(it.localizedMessage) + } + } else { + dismiss() + + (result.userData as NotePayload).let { + activity?.showSnackbar( + getString( + R.string.settings_imported_from, it.title + ) + ) + } + } + } + + viewModel.errorEvent.observeSingle(viewLifecycleOwner) { error -> + binding.dialogImportSettingsToolbar.subtitle = (error.cause ?: error).localizedMessage + } + + viewModel.openForTheFirstTime() + } + + private fun generateBreadcrumbs(path: List): CharSequence { + val breadcrumbs = Breadcrumbs() + + path.forEachIndexed { index, item -> + val onClick = if (index != path.size - 1) { // Not last + fun() { + viewModel.onBreadcrumbClick(item) + } + } else { + null + } + + when (val payload = item.payload) { + is SettingsImportViewModel.Home -> + breadcrumbs.add(getString(R.string.notebooks), 0, onClick = onClick) + is Book -> + breadcrumbs.add(payload.title ?: payload.name, 0, onClick = onClick) + is Note -> + breadcrumbs.add(payload.title, onClick = onClick) + } + } + + return breadcrumbs.toCharSequence() + } + + override fun onResume() { + super.onResume() + + dialog?.apply { + + val w = resources.displayMetrics.widthPixels + val h = resources.displayMetrics.heightPixels + + requireDialog().window?.apply { + if (h > w) { // Portrait + setLayout(ViewGroup.LayoutParams.MATCH_PARENT, (h * 0.90).toInt()) + } else { + setLayout((w * 0.90).toInt(), ViewGroup.LayoutParams.MATCH_PARENT) + } + } + + } + + } + + companion object { + fun getInstance(noteIds: Set, count: Int): SettingsImportFragment { + return SettingsImportFragment().also { fragment -> + fragment.arguments = Bundle().apply { + putLongArray(ARG_NOTE_IDS, noteIds.toLongArray()) + putInt(ARG_COUNT, count) + } + } + } + + private const val ARG_NOTE_IDS = "note_ids" + private const val ARG_COUNT = "count" + + private val TAG = SettingsImportFragment::class.java.name + + val FRAGMENT_TAG: String = SettingsImportFragment::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt new file mode 100644 index 000000000..ee72e2629 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModel.kt @@ -0,0 +1,178 @@ +package com.orgzly.android.ui.settings.importing + +import androidx.lifecycle.MutableLiveData +import com.orgzly.BuildConfig +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.data.DataRepository +import com.orgzly.android.db.dao.NoteDao.NoteIdBookId +import com.orgzly.android.db.entity.Book +import com.orgzly.android.db.entity.Note +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.ui.CommonViewModel +import com.orgzly.android.ui.SingleLiveEvent +import com.orgzly.android.usecase.UseCase.Companion.SYNC_DATA_MODIFIED +import com.orgzly.android.usecase.UseCaseResult +import com.orgzly.android.util.LogUtils +import java.util.Stack +import java.util.UUID + +class SettingsImportViewModel( + val dataRepository: DataRepository, + val noteIds: Set, + val count: Int) : CommonViewModel() { + + class Home + class Parent + + data class Item(val payload: Any? = null, val name: String? = null) + + private val breadcrumbs = Stack() + + val data = MutableLiveData, List>>() + + val importedEvent: SingleLiveEvent = SingleLiveEvent() + + fun openForTheFirstTime() { + val targetNote: NoteIdBookId? = dataRepository.findUniqueNoteHavingProperty( + "ID", AppPreferences.settingsExportAndImportNoteId(App.getAppContext())) + var item: Item? = null + if (targetNote != null) { + val note = dataRepository.getNote(targetNote.noteId) + if (note != null) { + val parentNote = dataRepository.getNoteAncestors(note.id).last() + item = replayUntilNoteId(parentNote.id) + } + } + if (item == null) + item = HOME + open(item) + } + + fun open(item: Item) { + val payload = item.payload + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, payload) + + when (payload) { + is Parent -> { + breadcrumbs.pop() + + open(breadcrumbs.pop()) + } + + is Home -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getBooks().map { book -> + Item(book.book, book.book.name) + } + + breadcrumbs.clear() + breadcrumbs.push(HOME) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Book -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getTopLevelNotes(payload.id).map { note -> + Item(note, note.title) + } + + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + + is Note -> { + App.EXECUTORS.diskIO().execute { + val items = dataRepository.getNoteChildren(payload.id).map { note -> + Item(note, note.title) + } + + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, "Items for $payload: $items") + + if (items.isNotEmpty()) { + breadcrumbs.push(item) + + data.postValue(Pair(breadcrumbs, items)) + } + } + } + } + } + + fun import(item: Item) { + val payload = item.payload as Note + val notePayload = dataRepository.getNotePayload(payload.id) ?: throw RuntimeException( + App.getAppContext().getString(R.string.failed_to_get_note_payload)) + if (notePayload.properties.get("ID") == null) { + // Note has no "ID" property - let's add one + notePayload.properties.put("ID", UUID.randomUUID().toString()) + dataRepository.updateNote(payload.id, notePayload) + } else { + // Check that the note's "ID" property value is unique + if (dataRepository.findNotesHavingProperty("ID", notePayload.properties.get("ID")).size > 1) { + importedEvent.postValue( + UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = RuntimeException(App.getAppContext().getString(R.string.selected_notes_id_property_value_is_not_unique)) + ) + ) + return + } + } + AppPreferences.settingsExportAndImportNoteId(App.getAppContext(), notePayload.properties.get("ID")) + dataRepository.importSettingsAndSearchesFromSelectedNote() + importedEvent.postValue(UseCaseResult( + modifiesLocalData = true, + triggersSync = SYNC_DATA_MODIFIED, + userData = notePayload + )) + } + + private fun replayUntilNoteId(noteId: Long): Item? { + val notes = dataRepository.getNoteAndAncestors(noteId) + + if (notes.isNotEmpty()) { + val lastNote = notes.last() + + val book = dataRepository.getBook(lastNote.position.bookId) + + if (book != null) { + breadcrumbs.clear() + breadcrumbs.add(HOME) + breadcrumbs.add(Item(book, book.name)) + + for (i in 0 until notes.count() - 1) { + val note = notes[i] + + val item = Item(note, note.title) + + breadcrumbs.push(item) + } + + + return Item(lastNote, lastNote.title) + } + } + + return null + } + + fun onBreadcrumbClick(item: Item) { + if (BuildConfig.LOG_DEBUG) LogUtils.d(TAG, item) + while (breadcrumbs.pop() != item) { + // Pop up to and including clicked item + } + open(item) + } + + companion object { + val HOME = Item(Home()) + private val TAG = SettingsImportViewModel::class.java.name + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt new file mode 100644 index 000000000..8313b9da2 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/settings/importing/SettingsImportViewModelFactory.kt @@ -0,0 +1,26 @@ +package com.orgzly.android.ui.settings.importing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.orgzly.android.data.DataRepository + +class SettingsImportViewModelFactory( + private val dataRepository: DataRepository, + private val noteIds: Set, + private val count: Int) : ViewModelProvider.Factory { + + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return SettingsImportViewModel(dataRepository, noteIds, count) as T + } + + companion object { + fun forNotes( + dataRepository: DataRepository, + noteIds: Set, + count: Int): ViewModelProvider.Factory { + + return SettingsImportViewModelFactory(dataRepository, noteIds, count) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt b/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt index 501d2783f..7113ee3d3 100644 --- a/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt +++ b/app/src/main/java/com/orgzly/android/usecase/NoteOrBookFindWithProperty.kt @@ -4,10 +4,10 @@ import com.orgzly.android.data.DataRepository class NoteOrBookFindWithProperty(val name: String, val value: String) : UseCase() { override fun run(dataRepository: DataRepository): UseCaseResult { - val noteOrBook = dataRepository.findNoteOrBookHavingProperty(name, value) + val notesOrBooks = dataRepository.findNotesOrBooksHavingProperty(name, value) return UseCaseResult( - userData = noteOrBook + userData = notesOrBooks ) } } \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_sync.xml b/app/src/main/res/drawable-anydpi/ic_sync.xml index 4069d8f3b..ae8c1e600 100644 --- a/app/src/main/res/drawable-anydpi/ic_sync.xml +++ b/app/src/main/res/drawable-anydpi/ic_sync.xml @@ -7,4 +7,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_export.xml b/app/src/main/res/drawable/ic_export.xml new file mode 100644 index 000000000..de0965b2c --- /dev/null +++ b/app/src/main/res/drawable/ic_export.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_import.xml b/app/src/main/res/drawable/ic_import.xml new file mode 100644 index 000000000..0dd2db47f --- /dev/null +++ b/app/src/main/res/drawable/ic_import.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/dialog_export_settings.xml b/app/src/main/res/layout/dialog_export_settings.xml new file mode 100644 index 000000000..dc11d4225 --- /dev/null +++ b/app/src/main/res/layout/dialog_export_settings.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_import_settings.xml b/app/src/main/res/layout/dialog_import_settings.xml new file mode 100644 index 000000000..774175b50 --- /dev/null +++ b/app/src/main/res/layout/dialog_import_settings.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_export.xml b/app/src/main/res/layout/item_settings_export.xml new file mode 100644 index 000000000..996ffb553 --- /dev/null +++ b/app/src/main/res/layout/item_settings_export.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_settings_import.xml b/app/src/main/res/layout/item_settings_import.xml new file mode 100644 index 000000000..324c58699 --- /dev/null +++ b/app/src/main/res/layout/item_settings_import.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/prefs_keys.xml b/app/src/main/res/values/prefs_keys.xml index 3267fd976..42ea2e4a6 100644 --- a/app/src/main/res/values/prefs_keys.xml +++ b/app/src/main/res/values/prefs_keys.xml @@ -595,6 +595,8 @@ pref_key_major_events_logs + pref_key_settings_export_note_id + pref_key_repos pref_key_ssh_keygen @@ -603,6 +605,8 @@ pref_key_git_commit pref_key_reload_getting_started pref_key_clear_database + pref_key_export_settings + pref_key_import_settings pref_key_dropbox_token diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5281ac4d6..adcb3967f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -591,6 +591,7 @@ Keep marks displayed (e.g. *bold*) No note or book found with the “%1$s” property set to “%2$s” + Found multiple notes with the “%1$s” property set to “%2$s” Imported one search @@ -797,4 +798,15 @@ Renaming notebook to a different subdirectory requires Android 7 or higher Support for subfolders is disabled + Export settings to note + Import settings from note + Exported settings to note “%1$s” + Imported settings from note “%1$s” + Failed to get note payload + Export app settings + Write a JSON representation of all settings to a note of your choice + Import app settings + Import settings as JSON from a note of your choice + WARNING! The selected note will be completely overwritten! + The selected note\'s “ID” property value is not unique (consider changing it) diff --git a/app/src/main/res/xml/prefs_screen_app.xml b/app/src/main/res/xml/prefs_screen_app.xml index a0c352e04..06d6418b5 100644 --- a/app/src/main/res/xml/prefs_screen_app.xml +++ b/app/src/main/res/xml/prefs_screen_app.xml @@ -10,6 +10,16 @@ android:title="@string/reload_getting_started" android:summary="@string/reload_getting_started_summary" /> + + + +