🐛 v1.2.1: Markdown Initial Export Bugfix + URL Normalization + GitHub Workflow Fix

## 🐛 Fixed
- Initial Markdown export: Existing notes now exported when Desktop Integration activated
- Markdown directory structure: Files now land correctly in /notes-md/
- JSON URL normalization: Smart detection for both Root-URL and /notes-URL
- GitHub release notes: Fixed language order (DE primary, EN collapsible) and emoji

##  Improved
- Settings UI: Example URL shows /notes instead of /webdav
- Server config: Enter only base URL (app adds /notes/ and /notes-md/ automatically)
- Flexible URL input: Both http://server/ and http://server/notes/ work
- Changelogs: Shortened for F-Droid 500 char limit

## 🔧 Technical
- getNotesUrl() helper with smart /notes/ detection
- getMarkdownUrl() simplified to use getNotesUrl()
- All JSON operations updated to use normalized URLs
- exportAllNotesToMarkdown() with progress callback
- Workflow: Swapped CHANGELOG_DE/EN, replaced broken emoji with 🌍

versionCode: 6
versionName: 1.2.1
This commit is contained in:
inventory69
2026-01-05 11:46:25 +01:00
parent 6d135e8f0d
commit 015b90d56e
18 changed files with 2583 additions and 324 deletions

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes
import android.app.ProgressDialog
import android.content.Context
import android.content.Intent
import android.net.Uri
@@ -548,11 +549,83 @@ 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")
// Initial-Export wenn Feature aktiviert wird
lifecycleScope.launch {
try {
val noteStorage = dev.dettmer.simplenotes.storage.NotesStorage(this@SettingsActivity)
val currentNoteCount = noteStorage.loadAllNotes().size
if (currentNoteCount > 0) {
// Zeige Progress-Dialog
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Export")
setMessage("Exportiere Notizen nach Markdown...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
max = currentNoteCount
progress = 0
setCancelable(false)
show()
}
try {
// Hole Server-Daten
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()) {
progressDialog.dismiss()
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
switchMarkdownExport.isChecked = false
return@launch
}
// Führe Initial-Export aus
val syncService = WebDavSyncService(this@SettingsActivity)
val exportedCount = syncService.exportAllNotesToMarkdown(
serverUrl = serverUrl,
username = username,
password = password,
onProgress = { current, total ->
runOnUiThread {
progressDialog.progress = current
progressDialog.setMessage("Exportiere $current/$total Notizen...")
}
}
)
progressDialog.dismiss()
// Speichere Einstellung
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
// Erfolgs-Nachricht
showToast("$exportedCount Notizen nach Markdown exportiert")
} catch (e: Exception) {
progressDialog.dismiss()
showToast("❌ Export fehlgeschlagen: ${e.message}")
// Deaktiviere Toggle bei Fehler
switchMarkdownExport.isChecked = false
return@launch
}
} else {
// Keine Notizen vorhanden - speichere Einstellung direkt
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
}
} catch (e: Exception) {
Logger.e(TAG, "Error toggling markdown export: ${e.message}")
showToast("Fehler: ${e.message}")
switchMarkdownExport.isChecked = false
}
}
} else {
// Deaktivieren - nur Setting speichern
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
}
}

View File

@@ -31,6 +31,7 @@ class WebDavSyncService(private val context: Context) {
private val storage: NotesStorage
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
private var markdownDirEnsured = false // Cache für Ordner-Existenz
init {
if (BuildConfig.DEBUG) {
@@ -189,6 +190,72 @@ class WebDavSyncService(private val context: Context) {
return prefs.getString(Constants.KEY_SERVER_URL, null)
}
/**
* Erzeugt notes/ URL aus Base-URL mit Smart Detection (Task #1.2.1-12)
*
* Beispiele:
* - http://server:8080/ → http://server:8080/notes/
* - http://server:8080/notes/ → http://server:8080/notes/
* - http://server:8080/notes → http://server:8080/notes/
* - http://server:8080/my-path/ → http://server:8080/my-path/notes/
*
* @param baseUrl Base Server-URL
* @return notes/ Ordner-URL (mit trailing /)
*/
private fun getNotesUrl(baseUrl: String): String {
val normalized = baseUrl.trimEnd('/')
// Wenn URL bereits mit /notes endet → direkt nutzen
return if (normalized.endsWith("/notes")) {
"$normalized/"
} else {
"$normalized/notes/"
}
}
/**
* Erzeugt Markdown-Ordner-URL basierend auf getNotesUrl() (Task #1.2.1-14)
*
* Beispiele:
* - http://server:8080/ → http://server:8080/notes-md/
* - http://server:8080/notes/ → http://server:8080/notes-md/
* - http://server:8080/notes → http://server:8080/notes-md/
*
* @param baseUrl Base Server-URL
* @return Markdown-Ordner-URL (mit trailing /)
*/
private fun getMarkdownUrl(baseUrl: String): String {
val notesUrl = getNotesUrl(baseUrl)
val normalized = notesUrl.trimEnd('/')
// Ersetze /notes mit /notes-md
return normalized.replace("/notes", "/notes-md") + "/"
}
/**
* Stellt sicher dass notes-md/ Ordner existiert
*
* Wird beim ersten erfolgreichen Sync aufgerufen (unabhängig von MD-Feature).
* Cached in Memory - nur einmal pro App-Session.
*/
private fun ensureMarkdownDirectoryExists(sardine: Sardine, serverUrl: String) {
if (markdownDirEnsured) return
try {
val mdUrl = getMarkdownUrl(serverUrl)
if (!sardine.exists(mdUrl)) {
sardine.createDirectory(mdUrl)
Logger.d(TAG, "📁 Created notes-md/ directory (for future use)")
}
markdownDirEnsured = true
} catch (e: Exception) {
Logger.e(TAG, "Failed to create notes-md/: ${e.message}")
// Nicht kritisch - User kann später manuell erstellen
}
}
/**
* Prüft ob lokale Änderungen seit letztem Sync vorhanden sind (v1.1.2)
* Performance-Optimierung: Vermeidet unnötige Sync-Operationen
@@ -350,20 +417,24 @@ class WebDavSyncService(private val context: Context) {
var conflictCount = 0
Logger.d(TAG, "📍 Step 3: Checking server directory")
// Ensure server directory exists
// Ensure notes/ directory exists
val notesUrl = getNotesUrl(serverUrl)
try {
Logger.d(TAG, "🔍 Checking if server directory exists...")
if (!sardine.exists(serverUrl)) {
Logger.d(TAG, "📁 Creating server directory...")
sardine.createDirectory(serverUrl)
Logger.d(TAG, "🔍 Checking if notes/ directory exists...")
if (!sardine.exists(notesUrl)) {
Logger.d(TAG, "📁 Creating notes/ directory...")
sardine.createDirectory(notesUrl)
}
Logger.d(TAG, "Server directory ready")
Logger.d(TAG, "notes/ directory ready")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH checking/creating server directory!", e)
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
e.printStackTrace()
throw e
}
// Ensure notes-md/ directory exists (for Markdown export)
ensureMarkdownDirectoryExists(sardine, serverUrl)
Logger.d(TAG, "📍 Step 4: Uploading local notes")
// Upload local notes
try {
@@ -448,9 +519,10 @@ class WebDavSyncService(private val context: Context) {
for (note in localNotes) {
try {
// 1. JSON-Upload (bestehend, unverändert)
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
val noteUrl = "$serverUrl/${note.id}.json"
val notesUrl = getNotesUrl(serverUrl)
val noteUrl = "$notesUrl${note.id}.json"
val jsonBytes = note.toJson().toByteArray()
sardine.put(noteUrl, jsonBytes, "application/json")
@@ -490,7 +562,7 @@ class WebDavSyncService(private val context: Context) {
* @param note Note zum Exportieren
*/
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
val mdUrl = serverUrl.replace("/notes", "/notes-md")
val mdUrl = getMarkdownUrl(serverUrl)
// Erstelle notes-md/ Ordner falls nicht vorhanden
if (!sardine.exists(mdUrl)) {
@@ -525,6 +597,79 @@ class WebDavSyncService(private val context: Context) {
.trim('_', ' ') // Trim Underscores/Spaces
}
/**
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
*
* Wird beim ersten Aktivieren der Desktop-Integration aufgerufen.
* Exportiert auch bereits synchronisierte Notizen.
*
* @return Anzahl exportierter Notizen
*/
suspend fun exportAllNotesToMarkdown(
serverUrl: String,
username: String,
password: String,
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }
): Int = withContext(Dispatchers.IO) {
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
// Erstelle Sardine-Client mit gegebenen Credentials
val wifiAddress = getWiFiInetAddress()
val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
OkHttpClient.Builder()
.socketFactory(WiFiSocketFactory(wifiAddress))
.build()
} else {
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
OkHttpClient.Builder().build()
}
val sardine = OkHttpSardine(okHttpClient).apply {
setCredentials(username, password)
}
val mdUrl = getMarkdownUrl(serverUrl)
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
ensureMarkdownDirectoryExists(sardine, serverUrl)
// Hole ALLE lokalen Notizen (inklusive SYNCED)
val allNotes = storage.loadAllNotes()
val totalCount = allNotes.size
var exportedCount = 0
Logger.d(TAG, "📝 Found $totalCount notes to export")
allNotes.forEachIndexed { index, note ->
try {
// Progress-Callback
onProgress(index + 1, totalCount)
// Sanitize Filename
val filename = sanitizeFilename(note.title) + ".md"
val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray()
// Upload (überschreibt falls vorhanden)
sardine.put(noteUrl, mdContent, "text/markdown")
exportedCount++
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}")
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
// Continue mit nächster Note (keine Abbruch bei Einzelfehlern)
}
}
Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes")
return@withContext exportedCount
}
private data class DownloadResult(
val downloadedCount: Int,
val conflictCount: Int
@@ -535,7 +680,8 @@ class WebDavSyncService(private val context: Context) {
var conflictCount = 0
try {
val resources = sardine.list(serverUrl)
val notesUrl = getNotesUrl(serverUrl)
val resources = sardine.list(notesUrl)
for (resource in resources) {
if (resource.isDirectory || !resource.name.endsWith(".json")) {
@@ -611,8 +757,10 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "🔄 Starting restore from server...")
val notesUrl = getNotesUrl(serverUrl)
// List all files on server
val resources = sardine.list(serverUrl)
val resources = sardine.list(notesUrl)
val jsonFiles = resources.filter {
!it.isDirectory && it.name.endsWith(".json")
}
@@ -624,7 +772,7 @@ class WebDavSyncService(private val context: Context) {
// Download and parse each file
for (resource in jsonFiles) {
try {
val fileUrl = serverUrl.trimEnd('/') + "/" + resource.name
val fileUrl = notesUrl.trimEnd('/') + "/" + resource.name
val content = sardine.get(fileUrl).bufferedReader().use { it.readText() }
val note = Note.fromJson(content)
@@ -697,7 +845,7 @@ class WebDavSyncService(private val context: Context) {
val sardine = OkHttpSardine()
sardine.setCredentials(username, password)
val mdUrl = serverUrl.replace("/notes", "/notes-md")
val mdUrl = getMarkdownUrl(serverUrl)
// Check if notes-md/ exists
if (!sardine.exists(mdUrl)) {

View File

@@ -109,7 +109,7 @@
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:startIconDrawable="@android:drawable/ic_menu_compass"
app:endIconMode="clear_text"
app:helperText="z.B. http://192.168.0.188:8080/webdav"
app:helperText="z.B. http://192.168.0.188:8080/notes"
app:helperTextEnabled="true"
app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp"