From c5c4aa6c8ab9c65b497bce470716cde3e6ec0f33 Mon Sep 17 00:00:00 2001 From: althafvly Date: Mon, 17 Feb 2025 16:11:34 +0530 Subject: [PATCH] notes: add option to export notes --- app/src/main/AndroidManifest.xml | 1 + .../e/notes/export/ExportPreference.kt | 115 +++++++++++++++++ .../e/notes/export/NotesExporter.kt | 122 ++++++++++++++++++ .../notes/persistence/NotesRepository.java | 7 + .../notes/persistence/dao/NoteDao.java | 4 + .../main/res/drawable/ic_import_export_24.xml | 10 ++ .../main/res/layout/dialog_export_notes.xml | 23 ++++ app/src/main/res/values-de/strings.xml | 4 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-fr/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 4 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/preferences.xml | 13 ++ 13 files changed, 318 insertions(+) create mode 100644 app/src/main/java/foundation/e/notes/export/ExportPreference.kt create mode 100644 app/src/main/java/foundation/e/notes/export/NotesExporter.kt create mode 100644 app/src/main/res/drawable/ic_import_export_24.xml create mode 100644 app/src/main/res/layout/dialog_export_notes.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d72a0ee0..bbb354c90 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/main/java/foundation/e/notes/export/ExportPreference.kt b/app/src/main/java/foundation/e/notes/export/ExportPreference.kt new file mode 100644 index 000000000..f5a7e9a77 --- /dev/null +++ b/app/src/main/java/foundation/e/notes/export/ExportPreference.kt @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2025 e Foundation + * + * 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 foundation.e.notes.export + +import android.content.Context +import android.util.AttributeSet + +import androidx.appcompat.app.AlertDialog +import androidx.preference.DialogPreference + +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.Account + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ExportPreference(context: Context, attrs: AttributeSet?) : DialogPreference(context, attrs) { + private val selectedValues = mutableSetOf() + private var accounts: List = emptyList() + private var alertDialog: AlertDialog? = null + + init { + CoroutineScope(Dispatchers.IO).launch { + val notesRepo = NotesRepository.getInstance(context) + val fetchedAccounts = notesRepo.accounts ?: emptyList() + + withContext(Dispatchers.Main) { + accounts = fetchedAccounts + } + } + dialogLayoutResource = R.layout.dialog_export_notes + } + + override fun onClick() { + loadSavedAccounts() + showDialog() + } + + /** + * Loads previously saved selected accounts. + * If nothing was selected before, selects all by default. + */ + private fun loadSavedAccounts() { + val savedValues = getPersistedStringSet(emptySet()) + + if (savedValues.isEmpty()) { + selectedValues.addAll(accounts.map { it.id.toString() }) + } else { + selectedValues.addAll(savedValues) + } + } + + private fun showDialog() { + val builder = AlertDialog.Builder(context) + builder.setTitle(context.getString(R.string.settings_export_notes_title)) + + val accountNames = accounts.map { it.accountName }.toTypedArray() + val checkedItems = BooleanArray(accounts.size) { + selectedValues.contains(accounts[it].id.toString()) + } + + builder.setMultiChoiceItems(accountNames, checkedItems) { _, index, isChecked -> + val id = accounts[index].id.toString() + if (isChecked) { + selectedValues.add(id) + } else { + selectedValues.remove(id) + } + } + + builder.setPositiveButton(R.string.settings_export) { _, _ -> saveSelection() } + builder.setNegativeButton(android.R.string.cancel, null) + + alertDialog = builder.create().apply { + setOnDismissListener { alertDialog = null } + show() + } + } + + /** + * Saves the selected accounts and triggers the export. + */ + private fun saveSelection() { + persistStringSet(selectedValues) + + CoroutineScope(Dispatchers.IO).launch { + val accountIds = selectedValues.map { it.toLong() } + NotesExporter().export(context, accountIds) + } + } + + override fun onDetached() { + super.onDetached() + alertDialog?.takeIf { it.isShowing }?.dismiss() + alertDialog = null + } +} diff --git a/app/src/main/java/foundation/e/notes/export/NotesExporter.kt b/app/src/main/java/foundation/e/notes/export/NotesExporter.kt new file mode 100644 index 000000000..bb48de865 --- /dev/null +++ b/app/src/main/java/foundation/e/notes/export/NotesExporter.kt @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2025 e Foundation + * + * 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 foundation.e.notes.export + +import android.content.Context +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.Toast + +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.persistence.NotesRepository +import it.niedermann.owncloud.notes.persistence.entity.Note + +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class NotesExporter { + + private var handler: Handler = Handler(Looper.getMainLooper()) + + fun export(context: Context, accountIds: List) { + val notesRepo = NotesRepository.getInstance(context) + val accounts = notesRepo.accounts.filter { it.id in accountIds } + + if (accounts.isEmpty()) { + handler.post { + Toast.makeText( + context, context.getString(R.string.export_empty_accounts), + Toast.LENGTH_SHORT).show() + } + return + } + + Log.d(TAG, "Exporting notes for ${accounts.size} accounts") + + accounts.forEach { account -> + val notes = notesRepo.getNotesByAccountId(account.id) + if (notes.isNotEmpty()) { + exportNotesForAccount(notes, account.userName) + } + } + + handler.post { + Toast.makeText( + context, context.getString(R.string.export_finished_message), + Toast.LENGTH_SHORT + ).show() + } + } + + private fun exportNotesForAccount(notes: List, accountUserName: String) { + Log.d(TAG, "Exporting ${notes.size} notes for account: $accountUserName") + + notes.forEach { note -> + val notesDir = getNotesDirectory(accountUserName, note.category) + ensureDirectoryExists(notesDir) + val fileName = "${note.title}-${note.modified?.time?.let { getCurrentTimestamp(it) }}" + writeNoteToFile(notesDir, fileName, note.content) + } + } + + private fun getNotesDirectory(userName: String, category: String): File { + val combinedDir = if (category.isNotEmpty()) "/${category}" else "" + return File(Environment.getExternalStorageDirectory(), "$EXPORT_DIR/$userName$combinedDir") + } + + private fun ensureDirectoryExists(directory: File) { + if (!directory.exists() && directory.mkdirs()) { + Log.d(TAG, "Created directory: ${directory.absolutePath}") + } + } + + private fun writeNoteToFile(directory: File, title: String, content: String) { + var sanitizedTitle = sanitizeFileName(title) + if (sanitizedTitle.isBlank()) { + sanitizedTitle = "Untitled-${getCurrentTimestamp(Date())}" + Log.w(TAG, "Renaming note with a random title") + } + + val noteFile = File(directory, "$sanitizedTitle.md") + try { + noteFile.writeText(content) + Log.d(TAG, "Written file: ${noteFile.absolutePath}") + } catch (e: IOException) { + Log.e(TAG, "Failed to write file: ${noteFile.absolutePath}", e) + } + } + + private fun getCurrentTimestamp(date: Date): String { + return SimpleDateFormat(DATE_TIME_FORMAT, Locale.getDefault()).format(date) + } + + private fun sanitizeFileName(name: String): String { + return name.trim().replace(Regex("[/:*?\"<>|]"), "_") + } + + companion object { + private const val TAG = "NotesExporter" + private const val EXPORT_DIR = "Notes" + private const val DATE_TIME_FORMAT = "yyyyMMdd-HHmmss" + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java index e62d06a67..b4f378e36 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -41,6 +41,8 @@ import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; import com.nextcloud.android.sso.helper.SingleAccountHelper; import com.nextcloud.android.sso.model.SingleSignOnAccount; +import org.jetbrains.annotations.NotNull; + import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; @@ -1177,4 +1179,9 @@ public class NotesRepository { newNote.setAccountId(note.getAccountId()); db.getNoteDao().addNote(newNote); } + + @NotNull + public List getNotesByAccountId(long accountId) { + return db.getNoteDao().getNotesByAccountId(accountId); + } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java index db143f86f..ad351c869 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/NoteDao.java @@ -30,6 +30,7 @@ public interface NoteDao { int updateNote(Note newNote); String getNoteById = "SELECT * FROM NOTE WHERE id = :id"; + String getNoteByAccountId = "SELECT * FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId ORDER BY modified DESC"; String count = "SELECT COUNT(*) FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId"; String countFavorites = "SELECT COUNT(*) FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId AND favorite = 1"; String searchRecentByModified = "SELECT id, remoteId, accountId, title, favorite, excerpt, modified, category, status, '' as eTag, '' as content, 0 as scrollY FROM NOTE WHERE accountId = :accountId AND status != 'LOCAL_DELETED' AND (title LIKE :query OR content LIKE :query) ORDER BY favorite DESC, modified DESC"; @@ -161,6 +162,9 @@ public interface NoteDao { @Query("SELECT * FROM NOTE WHERE status != 'LOCAL_DELETED' AND accountId = :accountId ORDER BY modified DESC LIMIT 4") List getRecentNotes(long accountId); + @Query(getNoteByAccountId) + List getNotesByAccountId(long accountId); + @Query("UPDATE NOTE SET status = :status, favorite = ((favorite | 1) - (favorite & 1)) WHERE id = :id") void toggleFavorite(long id, DBStatus status); diff --git a/app/src/main/res/drawable/ic_import_export_24.xml b/app/src/main/res/drawable/ic_import_export_24.xml new file mode 100644 index 000000000..53b09e2ea --- /dev/null +++ b/app/src/main/res/drawable/ic_import_export_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/dialog_export_notes.xml b/app/src/main/res/layout/dialog_export_notes.xml new file mode 100644 index 000000000..7ad43ddad --- /dev/null +++ b/app/src/main/res/layout/dialog_export_notes.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 6d32d5f19..5c796759f 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -295,4 +295,8 @@ LOKALE NOTIZ-SPEICHERUNG oder Wollen Sie Ihre Notizen mit Ihrem Murena Cloud-Konto synchronisieren, oder sie nur lokal auf Ihrem Gerät speichern (ohne Synchronisierung)? + Notizen exportieren + Exportieren + Keine Konten verfügbar + "Notizen in den Ordner 'Notes' im internen Speicher exportiert" \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0392e0275..06d4bec31 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -299,4 +299,8 @@ \n \n↵ ***Nota %1$d (Última modificación: %2$s):***↵ \n" + Exportar notas + Exportar + No hay cuentas disponibles + "Notas exportadas a la carpeta 'Notes' en el almacenamiento interno" \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 5f37d7649..c9b8c12a7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -300,4 +300,8 @@ \n \n***Note %1$d (Dernière modification: %2$s) :*** \n" + Exporter les notes + Exporter + Aucun compte disponible + "Notes exportées dans le dossier 'Notes' du stockage interne" \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c3e76909b..ea67514a9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -300,4 +300,8 @@ USA NOTE IN LOCALE o Scegli se sincronizzare le note con un account Murena già esistente o conservarle in locale sul tuo device (non sincronizzate). + Esporta note + Esporta + Nessun account disponibile + "Note esportate nella cartella 'Notes' nella memoria interna" \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c18333a3f..c95e614b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -132,6 +132,13 @@ Sync on Wi-Fi and mobile data Password protection + exportNotes + export + Export notes + Export + "No accounts available" + "Exported notes to the 'Notes' folder in internal storage" + Error Close Copy diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 0dbfa7092..f588eaf08 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -88,4 +88,17 @@ android:title="@string/settings_prevent_screen_capture" /> + + + + + -- GitLab