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:
inventory69
2026-01-04 01:57:31 +01:00
parent 4802c3d979
commit 6bb87816f3
13 changed files with 1500 additions and 17 deletions

152
CHANGELOG.md Normal file
View File

@@ -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

View File

@@ -29,11 +29,25 @@
* Swipe-to-delete with confirmation * Swipe-to-delete with confirmation
* Material Design 3 editor * 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 ### 🔄 Synchronization
* **Pull-to-refresh** for manual sync * **Pull-to-refresh** for manual sync
* **Auto-sync** (15/30/60 min) only on home WiFi * **Auto-sync** (15/30/60 min) only on home WiFi
* **Smart server check** - No errors on foreign networks * **Smart server check** - No errors on foreign networks
* **Conflict-free merging** - Your changes are never lost * **Conflict-free merging** - Your changes are never lost
* **6 sync triggers** - Periodic, app-start, WiFi, manual, pull-to-refresh, settings
### 🔒 Privacy & Self-Hosted ### 🔒 Privacy & Self-Hosted
* **WebDAV server** (Nextcloud, ownCloud, etc.) * **WebDAV server** (Nextcloud, ownCloud, etc.)
@@ -73,7 +87,98 @@ docker compose up -d
--- ---
## 📚 Documentation ## <EFBFBD> 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
---
## <20>📚 Documentation
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users - **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
- **[Server Setup](server/README.en.md)** - Configure WebDAV server - **[Server Setup](server/README.en.md)** - Configure WebDAV server
@@ -98,8 +203,14 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
--- ---
## 📄 License ## <EFBFBD> Changelog
All changes are documented in [CHANGELOG.md](CHANGELOG.md).
---
## <20>📄 License
MIT License - see [LICENSE](LICENSE) MIT License - see [LICENSE](LICENSE)
**v1.1.2** · Built with Kotlin + Material Design 3 **v1.2.0** · Built with Kotlin + Material Design 3

122
README.md
View File

@@ -29,11 +29,25 @@
* Swipe-to-Delete mit Bestätigung * Swipe-to-Delete mit Bestätigung
* Material Design 3 Editor * 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 ### 🔄 Synchronisation
* **Pull-to-Refresh** für manuellen Sync * **Pull-to-Refresh** für manuellen Sync
* **Auto-Sync** (15/30/60 Min) nur im Heim-WLAN * **Auto-Sync** (15/30/60 Min) nur im Heim-WLAN
* **Smart Server-Check** - Keine Fehler in fremden Netzwerken * **Smart Server-Check** - Keine Fehler in fremden Netzwerken
* **Konfliktfreies Merging** - Deine Änderungen gehen nie verloren * **Konfliktfreies Merging** - Deine Änderungen gehen nie verloren
* **6 Sync-Trigger** - Periodic, App-Start, WiFi, Manual, Pull-to-Refresh, Settings
### 🔒 Privacy & Self-Hosted ### 🔒 Privacy & Self-Hosted
* **WebDAV-Server** (Nextcloud, ownCloud, etc.) * **WebDAV-Server** (Nextcloud, ownCloud, etc.)
@@ -73,7 +87,103 @@ docker compose up -d
--- ---
## 📚 Dokumentation ## <EFBFBD> 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
---
## <20>📚 Dokumentation
- **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer - **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren - **[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 ## <EFBFBD> Changelog
Alle Änderungen sind in [CHANGELOG.md](CHANGELOG.md) dokumentiert.
---
## <20>📄 Lizenz
MIT License - siehe [LICENSE](LICENSE) MIT License - siehe [LICENSE](LICENSE)
**v1.1.2** · Gebaut mit Kotlin + Material Design 3 **v1.2.0** · Gebaut mit Kotlin + Material Design 3

View File

@@ -17,8 +17,8 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 4 // 🔥 v1.1.2: UX Fixes + CancellationException Handling versionCode = 5 // 🔥 v1.2.0: Local Backup + Markdown Desktop Integration
versionName = "1.1.2" // 🔥 v1.1.2: Better UX + Job Cancellation Fix versionName = "1.2.0" // 🔥 v1.2.0: Backup/Restore + Joplin/Obsidian Support
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -13,6 +13,7 @@ import android.widget.EditText
import android.widget.RadioButton import android.widget.RadioButton
import android.widget.RadioGroup import android.widget.RadioGroup
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat import androidx.appcompat.widget.SwitchCompat
@@ -26,6 +27,8 @@ import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.utils.UrlValidator import dev.dettmer.simplenotes.utils.UrlValidator
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
@@ -53,9 +56,13 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var editTextUsername: EditText private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText private lateinit var editTextPassword: EditText
private lateinit var switchAutoSync: SwitchCompat private lateinit var switchAutoSync: SwitchCompat
private lateinit var switchMarkdownExport: SwitchCompat
private lateinit var buttonTestConnection: Button private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button private lateinit var buttonSyncNow: Button
private lateinit var buttonCreateBackup: Button
private lateinit var buttonRestoreFromFile: Button
private lateinit var buttonRestoreFromServer: Button private lateinit var buttonRestoreFromServer: Button
private lateinit var buttonImportMarkdown: Button
private lateinit var textViewServerStatus: TextView private lateinit var textViewServerStatus: TextView
// Protocol Selection UI // Protocol Selection UI
@@ -73,6 +80,22 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var cardDeveloperProfile: MaterialCardView private lateinit var cardDeveloperProfile: MaterialCardView
private lateinit var cardLicense: 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 { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
} }
@@ -106,9 +129,13 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername = findViewById(R.id.editTextUsername) editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword) editTextPassword = findViewById(R.id.editTextPassword)
switchAutoSync = findViewById(R.id.switchAutoSync) switchAutoSync = findViewById(R.id.switchAutoSync)
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
buttonTestConnection = findViewById(R.id.buttonTestConnection) buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer) buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
textViewServerStatus = findViewById(R.id.textViewServerStatus) textViewServerStatus = findViewById(R.id.textViewServerStatus)
// Protocol Selection UI // Protocol Selection UI
@@ -152,6 +179,7 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, "")) editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, "")) editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) 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 // Update hint text based on selected protocol
updateProtocolHint() updateProtocolHint()
@@ -223,15 +251,36 @@ class SettingsActivity : AppCompatActivity() {
syncNow() 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 { buttonRestoreFromServer.setOnClickListener {
saveSettings() saveSettings()
showRestoreConfirmation() showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
}
buttonImportMarkdown.setOnClickListener {
saveSettings()
importMarkdownChanges()
} }
switchAutoSync.setOnCheckedChangeListener { _, isChecked -> switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked) onAutoSyncToggled(isChecked)
} }
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
onMarkdownExportToggled(isChecked)
}
// Clear error when user starts typing again // Clear error when user starts typing again
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher { editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} 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() { private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
val packageName = packageName val packageName = packageName
@@ -612,4 +722,231 @@ class SettingsActivity : AppCompatActivity() {
super.onPause() super.onPause()
saveSettings() 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()
}
} }

