update
This commit is contained in:
14
README.md
14
README.md
@@ -14,10 +14,11 @@ unter allen Fenstern, über dem Wallpaper.
|
|||||||
- **Notiz:** Rechtsklick irgendwo im Notiz-Panel, „Notiz erstellen“ fragt den Namen und öffnet den Editor.
|
- **Notiz:** Rechtsklick irgendwo im Notiz-Panel, „Notiz erstellen“ fragt den Namen und öffnet den Editor.
|
||||||
Klick in die Notizfläche bearbeitet. Mit Fokus steht der rohe Markdown-Text, ohne Fokus die formatierte Ansicht.
|
Klick in die Notizfläche bearbeitet. Mit Fokus steht der rohe Markdown-Text, ohne Fokus die formatierte Ansicht.
|
||||||
Gespeichert wird alle 5 Sekunden und beim Klick woanders hin.
|
Gespeichert wird alle 5 Sekunden und beim Klick woanders hin.
|
||||||
|
Enter in einer Liste setzt den nächsten Punkt, Enter auf einem leeren Punkt beendet die Liste.
|
||||||
- **Maustasten:** links bearbeitet, Mitte hakt ein Todo ab oder benennt Notiz/Ordner um, rechts öffnet das Menü.
|
- **Maustasten:** links bearbeitet, Mitte hakt ein Todo ab oder benennt Notiz/Ordner um, rechts öffnet das Menü.
|
||||||
Alle Popover öffnen am Mauszeiger.
|
Alle Popover öffnen am Mauszeiger.
|
||||||
- **Rechtsklick auf freie Fläche:** Erstellen (Todos, Tagesansicht, Notizbaum im Wurzelordner).
|
- **Rechtsklick auf freie Fläche:** Erstellen (Todos, Tagesansicht, Notizbaum im Wurzelordner).
|
||||||
- **Ordner:** Klick klappt auf und zu, das Symbol zeigt den Zustand.
|
- **Ordner:** Klick klappt auf und zu, der Pfeil zeigt den Zustand wie in Obsidian.
|
||||||
- **Rechtsklick auf Ordner:** Notiz oder Unterordner darin erstellen, Umbenennen, Löschen.
|
- **Rechtsklick auf Ordner:** Notiz oder Unterordner darin erstellen, Umbenennen, Löschen.
|
||||||
- **Rechtsklick auf Eintrag:** Umbenennen, Löschen.
|
- **Rechtsklick auf Eintrag:** Umbenennen, Löschen.
|
||||||
- Popover: Enter speichert, Esc oder Klick daneben bricht ab. Keine Buttons.
|
- Popover: Enter speichert, Esc oder Klick daneben bricht ab. Keine Buttons.
|
||||||
@@ -60,6 +61,15 @@ der Aktivität: nach einer Änderung (eigene oder vom Server) eine Minute lang j
|
|||||||
neueren `updated` (Notizen: Änderungszeit der Datei, ganze Datei). Offline geht weiter, beim
|
neueren `updated` (Notizen: Änderungszeit der Datei, ganze Datei). Offline geht weiter, beim
|
||||||
Wiederverbinden wird nachgeliefert. Eine gerade offene Notiz wird nicht von außen überschrieben.
|
Wiederverbinden wird nachgeliefert. Eine gerade offene Notiz wird nicht von außen überschrieben.
|
||||||
|
|
||||||
|
### Claude-Code-Sitzungen (nur PCs)
|
||||||
|
|
||||||
|
Die Extension gleicht auch `~/.claude/projects/` ab, also Sitzungen und Memory von Claude Code.
|
||||||
|
Alle 10 Minuten prüft sie den Ordner. Hoch geht eine Datei, wenn sie sich geändert hat und seit
|
||||||
|
einer Stunde nicht angefasst wurde. Vom Server kommt sie, wenn sie dort neuer ist, mit ihrer
|
||||||
|
Änderungszeit. Gelöschte Dateien verschwinden auch auf dem anderen PC. Die App fragt diese
|
||||||
|
Dateien nie an. Bedingung: gleicher Pfad auf beiden PCs, der Ordnername enthält ihn.
|
||||||
|
`"files": false` in `sync.json` schaltet es ab, ein Pfad statt `false` ersetzt den Ordner.
|
||||||
|
|
||||||
Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`:
|
Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -70,7 +80,7 @@ Ohne diese Datei läuft die Extension rein lokal. Optional `"interval"`: Ruhetak
|
|||||||
|
|
||||||
Server-API (alle Aufrufe mit `Authorization: Bearer <SYNC_TOKEN>`):
|
Server-API (alle Aufrufe mit `Authorization: Bearer <SYNC_TOKEN>`):
|
||||||
|
|
||||||
- `POST /api/sync` – `{"since": <cursor>, "events": [...], "todos": [...], "notes": [...], "folders": [...]}`; Antwort: Änderungen seit `since` plus neuer `cursor`
|
- `POST /api/sync` – `{"since": <cursor>, "events": [...], "todos": [...], "notes": [...], "folders": [...], "files": [...]}`; Antwort: Änderungen seit `since` in den angefragten Sammlungen plus neuer `cursor`. Ohne Sammlungen kommen die vier ersten, `files` nur auf Anfrage.
|
||||||
- `GET /api/events`, `/api/todos`, `/api/notes` – aktueller Stand ohne Gelöschte, für andere Anwendungen
|
- `GET /api/events`, `/api/todos`, `/api/notes` – aktueller Stand ohne Gelöschte, für andere Anwendungen
|
||||||
|
|
||||||
### Server starten
|
### Server starten
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Native App (Kotlin, Jetpack Compose) mit drei Tabs: Kalender, Todos, Notizen. Gl
|
|||||||
|
|
||||||
- **Kalender:** Monat, Woche, Tag. Tipp auf einen Tag im Monat öffnet den Tag. Langes Drücken auf einen Tag oder eine Stunde: Termin oder Todo erstellen. Kurzer Tipp auf eine Stunde in der Woche: Termin ab dieser Stunde. Tipp auf einen Block bearbeitet, im Dialog steht auch Löschen.
|
- **Kalender:** Monat, Woche, Tag. Tipp auf einen Tag im Monat öffnet den Tag. Langes Drücken auf einen Tag oder eine Stunde: Termin oder Todo erstellen. Kurzer Tipp auf eine Stunde in der Woche: Termin ab dieser Stunde. Tipp auf einen Block bearbeitet, im Dialog steht auch Löschen.
|
||||||
- **Todos:** Häkchen erledigt, Tipp auf den Text bearbeitet, Plus legt an.
|
- **Todos:** Häkchen erledigt, Tipp auf den Text bearbeitet, Plus legt an.
|
||||||
- **Notizen:** Tipp öffnet Ordner oder Notiz. Langes Drücken: Erstellen, Umbenennen, Löschen. In der Notiz bearbeitet ein Tipp auf den Text, gespeichert wird alle 5 Sekunden und bei „Fertig“.
|
- **Notizen:** Tipp öffnet Ordner oder Notiz, der Pfeil zeigt, ob ein Ordner aufgeklappt ist. Langes Drücken: Erstellen, Umbenennen, Löschen. In der Notiz bearbeitet ein Tipp auf den Text, gespeichert wird alle 5 Sekunden und bei „Fertig“. Die Leiste darüber setzt H1 bis H6, Fett, Aufzählung, Nummerierung, Aus- und Einrücken. Enter in einer Liste setzt den nächsten Punkt, Enter auf einem leeren Punkt beendet die Liste.
|
||||||
- **Server:** Zugang steckt schon in der APK (aus der `.env` beim Bauen). Hier lässt er sich ändern. Der Sync läuft im Hintergrund, solange die App offen ist.
|
- **Server:** Zugang steckt schon in der APK (aus der `.env` beim Bauen). Hier lässt er sich ändern. Der Sync läuft im Hintergrund, solange die App offen ist.
|
||||||
|
|
||||||
## Bauen und installieren
|
## Bauen und installieren
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -18,9 +22,13 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Description
|
import androidx.compose.material.icons.automirrored.filled.FormatIndentDecrease
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.automirrored.filled.FormatIndentIncrease
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.FormatBold
|
||||||
|
import androidx.compose.material.icons.filled.FormatListNumbered
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.FloatingActionButton
|
import androidx.compose.material3.FloatingActionButton
|
||||||
@@ -41,9 +49,14 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.marha.planer.data.Editor
|
||||||
import de.marha.planer.data.Markdown
|
import de.marha.planer.data.Markdown
|
||||||
import de.marha.planer.data.Store
|
import de.marha.planer.data.Store
|
||||||
import de.marha.planer.data.TreeNode
|
import de.marha.planer.data.TreeNode
|
||||||
@@ -155,8 +168,12 @@ private fun TreeRow(row: Row, onClick: () -> Unit, onLongClick: () -> Unit) {
|
|||||||
.padding(start = (12 + row.depth * 20).dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
|
.padding(start = (12 + row.depth * 20).dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
val icon = if (!row.isFolder) Icons.Filled.Description else if (row.open) Icons.Filled.FolderOpen else Icons.Filled.Folder
|
if (row.isFolder) {
|
||||||
Icon(icon, contentDescription = null, tint = Muted, modifier = Modifier.padding(end = 10.dp))
|
val icon = if (row.open) Icons.Filled.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight
|
||||||
|
Icon(icon, contentDescription = if (row.open) "aufgeklappt" else "zugeklappt", tint = Muted, modifier = Modifier.padding(end = 10.dp))
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.width(34.dp))
|
||||||
|
}
|
||||||
Text(row.name, style = if (row.isFolder) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge)
|
Text(row.name, style = if (row.isFolder) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyLarge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,11 +184,11 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
|
|||||||
store.version.intValue
|
store.version.intValue
|
||||||
val note = store.note(id)
|
val note = store.note(id)
|
||||||
var editing by rememberSaveable { mutableStateOf(false) }
|
var editing by rememberSaveable { mutableStateOf(false) }
|
||||||
var text by rememberSaveable { mutableStateOf(note?.content ?: "") }
|
var value by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(note?.content ?: "")) }
|
||||||
val focus = remember { FocusRequester() }
|
val focus = remember { FocusRequester() }
|
||||||
|
|
||||||
fun flush() {
|
fun flush() {
|
||||||
if (editing) store.writeNote(id, text)
|
if (editing) store.writeNote(id, value.text)
|
||||||
}
|
}
|
||||||
fun stopEdit() {
|
fun stopEdit() {
|
||||||
flush()
|
flush()
|
||||||
@@ -184,7 +201,8 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
|
|||||||
LaunchedEffect(editing) {
|
LaunchedEffect(editing) {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
store.lockedNote = id
|
store.lockedNote = id
|
||||||
text = store.note(id)?.content ?: ""
|
val content = store.note(id)?.content ?: ""
|
||||||
|
value = TextFieldValue(content, TextRange(content.length))
|
||||||
focus.requestFocus()
|
focus.requestFocus()
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(AUTOSAVE_MS)
|
delay(AUTOSAVE_MS)
|
||||||
@@ -200,9 +218,10 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
|
|||||||
if (editing) Text("Fertig", color = Orange, modifier = Modifier.clickable { stopEdit() }.padding(12.dp))
|
if (editing) Text("Fertig", color = Orange, modifier = Modifier.clickable { stopEdit() }.padding(12.dp))
|
||||||
}
|
}
|
||||||
if (editing) {
|
if (editing) {
|
||||||
|
EditorBar { change -> value = change(value) }
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = text,
|
value = value,
|
||||||
onValueChange = { text = it },
|
onValueChange = { value = Editor.afterInput(value, it) },
|
||||||
textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground, fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyMedium.fontSize),
|
textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground, fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyMedium.fontSize),
|
||||||
cursorBrush = SolidColor(Orange),
|
cursorBrush = SolidColor(Orange),
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp).focusRequester(focus),
|
modifier = Modifier.fillMaxSize().padding(16.dp).focusRequester(focus),
|
||||||
@@ -215,3 +234,26 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Werkzeugleiste über dem Editor: Überschriften, Fett, Listen, Ein- und Ausrücken
|
||||||
|
@Composable
|
||||||
|
private fun EditorBar(apply: ((TextFieldValue) -> TextFieldValue) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
for (level in 1..6) {
|
||||||
|
Text(
|
||||||
|
"H$level", color = Muted, fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.clickable { apply { Editor.heading(it, level) } }.padding(horizontal = 5.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BarIcon(Icons.Filled.FormatBold, "Fett") { apply(Editor::bold) }
|
||||||
|
BarIcon(Icons.AutoMirrored.Filled.FormatListBulleted, "Aufzählung") { apply { Editor.list(it, false) } }
|
||||||
|
BarIcon(Icons.Filled.FormatListNumbered, "Nummerierung") { apply { Editor.list(it, true) } }
|
||||||
|
BarIcon(Icons.AutoMirrored.Filled.FormatIndentDecrease, "Ausrücken") { apply { Editor.indent(it, -1) } }
|
||||||
|
BarIcon(Icons.AutoMirrored.Filled.FormatIndentIncrease, "Einrücken") { apply { Editor.indent(it, 1) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BarIcon(icon: ImageVector, label: String, onClick: () -> Unit) {
|
||||||
|
IconButton(onClick = onClick, modifier = Modifier.size(40.dp)) { Icon(icon, contentDescription = label, tint = Muted) }
|
||||||
|
}
|
||||||
|
|||||||
68
android/app/src/test/kotlin/de/marha/planer/EditorTest.kt
Normal file
68
android/app/src/test/kotlin/de/marha/planer/EditorTest.kt
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package de.marha.planer
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import de.marha.planer.data.Editor
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class EditorTest {
|
||||||
|
private fun v(text: String, cursor: Int = text.length, end: Int = cursor) = TextFieldValue(text, TextRange(cursor, end))
|
||||||
|
|
||||||
|
@Test fun continueList() {
|
||||||
|
assertEquals("- ", Editor.continueList("- eins"))
|
||||||
|
assertEquals(" * ", Editor.continueList(" * eins"))
|
||||||
|
assertEquals("- [ ] ", Editor.continueList("- [x] fertig"))
|
||||||
|
assertEquals("4. ", Editor.continueList("3. drei"))
|
||||||
|
assertEquals("", Editor.continueList("- "))
|
||||||
|
assertEquals("", Editor.continueList("2. "))
|
||||||
|
assertNull(Editor.continueList("nur Text"))
|
||||||
|
assertNull(Editor.continueList("---"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun enterContinuesAndEndsList() {
|
||||||
|
val next = Editor.afterInput(v("- a"), v("- a\n"))
|
||||||
|
assertEquals("- a\n- ", next.text)
|
||||||
|
assertEquals(6, next.selection.start)
|
||||||
|
val ended = Editor.afterInput(v("- a\n- "), v("- a\n- \n"))
|
||||||
|
assertEquals("- a\n", ended.text)
|
||||||
|
assertEquals(4, ended.selection.start)
|
||||||
|
// Umbruch mitten im Text: der Rest wandert hinter den neuen Marker
|
||||||
|
val mid = Editor.afterInput(v("1. ab", 4), v("1. a\nb", 5))
|
||||||
|
assertEquals("1. a\n2. b", mid.text)
|
||||||
|
assertEquals(8, mid.selection.start)
|
||||||
|
assertEquals("x\n", Editor.afterInput(v("x"), v("x\n")).text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun headings() {
|
||||||
|
val h2 = Editor.heading(v("abc"), 2)
|
||||||
|
assertEquals("## abc", h2.text)
|
||||||
|
assertEquals(6, h2.selection.start)
|
||||||
|
assertEquals("abc", Editor.heading(h2, 2).text)
|
||||||
|
assertEquals("# abc", Editor.heading(h2, 1).text)
|
||||||
|
assertEquals("x\n### y\nz", Editor.heading(v("x\ny\nz", 3), 3).text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun lists() {
|
||||||
|
val ul = Editor.list(v("abc", 1), false)
|
||||||
|
assertEquals("- abc", ul.text)
|
||||||
|
assertEquals(3, ul.selection.start)
|
||||||
|
assertEquals("abc", Editor.list(ul, false).text)
|
||||||
|
assertEquals("1. abc", Editor.list(ul, true).text)
|
||||||
|
assertEquals("1. x\n2. y", Editor.list(v("1. x\ny"), true).text)
|
||||||
|
assertEquals(" t", Editor.list(v(" - [ ] t"), false).text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun indentAndBold() {
|
||||||
|
assertEquals(" - a", Editor.indent(v("- a"), 1).text)
|
||||||
|
assertEquals("- a", Editor.indent(v(" - a"), -1).text)
|
||||||
|
assertEquals("- a", Editor.indent(v("- a"), -1).text)
|
||||||
|
val b = Editor.bold(v("abc", 0, 3))
|
||||||
|
assertEquals("**abc**", b.text)
|
||||||
|
assertEquals(TextRange(2, 5), b.selection)
|
||||||
|
val empty = Editor.bold(v("ab", 1))
|
||||||
|
assertEquals("a****b", empty.text)
|
||||||
|
assertEquals(3, empty.selection.start)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,6 @@ DB_PATH = Path(os.getenv("DB_PATH", DATA_DIR / "desktop.db"))
|
|||||||
# Jeder Client schickt diesen Token als "Authorization: Bearer <Token>"
|
# Jeder Client schickt diesen Token als "Authorization: Bearer <Token>"
|
||||||
SYNC_TOKEN = os.getenv("SYNC_TOKEN", "")
|
SYNC_TOKEN = os.getenv("SYNC_TOKEN", "")
|
||||||
|
|
||||||
COLLECTIONS = ("events", "todos", "notes", "folders")
|
# files: Dateien der PCs (Claude-Sitzungen), kommen nur zurück, wenn der Client sie anfragt
|
||||||
|
COLLECTIONS = ("events", "todos", "notes", "folders", "files")
|
||||||
|
BASE_COLLECTIONS = COLLECTIONS[:-1]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import FastAPI, Header, HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .config import COLLECTIONS, SYNC_TOKEN
|
from .config import BASE_COLLECTIONS, COLLECTIONS, SYNC_TOKEN
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger("app")
|
log = logging.getLogger("app")
|
||||||
@@ -29,10 +29,18 @@ def check_token(authorization: str):
|
|||||||
|
|
||||||
class SyncIn(BaseModel):
|
class SyncIn(BaseModel):
|
||||||
since: int = 0
|
since: int = 0
|
||||||
events: list[dict] = []
|
events: list[dict] | None = None
|
||||||
todos: list[dict] = []
|
todos: list[dict] | None = None
|
||||||
notes: list[dict] = []
|
notes: list[dict] | None = None
|
||||||
folders: list[dict] = []
|
folders: list[dict] | None = None
|
||||||
|
files: list[dict] | None = None
|
||||||
|
|
||||||
|
# Zurück kommen die Sammlungen der Anfrage. Ohne Sammlungen die vier Basis-Sammlungen,
|
||||||
|
# Dateien nur auf ausdrückliche Anfrage, sonst zieht das Handy Sitzungen mit.
|
||||||
|
@property
|
||||||
|
def requested(self):
|
||||||
|
named = [c for c in COLLECTIONS if getattr(self, c) is not None]
|
||||||
|
return named or list(BASE_COLLECTIONS)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -53,14 +61,14 @@ def sync(body: SyncIn, authorization: str = Header(default="")):
|
|||||||
accepted = 0
|
accepted = 0
|
||||||
rejected = {collection: [] for collection in COLLECTIONS}
|
rejected = {collection: [] for collection in COLLECTIONS}
|
||||||
for collection in COLLECTIONS:
|
for collection in COLLECTIONS:
|
||||||
for item in getattr(body, collection):
|
for item in getattr(body, collection) or []:
|
||||||
ok, stored = db.merge(conn, collection, item)
|
ok, stored = db.merge(conn, collection, item)
|
||||||
if ok:
|
if ok:
|
||||||
accepted += 1
|
accepted += 1
|
||||||
else:
|
else:
|
||||||
rejected[collection].append(stored)
|
rejected[collection].append(stored)
|
||||||
out = {}
|
out = {}
|
||||||
for collection in COLLECTIONS:
|
for collection in body.requested:
|
||||||
changed = db.since(conn, collection, body.since)
|
changed = db.since(conn, collection, body.since)
|
||||||
ids = {item["id"] for item in changed}
|
ids = {item["id"] for item in changed}
|
||||||
out[collection] = changed + [item for item in rejected[collection] if item["id"] not in ids]
|
out[collection] = changed + [item for item in rejected[collection] if item["id"] not in ids]
|
||||||
|
|||||||
@@ -90,3 +90,18 @@ def test_seit_cursor_liefert_nur_neues(client):
|
|||||||
sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}])
|
sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}])
|
||||||
r = sync(client, since=1)
|
r = sync(client, since=1)
|
||||||
assert [e["id"] for e in r["events"]] == ["e2"]
|
assert [e["id"] for e in r["events"]] == ["e2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dateien_nur_auf_anfrage(client):
|
||||||
|
sync(client, files=[{"id": "-home-x/s1.jsonl", "updated": "2026-09-22T10:00:00.000Z", "content": "zeile"}])
|
||||||
|
# Handy und Extension fragen ohne files: nichts davon in der Antwort
|
||||||
|
r = sync(client, events=[], todos=[], notes=[], folders=[])
|
||||||
|
assert "files" not in r and r["events"] == []
|
||||||
|
assert "files" not in sync(client)
|
||||||
|
# Nur Dateien angefragt: nur Dateien zurück, Stand zählt trotzdem
|
||||||
|
r = sync(client, files=[])
|
||||||
|
assert [f["id"] for f in r["files"]] == ["-home-x/s1.jsonl"] and "events" not in r
|
||||||
|
assert r["cursor"] == 1
|
||||||
|
# Tombstone gewinnt mit neuerem updated
|
||||||
|
r = sync(client, files=[{"id": "-home-x/s1.jsonl", "updated": "2026-09-22T11:00:00.000Z", "deleted": True}])
|
||||||
|
assert r["files"][-1]["deleted"] is True
|
||||||
|
|||||||
65
desktop@marha.de/files.js
Normal file
65
desktop@marha.de/files.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// Dateiabgleich für ~/.claude/projects (Claude-Code-Sitzungen und Memory), nur zwischen PCs.
|
||||||
|
// Reine Logik ohne Dateisystem, damit sie sich testen lässt. sync.js liest und schreibt die Dateien.
|
||||||
|
//
|
||||||
|
// Regeln: Eine Datei geht hoch, wenn sie sich geändert hat und seit einer Stunde nicht angefasst wurde.
|
||||||
|
// Vom Server kommt sie, wenn sie dort neuer ist. Bei Konflikten gewinnt die neuere Änderungszeit.
|
||||||
|
// Verschwundene Dateien werden als Tombstone gemeldet, gelöschte vom Server lokal entfernt.
|
||||||
|
|
||||||
|
// Zeitstempel aus der Änderungszeit der Datei, gleiche Form wie updated bei Terminen
|
||||||
|
export function fileStamp(mtimeSeconds) {
|
||||||
|
return new Date(mtimeSeconds * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stampToSeconds(stamp) {
|
||||||
|
return Math.floor(Date.parse(stamp) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was hochgeladen wird. local: Map id → mtime in Sekunden. pushed: id → updated, null = als gelöscht gemeldet.
|
||||||
|
// upload: Einträge ohne Inhalt, remove: IDs, die es lokal nicht mehr gibt.
|
||||||
|
export function planPush(local, pushed, nowSeconds, idleSeconds) {
|
||||||
|
const upload = [];
|
||||||
|
for (const [id, mtime] of local) {
|
||||||
|
if (nowSeconds - mtime < idleSeconds)
|
||||||
|
continue;
|
||||||
|
const updated = fileStamp(mtime);
|
||||||
|
if (pushed[id] !== updated)
|
||||||
|
upload.push({id, updated});
|
||||||
|
}
|
||||||
|
const remove = Object.keys(pushed).filter(id => pushed[id] !== null && !local.has(id));
|
||||||
|
return {upload, remove};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was mit Einträgen vom Server passiert: write, delete oder skip.
|
||||||
|
// mine: der lokale Zeitstempel oder null, damit sync.js den Stand nachhalten kann.
|
||||||
|
export function planPull(remote, local) {
|
||||||
|
return remote.map(file => {
|
||||||
|
const mtime = local.get(file.id);
|
||||||
|
const mine = mtime === undefined ? null : fileStamp(mtime);
|
||||||
|
let action = 'write';
|
||||||
|
if (mine !== null && mine >= file.updated)
|
||||||
|
action = 'skip';
|
||||||
|
else if (file.deleted)
|
||||||
|
action = mine === null ? 'skip' : 'delete';
|
||||||
|
return {file, action, mine};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploads in Pakete teilen, damit eine Anfrage nicht beliebig groß wird
|
||||||
|
export function batches(items, sizeOf, maxBytes) {
|
||||||
|
const out = [];
|
||||||
|
let current = [];
|
||||||
|
let size = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const bytes = sizeOf(item);
|
||||||
|
if (current.length && size + bytes > maxBytes) {
|
||||||
|
out.push(current);
|
||||||
|
current = [];
|
||||||
|
size = 0;
|
||||||
|
}
|
||||||
|
current.push(item);
|
||||||
|
size += bytes;
|
||||||
|
}
|
||||||
|
if (current.length)
|
||||||
|
out.push(current);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -77,3 +77,16 @@ function indent(spaces) {
|
|||||||
const level = spaces.replace(/\t/g, ' ').length / 2;
|
const level = spaces.replace(/\t/g, ' ').length / 2;
|
||||||
return ' '.repeat(Math.floor(level) + 1);
|
return ' '.repeat(Math.floor(level) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was nach Enter am Ende dieser Zeile in der nächsten stehen soll.
|
||||||
|
// null: keine Liste. '': leerer Listenpunkt, der Marker soll weg. Sonst der Marker für den nächsten Punkt.
|
||||||
|
export function continueList(line) {
|
||||||
|
let m;
|
||||||
|
if ((m = line.match(/^(\s*)([-*+])\s+\[[ xX]\]\s+(.*)$/)))
|
||||||
|
return m[3].trim() ? `${m[1]}${m[2]} [ ] ` : '';
|
||||||
|
if ((m = line.match(/^(\s*)([-*+])\s+(.*)$/)))
|
||||||
|
return m[3].trim() ? `${m[1]}${m[2]} ` : '';
|
||||||
|
if ((m = line.match(/^(\s*)(\d+)([.)])\s+(.*)$/)))
|
||||||
|
return m[4].trim() ? `${m[1]}${Number(m[2]) + 1}${m[3]} ` : '';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import GObject from 'gi://GObject';
|
|||||||
import Pango from 'gi://Pango';
|
import Pango from 'gi://Pango';
|
||||||
import St from 'gi://St';
|
import St from 'gi://St';
|
||||||
|
|
||||||
import {renderMarkdown} from './markdown.js';
|
import {continueList, renderMarkdown} from './markdown.js';
|
||||||
import {contextMenu, multilineEntry, Popover, scrollBox, textButton} from './widgets.js';
|
import {contextMenu, multilineEntry, Popover, scrollBox, textButton} from './widgets.js';
|
||||||
|
|
||||||
const AUTOSAVE_SECONDS = 5;
|
const AUTOSAVE_SECONDS = 5;
|
||||||
@@ -50,6 +50,7 @@ class NotesPanel extends St.BoxLayout {
|
|||||||
[this._editScroll, this._editor] = multilineEntry('', 'note-editor', 'note-editor-scroll');
|
[this._editScroll, this._editor] = multilineEntry('', 'note-editor', 'note-editor-scroll');
|
||||||
this._editor.clutter_text.connect('text-changed', () => (this._dirty = true));
|
this._editor.clutter_text.connect('text-changed', () => (this._dirty = true));
|
||||||
this._editor.clutter_text.connect('key-focus-out', () => this._stopEdit());
|
this._editor.clutter_text.connect('key-focus-out', () => this._stopEdit());
|
||||||
|
this._editor.clutter_text.connect('key-press-event', (text, event) => this._onEditorKey(text, event));
|
||||||
right.add_child(this._editScroll);
|
right.add_child(this._editScroll);
|
||||||
this.add_child(right);
|
this.add_child(right);
|
||||||
|
|
||||||
@@ -102,13 +103,34 @@ class NotesPanel extends St.BoxLayout {
|
|||||||
this._rowMenu(type, node);
|
this._rowMenu(type, node);
|
||||||
else
|
else
|
||||||
onClick();
|
onClick();
|
||||||
}, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'folder-open-symbolic' : 'folder-symbolic') : 'text-x-generic-symbolic');
|
}, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'pan-down-symbolic' : 'pan-end-symbolic') : null);
|
||||||
row.set_style(`padding-left: ${8 + depth * 16}px`);
|
// Notizen ohne Symbol, aber bündig mit dem Ordnernamen (Pfeil 14px + Abstand 6px)
|
||||||
|
row.set_style(`padding-left: ${8 + depth * 16 + (type === 'note' ? 20 : 0)}px`);
|
||||||
if (this._selected?.path === node.path)
|
if (this._selected?.path === node.path)
|
||||||
row.add_style_class_name('selected');
|
row.add_style_class_name('selected');
|
||||||
this._tree.add_child(row);
|
this._tree.add_child(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enter in einer Liste setzt den nächsten Punkt, Enter auf einem leeren Punkt beendet die Liste.
|
||||||
|
_onEditorKey(clutterText, event) {
|
||||||
|
const symbol = event.get_key_symbol();
|
||||||
|
if (symbol !== Clutter.KEY_Return && symbol !== Clutter.KEY_KP_Enter)
|
||||||
|
return Clutter.EVENT_PROPAGATE;
|
||||||
|
if (event.get_state() & (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK))
|
||||||
|
return Clutter.EVENT_PROPAGATE;
|
||||||
|
const text = clutterText.get_text();
|
||||||
|
const pos = clutterText.cursor_position < 0 ? text.length : clutterText.cursor_position;
|
||||||
|
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
|
||||||
|
const next = continueList(text.slice(lineStart, pos));
|
||||||
|
if (next === null)
|
||||||
|
return Clutter.EVENT_PROPAGATE;
|
||||||
|
if (next === '')
|
||||||
|
clutterText.delete_text(lineStart, pos);
|
||||||
|
else
|
||||||
|
clutterText.insert_text(`\n${next}`, pos);
|
||||||
|
return Clutter.EVENT_STOP;
|
||||||
|
}
|
||||||
|
|
||||||
// Auswahl wechseln. Ein offener Editor wird vorher gespeichert.
|
// Auswahl wechseln. Ein offener Editor wird vorher gespeichert.
|
||||||
_select(selected) {
|
_select(selected) {
|
||||||
this._stopEdit();
|
this._stopEdit();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Gio from 'gi://Gio';
|
|||||||
import GLib from 'gi://GLib';
|
import GLib from 'gi://GLib';
|
||||||
import Soup from 'gi://Soup';
|
import Soup from 'gi://Soup';
|
||||||
|
|
||||||
|
import {batches, fileStamp, planPull, planPush, stampToSeconds} from './files.js';
|
||||||
import {readJson, writeJson} from './store.js';
|
import {readJson, writeJson} from './store.js';
|
||||||
|
|
||||||
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
|
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
|
||||||
@@ -22,6 +23,13 @@ const INTERVALS = [
|
|||||||
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
|
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
|
||||||
const SOON_MS = 500;
|
const SOON_MS = 500;
|
||||||
|
|
||||||
|
// Dateien (Claude-Sitzungen und Memory): alle 10 Minuten, hoch geht nur, was eine Stunde ruht.
|
||||||
|
// "files" in sync.json: false schaltet ab, ein Pfad ersetzt ~/.claude/projects.
|
||||||
|
const FILES_FIRST_SECONDS = 60;
|
||||||
|
const FILES_INTERVAL_SECONDS = 10 * 60;
|
||||||
|
const FILES_IDLE_SECONDS = 60 * 60;
|
||||||
|
const FILES_BATCH_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
export class Sync {
|
export class Sync {
|
||||||
constructor(store) {
|
constructor(store) {
|
||||||
this._store = store;
|
this._store = store;
|
||||||
@@ -34,6 +42,11 @@ export class Sync {
|
|||||||
this._state.pushed.folders = {};
|
this._state.pushed.folders = {};
|
||||||
this._state.cursor = 0;
|
this._state.cursor = 0;
|
||||||
}
|
}
|
||||||
|
this._state.files ??= {cursor: 0, pushed: {}};
|
||||||
|
const files = this._config?.files;
|
||||||
|
this._filesRoot = files === false ? null : (files ?? GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'projects']));
|
||||||
|
this._filesTimerId = null;
|
||||||
|
this._filesBusy = false;
|
||||||
this._session = new Soup.Session({timeout: 10});
|
this._session = new Soup.Session({timeout: 10});
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
this._failing = false;
|
this._failing = false;
|
||||||
@@ -55,6 +68,8 @@ export class Sync {
|
|||||||
this._store.onChange(() => this._scheduleSoon());
|
this._store.onChange(() => this._scheduleSoon());
|
||||||
this._cycle().catch(e => this._fail(e.message));
|
this._cycle().catch(e => this._fail(e.message));
|
||||||
this._scheduleNext();
|
this._scheduleNext();
|
||||||
|
if (this._filesRoot)
|
||||||
|
this._scheduleFiles(FILES_FIRST_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aktueller Takt in Sekunden, je nachdem wie lange die letzte Aktivität her ist.
|
// Aktueller Takt in Sekunden, je nachdem wie lange die letzte Aktivität her ist.
|
||||||
@@ -98,6 +113,10 @@ export class Sync {
|
|||||||
GLib.source_remove(this._timerId);
|
GLib.source_remove(this._timerId);
|
||||||
this._timerId = null;
|
this._timerId = null;
|
||||||
}
|
}
|
||||||
|
if (this._filesTimerId) {
|
||||||
|
GLib.source_remove(this._filesTimerId);
|
||||||
|
this._filesTimerId = null;
|
||||||
|
}
|
||||||
if (this._soonId) {
|
if (this._soonId) {
|
||||||
GLib.source_remove(this._soonId);
|
GLib.source_remove(this._soonId);
|
||||||
this._soonId = null;
|
this._soonId = null;
|
||||||
@@ -219,6 +238,78 @@ export class Sync {
|
|||||||
writeJson(this._statePath, this._state);
|
writeJson(this._statePath, this._state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dateien ──────────────────────────────────────────────────────────────
|
||||||
|
_scheduleFiles(seconds) {
|
||||||
|
if (this._stopped)
|
||||||
|
return;
|
||||||
|
this._filesTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => {
|
||||||
|
this._filesTimerId = null;
|
||||||
|
this._filesCycle().catch(e => this._fail(e.message)).finally(() => this._scheduleFiles(FILES_INTERVAL_SECONDS));
|
||||||
|
return GLib.SOURCE_REMOVE;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigener Lauf mit eigenem Stand: die Antwort enthält nur Dateien, der Takt der Einträge bleibt unberührt.
|
||||||
|
async _filesCycle() {
|
||||||
|
if (this._filesBusy || !this._filesRoot)
|
||||||
|
return;
|
||||||
|
this._filesBusy = true;
|
||||||
|
try {
|
||||||
|
const state = this._state.files;
|
||||||
|
const local = scanFiles(this._filesRoot);
|
||||||
|
const now = Math.floor(GLib.get_real_time() / 1e6);
|
||||||
|
const {upload, remove} = planPush(local, state.pushed, now, FILES_IDLE_SECONDS);
|
||||||
|
const items = [];
|
||||||
|
for (const entry of upload) {
|
||||||
|
const content = readText(GLib.build_filenamev([this._filesRoot, entry.id]));
|
||||||
|
if (content !== null)
|
||||||
|
items.push({...entry, content});
|
||||||
|
}
|
||||||
|
const nowStamp = new Date(now * 1000).toISOString();
|
||||||
|
for (const id of remove)
|
||||||
|
items.push({id, updated: nowStamp, deleted: true});
|
||||||
|
// Mindestens ein Lauf, auch ohne eigene Änderungen: holt Neues vom Server
|
||||||
|
const packets = items.length ? batches(items, item => (item.content ?? '').length, FILES_BATCH_BYTES) : [[]];
|
||||||
|
for (const files of packets) {
|
||||||
|
const response = await this._post({since: state.cursor, files});
|
||||||
|
// Ein Server ohne Dateien-Sammlung würde alles verwerfen, der Stand bliebe sonst falsch
|
||||||
|
if (!Array.isArray(response.files))
|
||||||
|
throw new Error('Server kennt keine Dateien, dort erst make prod ausführen');
|
||||||
|
for (const item of files)
|
||||||
|
state.pushed[item.id] = item.deleted ? null : item.updated;
|
||||||
|
this._applyFiles(response, local);
|
||||||
|
writeJson(this._statePath, this._state);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._filesBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyFiles(response, local) {
|
||||||
|
const state = this._state.files;
|
||||||
|
for (const {file, action, mine} of planPull(response.files ?? [], local)) {
|
||||||
|
const path = GLib.build_filenamev([this._filesRoot, file.id]);
|
||||||
|
if (action === 'write') {
|
||||||
|
GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o700);
|
||||||
|
GLib.file_set_contents(path, file.content);
|
||||||
|
const seconds = stampToSeconds(file.updated);
|
||||||
|
Gio.File.new_for_path(path).set_attribute_uint64('time::modified', seconds, Gio.FileQueryInfoFlags.NONE, null);
|
||||||
|
local.set(file.id, seconds);
|
||||||
|
state.pushed[file.id] = file.updated;
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
GLib.unlink(path);
|
||||||
|
local.delete(file.id);
|
||||||
|
state.pushed[file.id] = null;
|
||||||
|
} else if (file.deleted && mine === null) {
|
||||||
|
state.pushed[file.id] = null;
|
||||||
|
} else if (mine === file.updated) {
|
||||||
|
state.pushed[file.id] = mine;
|
||||||
|
}
|
||||||
|
// Sonst ist die lokale Datei neuer und geht hoch, sobald sie eine Stunde ruht.
|
||||||
|
}
|
||||||
|
state.cursor = response.cursor;
|
||||||
|
}
|
||||||
|
|
||||||
_fail(message) {
|
_fail(message) {
|
||||||
if (this._stopped)
|
if (this._stopped)
|
||||||
return;
|
return;
|
||||||
@@ -227,3 +318,38 @@ export class Sync {
|
|||||||
this._failing = true;
|
this._failing = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alle Dateien unter root: id (Pfad relativ zu root) → Änderungszeit in Sekunden
|
||||||
|
function scanFiles(root) {
|
||||||
|
const local = new Map();
|
||||||
|
const walk = (dir, prefix) => {
|
||||||
|
let enumerator;
|
||||||
|
try {
|
||||||
|
enumerator = dir.enumerate_children('standard::name,standard::type,time::modified', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let info;
|
||||||
|
while ((info = enumerator.next_file(null))) {
|
||||||
|
const name = info.get_name();
|
||||||
|
const id = prefix ? `${prefix}/${name}` : name;
|
||||||
|
if (info.get_file_type() === Gio.FileType.DIRECTORY)
|
||||||
|
walk(dir.get_child(name), id);
|
||||||
|
else if (info.get_file_type() === Gio.FileType.REGULAR)
|
||||||
|
local.set(id, Number(info.get_attribute_uint64('time::modified')));
|
||||||
|
}
|
||||||
|
enumerator.close(null);
|
||||||
|
};
|
||||||
|
walk(Gio.File.new_for_path(root), '');
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textdatei lesen. null, wenn sie fehlt oder kein UTF-8 ist.
|
||||||
|
function readText(path) {
|
||||||
|
try {
|
||||||
|
const [, bytes] = GLib.file_get_contents(path);
|
||||||
|
return new TextDecoder('utf-8', {fatal: true}).decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ rm -rf /tmp/desktop-app-nested/data/desktop-app
|
|||||||
# Mit SYNC_URL bekommt die Test-Shell einen Server-Zugang (Token "test")
|
# Mit SYNC_URL bekommt die Test-Shell einen Server-Zugang (Token "test")
|
||||||
if [ -n "$SYNC_URL" ]; then
|
if [ -n "$SYNC_URL" ]; then
|
||||||
mkdir -p /tmp/desktop-app-nested/data/desktop-app
|
mkdir -p /tmp/desktop-app-nested/data/desktop-app
|
||||||
echo "{\"url\": \"$SYNC_URL\", \"token\": \"test\"}" > /tmp/desktop-app-nested/data/desktop-app/sync.json
|
rm -rf /tmp/desktop-app-nested/claude-projects
|
||||||
|
echo "{\"url\": \"$SYNC_URL\", \"token\": \"test\", \"files\": \"/tmp/desktop-app-nested/claude-projects\"}" > /tmp/desktop-app-nested/data/desktop-app/sync.json
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
(nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &)
|
(nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &)
|
||||||
|
|||||||
@@ -162,9 +162,15 @@ check "Notiz mit Überschrift wird nicht abgeschnitten" "$(val '(l => Math.round
|
|||||||
check "Textfarbe aus dem Stil bleibt erhalten" "$(val 'notes._content.clutter_text.get_attributes() !== null')" "true"
|
check "Textfarbe aus dem Stil bleibt erhalten" "$(val 'notes._content.clutter_text.get_attributes() !== null')" "true"
|
||||||
click 'notes._viewScroll'
|
click 'notes._viewScroll'
|
||||||
check "Klick in die Fläche startet den Editor" "$(val '[notes._editing, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true"
|
check "Klick in die Fläche startet den Editor" "$(val '[notes._editing, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true"
|
||||||
type_ " mehr"
|
type_ " mehr"; key Return; type_ "- eins"; key Return
|
||||||
|
check "Enter führt die Liste fort" "$(val 'JSON.stringify(notes._editor.get_text().slice(-14))')" '"mehr\n- eins\n- "'
|
||||||
|
type_ "zwei"; key Return; key Return
|
||||||
|
check "Enter auf leerem Punkt beendet die Liste" "$(val 'JSON.stringify(notes._editor.get_text().slice(-14))')" '"- eins\n- zwei\n"'
|
||||||
|
type_ "1. a"; key Return
|
||||||
|
check "Nummerierte Liste zählt weiter" "$(val 'JSON.stringify(notes._editor.get_text().slice(-8))')" '"1. a\n2. "'
|
||||||
|
key Return
|
||||||
click 'find(notes, "Allgemein")'
|
click 'find(notes, "Allgemein")'
|
||||||
check "Wechsel speichert sofort" "$(val 'read(store.notesDir + "/Testnotiz.md").endsWith("**Notiz** mehr") + " " + notes._selected.name')" "true Allgemein"
|
check "Wechsel speichert sofort" "$(val 'read(store.notesDir + "/Testnotiz.md").endsWith("- zwei\\n1. a\\n") + " " + notes._selected.name')" "true Allgemein"
|
||||||
mclick 'find(notes, "Testnotiz")'
|
mclick 'find(notes, "Testnotiz")'
|
||||||
check "Mittlere Taste öffnet Umbenennen am Zeiger" "$(val 'labels(grabbed())[0] + " " + entries(grabbed())[0]')" "Umbenennen Testnotiz"
|
check "Mittlere Taste öffnet Umbenennen am Zeiger" "$(val 'labels(grabbed())[0] + " " + entries(grabbed())[0]')" "Umbenennen Testnotiz"
|
||||||
check "Popover am Mauszeiger (Notiz)" "$(atpointer)" "true"
|
check "Popover am Mauszeiger (Notiz)" "$(atpointer)" "true"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Tests der reinen Module: gjs -m dev/tests/run.js
|
// Tests der reinen Module: gjs -m dev/tests/run.js
|
||||||
import * as D from '../../desktop@marha.de/dates.js';
|
import * as D from '../../desktop@marha.de/dates.js';
|
||||||
import {renderMarkdown} from '../../desktop@marha.de/markdown.js';
|
import {continueList, renderMarkdown} from '../../desktop@marha.de/markdown.js';
|
||||||
|
import {batches, fileStamp, planPull, planPush, stampToSeconds} from '../../desktop@marha.de/files.js';
|
||||||
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
function eq(actual, expected, name) {
|
function eq(actual, expected, name) {
|
||||||
@@ -61,6 +62,33 @@ eq(renderMarkdown('---'), '<span foreground="#666666">────────
|
|||||||
eq(renderMarkdown('~~weg~~'), '<s>weg</s>', 'md strike');
|
eq(renderMarkdown('~~weg~~'), '<s>weg</s>', 'md strike');
|
||||||
eq(renderMarkdown('a\n\nb'), 'a\n\nb', 'md leere Zeile bleibt');
|
eq(renderMarkdown('a\n\nb'), 'a\n\nb', 'md leere Zeile bleibt');
|
||||||
|
|
||||||
|
eq(continueList('- eins'), '- ', 'Liste: nächster Punkt');
|
||||||
|
eq(continueList(' * eins'), ' * ', 'Liste: Einrückung und Marker bleiben');
|
||||||
|
eq(continueList('- [x] fertig'), '- [ ] ', 'Liste: Checkbox leer weiter');
|
||||||
|
eq(continueList('3. drei'), '4. ', 'Liste: Nummer zählt hoch');
|
||||||
|
eq(continueList('- '), '', 'Liste: leerer Punkt beendet');
|
||||||
|
eq(continueList('2. '), '', 'Liste: leerer nummerierter Punkt beendet');
|
||||||
|
eq(continueList('nur Text'), null, 'Liste: kein Marker');
|
||||||
|
eq(continueList('---'), null, 'Liste: Trennlinie ist keine Liste');
|
||||||
|
|
||||||
|
const H = 3600, NOW = 1_800_000_000;
|
||||||
|
eq(fileStamp(NOW), '2027-01-15T08:00:00.000Z', 'fileStamp');
|
||||||
|
eq(stampToSeconds('2027-01-15T08:00:00.000Z'), NOW, 'stampToSeconds');
|
||||||
|
const local = new Map([['a/s.jsonl', NOW - 2 * H], ['a/frisch.jsonl', NOW - 10], ['m/MEMORY.md', NOW - 3 * H]]);
|
||||||
|
eq(planPush(local, {'m/MEMORY.md': fileStamp(NOW - 3 * H), 'weg.md': fileStamp(NOW - 5 * H), 'alt.md': null}, NOW, H),
|
||||||
|
{upload: [{id: 'a/s.jsonl', updated: fileStamp(NOW - 2 * H)}], remove: ['weg.md']},
|
||||||
|
'planPush: nur ruhende, geänderte Dateien hoch, Verschwundene als Tombstone, Gemeldete nicht nochmal');
|
||||||
|
eq(planPull([
|
||||||
|
{id: 'a/s.jsonl', updated: fileStamp(NOW - 1 * H), content: 'x'},
|
||||||
|
{id: 'a/s.jsonl', updated: fileStamp(NOW - 3 * H), content: 'x'},
|
||||||
|
{id: 'neu.md', updated: fileStamp(NOW), content: 'y'},
|
||||||
|
{id: 'm/MEMORY.md', updated: fileStamp(NOW), deleted: true},
|
||||||
|
{id: 'nie.md', updated: fileStamp(NOW), deleted: true},
|
||||||
|
], local).map(p => `${p.file.id}:${p.action}`),
|
||||||
|
['a/s.jsonl:write', 'a/s.jsonl:skip', 'neu.md:write', 'm/MEMORY.md:delete', 'nie.md:skip'], 'planPull: neuer schreibt, älter überspringt, Tombstone löscht');
|
||||||
|
eq(batches([1, 2, 3, 4], n => n, 5), [[1, 2], [3], [4]], 'batches nach Größe');
|
||||||
|
eq(batches([], n => n, 5), [], 'batches leer');
|
||||||
|
|
||||||
print(failed ? `\n${failed} FEHLER` : '\nalle Tests ok');
|
print(failed ? `\n${failed} FEHLER` : '\nalle Tests ok');
|
||||||
if (failed)
|
if (failed)
|
||||||
imports.system.exit(1);
|
imports.system.exit(1);
|
||||||
|
|||||||
@@ -153,6 +153,33 @@ check "Ordner vom Server gelöscht" "$(val 'GLib.file_test(store.notesDir + "/Vo
|
|||||||
sleep 3
|
sleep 3
|
||||||
check "Lokal gelöschter Ordner auf dem Server als gelöscht" "$(folders | python3 -c 'import sys; print("Voll/Tief\x27, True" in sys.stdin.read())')" "True"
|
check "Lokal gelöschter Ordner auf dem Server als gelöscht" "$(folders | python3 -c 'import sys; print("Voll/Tief\x27, True" in sys.stdin.read())')" "True"
|
||||||
|
|
||||||
|
echo "=== Dateien: Claude-Sitzungen zwischen PCs"
|
||||||
|
FILES=/tmp/desktop-app-nested/claude-projects
|
||||||
|
files() { api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["files"]))'; }
|
||||||
|
filescycle() { "$E" "$PRE ext._sync._filesCycle(); 'ok'" >/dev/null; sleep 3; }
|
||||||
|
mkdir -p "$FILES/-home-x/memory"
|
||||||
|
echo '{"a":1}' > "$FILES/-home-x/alt.jsonl"; touch -d '2 hours ago' "$FILES/-home-x/alt.jsonl"
|
||||||
|
echo '# Memo' > "$FILES/-home-x/memory/MEMORY.md"; touch -d '90 minutes ago' "$FILES/-home-x/memory/MEMORY.md"
|
||||||
|
echo 'läuft noch' > "$FILES/-home-x/frisch.jsonl"
|
||||||
|
filescycle
|
||||||
|
check "Ruhende Dateien auf dem Server, frische nicht" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/memory/MEMORY.md', False)]"
|
||||||
|
check "Normale Anfrage bekommt keine Dateien" "$(api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("files" in d, "events" in d)')" "False True"
|
||||||
|
check "Stand der Dateien getrennt gemerkt" "$(val 'Object.keys(state().files.pushed).sort().join(",") + " " + (state().files.cursor > 0)')" "-home-x/alt.jsonl,-home-x/memory/MEMORY.md true"
|
||||||
|
push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "2026-09-01T00:00:00.000Z", "content": "vom anderen PC"}]}'
|
||||||
|
filescycle
|
||||||
|
check "Datei vom Server geschrieben, Änderungszeit übernommen" "$(cat "$FILES/-home-y/s2.jsonl") $(stat -c %Y "$FILES/-home-y/s2.jsonl")" "vom anderen PC $(date -u -d 2026-09-01T00:00:00Z +%s)"
|
||||||
|
echo '{"a":2}' > "$FILES/-home-x/alt.jsonl"; touch -d '70 minutes ago' "$FILES/-home-x/alt.jsonl"
|
||||||
|
push '{"files": [{"id": "-home-x/alt.jsonl", "updated": "'$(date -u -d '3 hours ago' +%Y-%m-%dT%H:%M:%S.000Z)'", "content": "veraltet"}]}'
|
||||||
|
filescycle
|
||||||
|
check "Lokal neuer gewinnt und geht hoch" "$(api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print([f["content"].strip() for f in json.load(sys.stdin)["files"] if f["id"] == "-home-x/alt.jsonl"])')" "['{\"a\":2}']"
|
||||||
|
rm "$FILES/-home-x/memory/MEMORY.md"
|
||||||
|
push '{"files": [{"id": "-home-y/s2.jsonl", "updated": "'$(now)'", "deleted": true}]}'
|
||||||
|
filescycle
|
||||||
|
check "Gelöscht in beide Richtungen" "$(files | python3 -c 'import sys; print("MEMORY.md\x27, True" in sys.stdin.read())') $([ -e "$FILES/-home-y/s2.jsonl" ] && echo da || echo weg)" "True weg"
|
||||||
|
check "Gemeldete Löschung nicht nochmal" "$(val 'state().files.pushed["-home-x/memory/MEMORY.md"] === null && state().files.pushed["-home-y/s2.jsonl"] === null')" "true"
|
||||||
|
filescycle
|
||||||
|
check "Zweiter Lauf schickt nichts erneut" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/memory/MEMORY.md', True), ('-home-y/s2.jsonl', True)]"
|
||||||
|
|
||||||
echo "=== Server weg und wieder da"
|
echo "=== Server weg und wieder da"
|
||||||
kill $SERVER_PID; sleep 1
|
kill $SERVER_PID; sleep 1
|
||||||
"$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null
|
"$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null
|
||||||
|
|||||||
Reference in New Issue
Block a user