This commit is contained in:
team3
2026-09-22 13:14:06 +02:00
parent a62b587340
commit 2472e89b9c
15 changed files with 461 additions and 28 deletions

View File

@@ -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 <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
### Server starten

View File

@@ -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

View File

@@ -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) }
}

View 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)
}
}

View File

@@ -9,4 +9,6 @@ DB_PATH = Path(os.getenv("DB_PATH", DATA_DIR / "desktop.db"))
# Jeder Client schickt diesen Token als "Authorization: Bearer <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]

View File

@@ -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]

View File

@@ -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

65
desktop@marha.de/files.js Normal file
View 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;
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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 &)

View File

@@ -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"

View File

@@ -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('---'), '<span foreground="#666666">────────
eq(renderMarkdown('~~weg~~'), '<s>weg</s>', '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);

View File

@@ -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