diff --git a/README.md b/README.md index e62c1f6..5676bdc 100644 --- a/README.md +++ b/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. 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. + 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ü. Alle Popover öffnen am Mauszeiger. - **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 Eintrag:** Umbenennen, Löschen. - 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 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`: ``` @@ -70,7 +80,7 @@ Ohne diese Datei läuft die Extension rein lokal. Optional `"interval"`: Ruhetak Server-API (alle Aufrufe mit `Authorization: Bearer `): -- `POST /api/sync` – `{"since": , "events": [...], "todos": [...], "notes": [...], "folders": [...]}`; Antwort: Änderungen seit `since` plus neuer `cursor` +- `POST /api/sync` – `{"since": , "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 ### Server starten diff --git a/android/README.md b/android/README.md index d7f5001..7370761 100644 --- a/android/README.md +++ b/android/README.md @@ -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. - **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. ## Bauen und installieren diff --git a/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt b/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt index 5c60376..795be3e 100644 --- a/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt +++ b/android/app/src/main/kotlin/de/marha/planer/ui/NotesScreen.kt @@ -6,7 +6,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.horizontalScroll 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.fillMaxWidth 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Folder -import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.automirrored.filled.FormatIndentDecrease +import androidx.compose.material.icons.automirrored.filled.FormatIndentIncrease +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.DropdownMenuItem 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.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.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import de.marha.planer.data.Editor import de.marha.planer.data.Markdown import de.marha.planer.data.Store 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), verticalAlignment = Alignment.CenterVertically, ) { - val icon = if (!row.isFolder) Icons.Filled.Description else if (row.open) Icons.Filled.FolderOpen else Icons.Filled.Folder - Icon(icon, contentDescription = null, tint = Muted, modifier = Modifier.padding(end = 10.dp)) + if (row.isFolder) { + 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) } } @@ -167,11 +184,11 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) { store.version.intValue val note = store.note(id) 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() } fun flush() { - if (editing) store.writeNote(id, text) + if (editing) store.writeNote(id, value.text) } fun stopEdit() { flush() @@ -184,7 +201,8 @@ fun NoteView(store: Store, id: String, onClose: () -> Unit) { LaunchedEffect(editing) { if (editing) { store.lockedNote = id - text = store.note(id)?.content ?: "" + val content = store.note(id)?.content ?: "" + value = TextFieldValue(content, TextRange(content.length)) focus.requestFocus() while (true) { 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) { + EditorBar { change -> value = change(value) } BasicTextField( - value = text, - onValueChange = { text = it }, + value = value, + onValueChange = { value = Editor.afterInput(value, it) }, textStyle = TextStyle(color = MaterialTheme.colorScheme.onBackground, fontFamily = FontFamily.Monospace, fontSize = MaterialTheme.typography.bodyMedium.fontSize), cursorBrush = SolidColor(Orange), 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) } +} diff --git a/android/app/src/test/kotlin/de/marha/planer/EditorTest.kt b/android/app/src/test/kotlin/de/marha/planer/EditorTest.kt new file mode 100644 index 0000000..32c3104 --- /dev/null +++ b/android/app/src/test/kotlin/de/marha/planer/EditorTest.kt @@ -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) + } +} diff --git a/backend/app/config.py b/backend/app/config.py index 5d47eb2..f4d7a28 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,4 +9,6 @@ DB_PATH = Path(os.getenv("DB_PATH", DATA_DIR / "desktop.db")) # Jeder Client schickt diesen Token als "Authorization: Bearer " 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] diff --git a/backend/app/main.py b/backend/app/main.py index 2148962..92150de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel 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") log = logging.getLogger("app") @@ -29,10 +29,18 @@ def check_token(authorization: str): class SyncIn(BaseModel): since: int = 0 - events: list[dict] = [] - todos: list[dict] = [] - notes: list[dict] = [] - folders: list[dict] = [] + events: list[dict] | None = None + todos: list[dict] | None = None + notes: list[dict] | None = None + 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") @@ -53,14 +61,14 @@ def sync(body: SyncIn, authorization: str = Header(default="")): accepted = 0 rejected = {collection: [] 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) if ok: accepted += 1 else: rejected[collection].append(stored) out = {} - for collection in COLLECTIONS: + for collection in body.requested: changed = db.since(conn, collection, body.since) ids = {item["id"] for item in changed} out[collection] = changed + [item for item in rejected[collection] if item["id"] not in ids] diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index a1c0b32..fdf977b 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -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"}]) r = sync(client, since=1) 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 diff --git a/desktop@marha.de/files.js b/desktop@marha.de/files.js new file mode 100644 index 0000000..091d162 --- /dev/null +++ b/desktop@marha.de/files.js @@ -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; +} diff --git a/desktop@marha.de/markdown.js b/desktop@marha.de/markdown.js index b811729..fa4bc6d 100644 --- a/desktop@marha.de/markdown.js +++ b/desktop@marha.de/markdown.js @@ -77,3 +77,16 @@ function indent(spaces) { const level = spaces.replace(/\t/g, ' ').length / 2; 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; +} diff --git a/desktop@marha.de/notes.js b/desktop@marha.de/notes.js index 17bef03..49e1d13 100644 --- a/desktop@marha.de/notes.js +++ b/desktop@marha.de/notes.js @@ -4,7 +4,7 @@ import GObject from 'gi://GObject'; import Pango from 'gi://Pango'; 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'; const AUTOSAVE_SECONDS = 5; @@ -50,6 +50,7 @@ class NotesPanel extends St.BoxLayout { [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('key-focus-out', () => this._stopEdit()); + this._editor.clutter_text.connect('key-press-event', (text, event) => this._onEditorKey(text, event)); right.add_child(this._editScroll); this.add_child(right); @@ -102,13 +103,34 @@ class NotesPanel extends St.BoxLayout { this._rowMenu(type, node); else onClick(); - }, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'folder-open-symbolic' : 'folder-symbolic') : 'text-x-generic-symbolic'); - row.set_style(`padding-left: ${8 + depth * 16}px`); + }, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'pan-down-symbolic' : 'pan-end-symbolic') : null); + // 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) row.add_style_class_name('selected'); 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. _select(selected) { this._stopEdit(); diff --git a/desktop@marha.de/sync.js b/desktop@marha.de/sync.js index 079995d..34f9dba 100644 --- a/desktop@marha.de/sync.js +++ b/desktop@marha.de/sync.js @@ -2,6 +2,7 @@ import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Soup from 'gi://Soup'; +import {batches, fileStamp, planPull, planPush, stampToSeconds} from './files.js'; import {readJson, writeJson} from './store.js'; // 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 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 { constructor(store) { this._store = store; @@ -34,6 +42,11 @@ export class Sync { this._state.pushed.folders = {}; 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._busy = false; this._failing = false; @@ -55,6 +68,8 @@ export class Sync { this._store.onChange(() => this._scheduleSoon()); this._cycle().catch(e => this._fail(e.message)); this._scheduleNext(); + if (this._filesRoot) + this._scheduleFiles(FILES_FIRST_SECONDS); } // 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); this._timerId = null; } + if (this._filesTimerId) { + GLib.source_remove(this._filesTimerId); + this._filesTimerId = null; + } if (this._soonId) { GLib.source_remove(this._soonId); this._soonId = null; @@ -219,6 +238,78 @@ export class Sync { 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) { if (this._stopped) return; @@ -227,3 +318,38 @@ export class Sync { 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; + } +} diff --git a/dev/restart.sh b/dev/restart.sh index c5dac5e..875e5d3 100755 --- a/dev/restart.sh +++ b/dev/restart.sh @@ -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") if [ -n "$SYNC_URL" ]; then 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 sleep 2 (nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &) diff --git a/dev/tests/input.sh b/dev/tests/input.sh index 54ccc29..d91b9d2 100755 --- a/dev/tests/input.sh +++ b/dev/tests/input.sh @@ -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" 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" -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")' -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")' check "Mittlere Taste öffnet Umbenennen am Zeiger" "$(val 'labels(grabbed())[0] + " " + entries(grabbed())[0]')" "Umbenennen Testnotiz" check "Popover am Mauszeiger (Notiz)" "$(atpointer)" "true" diff --git a/dev/tests/run.js b/dev/tests/run.js index 79e99b1..5dd0305 100644 --- a/dev/tests/run.js +++ b/dev/tests/run.js @@ -1,6 +1,7 @@ // Tests der reinen Module: gjs -m dev/tests/run.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; function eq(actual, expected, name) { @@ -61,6 +62,33 @@ eq(renderMarkdown('---'), '──────── eq(renderMarkdown('~~weg~~'), 'weg', 'md strike'); 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'); if (failed) imports.system.exit(1); diff --git a/dev/tests/sync.sh b/dev/tests/sync.sh index 61102c6..65f093a 100755 --- a/dev/tests/sync.sh +++ b/dev/tests/sync.sh @@ -153,6 +153,33 @@ check "Ordner vom Server gelöscht" "$(val 'GLib.file_test(store.notesDir + "/Vo 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" +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" kill $SERVER_PID; sleep 1 "$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null