diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d72a0ee0c95d5604348e2dbfabc2bda8de8e038..bbb354c907b4e558e9bfadbbd6db819e48b2bb4a 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 0000000000000000000000000000000000000000..f5a7e9a771f0c0c68f05eff34782cc0d06b1303c --- /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 0000000000000000000000000000000000000000..bb48de8650749b13387792fdc703819ed0d46a9b --- /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 e62d06a67e9b8df8f47f10c5dc4332ab33b7c6d8..b4f378e36a6291a9c59d1eebcc8967dd0531cb6f 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 db143f86f3fd77f9d375b2b92eb81b40766359b5..ad351c86922bc05c934f385affe8118aae6f906f 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 0000000000000000000000000000000000000000..53b09e2eaf3fc2516257cf64a29c363d8a2b4d6e --- /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 0000000000000000000000000000000000000000..7ad43ddad154221b6bcdca6fa4ad20f2942a2cc8 --- /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 6d32d5f192b2ea1843cc33c25a2308d6d95a8410..5c796759f1bfcb331c60c9269c59cfc9290ac31e 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 0392e0275fc5b26457e38acb7bec272d65107699..06d4bec31c723289d8cce8566dfe8b4f52dbd249 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 5f37d764925e6f376d093264cdf6ad55dfb67287..c9b8c12a7cd3c3d62df60b876e0274fc4c4b7c32 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 c3e76909b548ac8f0ea8d965a52fb40dce3d01b2..ea67514a9d29c485df669011dfad285295d3c55c 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 c18333a3ffefad0aa8aba6bf577e7892bace6310..c95e614b269a71c0f7eb5bef31d03378760944ed 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 0dbfa70924f5ea626c7a5f9343521742d76b8600..f588eaf0820d10c2c70b9b08a04a6e2b9d0e20bb 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" /> + + + + +