View File

@@ -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
)

View File

@@ -1,5 +1,9 @@
package dev.dettmer.simplenotes.models 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 import java.util.UUID
data class Note( data class Note(
@@ -25,6 +29,25 @@ data class Note(
""".trimIndent() """.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 { companion object {
fun fromJson(json: String): Note? { fun fromJson(json: String): Note? {
return try { return try {
@@ -34,6 +57,78 @@ data class Note(
null 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
}
}
} }
} }

View File

@@ -444,9 +444,11 @@ class WebDavSyncService(private val context: Context) {
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
var uploadedCount = 0 var uploadedCount = 0
val localNotes = storage.loadAllNotes() val localNotes = storage.loadAllNotes()
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
for (note in localNotes) { for (note in localNotes) {
try { try {
// 1. JSON-Upload (bestehend, unverändert)
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) { if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
val noteUrl = "$serverUrl/${note.id}.json" val noteUrl = "$serverUrl/${note.id}.json"
val jsonBytes = note.toJson().toByteArray() val jsonBytes = note.toJson().toByteArray()
@@ -457,6 +459,18 @@ class WebDavSyncService(private val context: Context) {
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED) val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
storage.saveNote(updatedNote) storage.saveNote(updatedNote)
uploadedCount++ 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) { } catch (e: Exception) {
// Mark as pending for retry // Mark as pending for retry
@@ -468,6 +482,49 @@ class WebDavSyncService(private val context: Context) {
return uploadedCount 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( private data class DownloadResult(
val downloadedCount: Int, val downloadedCount: Int,
val conflictCount: 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( data class RestoreResult(

View File

@@ -19,6 +19,10 @@ object Constants {
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes" const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L 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 // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L

View File

@@ -387,6 +387,92 @@
</com.google.android.material.card.MaterialCardView> </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 --> <!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -409,12 +495,12 @@
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />
<!-- Warning Info Card --> <!-- Info Card (anstatt Warning) -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer" app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="12dp" app:cardCornerRadius="12dp"
app:cardElevation="0dp"> app:cardElevation="0dp">
@@ -422,19 +508,61 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="16dp" 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:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnErrorContainer" android:textColor="?attr/colorOnPrimaryContainer"
android:lineSpacingMultiplier="1.3" /> android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView> </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 <Button
android:id="@+id/buttonRestoreFromServer" android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/restore_from_server" android:text="🔄 Vom Server wiederherstellen"
style="@style/Widget.Material3.Button.TonalButton" /> style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,13 @@
v1.2.0 - Backup & Desktop-Integration
🆕 Lokales Backup/Restore
• Exportiere alle Notizen als JSON
• 3 Wiederherstellungs-Modi (Merge/Replace/Overwrite)
• Auto-Backup vor Restore
🆕 Markdown Desktop-Integration (optional)
• .md Export für Desktop-Editoren (WebDAV-Mount)
• Last-Write-Wins Sync
• Manueller Import
📚 Sync-Architektur Doku

View File

@@ -0,0 +1,13 @@
v1.2.0 - Backup & Desktop Integration
🆕 Local Backup/Restore
• Export all notes as JSON
• 3 restore modes (Merge/Replace/Overwrite)
• Auto-backup before restore
🆕 Markdown Desktop Integration (optional)
• .md export for desktop editors (WebDAV mount)
• Last-Write-Wins sync
• Manual import
📚 Sync architecture docs

View File

@@ -47,7 +47,23 @@ Builds:
scandelete: scandelete:
- android/gradle/wrapper - android/gradle/wrapper
- versionName: 1.2.0
versionCode: 5
commit: v1.2.0
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
srclibs:
- reproducible-apk-tools@v0.2.8
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
AutoUpdateMode: Version AutoUpdateMode: Version
UpdateCheckMode: Tags UpdateCheckMode: Tags
CurrentVersion: 1.1.1 CurrentVersion: 1.2.0
CurrentVersionCode: 3 CurrentVersionCode: 5