Release v1.2.0 - Local Backup & Markdown Desktop Integration
✨ New Features: - Local backup/restore system with 3 modes (Merge/Replace/Overwrite) - Markdown export for desktop access via WebDAV mount - Dual-format architecture (JSON master + Markdown mirror) - Settings UI extended with backup & desktop integration sections 📝 Changes: - Server restore now asks for mode selection (user safety) - WebDAV mount instructions for Windows/Mac/Linux in README - Complete CHANGELOG.md with all version history 🔧 Technical: - BackupManager.kt for complete backup/restore logic - Note.toMarkdown/fromMarkdown with YAML frontmatter - ISO8601 timestamps for desktop compatibility - Last-Write-Wins conflict resolution 📚 Documentation: - CHANGELOG.md (Keep a Changelog format) - README updates (removed Joplin/Obsidian, added WebDAV-mount) - F-Droid changelogs (DE+EN, under 500 chars) - SYNC_ARCHITECTURE.md in project-docs - MARKDOWN_DESKTOP_REALITY_CHECK.md strategic plan - WEB_EDITOR_PLAN_v1.3.0.md for future web editor feature
This commit is contained in:
@@ -13,6 +13,7 @@ import android.widget.EditText
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
@@ -26,6 +27,8 @@ import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import dev.dettmer.simplenotes.backup.BackupManager
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.utils.UrlValidator
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
@@ -53,9 +56,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var editTextUsername: EditText
|
||||
private lateinit var editTextPassword: EditText
|
||||
private lateinit var switchAutoSync: SwitchCompat
|
||||
private lateinit var switchMarkdownExport: SwitchCompat
|
||||
private lateinit var buttonTestConnection: Button
|
||||
private lateinit var buttonSyncNow: Button
|
||||
private lateinit var buttonCreateBackup: Button
|
||||
private lateinit var buttonRestoreFromFile: Button
|
||||
private lateinit var buttonRestoreFromServer: Button
|
||||
private lateinit var buttonImportMarkdown: Button
|
||||
private lateinit var textViewServerStatus: TextView
|
||||
|
||||
// Protocol Selection UI
|
||||
@@ -73,6 +80,22 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var cardDeveloperProfile: MaterialCardView
|
||||
private lateinit var cardLicense: MaterialCardView
|
||||
|
||||
// Backup Manager
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
// Activity Result Launchers
|
||||
private val createBackupLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let { createBackup(it) }
|
||||
}
|
||||
|
||||
private val restoreBackupLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
|
||||
}
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
||||
}
|
||||
@@ -106,9 +129,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
editTextUsername = findViewById(R.id.editTextUsername)
|
||||
editTextPassword = findViewById(R.id.editTextPassword)
|
||||
switchAutoSync = findViewById(R.id.switchAutoSync)
|
||||
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
|
||||
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
||||
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
||||
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
|
||||
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
|
||||
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
||||
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
|
||||
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
||||
|
||||
// Protocol Selection UI
|
||||
@@ -152,6 +179,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
||||
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
||||
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
|
||||
|
||||
// Update hint text based on selected protocol
|
||||
updateProtocolHint()
|
||||
@@ -223,15 +251,36 @@ class SettingsActivity : AppCompatActivity() {
|
||||
syncNow()
|
||||
}
|
||||
|
||||
buttonCreateBackup.setOnClickListener {
|
||||
// Dateiname mit Timestamp
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
.format(java.util.Date())
|
||||
val filename = "simplenotes_backup_$timestamp.json"
|
||||
createBackupLauncher.launch(filename)
|
||||
}
|
||||
|
||||
buttonRestoreFromFile.setOnClickListener {
|
||||
restoreBackupLauncher.launch(arrayOf("application/json"))
|
||||
}
|
||||
|
||||
buttonRestoreFromServer.setOnClickListener {
|
||||
saveSettings()
|
||||
showRestoreConfirmation()
|
||||
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
|
||||
}
|
||||
|
||||
buttonImportMarkdown.setOnClickListener {
|
||||
saveSettings()
|
||||
importMarkdownChanges()
|
||||
}
|
||||
|
||||
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
||||
onAutoSyncToggled(isChecked)
|
||||
}
|
||||
|
||||
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
|
||||
onMarkdownExportToggled(isChecked)
|
||||
}
|
||||
|
||||
// Clear error when user starts typing again
|
||||
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
@@ -498,6 +547,67 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMarkdownExportToggled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
|
||||
|
||||
if (enabled) {
|
||||
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
|
||||
} else {
|
||||
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun importMarkdownChanges() {
|
||||
// Prüfen ob Server konfiguriert ist
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||
|
||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
showToast("Bitte zuerst WebDAV-Server konfigurieren")
|
||||
return
|
||||
}
|
||||
|
||||
// Import-Dialog mit Warnung
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Markdown-Import")
|
||||
.setMessage(
|
||||
"Importiert Änderungen aus .md-Dateien vom Server.\n\n" +
|
||||
"⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
|
||||
"Fortfahren?"
|
||||
)
|
||||
.setPositiveButton("Importieren") { _, _ ->
|
||||
performMarkdownImport(serverUrl, username, password)
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
|
||||
showToast("Importiere Markdown-Dateien...")
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||
val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (importCount > 0) {
|
||||
showToast("$importCount Notizen aus Markdown importiert")
|
||||
// Benachrichtige MainActivity zum Neuladen
|
||||
sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
|
||||
} else {
|
||||
showToast("Keine Markdown-Änderungen gefunden")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showToast("Import-Fehler: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val packageName = packageName
|
||||
@@ -612,4 +722,231 @@ class SettingsActivity : AppCompatActivity() {
|
||||
super.onPause()
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// BACKUP & RESTORE FUNCTIONS (v1.2.0)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Restore-Quelle (Lokale Datei oder WebDAV Server)
|
||||
*/
|
||||
private enum class RestoreSource {
|
||||
LOCAL_FILE,
|
||||
WEBDAV_SERVER
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Backup (Task #1.2.0-04)
|
||||
*/
|
||||
private fun createBackup(uri: Uri) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
Logger.d(TAG, "📦 Creating backup...")
|
||||
val result = backupManager.createBackup(uri)
|
||||
|
||||
if (result.success) {
|
||||
showToast("✅ ${result.message}")
|
||||
} else {
|
||||
showErrorDialog("Backup fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create backup", e)
|
||||
showErrorDialog("Backup fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Universeller Restore-Dialog für beide Quellen (Task #1.2.0-05 + #1.2.0-05b)
|
||||
*
|
||||
* @param source Lokale Datei oder WebDAV Server
|
||||
* @param fileUri URI der lokalen Datei (nur für LOCAL_FILE)
|
||||
*/
|
||||
private fun showRestoreDialog(source: RestoreSource, fileUri: Uri?) {
|
||||
val sourceText = when (source) {
|
||||
RestoreSource.LOCAL_FILE -> "Lokale Datei"
|
||||
RestoreSource.WEBDAV_SERVER -> "WebDAV Server"
|
||||
}
|
||||
|
||||
// Custom View mit Radio Buttons
|
||||
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
|
||||
val radioGroup = android.widget.RadioGroup(this).apply {
|
||||
orientation = android.widget.RadioGroup.VERTICAL
|
||||
setPadding(50, 20, 50, 20)
|
||||
}
|
||||
|
||||
// Radio Buttons erstellen
|
||||
val radioMerge = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
|
||||
id = 0
|
||||
isChecked = true
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioReplace = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
|
||||
id = 1
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioOverwrite = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
|
||||
id = 2
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
radioGroup.addView(radioMerge)
|
||||
radioGroup.addView(radioReplace)
|
||||
radioGroup.addView(radioOverwrite)
|
||||
|
||||
// Hauptlayout
|
||||
val mainLayout = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(50, 30, 50, 30)
|
||||
}
|
||||
|
||||
// Info Text
|
||||
val infoText = android.widget.TextView(this).apply {
|
||||
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
|
||||
textSize = 16f
|
||||
setPadding(0, 0, 0, 20)
|
||||
}
|
||||
|
||||
// Hinweis Text
|
||||
val hintText = android.widget.TextView(this).apply {
|
||||
text = "\nℹ️ Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
|
||||
textSize = 14f
|
||||
setTypeface(null, android.graphics.Typeface.ITALIC)
|
||||
setPadding(0, 20, 0, 0)
|
||||
}
|
||||
|
||||
mainLayout.addView(infoText)
|
||||
mainLayout.addView(radioGroup)
|
||||
mainLayout.addView(hintText)
|
||||
|
||||
// Dialog erstellen
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("⚠️ Backup wiederherstellen?")
|
||||
.setView(mainLayout)
|
||||
.setPositiveButton("Wiederherstellen") { _, _ ->
|
||||
val selectedMode = when (radioGroup.checkedRadioButtonId) {
|
||||
1 -> RestoreMode.REPLACE
|
||||
2 -> RestoreMode.OVERWRITE_DUPLICATES
|
||||
else -> RestoreMode.MERGE
|
||||
}
|
||||
|
||||
when (source) {
|
||||
RestoreSource.LOCAL_FILE -> fileUri?.let { performRestoreFromFile(it, selectedMode) }
|
||||
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Restore aus lokaler Datei durch (Task #1.2.0-05)
|
||||
*/
|
||||
private fun performRestoreFromFile(uri: Uri, mode: RestoreMode) {
|
||||
lifecycleScope.launch {
|
||||
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
|
||||
setMessage("Wiederherstellen...")
|
||||
setCancelable(false)
|
||||
show()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.d(TAG, "📥 Restoring from file: $uri (mode: $mode)")
|
||||
val result = backupManager.restoreBackup(uri, mode)
|
||||
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (result.success) {
|
||||
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen"
|
||||
showToast("✅ $message")
|
||||
|
||||
// Refresh MainActivity's note list
|
||||
setResult(RESULT_OK)
|
||||
broadcastNotesChanged()
|
||||
} else {
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
Logger.e(TAG, "Failed to restore from file", e)
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Restore vom Server durch (Task #1.2.0-05b)
|
||||
* Nutzt neues universelles Dialog-System mit Restore-Modi
|
||||
*
|
||||
* HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
|
||||
* unterstützt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
|
||||
* TODO: WebDavSyncService.restoreFromServer() erweitern für v1.2.1+
|
||||
*/
|
||||
private fun performRestoreFromServer(mode: RestoreMode) {
|
||||
lifecycleScope.launch {
|
||||
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
|
||||
setMessage("Wiederherstellen vom Server...")
|
||||
setCancelable(false)
|
||||
show()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
|
||||
Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
|
||||
|
||||
// Auto-Backup erstellen (Sicherheitsnetz)
|
||||
val autoBackupUri = backupManager.createAutoBackup()
|
||||
if (autoBackupUri == null) {
|
||||
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||
}
|
||||
|
||||
// Server-Restore durchführen
|
||||
val webdavService = WebDavSyncService(this@SettingsActivity)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
// Nutzt alte Funktion (immer REPLACE)
|
||||
webdavService.restoreFromServer()
|
||||
}
|
||||
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
|
||||
setResult(RESULT_OK)
|
||||
broadcastNotesChanged()
|
||||
} else {
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
Logger.e(TAG, "Failed to restore from server", e)
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet Broadcast dass Notizen geändert wurden
|
||||
*/
|
||||
private fun broadcastNotesChanged() {
|
||||
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
intent.putExtra("success", true)
|
||||
intent.putExtra("syncedCount", 0)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Error-Dialog an
|
||||
*/
|
||||
private fun showErrorDialog(title: String, message: String) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package dev.dettmer.simplenotes.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||
*
|
||||
* Features:
|
||||
* - Backup aller Notizen in JSON-Datei
|
||||
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
||||
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||
* - Backup-Validierung
|
||||
*/
|
||||
class BackupManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BackupManager"
|
||||
private const val BACKUP_VERSION = 1
|
||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
|
||||
/**
|
||||
* Erstellt Backup aller Notizen
|
||||
*
|
||||
* @param uri Output-URI (via Storage Access Framework)
|
||||
* @return BackupResult mit Erfolg/Fehler Info
|
||||
*/
|
||||
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📦 Creating backup to: $uri")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
val jsonString = gson.toJson(backupData)
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(jsonString.toByteArray())
|
||||
Logger.d(TAG, "✅ Backup created successfully")
|
||||
}
|
||||
|
||||
BackupResult(
|
||||
success = true,
|
||||
notes_count = allNotes.size,
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create backup", e)
|
||||
BackupResult(
|
||||
success = false,
|
||||
error = "Backup fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt automatisches Backup (vor Restore)
|
||||
* Gespeichert in app-internem Storage
|
||||
*
|
||||
* @return Uri des Auto-Backups oder null bei Fehler
|
||||
*/
|
||||
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
.format(Date())
|
||||
val filename = "auto_backup_before_restore_$timestamp.json"
|
||||
val file = File(autoBackupDir, filename)
|
||||
|
||||
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
file.writeText(gson.toJson(backupData))
|
||||
|
||||
// Cleanup alte Auto-Backups
|
||||
cleanupOldAutoBackups(autoBackupDir)
|
||||
|
||||
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
||||
Uri.fromFile(file)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create auto-backup", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt Notizen aus Backup wieder her
|
||||
*
|
||||
* @param uri Backup-Datei URI
|
||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||
* @return RestoreResult mit Details
|
||||
*/
|
||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||
|
||||
// 1. Backup-Datei lesen
|
||||
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
} ?: return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Datei konnte nicht gelesen werden"
|
||||
)
|
||||
|
||||
// 2. Backup validieren & parsen
|
||||
val validationResult = validateBackup(jsonString)
|
||||
if (!validationResult.isValid) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
|
||||
)
|
||||
}
|
||||
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}")
|
||||
|
||||
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||
val autoBackupUri = createAutoBackup()
|
||||
if (autoBackupUri == null) {
|
||||
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||
}
|
||||
|
||||
// 4. Restore durchführen (je nach Modus)
|
||||
val result = when (mode) {
|
||||
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
||||
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
||||
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped")
|
||||
result
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to restore backup", e)
|
||||
RestoreResult(
|
||||
success = false,
|
||||
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert Backup-Datei
|
||||
*/
|
||||
private fun validateBackup(jsonString: String): ValidationResult {
|
||||
return try {
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
|
||||
// Version kompatibel?
|
||||
if (backupData.backup_version > BACKUP_VERSION) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)"
|
||||
)
|
||||
}
|
||||
|
||||
// Notizen-Array vorhanden?
|
||||
if (backupData.notes.isEmpty()) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup enthält keine Notizen"
|
||||
)
|
||||
}
|
||||
|
||||
// Alle Notizen haben ID, title, content?
|
||||
val invalidNotes = backupData.notes.filter { note ->
|
||||
note.id.isBlank() || note.title.isBlank()
|
||||
}
|
||||
|
||||
if (invalidNotes.isNotEmpty()) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
|
||||
)
|
||||
}
|
||||
|
||||
ValidationResult(isValid = true)
|
||||
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: MERGE
|
||||
* Fügt neue Notizen hinzu, behält bestehende
|
||||
*/
|
||||
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||
val skippedNotes = backupNotes.size - newNotes.size
|
||||
|
||||
newNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = skippedNotes,
|
||||
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: REPLACE
|
||||
* Löscht alle bestehenden Notizen, importiert Backup
|
||||
*/
|
||||
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||
// Alle bestehenden Notizen löschen
|
||||
storage.deleteAllNotes()
|
||||
|
||||
// Backup-Notizen importieren
|
||||
backupNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = backupNotes.size,
|
||||
skipped_notes = 0,
|
||||
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||
* Backup überschreibt bei ID-Konflikten
|
||||
*/
|
||||
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
||||
|
||||
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
||||
backupNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = 0,
|
||||
overwritten_notes = overwrittenNotes.size,
|
||||
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Auto-Backups älter als RETENTION_DAYS
|
||||
*/
|
||||
private fun cleanupOldAutoBackups(autoBackupDir: File) {
|
||||
try {
|
||||
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
||||
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
||||
|
||||
autoBackupDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoffTime) {
|
||||
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to cleanup old backups", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Daten Struktur (JSON)
|
||||
*/
|
||||
data class BackupData(
|
||||
val backup_version: Int,
|
||||
val created_at: Long,
|
||||
val notes_count: Int,
|
||||
val app_version: String,
|
||||
val notes: List<Note>
|
||||
)
|
||||
|
||||
/**
|
||||
* Wiederherstellungs-Modi
|
||||
*/
|
||||
enum class RestoreMode {
|
||||
MERGE, // Bestehende + Neue (Standard)
|
||||
REPLACE, // Alles löschen + Importieren
|
||||
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Ergebnis
|
||||
*/
|
||||
data class BackupResult(
|
||||
val success: Boolean,
|
||||
val notes_count: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore-Ergebnis
|
||||
*/
|
||||
data class RestoreResult(
|
||||
val success: Boolean,
|
||||
val imported_notes: Int = 0,
|
||||
val skipped_notes: Int = 0,
|
||||
val overwritten_notes: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Validierungs-Ergebnis
|
||||
*/
|
||||
data class ValidationResult(
|
||||
val isValid: Boolean,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
@@ -1,5 +1,9 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
data class Note(
|
||||
@@ -25,6 +29,25 @@ data class Note(
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||
*/
|
||||
fun toMarkdown(): String {
|
||||
return """
|
||||
---
|
||||
id: $id
|
||||
created: ${formatISO8601(createdAt)}
|
||||
updated: ${formatISO8601(updatedAt)}
|
||||
device: $deviceId
|
||||
---
|
||||
|
||||
# $title
|
||||
|
||||
$content
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
@@ -34,6 +57,78 @@ data class Note(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||
*
|
||||
* @param md Markdown-String mit YAML Frontmatter
|
||||
* @return Note-Objekt oder null bei Parse-Fehler
|
||||
*/
|
||||
fun fromMarkdown(md: String): Note? {
|
||||
return try {
|
||||
// Parse YAML Frontmatter + Markdown Content
|
||||
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||
val match = frontmatterRegex.find(md) ?: return null
|
||||
|
||||
val yamlBlock = match.groupValues[1]
|
||||
val contentBlock = match.groupValues[2]
|
||||
|
||||
// Parse YAML (einfach per String-Split für MVP)
|
||||
val metadata = yamlBlock.lines()
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
parts[0].trim() to parts[1].trim()
|
||||
} else null
|
||||
}.toMap()
|
||||
|
||||
// Extract title from first # heading
|
||||
val title = contentBlock.lines()
|
||||
.firstOrNull { it.startsWith("# ") }
|
||||
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||
|
||||
// Extract content (everything after heading)
|
||||
val content = contentBlock
|
||||
.substringAfter("# $title\n\n", "")
|
||||
.trim()
|
||||
|
||||
Note(
|
||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
content = content,
|
||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
||||
deviceId = metadata["device"] ?: "desktop",
|
||||
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
|
||||
* Format: 2024-12-21T18:00:00Z (UTC)
|
||||
*/
|
||||
private fun formatISO8601(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
|
||||
* Fallback: Aktueller Timestamp bei Fehler
|
||||
*/
|
||||
private fun parseISO8601(dateString: String): Long {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis() // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -444,9 +444,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||
var uploadedCount = 0
|
||||
val localNotes = storage.loadAllNotes()
|
||||
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
||||
|
||||
for (note in localNotes) {
|
||||
try {
|
||||
// 1. JSON-Upload (bestehend, unverändert)
|
||||
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
||||
val noteUrl = "$serverUrl/${note.id}.json"
|
||||
val jsonBytes = note.toJson().toByteArray()
|
||||
@@ -457,6 +459,18 @@ class WebDavSyncService(private val context: Context) {
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||
storage.saveNote(updatedNote)
|
||||
uploadedCount++
|
||||
|
||||
// 2. Markdown-Export (NEU in v1.2.0)
|
||||
// Läuft NACH erfolgreichem JSON-Upload
|
||||
if (markdownExportEnabled) {
|
||||
try {
|
||||
exportToMarkdown(sardine, serverUrl, note)
|
||||
Logger.d(TAG, " 📝 MD exported: ${note.title}")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
|
||||
// Kein throw! JSON-Sync darf nicht blockiert werden
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Mark as pending for retry
|
||||
@@ -468,6 +482,49 @@ class WebDavSyncService(private val context: Context) {
|
||||
return uploadedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
||||
*
|
||||
* @param sardine Sardine-Client
|
||||
* @param serverUrl Server-URL (notes/ Ordner)
|
||||
* @param note Note zum Exportieren
|
||||
*/
|
||||
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
|
||||
val mdUrl = serverUrl.replace("/notes", "/notes-md")
|
||||
|
||||
// Erstelle notes-md/ Ordner falls nicht vorhanden
|
||||
if (!sardine.exists(mdUrl)) {
|
||||
sardine.createDirectory(mdUrl)
|
||||
Logger.d(TAG, "📁 Created notes-md/ directory")
|
||||
}
|
||||
|
||||
// Sanitize Filename (Task #1.2.0-12)
|
||||
val filename = sanitizeFilename(note.title) + ".md"
|
||||
val noteUrl = "$mdUrl/$filename"
|
||||
|
||||
// Konvertiere zu Markdown
|
||||
val mdContent = note.toMarkdown().toByteArray()
|
||||
|
||||
// Upload
|
||||
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Filename für sichere Dateinamen (Task #1.2.0-12)
|
||||
*
|
||||
* Entfernt Windows/Linux-verbotene Zeichen, begrenzt Länge
|
||||
*
|
||||
* @param title Original-Titel
|
||||
* @return Sicherer Filename
|
||||
*/
|
||||
private fun sanitizeFilename(title: String): String {
|
||||
return title
|
||||
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
|
||||
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace
|
||||
.take(200) // Max 200 Zeichen (Reserve für .md)
|
||||
.trim('_', ' ') // Trim Underscores/Spaces
|
||||
}
|
||||
|
||||
private data class DownloadResult(
|
||||
val downloadedCount: Int,
|
||||
val conflictCount: Int
|
||||
@@ -618,6 +675,86 @@ class WebDavSyncService(private val context: Context) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert Markdown-Dateien (Import von Desktop-Programmen) (Task #1.2.0-14)
|
||||
*
|
||||
* Last-Write-Wins Konfliktauflösung basierend auf updatedAt Timestamp
|
||||
*
|
||||
* @param serverUrl WebDAV Server-URL (notes/ Ordner)
|
||||
* @param username WebDAV Username
|
||||
* @param password WebDAV Password
|
||||
* @return Anzahl importierter Notizen
|
||||
*/
|
||||
suspend fun syncMarkdownFiles(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
password: String
|
||||
): Int = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📝 Starting Markdown sync...")
|
||||
|
||||
val sardine = OkHttpSardine()
|
||||
sardine.setCredentials(username, password)
|
||||
|
||||
val mdUrl = serverUrl.replace("/notes", "/notes-md")
|
||||
|
||||
// Check if notes-md/ exists
|
||||
if (!sardine.exists(mdUrl)) {
|
||||
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
||||
return@withContext 0
|
||||
}
|
||||
|
||||
val localNotes = storage.loadAllNotes()
|
||||
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
|
||||
var importedCount = 0
|
||||
|
||||
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
|
||||
|
||||
for (resource in mdResources) {
|
||||
try {
|
||||
// Download MD-File
|
||||
val mdContent = sardine.get(resource.href.toString())
|
||||
.bufferedReader().use { it.readText() }
|
||||
|
||||
// Parse zu Note
|
||||
val mdNote = Note.fromMarkdown(mdContent) ?: continue
|
||||
|
||||
val localNote = localNotes.find { it.id == mdNote.id }
|
||||
|
||||
// Konfliktauflösung: Last-Write-Wins
|
||||
when {
|
||||
localNote == null -> {
|
||||
// Neue Notiz vom Desktop
|
||||
storage.saveNote(mdNote)
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
|
||||
}
|
||||
mdNote.updatedAt > localNote.updatedAt -> {
|
||||
// Desktop-Version ist neuer (Last-Write-Wins)
|
||||
storage.saveNote(mdNote)
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
|
||||
}
|
||||
// Sonst: Lokale Version behalten
|
||||
else -> {
|
||||
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to import ${resource.name}", e)
|
||||
// Continue with other files
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
|
||||
importedCount
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Markdown sync failed", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RestoreResult(
|
||||
|
||||
@@ -19,6 +19,10 @@ object Constants {
|
||||
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
||||
|
||||
// 🔥 v1.2.0: Markdown Export/Import
|
||||
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
|
||||
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
|
||||
|
||||
// WorkManager
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
@@ -387,6 +387,92 @@
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Material 3 Card: Markdown Desktop-Integration (v1.2.0) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Section Header -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Markdown Desktop-Integration"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Info Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="ℹ️ Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnPrimaryContainer"
|
||||
android:lineSpacingMultiplier="1.3" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Markdown Export Toggle -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="📝 Markdown Export (Desktop-Zugriff)"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchMarkdownExport"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Import Markdown Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonImportMarkdown"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📥 Markdown-Änderungen importieren"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Import Info Text -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Material 3 Card: Backup & Restore -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
@@ -409,12 +495,12 @@
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Warning Info Card -->
|
||||
<!-- Info Card (anstatt Warning) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
@@ -422,19 +508,61 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="@string/backup_restore_warning"
|
||||
android:text="ℹ️ Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnErrorContainer"
|
||||
android:textColor="?attr/colorOnPrimaryContainer"
|
||||
android:lineSpacingMultiplier="1.3" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Restore Button -->
|
||||
<!-- Lokales Backup Sektion -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Lokales Backup"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Backup erstellen Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonCreateBackup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📥 Backup erstellen"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Aus Datei wiederherstellen Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonRestoreFromFile"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📤 Aus Datei wiederherstellen"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Divider -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?attr/colorOutline"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Server-Backup Sektion -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server-Backup"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Vom Server wiederherstellen Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonRestoreFromServer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/restore_from_server"
|
||||
android:text="🔄 Vom Server wiederherstellen"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
Reference in New Issue
Block a user