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" />
+
+
+
+
+