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:
152
CHANGELOG.md
Normal file
152
CHANGELOG.md
Normal 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
|
||||||
117
README.en.md
117
README.en.md
@@ -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
122
README.md
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
package dev.dettmer.simplenotes.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Backup aller Notizen in JSON-Datei
|
||||||
|
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
||||||
|
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||||
|
* - Backup-Validierung
|
||||||
|
*/
|
||||||
|
class BackupManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackupManager"
|
||||||
|
private const val BACKUP_VERSION = 1
|
||||||
|
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||||
|
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
private val storage = NotesStorage(context)
|
||||||
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt Backup aller Notizen
|
||||||
|
*
|
||||||
|
* @param uri Output-URI (via Storage Access Framework)
|
||||||
|
* @return BackupResult mit Erfolg/Fehler Info
|
||||||
|
*/
|
||||||
|
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
Logger.d(TAG, "📦 Creating backup to: $uri")
|
||||||
|
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||||
|
|
||||||
|
val backupData = BackupData(
|
||||||
|
backup_version = BACKUP_VERSION,
|
||||||
|
created_at = System.currentTimeMillis(),
|
||||||
|
notes_count = allNotes.size,
|
||||||
|
app_version = BuildConfig.VERSION_NAME,
|
||||||
|
notes = allNotes
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonString = gson.toJson(backupData)
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.write(jsonString.toByteArray())
|
||||||
|
Logger.d(TAG, "✅ Backup created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupResult(
|
||||||
|
success = true,
|
||||||
|
notes_count = allNotes.size,
|
||||||
|
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to create backup", e)
|
||||||
|
BackupResult(
|
||||||
|
success = false,
|
||||||
|
error = "Backup fehlgeschlagen: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt automatisches Backup (vor Restore)
|
||||||
|
* Gespeichert in app-internem Storage
|
||||||
|
*
|
||||||
|
* @return Uri des Auto-Backups oder null bei Fehler
|
||||||
|
*/
|
||||||
|
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
|
.format(Date())
|
||||||
|
val filename = "auto_backup_before_restore_$timestamp.json"
|
||||||
|
val file = File(autoBackupDir, filename)
|
||||||
|
|
||||||
|
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
||||||
|
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
val backupData = BackupData(
|
||||||
|
backup_version = BACKUP_VERSION,
|
||||||
|
created_at = System.currentTimeMillis(),
|
||||||
|
notes_count = allNotes.size,
|
||||||
|
app_version = BuildConfig.VERSION_NAME,
|
||||||
|
notes = allNotes
|
||||||
|
)
|
||||||
|
|
||||||
|
file.writeText(gson.toJson(backupData))
|
||||||
|
|
||||||
|
// Cleanup alte Auto-Backups
|
||||||
|
cleanupOldAutoBackups(autoBackupDir)
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
||||||
|
Uri.fromFile(file)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to create auto-backup", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt Notizen aus Backup wieder her
|
||||||
|
*
|
||||||
|
* @param uri Backup-Datei URI
|
||||||
|
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||||
|
* @return RestoreResult mit Details
|
||||||
|
*/
|
||||||
|
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||||
|
|
||||||
|
// 1. Backup-Datei lesen
|
||||||
|
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} ?: return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Datei konnte nicht gelesen werden"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Backup validieren & parsen
|
||||||
|
val validationResult = validateBackup(jsonString)
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||||
|
Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}")
|
||||||
|
|
||||||
|
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||||
|
val autoBackupUri = createAutoBackup()
|
||||||
|
if (autoBackupUri == null) {
|
||||||
|
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Restore durchführen (je nach Modus)
|
||||||
|
val result = when (mode) {
|
||||||
|
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
||||||
|
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
||||||
|
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped")
|
||||||
|
result
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to restore backup", e)
|
||||||
|
RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert Backup-Datei
|
||||||
|
*/
|
||||||
|
private fun validateBackup(jsonString: String): ValidationResult {
|
||||||
|
return try {
|
||||||
|
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||||
|
|
||||||
|
// Version kompatibel?
|
||||||
|
if (backupData.backup_version > BACKUP_VERSION) {
|
||||||
|
return ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notizen-Array vorhanden?
|
||||||
|
if (backupData.notes.isEmpty()) {
|
||||||
|
return ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup enthält keine Notizen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Notizen haben ID, title, content?
|
||||||
|
val invalidNotes = backupData.notes.filter { note ->
|
||||||
|
note.id.isBlank() || note.title.isBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidNotes.isNotEmpty()) {
|
||||||
|
return ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationResult(isValid = true)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Modus: MERGE
|
||||||
|
* Fügt neue Notizen hinzu, behält bestehende
|
||||||
|
*/
|
||||||
|
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||||
|
val existingNotes = storage.loadAllNotes()
|
||||||
|
val existingIds = existingNotes.map { it.id }.toSet()
|
||||||
|
|
||||||
|
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||||
|
val skippedNotes = backupNotes.size - newNotes.size
|
||||||
|
|
||||||
|
newNotes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreResult(
|
||||||
|
success = true,
|
||||||
|
imported_notes = newNotes.size,
|
||||||
|
skipped_notes = skippedNotes,
|
||||||
|
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Modus: REPLACE
|
||||||
|
* Löscht alle bestehenden Notizen, importiert Backup
|
||||||
|
*/
|
||||||
|
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||||
|
// Alle bestehenden Notizen löschen
|
||||||
|
storage.deleteAllNotes()
|
||||||
|
|
||||||
|
// Backup-Notizen importieren
|
||||||
|
backupNotes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreResult(
|
||||||
|
success = true,
|
||||||
|
imported_notes = backupNotes.size,
|
||||||
|
skipped_notes = 0,
|
||||||
|
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||||
|
* Backup überschreibt bei ID-Konflikten
|
||||||
|
*/
|
||||||
|
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||||
|
val existingNotes = storage.loadAllNotes()
|
||||||
|
val existingIds = existingNotes.map { it.id }.toSet()
|
||||||
|
|
||||||
|
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||||
|
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
||||||
|
|
||||||
|
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
||||||
|
backupNotes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreResult(
|
||||||
|
success = true,
|
||||||
|
imported_notes = newNotes.size,
|
||||||
|
skipped_notes = 0,
|
||||||
|
overwritten_notes = overwrittenNotes.size,
|
||||||
|
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht Auto-Backups älter als RETENTION_DAYS
|
||||||
|
*/
|
||||||
|
private fun cleanupOldAutoBackups(autoBackupDir: File) {
|
||||||
|
try {
|
||||||
|
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
||||||
|
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
||||||
|
|
||||||
|
autoBackupDir.listFiles()?.forEach { file ->
|
||||||
|
if (file.lastModified() < cutoffTime) {
|
||||||
|
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to cleanup old backups", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup-Daten Struktur (JSON)
|
||||||
|
*/
|
||||||
|
data class BackupData(
|
||||||
|
val backup_version: Int,
|
||||||
|
val created_at: Long,
|
||||||
|
val notes_count: Int,
|
||||||
|
val app_version: String,
|
||||||
|
val notes: List<Note>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederherstellungs-Modi
|
||||||
|
*/
|
||||||
|
enum class RestoreMode {
|
||||||
|
MERGE, // Bestehende + Neue (Standard)
|
||||||
|
REPLACE, // Alles löschen + Importieren
|
||||||
|
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup-Ergebnis
|
||||||
|
*/
|
||||||
|
data class BackupResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val notes_count: Int = 0,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Ergebnis
|
||||||
|
*/
|
||||||
|
data class RestoreResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val imported_notes: Int = 0,
|
||||||
|
val skipped_notes: Int = 0,
|
||||||
|
val overwritten_notes: Int = 0,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validierungs-Ergebnis
|
||||||
|
*/
|
||||||
|
data class ValidationResult(
|
||||||
|
val isValid: Boolean,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package dev.dettmer.simplenotes.models
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
13
fastlane/metadata/android/de-DE/changelogs/5.txt
Normal file
13
fastlane/metadata/android/de-DE/changelogs/5.txt
Normal 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
|
||||||
13
fastlane/metadata/android/en-US/changelogs/5.txt
Normal file
13
fastlane/metadata/android/en-US/changelogs/5.txt
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user