Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d135e8f0d | ||
|
|
5d82431bb6 | ||
|
|
6bb87816f3 | ||
|
|
4802c3d979 |
24
.github/workflows/build-production-apk.yml
vendored
24
.github/workflows/build-production-apk.yml
vendored
@@ -104,24 +104,22 @@ jobs:
|
||||
|
||||
- name: F-Droid Changelogs lesen
|
||||
run: |
|
||||
# Lese deutsche Changelog (Hauptsprache)
|
||||
if [ -f "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
{
|
||||
echo 'CHANGELOG_DE<<EOF'
|
||||
cat "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
||||
echo 'EOF'
|
||||
} >> $GITHUB_ENV
|
||||
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
|
||||
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Lese englische Changelog (optional)
|
||||
if [ -f "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
{
|
||||
echo 'CHANGELOG_EN<<EOF'
|
||||
cat "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
||||
echo 'EOF'
|
||||
} >> $GITHUB_ENV
|
||||
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
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
|
||||
152
README.en.md
152
README.en.md
@@ -25,30 +25,41 @@
|
||||
## Features
|
||||
|
||||
### 📝 Notes
|
||||
- Create and edit simple text notes
|
||||
- Automatic save
|
||||
- Swipe-to-delete with confirmation
|
||||
* Simple text notes with auto-save
|
||||
* Swipe-to-delete with confirmation
|
||||
* Material Design 3 editor
|
||||
|
||||
### 💾 Backup & Restore **NEW in v1.2.0**
|
||||
* **Local backup** - Export all notes as JSON file
|
||||
* **Flexible restore** - 3 modes (Merge, Replace, Overwrite)
|
||||
* **Automatic safety net** - Auto-backup before every restore
|
||||
* **Independent from server** - Works completely offline
|
||||
|
||||
### 🖥️ Desktop Integration **NEW in v1.2.0**
|
||||
* **Markdown export** - Notes are automatically exported as `.md` files
|
||||
* **WebDAV access** - Mount WebDAV as network drive for direct access
|
||||
* **Editor compatibility** - VS Code, Typora, Notepad++, or any Markdown editor
|
||||
* **Last-Write-Wins** - Intelligent conflict resolution via timestamps
|
||||
* **Dual-format** - JSON sync remains master, Markdown is optional mirror
|
||||
|
||||
### 🔄 Synchronization
|
||||
- Auto-sync (15/30/60 min intervals)
|
||||
- WiFi-based - Sync on home WiFi connection
|
||||
- Server check (2s timeout) - No errors in foreign networks
|
||||
- Conflict-free merging via timestamps
|
||||
* **Pull-to-refresh** for manual sync
|
||||
* **Auto-sync** (15/30/60 min) only on home WiFi
|
||||
* **Smart server check** - No errors on foreign networks
|
||||
* **Conflict-free merging** - Your changes are never lost
|
||||
* **6 sync triggers** - Periodic, app-start, WiFi, manual, pull-to-refresh, settings
|
||||
|
||||
### 🏠 Self-Hosted & Privacy
|
||||
- WebDAV server (Nextcloud, ownCloud, etc.)
|
||||
- Your data stays with you - No tracking, no analytics
|
||||
- 100% Open Source (MIT license)
|
||||
### 🔒 Privacy & Self-Hosted
|
||||
* **WebDAV server** (Nextcloud, ownCloud, etc.)
|
||||
* **Docker setup guide** included in docs
|
||||
* **Your data stays with you** - No tracking, no cloud
|
||||
* **HTTP only local** - HTTPS for external servers
|
||||
* **100% open source** (MIT License)
|
||||
|
||||
### 🔋 Performance
|
||||
- Battery-friendly (~0.2-0.8% per day)
|
||||
- Doze Mode optimized
|
||||
- Offline-first - All features work without internet
|
||||
|
||||
### 🎨 Material Design 3
|
||||
- Dynamic Colors (Material You)
|
||||
- Dark Mode
|
||||
- Modern, intuitive UI
|
||||
* **Battery-friendly** (~0.2-0.8% per day)
|
||||
* **Offline-first** - Works without internet
|
||||
* **Dark mode** & dynamic colors
|
||||
|
||||
---
|
||||
|
||||
@@ -76,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
|
||||
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
|
||||
@@ -101,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)
|
||||
|
||||
**v1.1.1** · Built with Kotlin + Material Design 3
|
||||
**v1.2.0** · Built with Kotlin + Material Design 3
|
||||
|
||||
157
README.md
157
README.md
@@ -25,30 +25,41 @@
|
||||
## Features
|
||||
|
||||
### 📝 Notizen
|
||||
- Einfache Textnotizen erstellen und bearbeiten
|
||||
- Automatisches Speichern
|
||||
- Swipe-to-Delete mit Bestätigung
|
||||
* Einfache Textnotizen mit automatischem Speichern
|
||||
* Swipe-to-Delete mit Bestätigung
|
||||
* Material Design 3 Editor
|
||||
|
||||
### 💾 Backup & Wiederherstellung **NEU in v1.2.0**
|
||||
* **Lokales Backup** - Exportiere alle Notizen als JSON-Datei
|
||||
* **Flexible Wiederherstellung** - 3 Modi (Zusammenführen, Ersetzen, Überschreiben)
|
||||
* **Automatisches Sicherheitsnetz** - Auto-Backup vor jeder Wiederherstellung
|
||||
* **Unabhängig vom Server** - Funktioniert komplett offline
|
||||
|
||||
### 🖥️ Desktop-Integration **NEU in v1.2.0**
|
||||
* **Markdown-Export** - Notizen werden automatisch als `.md` Dateien exportiert
|
||||
* **WebDAV-Zugriff** - Mounte WebDAV als Netzlaufwerk für direkten Zugriff
|
||||
* **Editor-Kompatibilität** - VS Code, Typora, Notepad++, oder beliebiger Markdown-Editor
|
||||
* **Last-Write-Wins** - Intelligente Konfliktauflösung via Zeitstempel
|
||||
* **Dual-Format** - JSON-Sync bleibt Master, Markdown ist optionaler Mirror
|
||||
|
||||
### 🔄 Synchronisation
|
||||
- Auto-Sync (15/30/60 Min Intervalle)
|
||||
- WiFi-basiert - Sync bei Heim-WLAN-Verbindung
|
||||
- Server-Check (2s Timeout) - Keine Fehler in fremden Netzwerken
|
||||
- Konfliktfreies Merging via Timestamps
|
||||
* **Pull-to-Refresh** für manuellen Sync
|
||||
* **Auto-Sync** (15/30/60 Min) nur im Heim-WLAN
|
||||
* **Smart Server-Check** - Keine Fehler in fremden Netzwerken
|
||||
* **Konfliktfreies Merging** - Deine Änderungen gehen nie verloren
|
||||
* **6 Sync-Trigger** - Periodic, App-Start, WiFi, Manual, Pull-to-Refresh, Settings
|
||||
|
||||
### 🏠 Self-Hosted & Privacy
|
||||
- WebDAV-Server (Nextcloud, ownCloud, etc.)
|
||||
- Deine Daten bei dir - Kein Tracking, keine Analytics
|
||||
- 100% Open Source (MIT Lizenz)
|
||||
### 🔒 Privacy & Self-Hosted
|
||||
* **WebDAV-Server** (Nextcloud, ownCloud, etc.)
|
||||
* **Docker Setup-Anleitung** in den Docs enthalten
|
||||
* **Deine Daten bleiben bei dir** - Kein Tracking, keine Cloud
|
||||
* **HTTP nur lokal** - HTTPS für externe Server
|
||||
* **100% Open Source** (MIT Lizenz)
|
||||
|
||||
### 🔋 Performance
|
||||
- Akkuschonend (~0.2-0.8% pro Tag)
|
||||
- Doze Mode optimiert
|
||||
- Offline-First - Alle Features ohne Internet
|
||||
|
||||
### 🎨 Material Design 3
|
||||
- Dynamic Colors (Material You)
|
||||
- Dark Mode
|
||||
- Moderne, intuitive UI
|
||||
* **Akkuschonend** (~0.2-0.8% pro Tag)
|
||||
* **Offline-First** - Funktioniert ohne Internet
|
||||
* **Dark Mode** & Dynamic Colors
|
||||
|
||||
---
|
||||
|
||||
@@ -76,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
|
||||
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
|
||||
@@ -101,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)
|
||||
|
||||
**v1.1.1** · Gebaut mit Kotlin + Material Design 3
|
||||
**v1.2.0** · Gebaut mit Kotlin + Material Design 3
|
||||
|
||||
@@ -17,8 +17,8 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 4 // 🔥 v1.1.2: UX Fixes + CancellationException Handling
|
||||
versionName = "1.1.2" // 🔥 v1.1.2: Better UX + Job Cancellation Fix
|
||||
versionCode = 5 // 🔥 v1.2.0: Local Backup + Markdown Desktop Integration
|
||||
versionName = "1.2.0" // 🔥 v1.2.0: Backup/Restore + Joplin/Obsidian Support
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.widget.EditText
|
||||
import android.widget.RadioButton
|
||||
import android.widget.RadioGroup
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
@@ -26,6 +27,8 @@ import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import dev.dettmer.simplenotes.backup.BackupManager
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.utils.UrlValidator
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
@@ -53,9 +56,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var editTextUsername: EditText
|
||||
private lateinit var editTextPassword: EditText
|
||||
private lateinit var switchAutoSync: SwitchCompat
|
||||
private lateinit var switchMarkdownExport: SwitchCompat
|
||||
private lateinit var buttonTestConnection: Button
|
||||
private lateinit var buttonSyncNow: Button
|
||||
private lateinit var buttonCreateBackup: Button
|
||||
private lateinit var buttonRestoreFromFile: Button
|
||||
private lateinit var buttonRestoreFromServer: Button
|
||||
private lateinit var buttonImportMarkdown: Button
|
||||
private lateinit var textViewServerStatus: TextView
|
||||
|
||||
// Protocol Selection UI
|
||||
@@ -73,6 +80,22 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var cardDeveloperProfile: MaterialCardView
|
||||
private lateinit var cardLicense: MaterialCardView
|
||||
|
||||
// Backup Manager
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
// Activity Result Launchers
|
||||
private val createBackupLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let { createBackup(it) }
|
||||
}
|
||||
|
||||
private val restoreBackupLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
) { uri ->
|
||||
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
|
||||
}
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
||||
}
|
||||
@@ -106,9 +129,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
editTextUsername = findViewById(R.id.editTextUsername)
|
||||
editTextPassword = findViewById(R.id.editTextPassword)
|
||||
switchAutoSync = findViewById(R.id.switchAutoSync)
|
||||
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
|
||||
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
||||
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
||||
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
|
||||
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
|
||||
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
||||
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
|
||||
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
||||
|
||||
// Protocol Selection UI
|
||||
@@ -152,6 +179,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
||||
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
||||
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
|
||||
|
||||
// Update hint text based on selected protocol
|
||||
updateProtocolHint()
|
||||
@@ -223,15 +251,36 @@ class SettingsActivity : AppCompatActivity() {
|
||||
syncNow()
|
||||
}
|
||||
|
||||
buttonCreateBackup.setOnClickListener {
|
||||
// Dateiname mit Timestamp
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
.format(java.util.Date())
|
||||
val filename = "simplenotes_backup_$timestamp.json"
|
||||
createBackupLauncher.launch(filename)
|
||||
}
|
||||
|
||||
buttonRestoreFromFile.setOnClickListener {
|
||||
restoreBackupLauncher.launch(arrayOf("application/json"))
|
||||
}
|
||||
|
||||
buttonRestoreFromServer.setOnClickListener {
|
||||
saveSettings()
|
||||
showRestoreConfirmation()
|
||||
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
|
||||
}
|
||||
|
||||
buttonImportMarkdown.setOnClickListener {
|
||||
saveSettings()
|
||||
importMarkdownChanges()
|
||||
}
|
||||
|
||||
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
||||
onAutoSyncToggled(isChecked)
|
||||
}
|
||||
|
||||
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
|
||||
onMarkdownExportToggled(isChecked)
|
||||
}
|
||||
|
||||
// Clear error when user starts typing again
|
||||
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
@@ -498,6 +547,67 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMarkdownExportToggled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
|
||||
|
||||
if (enabled) {
|
||||
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
|
||||
} else {
|
||||
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun importMarkdownChanges() {
|
||||
// Prüfen ob Server konfiguriert ist
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||
|
||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
showToast("Bitte zuerst WebDAV-Server konfigurieren")
|
||||
return
|
||||
}
|
||||
|
||||
// Import-Dialog mit Warnung
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Markdown-Import")
|
||||
.setMessage(
|
||||
"Importiert Änderungen aus .md-Dateien vom Server.\n\n" +
|
||||
"⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
|
||||
"Fortfahren?"
|
||||
)
|
||||
.setPositiveButton("Importieren") { _, _ ->
|
||||
performMarkdownImport(serverUrl, username, password)
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
|
||||
showToast("Importiere Markdown-Dateien...")
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||
val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (importCount > 0) {
|
||||
showToast("$importCount Notizen aus Markdown importiert")
|
||||
// Benachrichtige MainActivity zum Neuladen
|
||||
sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
|
||||
} else {
|
||||
showToast("Keine Markdown-Änderungen gefunden")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
showToast("Import-Fehler: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val packageName = packageName
|
||||
@@ -612,4 +722,231 @@ class SettingsActivity : AppCompatActivity() {
|
||||
super.onPause()
|
||||
saveSettings()
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// BACKUP & RESTORE FUNCTIONS (v1.2.0)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Restore-Quelle (Lokale Datei oder WebDAV Server)
|
||||
*/
|
||||
private enum class RestoreSource {
|
||||
LOCAL_FILE,
|
||||
WEBDAV_SERVER
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Backup (Task #1.2.0-04)
|
||||
*/
|
||||
private fun createBackup(uri: Uri) {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
Logger.d(TAG, "📦 Creating backup...")
|
||||
val result = backupManager.createBackup(uri)
|
||||
|
||||
if (result.success) {
|
||||
showToast("✅ ${result.message}")
|
||||
} else {
|
||||
showErrorDialog("Backup fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create backup", e)
|
||||
showErrorDialog("Backup fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Universeller Restore-Dialog für beide Quellen (Task #1.2.0-05 + #1.2.0-05b)
|
||||
*
|
||||
* @param source Lokale Datei oder WebDAV Server
|
||||
* @param fileUri URI der lokalen Datei (nur für LOCAL_FILE)
|
||||
*/
|
||||
private fun showRestoreDialog(source: RestoreSource, fileUri: Uri?) {
|
||||
val sourceText = when (source) {
|
||||
RestoreSource.LOCAL_FILE -> "Lokale Datei"
|
||||
RestoreSource.WEBDAV_SERVER -> "WebDAV Server"
|
||||
}
|
||||
|
||||
// Custom View mit Radio Buttons
|
||||
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
|
||||
val radioGroup = android.widget.RadioGroup(this).apply {
|
||||
orientation = android.widget.RadioGroup.VERTICAL
|
||||
setPadding(50, 20, 50, 20)
|
||||
}
|
||||
|
||||
// Radio Buttons erstellen
|
||||
val radioMerge = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
|
||||
id = 0
|
||||
isChecked = true
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioReplace = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
|
||||
id = 1
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioOverwrite = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
|
||||
id = 2
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
radioGroup.addView(radioMerge)
|
||||
radioGroup.addView(radioReplace)
|
||||
radioGroup.addView(radioOverwrite)
|
||||
|
||||
// Hauptlayout
|
||||
val mainLayout = android.widget.LinearLayout(this).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setPadding(50, 30, 50, 30)
|
||||
}
|
||||
|
||||
// Info Text
|
||||
val infoText = android.widget.TextView(this).apply {
|
||||
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
|
||||
textSize = 16f
|
||||
setPadding(0, 0, 0, 20)
|
||||
}
|
||||
|
||||
// Hinweis Text
|
||||
val hintText = android.widget.TextView(this).apply {
|
||||
text = "\nℹ️ Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
|
||||
textSize = 14f
|
||||
setTypeface(null, android.graphics.Typeface.ITALIC)
|
||||
setPadding(0, 20, 0, 0)
|
||||
}
|
||||
|
||||
mainLayout.addView(infoText)
|
||||
mainLayout.addView(radioGroup)
|
||||
mainLayout.addView(hintText)
|
||||
|
||||
// Dialog erstellen
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("⚠️ Backup wiederherstellen?")
|
||||
.setView(mainLayout)
|
||||
.setPositiveButton("Wiederherstellen") { _, _ ->
|
||||
val selectedMode = when (radioGroup.checkedRadioButtonId) {
|
||||
1 -> RestoreMode.REPLACE
|
||||
2 -> RestoreMode.OVERWRITE_DUPLICATES
|
||||
else -> RestoreMode.MERGE
|
||||
}
|
||||
|
||||
when (source) {
|
||||
RestoreSource.LOCAL_FILE -> fileUri?.let { performRestoreFromFile(it, selectedMode) }
|
||||
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
|
||||
}
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Restore aus lokaler Datei durch (Task #1.2.0-05)
|
||||
*/
|
||||
private fun performRestoreFromFile(uri: Uri, mode: RestoreMode) {
|
||||
lifecycleScope.launch {
|
||||
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
|
||||
setMessage("Wiederherstellen...")
|
||||
setCancelable(false)
|
||||
show()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.d(TAG, "📥 Restoring from file: $uri (mode: $mode)")
|
||||
val result = backupManager.restoreBackup(uri, mode)
|
||||
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (result.success) {
|
||||
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen"
|
||||
showToast("✅ $message")
|
||||
|
||||
// Refresh MainActivity's note list
|
||||
setResult(RESULT_OK)
|
||||
broadcastNotesChanged()
|
||||
} else {
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
Logger.e(TAG, "Failed to restore from file", e)
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt Restore vom Server durch (Task #1.2.0-05b)
|
||||
* Nutzt neues universelles Dialog-System mit Restore-Modi
|
||||
*
|
||||
* HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
|
||||
* unterstützt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
|
||||
* TODO: WebDavSyncService.restoreFromServer() erweitern für v1.2.1+
|
||||
*/
|
||||
private fun performRestoreFromServer(mode: RestoreMode) {
|
||||
lifecycleScope.launch {
|
||||
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
|
||||
setMessage("Wiederherstellen vom Server...")
|
||||
setCancelable(false)
|
||||
show()
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
|
||||
Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
|
||||
|
||||
// Auto-Backup erstellen (Sicherheitsnetz)
|
||||
val autoBackupUri = backupManager.createAutoBackup()
|
||||
if (autoBackupUri == null) {
|
||||
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||
}
|
||||
|
||||
// Server-Restore durchführen
|
||||
val webdavService = WebDavSyncService(this@SettingsActivity)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
// Nutzt alte Funktion (immer REPLACE)
|
||||
webdavService.restoreFromServer()
|
||||
}
|
||||
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
|
||||
setResult(RESULT_OK)
|
||||
broadcastNotesChanged()
|
||||
} else {
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
progressDialog.dismiss()
|
||||
Logger.e(TAG, "Failed to restore from server", e)
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet Broadcast dass Notizen geändert wurden
|
||||
*/
|
||||
private fun broadcastNotesChanged() {
|
||||
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
intent.putExtra("success", true)
|
||||
intent.putExtra("syncedCount", 0)
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Error-Dialog an
|
||||
*/
|
||||
private fun showErrorDialog(title: String, message: String) {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package dev.dettmer.simplenotes.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||
*
|
||||
* Features:
|
||||
* - Backup aller Notizen in JSON-Datei
|
||||
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
||||
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||
* - Backup-Validierung
|
||||
*/
|
||||
class BackupManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BackupManager"
|
||||
private const val BACKUP_VERSION = 1
|
||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
|
||||
/**
|
||||
* Erstellt Backup aller Notizen
|
||||
*
|
||||
* @param uri Output-URI (via Storage Access Framework)
|
||||
* @return BackupResult mit Erfolg/Fehler Info
|
||||
*/
|
||||
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📦 Creating backup to: $uri")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
val jsonString = gson.toJson(backupData)
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(jsonString.toByteArray())
|
||||
Logger.d(TAG, "✅ Backup created successfully")
|
||||
}
|
||||
|
||||
BackupResult(
|
||||
success = true,
|
||||
notes_count = allNotes.size,
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create backup", e)
|
||||
BackupResult(
|
||||
success = false,
|
||||
error = "Backup fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt automatisches Backup (vor Restore)
|
||||
* Gespeichert in app-internem Storage
|
||||
*
|
||||
* @return Uri des Auto-Backups oder null bei Fehler
|
||||
*/
|
||||
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
.format(Date())
|
||||
val filename = "auto_backup_before_restore_$timestamp.json"
|
||||
val file = File(autoBackupDir, filename)
|
||||
|
||||
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
file.writeText(gson.toJson(backupData))
|
||||
|
||||
// Cleanup alte Auto-Backups
|
||||
cleanupOldAutoBackups(autoBackupDir)
|
||||
|
||||
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
||||
Uri.fromFile(file)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to create auto-backup", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt Notizen aus Backup wieder her
|
||||
*
|
||||
* @param uri Backup-Datei URI
|
||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||
* @return RestoreResult mit Details
|
||||
*/
|
||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||
|
||||
// 1. Backup-Datei lesen
|
||||
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
} ?: return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Datei konnte nicht gelesen werden"
|
||||
)
|
||||
|
||||
// 2. Backup validieren & parsen
|
||||
val validationResult = validateBackup(jsonString)
|
||||
if (!validationResult.isValid) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
|
||||
)
|
||||
}
|
||||
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}")
|
||||
|
||||
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||
val autoBackupUri = createAutoBackup()
|
||||
if (autoBackupUri == null) {
|
||||
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||
}
|
||||
|
||||
// 4. Restore durchführen (je nach Modus)
|
||||
val result = when (mode) {
|
||||
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
||||
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
||||
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped")
|
||||
result
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to restore backup", e)
|
||||
RestoreResult(
|
||||
success = false,
|
||||
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert Backup-Datei
|
||||
*/
|
||||
private fun validateBackup(jsonString: String): ValidationResult {
|
||||
return try {
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
|
||||
// Version kompatibel?
|
||||
if (backupData.backup_version > BACKUP_VERSION) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)"
|
||||
)
|
||||
}
|
||||
|
||||
// Notizen-Array vorhanden?
|
||||
if (backupData.notes.isEmpty()) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup enthält keine Notizen"
|
||||
)
|
||||
}
|
||||
|
||||
// Alle Notizen haben ID, title, content?
|
||||
val invalidNotes = backupData.notes.filter { note ->
|
||||
note.id.isBlank() || note.title.isBlank()
|
||||
}
|
||||
|
||||
if (invalidNotes.isNotEmpty()) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
|
||||
)
|
||||
}
|
||||
|
||||
ValidationResult(isValid = true)
|
||||
|
||||
} catch (e: Exception) {
|
||||
ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: MERGE
|
||||
* Fügt neue Notizen hinzu, behält bestehende
|
||||
*/
|
||||
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||
val skippedNotes = backupNotes.size - newNotes.size
|
||||
|
||||
newNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = skippedNotes,
|
||||
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: REPLACE
|
||||
* Löscht alle bestehenden Notizen, importiert Backup
|
||||
*/
|
||||
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||
// Alle bestehenden Notizen löschen
|
||||
storage.deleteAllNotes()
|
||||
|
||||
// Backup-Notizen importieren
|
||||
backupNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = backupNotes.size,
|
||||
skipped_notes = 0,
|
||||
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||
* Backup überschreibt bei ID-Konflikten
|
||||
*/
|
||||
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||
val existingNotes = storage.loadAllNotes()
|
||||
val existingIds = existingNotes.map { it.id }.toSet()
|
||||
|
||||
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
||||
|
||||
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
||||
backupNotes.forEach { note ->
|
||||
storage.saveNote(note)
|
||||
}
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = 0,
|
||||
overwritten_notes = overwrittenNotes.size,
|
||||
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht Auto-Backups älter als RETENTION_DAYS
|
||||
*/
|
||||
private fun cleanupOldAutoBackups(autoBackupDir: File) {
|
||||
try {
|
||||
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
||||
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
||||
|
||||
autoBackupDir.listFiles()?.forEach { file ->
|
||||
if (file.lastModified() < cutoffTime) {
|
||||
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to cleanup old backups", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Daten Struktur (JSON)
|
||||
*/
|
||||
data class BackupData(
|
||||
val backup_version: Int,
|
||||
val created_at: Long,
|
||||
val notes_count: Int,
|
||||
val app_version: String,
|
||||
val notes: List<Note>
|
||||
)
|
||||
|
||||
/**
|
||||
* Wiederherstellungs-Modi
|
||||
*/
|
||||
enum class RestoreMode {
|
||||
MERGE, // Bestehende + Neue (Standard)
|
||||
REPLACE, // Alles löschen + Importieren
|
||||
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Ergebnis
|
||||
*/
|
||||
data class BackupResult(
|
||||
val success: Boolean,
|
||||
val notes_count: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Restore-Ergebnis
|
||||
*/
|
||||
data class RestoreResult(
|
||||
val success: Boolean,
|
||||
val imported_notes: Int = 0,
|
||||
val skipped_notes: Int = 0,
|
||||
val overwritten_notes: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Validierungs-Ergebnis
|
||||
*/
|
||||
data class ValidationResult(
|
||||
val isValid: Boolean,
|
||||
val errorMessage: String? = null
|
||||
)
|
||||
@@ -1,5 +1,9 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
|
||||
data class Note(
|
||||
@@ -25,6 +29,25 @@ data class Note(
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||
*/
|
||||
fun toMarkdown(): String {
|
||||
return """
|
||||
---
|
||||
id: $id
|
||||
created: ${formatISO8601(createdAt)}
|
||||
updated: ${formatISO8601(updatedAt)}
|
||||
device: $deviceId
|
||||
---
|
||||
|
||||
# $title
|
||||
|
||||
$content
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
@@ -34,6 +57,78 @@ data class Note(
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||
*
|
||||
* @param md Markdown-String mit YAML Frontmatter
|
||||
* @return Note-Objekt oder null bei Parse-Fehler
|
||||
*/
|
||||
fun fromMarkdown(md: String): Note? {
|
||||
return try {
|
||||
// Parse YAML Frontmatter + Markdown Content
|
||||
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||
val match = frontmatterRegex.find(md) ?: return null
|
||||
|
||||
val yamlBlock = match.groupValues[1]
|
||||
val contentBlock = match.groupValues[2]
|
||||
|
||||
// Parse YAML (einfach per String-Split für MVP)
|
||||
val metadata = yamlBlock.lines()
|
||||
.mapNotNull { line ->
|
||||
val parts = line.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
parts[0].trim() to parts[1].trim()
|
||||
} else null
|
||||
}.toMap()
|
||||
|
||||
// Extract title from first # heading
|
||||
val title = contentBlock.lines()
|
||||
.firstOrNull { it.startsWith("# ") }
|
||||
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||
|
||||
// Extract content (everything after heading)
|
||||
val content = contentBlock
|
||||
.substringAfter("# $title\n\n", "")
|
||||
.trim()
|
||||
|
||||
Note(
|
||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||
title = title,
|
||||
content = content,
|
||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
||||
deviceId = metadata["device"] ?: "desktop",
|
||||
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
|
||||
* Format: 2024-12-21T18:00:00Z (UTC)
|
||||
*/
|
||||
private fun formatISO8601(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
|
||||
/**
|
||||
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
|
||||
* Fallback: Aktueller Timestamp bei Fehler
|
||||
*/
|
||||
private fun parseISO8601(dateString: String): Long {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
System.currentTimeMillis() // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -444,9 +444,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||
var uploadedCount = 0
|
||||
val localNotes = storage.loadAllNotes()
|
||||
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
||||
|
||||
for (note in localNotes) {
|
||||
try {
|
||||
// 1. JSON-Upload (bestehend, unverändert)
|
||||
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
||||
val noteUrl = "$serverUrl/${note.id}.json"
|
||||
val jsonBytes = note.toJson().toByteArray()
|
||||
@@ -457,6 +459,18 @@ class WebDavSyncService(private val context: Context) {
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||
storage.saveNote(updatedNote)
|
||||
uploadedCount++
|
||||
|
||||
// 2. Markdown-Export (NEU in v1.2.0)
|
||||
// Läuft NACH erfolgreichem JSON-Upload
|
||||
if (markdownExportEnabled) {
|
||||
try {
|
||||
exportToMarkdown(sardine, serverUrl, note)
|
||||
Logger.d(TAG, " 📝 MD exported: ${note.title}")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
|
||||
// Kein throw! JSON-Sync darf nicht blockiert werden
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Mark as pending for retry
|
||||
@@ -468,6 +482,49 @@ class WebDavSyncService(private val context: Context) {
|
||||
return uploadedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
||||
*
|
||||
* @param sardine Sardine-Client
|
||||
* @param serverUrl Server-URL (notes/ Ordner)
|
||||
* @param note Note zum Exportieren
|
||||
*/
|
||||
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
|
||||
val mdUrl = serverUrl.replace("/notes", "/notes-md")
|
||||
|
||||
// Erstelle notes-md/ Ordner falls nicht vorhanden
|
||||
if (!sardine.exists(mdUrl)) {
|
||||
sardine.createDirectory(mdUrl)
|
||||
Logger.d(TAG, "📁 Created notes-md/ directory")
|
||||
}
|
||||
|
||||
// Sanitize Filename (Task #1.2.0-12)
|
||||
val filename = sanitizeFilename(note.title) + ".md"
|
||||
val noteUrl = "$mdUrl/$filename"
|
||||
|
||||
// Konvertiere zu Markdown
|
||||
val mdContent = note.toMarkdown().toByteArray()
|
||||
|
||||
// Upload
|
||||
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Filename für sichere Dateinamen (Task #1.2.0-12)
|
||||
*
|
||||
* Entfernt Windows/Linux-verbotene Zeichen, begrenzt Länge
|
||||
*
|
||||
* @param title Original-Titel
|
||||
* @return Sicherer Filename
|
||||
*/
|
||||
private fun sanitizeFilename(title: String): String {
|
||||
return title
|
||||
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
|
||||
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace
|
||||
.take(200) // Max 200 Zeichen (Reserve für .md)
|
||||
.trim('_', ' ') // Trim Underscores/Spaces
|
||||
}
|
||||
|
||||
private data class DownloadResult(
|
||||
val downloadedCount: Int,
|
||||
val conflictCount: Int
|
||||
@@ -618,6 +675,86 @@ class WebDavSyncService(private val context: Context) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronisiert Markdown-Dateien (Import von Desktop-Programmen) (Task #1.2.0-14)
|
||||
*
|
||||
* Last-Write-Wins Konfliktauflösung basierend auf updatedAt Timestamp
|
||||
*
|
||||
* @param serverUrl WebDAV Server-URL (notes/ Ordner)
|
||||
* @param username WebDAV Username
|
||||
* @param password WebDAV Password
|
||||
* @return Anzahl importierter Notizen
|
||||
*/
|
||||
suspend fun syncMarkdownFiles(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
password: String
|
||||
): Int = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📝 Starting Markdown sync...")
|
||||
|
||||
val sardine = OkHttpSardine()
|
||||
sardine.setCredentials(username, password)
|
||||
|
||||
val mdUrl = serverUrl.replace("/notes", "/notes-md")
|
||||
|
||||
// Check if notes-md/ exists
|
||||
if (!sardine.exists(mdUrl)) {
|
||||
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
||||
return@withContext 0
|
||||
}
|
||||
|
||||
val localNotes = storage.loadAllNotes()
|
||||
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
|
||||
var importedCount = 0
|
||||
|
||||
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
|
||||
|
||||
for (resource in mdResources) {
|
||||
try {
|
||||
// Download MD-File
|
||||
val mdContent = sardine.get(resource.href.toString())
|
||||
.bufferedReader().use { it.readText() }
|
||||
|
||||
// Parse zu Note
|
||||
val mdNote = Note.fromMarkdown(mdContent) ?: continue
|
||||
|
||||
val localNote = localNotes.find { it.id == mdNote.id }
|
||||
|
||||
// Konfliktauflösung: Last-Write-Wins
|
||||
when {
|
||||
localNote == null -> {
|
||||
// Neue Notiz vom Desktop
|
||||
storage.saveNote(mdNote)
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
|
||||
}
|
||||
mdNote.updatedAt > localNote.updatedAt -> {
|
||||
// Desktop-Version ist neuer (Last-Write-Wins)
|
||||
storage.saveNote(mdNote)
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
|
||||
}
|
||||
// Sonst: Lokale Version behalten
|
||||
else -> {
|
||||
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to import ${resource.name}", e)
|
||||
// Continue with other files
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
|
||||
importedCount
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Markdown sync failed", e)
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RestoreResult(
|
||||
|
||||
@@ -19,6 +19,10 @@ object Constants {
|
||||
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
||||
|
||||
// 🔥 v1.2.0: Markdown Export/Import
|
||||
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
|
||||
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
|
||||
|
||||
// WorkManager
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
@@ -387,6 +387,92 @@
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Material 3 Card: Markdown Desktop-Integration (v1.2.0) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Section Header -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Markdown Desktop-Integration"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Info Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="ℹ️ Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnPrimaryContainer"
|
||||
android:lineSpacingMultiplier="1.3" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Markdown Export Toggle -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="📝 Markdown Export (Desktop-Zugriff)"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switchMarkdownExport"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:checked="true" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Import Markdown Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonImportMarkdown"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📥 Markdown-Änderungen importieren"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Import Info Text -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Material 3 Card: Backup & Restore -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
@@ -409,12 +495,12 @@
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Warning Info Card -->
|
||||
<!-- Info Card (anstatt Warning) -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
@@ -422,19 +508,61 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:text="@string/backup_restore_warning"
|
||||
android:text="ℹ️ Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnErrorContainer"
|
||||
android:textColor="?attr/colorOnPrimaryContainer"
|
||||
android:lineSpacingMultiplier="1.3" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Restore Button -->
|
||||
<!-- Lokales Backup Sektion -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Lokales Backup"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Backup erstellen Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonCreateBackup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📥 Backup erstellen"
|
||||
android:layout_marginBottom="8dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Aus Datei wiederherstellen Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonRestoreFromFile"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📤 Aus Datei wiederherstellen"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
<!-- Divider -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?attr/colorOutline"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Server-Backup Sektion -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server-Backup"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Vom Server wiederherstellen Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonRestoreFromServer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/restore_from_server"
|
||||
android:text="🔄 Vom Server wiederherstellen"
|
||||
style="@style/Widget.Material3.Button.TonalButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 134 KiB |
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:
|
||||
- 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
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 1.1.1
|
||||
CurrentVersionCode: 3
|
||||
CurrentVersion: 1.2.0
|
||||
CurrentVersionCode: 5
|
||||
|
||||
Reference in New Issue
Block a user