Skip to content

Commit 4a57bc1

Browse files
committed
release(v2.7.0): merge feature/folders-v2.7.0 into main
2 parents 1ff0b07 + ed36a5d commit 4a57bc1

44 files changed

Lines changed: 2890 additions & 408 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.de.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88

99
---
1010

11+
## [2.7.0] - 2026-05-30
12+
13+
### ✨ Neue Features
14+
15+
**Ordner-Unterstützung** ([e38553d](https://github.com/inventory69/simple-notes-sync/commit/e38553d))
16+
- Notizen können in Ordner organisiert werden; jede Notiz trägt ein optionales `folderName`-Feld
17+
- Ordner erstellen, umbenennen und löschen über dedizierte CRUD-Dialoge
18+
- Notizen per Bottom-Sheet in der Notizliste in Ordner verschieben
19+
- Ordner-Navigation in der Hauptansicht filtert die Liste nach Ordner
20+
- Ordner werden mit Tombstone-basiertem Lösch-Tracking persistiert (`FolderStore`)
21+
- Ordner synchronisieren bidirektional via WebDAV mit ordner-bewusstem URL-Routing (`FolderSyncManager`)
22+
- Danke an [@happy-turtle](https://github.com/happy-turtle) und [@racehd](https://github.com/racehd) für die Idee, und an [@afoni95](https://github.com/afoni95) fürs Testen!
23+
24+
### 🐛 Bug-Fixes
25+
26+
**Raster & Angepinnte Notizen – Polishing** ([1e02895](https://github.com/inventory69/simple-notes-sync/commit/1e02895))
27+
- Pop-in-Artefakt beim Erscheinen angepinnter Notizen im gestaffelten Raster behoben
28+
- Scroll-Position wird beim Filterwechsel wieder auf den Anfang zurückgesetzt ([2375c97](https://github.com/inventory69/simple-notes-sync/commit/2375c97))
29+
- Berechnungen der gestaffelten Raster-Aufteilung gecacht, weniger Layout-Stress ([dc117ba](https://github.com/inventory69/simple-notes-sync/commit/dc117ba))
30+
- Visuellen Glitch beim GridColumnChip in der Spaltenauswahl der Anzeigeeinstellungen behoben ([27a7c0a](https://github.com/inventory69/simple-notes-sync/commit/27a7c0a))
31+
32+
### 🌍 Übersetzungen
33+
34+
- **Spanisch** (neu): [08f0948](https://github.com/inventory69/simple-notes-sync/commit/08f0948)
35+
36+
---
37+
1138
## [2.6.0] - 2026-05-24
1239

1340
### ✨ Neue Features

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
88

99
---
1010

11+
## [2.7.0] - 2026-05-30
12+
13+
### ✨ New Features
14+
15+
**Folder Support** ([e38553d](https://github.com/inventory69/simple-notes-sync/commit/e38553d))
16+
- Notes can be organized into folders; each note carries an optional `folderName` field
17+
- Create, rename, and delete folders via dedicated CRUD dialogs
18+
- Move notes to a folder from a bottom sheet accessible in the note list
19+
- Folder navigation in the main screen filters the list by folder
20+
- Folders are persisted with tombstone-based deletion tracking (`FolderStore`)
21+
- Folders sync bidirectionally via WebDAV using folder-aware URL routing (`FolderSyncManager`)
22+
- Thanks to [@happy-turtle](https://github.com/happy-turtle) and [@racehd](https://github.com/racehd) for the idea, and [@afoni95](https://github.com/afoni95) for testing!
23+
24+
### 🐛 Bug Fixes
25+
26+
**Grid & Pinned Notes Polish** ([1e02895](https://github.com/inventory69/simple-notes-sync/commit/1e02895))
27+
- Fixed pop-in artifact when pinned notes appeared in staggered grid
28+
- Grid scroll now resets to top when the active filter changes ([2375c97](https://github.com/inventory69/simple-notes-sync/commit/2375c97))
29+
- Staggered grid split calculations memoized, reducing layout churn ([dc117ba](https://github.com/inventory69/simple-notes-sync/commit/dc117ba))
30+
- Fixed visual glitch in GridColumnChip in the display settings column picker ([27a7c0a](https://github.com/inventory69/simple-notes-sync/commit/27a7c0a))
31+
32+
### 🌍 Translations
33+
34+
- **Spanish** (new): [08f0948](https://github.com/inventory69/simple-notes-sync/commit/08f0948)
35+
36+
---
37+
1138
## [2.6.0] - 2026-05-24
1239

1340
### ✨ New Features

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,6 @@ GNU Affero General Public License v3.0 - see [LICENSE](LICENSE)
173173
<div align="center">
174174
<br /><br />
175175

176-
**v2.6.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
176+
**v2.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
177177

178178
</div>

android/app/build.gradle.kts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ android {
2020
applicationId = "dev.dettmer.simplenotes"
2121
minSdk = 24
2222
targetSdk = 36
23-
versionCode = 37 // 🆕 v2.6.0 - pinned notes, editor upgrades, checklist fixes
24-
versionName = "2.6.0" // 🆕 v2.6.0 - pinned notes, editor upgrades, checklist fixes
23+
versionCode = 38 // 🆕 v2.7.0 - folder support, Spanish locale
24+
versionName = "2.7.0" // 🆕 v2.7.0 - folder support, Spanish locale
2525

2626
// APK-Size: nur tatsächlich gepflegte Locales ausliefern. AndroidX/Material/
2727
// Compose schleppen sonst ~70+ Sprachvarianten in resources.arsc mit. Geräte
2828
// mit nicht gelisteten Locales fallen wie gewohnt auf den Default (en) zurück.
2929
// Liste muss synchron zu app/src/main/res/values-* gehalten werden.
3030
androidResources {
3131
localeFilters += listOf(
32-
"en", "de", "hi", "in", "it", "nb-rNO", "ru", "tr", "uk", "zh-rCN",
32+
"en", "de", "es", "hi", "in", "it", "nb-rNO", "ru", "tr", "uk", "zh-rCN",
3333
)
3434
}
3535

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package dev.dettmer.simplenotes.models
2+
3+
/** 🆕 v2.7.0 (Folders): UI-Repräsentation eines Ordners (Name + optionale Farbe). */
4+
data class Folder(
5+
val name: String,
6+
val color: String? = null // canonical "#RRGGBB" wie Note.color; null = keine Farbe
7+
)

android/app/src/main/java/dev/dettmer/simplenotes/models/Note.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ data class Note(
4040
val color: String? = null,
4141
// 🆕 v2.5.0: Vorbereitung v2.6.0 Pin-Feature. null = nicht angepinnt.
4242
// UI-Inertheit v2.5.0: nur persistiert, NICHT gerendert (siehe Analyseplan §2.4.1).
43-
val isPinned: Boolean? = null
43+
val isPinned: Boolean? = null,
44+
// 🆕 v2.7.0 (Folders): Ordner-Zuordnung. null = Root, sonst Verzeichnisname (ohne "/").
45+
// Lokal flach gespeichert; auf dem Server ein echtes Subdirectory (siehe FolderStore/Sync).
46+
val folderName: String? = null
4447
) {
4548
/**
4649
* Serialisiert Note zu JSON
@@ -120,14 +123,15 @@ data class Note(
120123
.orEmpty()
121124
val colorLine = color?.let { "\ncolor: \"$it\"" }.orEmpty()
122125
val pinnedLine = isPinned?.let { "\npinned: $it" }.orEmpty()
126+
val folderLine = folderName?.let { "\nfolder: \"$it\"" }.orEmpty()
123127

124128
val header = """
125129
---
126130
id: $id
127131
created: ${formatISO8601(createdAt)}
128132
updated: ${formatISO8601(updatedAt)}
129133
device: $deviceId
130-
type: ${noteType.name.lowercase()}$sortLine$importedLine$labelsLine$colorLine$pinnedLine
134+
type: ${noteType.name.lowercase()}$sortLine$importedLine$labelsLine$colorLine$pinnedLine$folderLine
131135
---
132136
133137
# $title
@@ -263,7 +267,8 @@ type: ${noteType.name.lowercase()}$sortLine$importedLine$labelsLine$colorLine$pi
263267
importedAt = rawNote.importedAt,
264268
labels = rawNote.labels,
265269
color = rawNote.color,
266-
isPinned = rawNote.isPinned
270+
isPinned = rawNote.isPinned,
271+
folderName = rawNote.folderName
267272
)
268273
} catch (e: Exception) {
269274
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
@@ -286,7 +291,8 @@ type: ${noteType.name.lowercase()}$sortLine$importedLine$labelsLine$colorLine$pi
286291
val importedAt: Long? = null,
287292
val labels: List<String>? = null,
288293
val color: String? = null,
289-
val isPinned: Boolean? = null
294+
val isPinned: Boolean? = null,
295+
val folderName: String? = null
290296
)
291297

292298
/**
@@ -400,6 +406,9 @@ type: ${noteType.name.lowercase()}$sortLine$importedLine$labelsLine$colorLine$pi
400406
}
401407
}
402408
}
409+
// 🆕 v2.7.0 (Folders): optionaler Ordnername aus YAML.
410+
val folderName: String? = metadata["folder"]?.trim()?.removeSurrounding("\"")
411+
?.takeIf { it.isNotEmpty() }
403412

404413
// v1.4.0: Parse Content basierend auf Typ
405414
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
@@ -485,7 +494,8 @@ type: ${noteType.name.lowercase()}$sortLine$importedLine$labelsLine$colorLine$pi
485494
importedAt = importedAt,
486495
labels = labels,
487496
color = color,
488-
isPinned = isPinned
497+
isPinned = isPinned,
498+
folderName = folderName
489499
)
490500
} catch (e: Exception) {
491501
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package dev.dettmer.simplenotes.storage
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import androidx.core.content.edit
6+
import com.google.gson.Gson
7+
import com.google.gson.JsonParser
8+
import com.google.gson.annotations.SerializedName
9+
import com.google.gson.reflect.TypeToken
10+
import dev.dettmer.simplenotes.models.Folder
11+
import dev.dettmer.simplenotes.utils.Constants
12+
import dev.dettmer.simplenotes.utils.Logger
13+
import java.io.File
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.sync.Mutex
16+
import kotlinx.coroutines.sync.withLock
17+
import kotlinx.coroutines.withContext
18+
19+
/** 🆕 v2.7.0 (Folders): Persistierte Ordner-Metadaten (lokal + serverseitig in folders.json). */
20+
data class FolderMeta(
21+
@SerializedName("name") val name: String,
22+
@SerializedName("color") val color: String? = null,
23+
@SerializedName("updatedAt") val updatedAt: Long = 0L,
24+
@SerializedName("deleted") val deleted: Boolean = false
25+
)
26+
27+
/**
28+
* Gson umgeht Kotlin-Null-Safety und kann das non-null `name`-Feld zur Laufzeit mit `null`
29+
* befüllen (fehlender/`null`-Key im JSON). Solche korrupten Einträge würden später bei
30+
* `name.lowercase()` einen NPE auslösen → hier defensiv verwerfen.
31+
*/
32+
internal fun List<FolderMeta>.sanitized(): List<FolderMeta> = filter { !it.name.isNullOrBlank() }
33+
34+
/**
35+
* 🆕 v2.7.0 (Folders): Persistiert Ordner-Metadaten (Name, Farbe, Tombstones) in `filesDir/folders.json`.
36+
*
37+
* Altes Format `["A","B"]` wird beim Lesen auf `FolderMeta(name, updatedAt=0, deleted=false)`
38+
* abgebildet (Backward-Compat). Schreib-Operationen sind Mutex-geschützt und atomar (tmp-Rename).
39+
* Dirty-Flag in SharedPreferences signalisiert ausstehende Uploads an den `FolderSyncManager`.
40+
*/
41+
class FolderStore(private val context: Context) {
42+
private val mutex = Mutex()
43+
private val gson = Gson()
44+
private val file: File get() = File(context.filesDir, FILE_NAME)
45+
private val prefs: SharedPreferences by lazy {
46+
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
47+
}
48+
49+
/** Alle Einträge inkl. Tombstones (für Sync-Merge). */
50+
suspend fun loadMeta(): List<FolderMeta> = mutex.withLock { loadMetaUnsafe() }
51+
52+
/** Komplette Liste schreiben (Sync-Merge-Ergebnis). Kein dirty-Flag. */
53+
suspend fun replaceMeta(list: List<FolderMeta>) = mutex.withLock {
54+
writeMetaUnsafe(list)
55+
}
56+
57+
/** Sichtbare Ordnernamen (nicht deleted), alphabetisch case-insensitiv. */
58+
suspend fun load(): List<String> = mutex.withLock {
59+
loadMetaUnsafe().filter { !it.deleted }.map { it.name }.sortedBy { it.lowercase() }
60+
}
61+
62+
/** Sichtbare Ordner als Folder(name, color), sortiert. */
63+
suspend fun loadFolders(): List<Folder> = mutex.withLock {
64+
loadMetaUnsafe()
65+
.filter { !it.deleted }
66+
.map { Folder(it.name, it.color) }
67+
.sortedBy { it.name.lowercase() }
68+
}
69+
70+
/** User-Anlage: Eintrag anlegen/re-aktivieren. Setzt dirty-Flag. */
71+
suspend fun addFolder(name: String) {
72+
val trimmed = name.trim()
73+
if (trimmed.isEmpty()) return
74+
mutex.withLock {
75+
val current = loadMetaUnsafe().toMutableList()
76+
val idx = current.indexOfFirst { it.name.equals(trimmed, ignoreCase = true) }
77+
if (idx >= 0) {
78+
val existing = current[idx]
79+
if (!existing.deleted && existing.name == trimmed) return@withLock
80+
current[idx] = existing.copy(name = trimmed, deleted = false, updatedAt = now())
81+
} else {
82+
current.add(FolderMeta(name = trimmed, updatedAt = now()))
83+
}
84+
writeMetaUnsafe(current.sortedBy { it.name.lowercase() })
85+
markDirty()
86+
}
87+
}
88+
89+
/** Discovery-Mirror: Registriert nur unbekannte Namen mit updatedAt=0. Kein dirty-Flag. */
90+
suspend fun addFolders(names: Collection<String>) {
91+
if (names.isEmpty()) return
92+
mutex.withLock {
93+
val current = loadMetaUnsafe().toMutableList()
94+
var changed = false
95+
for (raw in names) {
96+
val t = raw.trim()
97+
if (t.isNotEmpty() && current.none { it.name.equals(t, ignoreCase = true) }) {
98+
current.add(FolderMeta(name = t, updatedAt = 0L))
99+
changed = true
100+
}
101+
}
102+
if (changed) writeMetaUnsafe(current.sortedBy { it.name.lowercase() })
103+
}
104+
}
105+
106+
/** Farbe setzen. Setzt dirty-Flag. */
107+
suspend fun setColor(name: String, color: String?) = mutex.withLock {
108+
val current = loadMetaUnsafe().toMutableList()
109+
val idx = current.indexOfFirst { it.name.equals(name, ignoreCase = true) && !it.deleted }
110+
if (idx >= 0) {
111+
current[idx] = current[idx].copy(color = color, updatedAt = now())
112+
writeMetaUnsafe(current)
113+
markDirty()
114+
}
115+
}
116+
117+
/** Tombstoned old → new mit Farbe übernehmen. Setzt dirty-Flag. */
118+
suspend fun rename(old: String, new: String) {
119+
val trimmedNew = new.trim()
120+
if (trimmedNew.isEmpty()) return
121+
mutex.withLock {
122+
val current = loadMetaUnsafe().toMutableList()
123+
val oldIdx = current.indexOfFirst { it.name.equals(old, ignoreCase = true) }
124+
val oldColor = current.getOrNull(oldIdx)?.color
125+
if (oldIdx >= 0) {
126+
current[oldIdx] = current[oldIdx].copy(deleted = true, updatedAt = now())
127+
}
128+
val newIdx = current.indexOfFirst { it.name.equals(trimmedNew, ignoreCase = true) }
129+
if (newIdx >= 0) {
130+
current[newIdx] = current[newIdx].copy(name = trimmedNew, color = oldColor, deleted = false, updatedAt = now())
131+
} else {
132+
current.add(FolderMeta(name = trimmedNew, color = oldColor, updatedAt = now()))
133+
}
134+
writeMetaUnsafe(current.sortedBy { it.name.lowercase() })
135+
markDirty()
136+
}
137+
}
138+
139+
/** Tombstone statt Hard-Remove. Setzt dirty-Flag. */
140+
suspend fun deleteFolder(name: String) = mutex.withLock {
141+
val current = loadMetaUnsafe().toMutableList()
142+
val idx = current.indexOfFirst { it.name.equals(name, ignoreCase = true) && !it.deleted }
143+
if (idx >= 0) {
144+
current[idx] = current[idx].copy(deleted = true, updatedAt = now())
145+
writeMetaUnsafe(current)
146+
markDirty()
147+
}
148+
}
149+
150+
/** Nur für Tests / „Alle Daten löschen". */
151+
suspend fun clear() = mutex.withLock {
152+
try { if (file.exists()) file.delete() } catch (e: Exception) {
153+
Logger.w(TAG, "clear failed: ${e.message}")
154+
}
155+
}
156+
157+
private fun markDirty() {
158+
prefs.edit { putBoolean(Constants.KEY_FOLDERS_DIRTY, true) }
159+
}
160+
161+
private fun now() = System.currentTimeMillis()
162+
163+
// ── Helpers (Mutex-FREE — nur unter withLock aufrufen!) ───────────────
164+
165+
private suspend fun loadMetaUnsafe(): List<FolderMeta> = withContext(Dispatchers.IO) {
166+
if (!file.exists()) return@withContext emptyList()
167+
val raw = try { file.readText() } catch (e: Exception) {
168+
Logger.w(TAG, "read failed: ${e.message}"); return@withContext emptyList()
169+
}
170+
if (raw.isBlank()) return@withContext emptyList()
171+
try {
172+
val arr = JsonParser.parseString(raw).asJsonArray
173+
if (arr.size() == 0) return@withContext emptyList()
174+
return@withContext if (arr[0].isJsonObject) {
175+
// Neues Format: List<FolderMeta>
176+
val type = object : TypeToken<List<FolderMeta>>() {}.type
177+
(gson.fromJson<List<FolderMeta>>(raw, type) ?: emptyList()).sanitized()
178+
} else {
179+
// Altes Format: List<String> → Backward-Compat-Migration
180+
val type = object : TypeToken<List<String>>() {}.type
181+
val names = gson.fromJson<List<String>>(raw, type) ?: emptyList()
182+
names.map { FolderMeta(name = it, updatedAt = 0L) }.sanitized()
183+
}
184+
} catch (e: Exception) {
185+
Logger.w(TAG, "parse failed: ${e.message}")
186+
emptyList()
187+
}
188+
}
189+
190+
private suspend fun writeMetaUnsafe(list: List<FolderMeta>) = withContext(Dispatchers.IO) {
191+
try {
192+
val tmp = File(file.parentFile, "$FILE_NAME.tmp")
193+
tmp.writeText(gson.toJson(list))
194+
if (file.exists()) file.delete()
195+
if (!tmp.renameTo(file)) {
196+
file.writeText(gson.toJson(list))
197+
tmp.delete()
198+
}
199+
} catch (e: Exception) {
200+
Logger.w(TAG, "write failed: ${e.message}")
201+
}
202+
}
203+
204+
companion object {
205+
private const val TAG = "FolderStore"
206+
const val FILE_NAME = "folders.json"
207+
}
208+
}

0 commit comments

Comments
 (0)