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.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
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>"
|
||||
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 . 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]
|
||||
|
||||
@@ -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
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;
|
||||
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 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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 &)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user