From 6bb87816f3419cc4d97eb0c7fe865a15f094e370 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Sun, 4 Jan 2026 01:57:31 +0100 Subject: [PATCH] Release v1.2.0 - Local Backup & Markdown Desktop Integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 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 --- CHANGELOG.md | 152 ++++++++ README.en.md | 117 +++++- README.md | 122 +++++- android/app/build.gradle.kts | 4 +- .../dettmer/simplenotes/SettingsActivity.kt | 339 +++++++++++++++- .../simplenotes/backup/BackupManager.kt | 361 ++++++++++++++++++ .../dev/dettmer/simplenotes/models/Note.kt | 95 +++++ .../simplenotes/sync/WebDavSyncService.kt | 137 +++++++ .../dettmer/simplenotes/utils/Constants.kt | 4 + .../src/main/res/layout/activity_settings.xml | 140 ++++++- .../metadata/android/de-DE/changelogs/5.txt | 13 + .../metadata/android/en-US/changelogs/5.txt | 13 + metadata/dev.dettmer.simplenotes.yml | 20 +- 13 files changed, 1500 insertions(+), 17 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt create mode 100644 fastlane/metadata/android/de-DE/changelogs/5.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e5950d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,152 @@ +# Changelog + +All notable changes to Simple Notes Sync will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [1.2.0] - 2026-01-04 + +### Added +- **Local Backup System** + - Export all notes as JSON file to any location (Downloads, SD card, cloud folder) + - Import backup with 3 modes: Merge, Replace, or Overwrite duplicates + - Automatic safety backup created before every restore + - Backup validation (format and version check) + +- **Markdown Desktop Integration** + - Optional Markdown export parallel to JSON sync + - `.md` files synced to `notes-md/` folder on WebDAV + - YAML frontmatter with `id`, `created`, `updated`, `device` + - Manual import button to pull Markdown changes from server + - Last-Write-Wins conflict resolution via timestamps + +- **Settings UI Extensions** + - New "Backup & Restore" section with local + server restore + - New "Desktop Integration" section with Markdown toggle + - Universal restore dialog with radio button mode selection + +### Changed +- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all + +### Technical +- `BackupManager.kt` - Complete backup/restore logic +- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter +- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror) +- ISO8601 timestamp formatting for desktop compatibility +- Filename sanitization for safe Markdown file names + +### Documentation +- Added WebDAV mount instructions (Windows, macOS, Linux) +- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation +- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis + +--- + +## [1.1.2] - 2025-12-28 + +### Fixed +- **"Job was cancelled" Error** + - Fixed coroutine cancellation in sync worker + - Proper error handling for interrupted syncs + +- **UI Improvements** + - Back arrow instead of X in note editor (better UX) + - Pull-to-refresh for manual sync trigger + - HTTP/HTTPS protocol selection with radio buttons + - Inline error display (no toast spam) + +- **Performance & Battery** + - Sync only on actual changes (saves battery) + - Auto-save notifications removed + - 24-hour server offline warning instead of instant error + +### Changed +- Settings grouped into "Auto-Sync" and "Sync Interval" sections +- HTTP only allowed for local networks (RFC 1918 IPs) +- Swipe-to-delete without UI flicker + +--- + +## [1.1.1] - 2025-12-27 + +### Fixed +- **WiFi Connect Sync** + - No error notifications in foreign WiFi networks + - Server reachability check before sync (2s timeout) + - Silent abort when server offline + - Pre-check waits until network is ready + - No errors during network initialization + +### Changed +- **Notifications** + - Old sync notifications cleared on app start + - Error notifications auto-dismiss after 30 seconds + +### UI +- Sync icon only shown when sync is configured +- Swipe-to-delete without flicker +- Scroll to top after saving note + +### Technical +- Server check with 2-second timeout before sync attempts +- Network readiness check in WiFi connect trigger +- Notification cleanup on MainActivity.onCreate() + +--- + +## [1.1.0] - 2025-12-26 + +### Added +- **Configurable Sync Intervals** + - User choice: 15, 30, or 60 minutes + - Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day) + - Radio button selection in settings + - Doze Mode optimization (syncs batched in maintenance windows) + +- **About Section** + - App version from BuildConfig + - Links to GitHub repository and developer profile + - MIT license information + - Material 3 card design + +### Changed +- Settings UI redesigned with grouped sections +- Periodic sync updated dynamically when interval changes +- WorkManager uses selected interval for background sync + +### Removed +- Debug/Logs section from settings (cleaner UI) + +### Technical +- `PREF_SYNC_INTERVAL_MINUTES` preference key +- NetworkMonitor reads interval from SharedPreferences +- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes + +--- + +## [1.0.0] - 2025-12-25 + +### Added +- Initial release +- WebDAV synchronization +- Note creation, editing, deletion +- 6 sync triggers: + - Periodic sync (configurable interval) + - App start sync + - WiFi connect sync + - Manual sync (menu button) + - Pull-to-refresh + - Settings "Sync Now" button +- Material 3 design +- Light/Dark theme support +- F-Droid compatible (100% FOSS) + +--- + +[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0 +[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2 +[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1 +[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0 +[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0 diff --git a/README.en.md b/README.en.md index 1eba2bf..7d23040 100644 --- a/README.en.md +++ b/README.en.md @@ -29,11 +29,25 @@ * Swipe-to-delete with confirmation * Material Design 3 editor +### 💾 Backup & Restore **NEW in v1.2.0** +* **Local backup** - Export all notes as JSON file +* **Flexible restore** - 3 modes (Merge, Replace, Overwrite) +* **Automatic safety net** - Auto-backup before every restore +* **Independent from server** - Works completely offline + +### 🖥️ Desktop Integration **NEW in v1.2.0** +* **Markdown export** - Notes are automatically exported as `.md` files +* **WebDAV access** - Mount WebDAV as network drive for direct access +* **Editor compatibility** - VS Code, Typora, Notepad++, or any Markdown editor +* **Last-Write-Wins** - Intelligent conflict resolution via timestamps +* **Dual-format** - JSON sync remains master, Markdown is optional mirror + ### 🔄 Synchronization * **Pull-to-refresh** for manual sync * **Auto-sync** (15/30/60 min) only on home WiFi * **Smart server check** - No errors on foreign networks * **Conflict-free merging** - Your changes are never lost +* **6 sync triggers** - Periodic, app-start, WiFi, manual, pull-to-refresh, settings ### 🔒 Privacy & Self-Hosted * **WebDAV server** (Nextcloud, ownCloud, etc.) @@ -73,7 +87,98 @@ docker compose up -d --- -## 📚 Documentation +## � Local Backup & Restore + +### Create Backup + +1. **Settings** → **Backup & Restore** +2. Tap **"📥 Create backup"** +3. Choose location (Downloads, SD card, cloud folder) +4. Done! All notes are saved in a `.json` file + +**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json` + +### Restore + +1. **Settings** → **"📤 Restore from file"** +2. Select backup file +3. **Choose restore mode:** + - **Merge** _(Default)_ - Add new notes, keep existing ones + - **Replace** - Delete all and import backup + - **Overwrite duplicates** - Backup wins on ID conflicts +4. Confirm - _Automatic safety backup is created!_ + +**💡 Tip:** Before every restore, an automatic safety backup is created - your data is safe! + +--- + +## 🖥️ Desktop Integration (WebDAV + Markdown) + +### Why Markdown? + +The app automatically exports your notes as `.md` files so you can edit them on desktop: + +- **JSON remains master** - Primary sync mechanism (reliable, fast) +- **Markdown is mirror** - Additional export for desktop access +- **Dual-format** - Both formats are always in sync + +### Setup: WebDAV as Network Drive + +**With WebDAV mount ANY Markdown editor works!** + +#### Windows: + +1. **Open Explorer** → Right-click on "This PC" +2. **"Map network drive"** +3. **Enter WebDAV URL:** `http://YOUR-SERVER:8080/notes-md/` +4. Enter username/password +5. **Done!** - Folder appears as drive (e.g. Z:\) + +#### macOS: + +1. **Finder** → Menu "Go" → "Connect to Server" (⌘K) +2. **Server Address:** `http://YOUR-SERVER:8080/notes-md/` +3. Enter username/password +4. **Done!** - Folder appears under "Network" + +#### Linux: + +```bash +# Option 1: GNOME Files / Nautilus +Files → Other Locations → Connect to Server +Server Address: dav://YOUR-SERVER:8080/notes-md/ + +# Option 2: davfs2 (permanent mount) +sudo apt install davfs2 +sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes +``` + +### Workflow: + +1. **Enable Markdown export** (App → Settings) +2. **Mount WebDAV** (see above) +3. **Open editor** (VS Code, Typora, Notepad++, etc.) +4. **Edit notes** - Changes are saved directly +5. **"Import Markdown Changes" in app** - Import desktop changes + +**Recommended Editors:** +- **VS Code** - Free, powerful, with Markdown preview +- **Typora** - Minimalist, WYSIWYG Markdown +- **Notepad++** - Lightweight, fast +- **iA Writer** - Focused writing + +- **VS Code** with WebDAV extension +- **Typora** (local copy) +- **iA Writer** (read/edit only, no auto-sync) + +**⚠️ Important:** +- Markdown export is **optional** (toggle in settings) +- JSON sync **always** works - Markdown is additional +- All 6 sync triggers remain unchanged + +--- + +## �📚 Documentation - **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users - **[Server Setup](server/README.en.md)** - Configure WebDAV server @@ -98,8 +203,14 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details. --- -## 📄 License +## � Changelog + +All changes are documented in [CHANGELOG.md](CHANGELOG.md). + +--- + +## �📄 License MIT License - see [LICENSE](LICENSE) -**v1.1.2** · Built with Kotlin + Material Design 3 +**v1.2.0** · Built with Kotlin + Material Design 3 diff --git a/README.md b/README.md index 879023c..52d20b0 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,25 @@ * Swipe-to-Delete mit Bestätigung * Material Design 3 Editor +### 💾 Backup & Wiederherstellung **NEU in v1.2.0** +* **Lokales Backup** - Exportiere alle Notizen als JSON-Datei +* **Flexible Wiederherstellung** - 3 Modi (Zusammenführen, Ersetzen, Überschreiben) +* **Automatisches Sicherheitsnetz** - Auto-Backup vor jeder Wiederherstellung +* **Unabhängig vom Server** - Funktioniert komplett offline + +### 🖥️ Desktop-Integration **NEU in v1.2.0** +* **Markdown-Export** - Notizen werden automatisch als `.md` Dateien exportiert +* **WebDAV-Zugriff** - Mounte WebDAV als Netzlaufwerk für direkten Zugriff +* **Editor-Kompatibilität** - VS Code, Typora, Notepad++, oder beliebiger Markdown-Editor +* **Last-Write-Wins** - Intelligente Konfliktauflösung via Zeitstempel +* **Dual-Format** - JSON-Sync bleibt Master, Markdown ist optionaler Mirror + ### 🔄 Synchronisation * **Pull-to-Refresh** für manuellen Sync * **Auto-Sync** (15/30/60 Min) nur im Heim-WLAN * **Smart Server-Check** - Keine Fehler in fremden Netzwerken * **Konfliktfreies Merging** - Deine Änderungen gehen nie verloren +* **6 Sync-Trigger** - Periodic, App-Start, WiFi, Manual, Pull-to-Refresh, Settings ### 🔒 Privacy & Self-Hosted * **WebDAV-Server** (Nextcloud, ownCloud, etc.) @@ -73,7 +87,103 @@ docker compose up -d --- -## 📚 Dokumentation +## � Lokales Backup & Wiederherstellung + +### Backup erstellen + +1. **Einstellungen** → **Backup & Wiederherstellung** +2. Tippe auf **"📥 Backup erstellen"** +3. Wähle Speicherort (Downloads, SD-Karte, Cloud-Ordner) +4. Fertig! Alle Notizen sind in einer `.json` Datei gesichert + +**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json` + +### Wiederherstellen + +1. **Einstellungen** → **"📤 Aus Datei wiederherstellen"** +2. Wähle Backup-Datei +3. **Wiederherstellungs-Modus auswählen:** + - **Zusammenführen** _(Standard)_ - Neue Notizen hinzufügen, bestehende behalten + - **Ersetzen** - Alle löschen und Backup importieren + - **Duplikate überschreiben** - Backup gewinnt bei ID-Konflikten +4. Bestätigen - _Automatisches Sicherheits-Backup wird erstellt!_ + +**💡 Tipp:** Vor jeder Wiederherstellung wird automatisch ein Auto-Backup erstellt - deine Daten sind sicher! + +--- + +## 🖥️ Desktop-Integration (WebDAV + Markdown) + +### Warum Markdown? + +Die App exportiert deine Notizen automatisch als `.md` Dateien, damit du sie auf dem Desktop bearbeiten kannst: + +- **JSON bleibt Master** - Primärer Sync-Mechanismus (verlässlich, schnell) +- **Markdown ist Mirror** - Zusätzlicher Export für Desktop-Zugriff +- **Dual-Format** - Beide Formate sind immer synchron + +### Setup: WebDAV als Netzlaufwerk + +**Mit WebDAV-Mount funktioniert JEDER Markdown-Editor!** + +#### Windows: + +1. **Explorer öffnen** → Rechtsklick auf "Dieser PC" +2. **"Netzlaufwerk verbinden"** wählen +3. **WebDAV-URL eingeben:** `http://DEIN-SERVER:8080/notes-md/` +4. Benutzername/Passwort eingeben +5. **Fertig!** - Ordner erscheint als Laufwerk (z.B. Z:\) + +#### macOS: + +1. **Finder** → Menü "Gehe zu" → "Mit Server verbinden" (⌘K) +2. **Server-Adresse:** `http://DEIN-SERVER:8080/notes-md/` +3. Benutzername/Passwort eingeben +4. **Fertig!** - Ordner erscheint unter "Netzwerk" + +#### Linux: + +```bash +# Option 1: GNOME Files / Nautilus +Dateien → Andere Orte → Mit Server verbinden +Server-Adresse: dav://DEIN-SERVER:8080/notes-md/ + +# Option 2: davfs2 (permanent mount) +sudo apt install davfs2 +sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes +``` + +### Workflow: + +1. **Markdown-Export aktivieren** (App → Einstellungen) +2. **WebDAV mounten** (siehe oben) +3. **Editor öffnen** (VS Code, Typora, Notepad++, etc.) +4. **Notizen bearbeiten** - Änderungen werden direkt gespeichert +5. **"Import Markdown Changes" in App** - Desktop-Änderungen importieren + +**Empfohlene Editoren:** +- **VS Code** - Kostenlos, mächtig, mit Markdown-Preview +- **Typora** - Minimalistisch, WYSIWYG-Markdown +- **Notepad++** - Leichtgewichtig, schnell +- **iA Writer** - Fokussiertes Schreiben +3. Notizen bearbeiten - Änderungen via "Import Markdown Changes" in die App importieren + +### Alternative: Direkter Zugriff + +Du kannst die `.md` Dateien auch direkt mit jedem Markdown-Editor öffnen: + +- **VS Code** mit WebDAV-Extension +- **Typora** (lokale Kopie) +- **iA Writer** (nur lesen/bearbeiten, kein Auto-Sync) + +**⚠️ Wichtig:** +- Markdown-Export ist **optional** (in Einstellungen ein/ausschaltbar) +- JSON-Sync funktioniert **immer** - Markdown ist zusätzlich +- Alle 6 Sync-Trigger bleiben unverändert erhalten + +--- + +## �📚 Dokumentation - **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer - **[Server Setup](server/README.md)** - WebDAV Server konfigurieren @@ -98,8 +208,14 @@ Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details --- -## 📄 Lizenz +## � Changelog + +Alle Änderungen sind in [CHANGELOG.md](CHANGELOG.md) dokumentiert. + +--- + +## �📄 Lizenz MIT License - siehe [LICENSE](LICENSE) -**v1.1.2** · Gebaut mit Kotlin + Material Design 3 +**v1.2.0** · Gebaut mit Kotlin + Material Design 3 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c263b03..f4ab7ea 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 4 // 🔥 v1.1.2: UX Fixes + CancellationException Handling - versionName = "1.1.2" // 🔥 v1.1.2: Better UX + Job Cancellation Fix + versionCode = 5 // 🔥 v1.2.0: Local Backup + Markdown Desktop Integration + versionName = "1.2.0" // 🔥 v1.2.0: Backup/Restore + Joplin/Obsidian Support testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt index 80bae4e..7ca50ed 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -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() + } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt b/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt new file mode 100644 index 0000000..992cceb --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/backup/BackupManager.kt @@ -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): 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): 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): 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 +) + +/** + * 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 +) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt index f02a6b5..0098eb7 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt @@ -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 + } + } } } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index be95b92..f61fbca 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -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( diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt index e3bf7ab..9b07ede 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt @@ -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 diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml index fddbf2b..a700d0a 100644 --- a/android/app/src/main/res/layout/activity_settings.xml +++ b/android/app/src/main/res/layout/activity_settings.xml @@ -387,6 +387,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +