From a5b92054dc3e6c9111724c8456b4284d3aed84a9 Mon Sep 17 00:00:00 2001 From: team3 Date: Sat, 19 Sep 2026 21:20:17 +0200 Subject: [PATCH] update --- README.md | 3 +- backend/app/config.py | 2 +- backend/app/main.py | 1 + backend/tests/test_sync.py | 7 +++++ desktop@marha.de/store.js | 56 +++++++++++++++++++++++++++++++++++++- desktop@marha.de/sync.js | 14 +++++++++- dev/tests/sync.sh | 18 ++++++++++++ 7 files changed, 97 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9741c41..4b31d71 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ unter allen Fenstern, über dem Wallpaper. - `todos.json` – Todos - `notes/` – Notizen als `.md`, Ordner beliebig verschachtelt (Obsidian kann den Ordner öffnen) - `deleted-notes.json` – gelöschte Notizen mit Zeitpunkt, damit der Sync sie auch woanders löscht +- `folders.json` – Ordner mit Zeitpunkt und deleted-Flag, damit auch leere Ordner mitlaufen - `sync.json` – Server-Zugang, siehe Sync - `sync-state.json` – letzter Server-Stand @@ -63,7 +64,7 @@ Ohne diese Datei läuft die Extension rein lokal. Server-API (alle Aufrufe mit `Authorization: Bearer `): -- `POST /api/sync` – `{"since": , "events": [...], "todos": [...], "notes": [...]}`; Antwort: Änderungen seit `since` plus neuer `cursor` +- `POST /api/sync` – `{"since": , "events": [...], "todos": [...], "notes": [...], "folders": [...]}`; Antwort: Änderungen seit `since` plus neuer `cursor` - `GET /api/events`, `/api/todos`, `/api/notes` – aktueller Stand ohne Gelöschte, für andere Anwendungen ### Server starten diff --git a/backend/app/config.py b/backend/app/config.py index c771e52..5d47eb2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,4 +9,4 @@ 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") +COLLECTIONS = ("events", "todos", "notes", "folders") diff --git a/backend/app/main.py b/backend/app/main.py index 68643ca..2148962 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -32,6 +32,7 @@ class SyncIn(BaseModel): events: list[dict] = [] todos: list[dict] = [] notes: list[dict] = [] + folders: list[dict] = [] @app.get("/api/health") diff --git a/backend/tests/test_sync.py b/backend/tests/test_sync.py index d6cbe7b..a1c0b32 100644 --- a/backend/tests/test_sync.py +++ b/backend/tests/test_sync.py @@ -78,6 +78,13 @@ def test_tombstone(client): assert client.get("/api/notes", headers=AUTH).json() == [] +def test_ordner_laufen_mit(client): + r = sync(client, folders=[{"id": "Projekte/Alt", "updated": "2026-09-19T10:00:00.000Z", "deleted": False}]) + assert r["folders"][0]["id"] == "Projekte/Alt" + r = sync(client, since=1, folders=[{"id": "Projekte/Alt", "updated": "2026-09-19T10:05:00.000Z", "deleted": True}]) + assert r["folders"][0]["deleted"] is True + + def test_seit_cursor_liefert_nur_neues(client): sync(client, events=[{"id": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "a"}]) sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}]) diff --git a/desktop@marha.de/store.js b/desktop@marha.de/store.js index a004980..9194292 100644 --- a/desktop@marha.de/store.js +++ b/desktop@marha.de/store.js @@ -6,6 +6,7 @@ import GLib from 'gi://GLib'; // todos.json Todos // notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt // deleted-notes.json gelöschte Notizen mit Zeitpunkt, damit der Sync sie auch woanders löscht +// folders.json Ordner mit Zeitpunkt und deleted-Flag, damit auch leere Ordner mitlaufen // sync.json Server-Zugang (url, token), siehe sync.js // Gelöschte Termine und Todos bleiben mit deleted: true in der Liste (Tombstone) und werden nur ausgeblendet. @@ -86,9 +87,11 @@ export class Store { this._eventsPath = GLib.build_filenamev([this.dir, 'events.json']); this._todosPath = GLib.build_filenamev([this.dir, 'todos.json']); this._deletedNotesPath = GLib.build_filenamev([this.dir, 'deleted-notes.json']); + this._foldersPath = GLib.build_filenamev([this.dir, 'folders.json']); this.events = readJson(this._eventsPath); this.todos = readJson(this._todosPath); this.deletedNotes = readJson(this._deletedNotesPath, {}); + this.folders = readJson(this._foldersPath, {}); this._listeners = []; // Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an. this.lockedNote = null; @@ -238,6 +241,46 @@ export class Store { writeJson(this._deletedNotesPath, this.deletedNotes); } + // ---- Ordner für den Sync ---- + + _setFolder(relative, deleted = false) { + this.folders[relative] = {updated: new Date().toISOString(), deleted}; + writeJson(this._foldersPath, this.folders); + } + + // Alle Ordner als Einträge. Vorhandene Ordner ohne Eintrag (z.B. von Hand angelegt) kommen dazu. + listFolders() { + let added = false; + const walk = folder => { + for (const sub of folder.folders) { + const relative = this.relative(sub.path); + if (!this.folders[relative] || this.folders[relative].deleted) { + this.folders[relative] = {updated: new Date().toISOString(), deleted: false}; + added = true; + } + walk(sub); + } + }; + walk(this.tree()); + if (added) + writeJson(this._foldersPath, this.folders); + return Object.entries(this.folders).map(([id, {updated, deleted}]) => ({id, updated, deleted})); + } + + // Ordner vom Server übernehmen. Gelöscht wird nur, wenn er leer ist. + applyRemoteFolder(record) { + const path = this.absolute(record.id); + if (record.deleted) { + if (GLib.file_test(path, GLib.FileTest.IS_DIR) && this.folderEmpty(path)) + Gio.File.new_for_path(path).delete(null); + } else { + GLib.mkdir_with_parents(path, 0o755); + } + this.folders[record.id] = {updated: record.updated, deleted: Boolean(record.deleted)}; + writeJson(this._foldersPath, this.folders); + this._changed(); + } + readNote(path) { const [, bytes] = GLib.file_get_contents(path); return decoder.decode(bytes); @@ -264,6 +307,7 @@ export class Store { if (GLib.file_test(path, GLib.FileTest.EXISTS)) throw new Error('Diesen Ordner gibt es schon'); Gio.File.new_for_path(path).make_directory(null); + this._setFolder(this.relative(path)); return path; } @@ -289,16 +333,25 @@ export class Store { if (GLib.file_test(target, GLib.FileTest.EXISTS)) throw new Error('Diesen Ordner gibt es schon'); const notes = []; + const folders = [path]; const collect = folder => { - folder.folders.forEach(collect); + for (const sub of folder.folders) { + folders.push(sub.path); + collect(sub); + } notes.push(...folder.notes.map(n => n.path)); }; collect(listFolder(path)); Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null); + // Für den Sync ist das löschen plus neu anlegen, für Ordner wie für Notizen for (const note of notes) { this._rememberDeleted(note); this._touch(target + note.slice(path.length)); } + for (const folder of folders) { + this._setFolder(this.relative(folder), true); + this._setFolder(this.relative(target + folder.slice(path.length))); + } return target; } @@ -316,5 +369,6 @@ export class Store { if (!this.folderEmpty(path)) throw new Error('Der Ordner ist nicht leer'); Gio.File.new_for_path(path).delete(null); + this._setFolder(this.relative(path), true); } } diff --git a/desktop@marha.de/sync.js b/desktop@marha.de/sync.js index 5250b79..cae0a25 100644 --- a/desktop@marha.de/sync.js +++ b/desktop@marha.de/sync.js @@ -17,7 +17,8 @@ export class Sync { this._store = store; this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null); this._statePath = GLib.build_filenamev([store.dir, 'sync-state.json']); - this._state = readJson(this._statePath, {cursor: 0, pushed: {events: {}, todos: {}, notes: {}}}); + this._state = readJson(this._statePath, {cursor: 0, pushed: {events: {}, todos: {}, notes: {}, folders: {}}}); + this._state.pushed.folders ??= {}; this._session = new Soup.Session({timeout: 10}); this._busy = false; this._failing = false; @@ -81,6 +82,7 @@ export class Sync { events: changed('events', store.events), todos: changed('todos', store.todos), notes, + folders: changed('folders', store.listFolders()), }; } @@ -115,6 +117,16 @@ export class Sync { } for (const note of payload.notes) pushed.notes[note.id] = note.updated; + for (const folder of payload.folders) + pushed.folders[folder.id] = folder.updated; + + // Ordner: neuester Eintrag gewinnt, Anlegen und Löschen wirken auf das Dateisystem. + for (const folder of response.folders ?? []) { + const mine = store.folders[folder.id]; + if (!mine || mine.updated < folder.updated) + store.applyRemoteFolder(folder); + pushed.folders[folder.id] = store.folders[folder.id].updated; + } // Notizen: ganze Datei, neuere Änderungszeit gewinnt. Eine offene Notiz bleibt unangetastet, // dann rückt der Stand nicht vor und der Server liefert sie beim nächsten Lauf erneut. diff --git a/dev/tests/sync.sh b/dev/tests/sync.sh index fe52c03..75d0d45 100755 --- a/dev/tests/sync.sh +++ b/dev/tests/sync.sh @@ -116,6 +116,24 @@ sleep 1 sleep 3 check "Umbenannt auf dem Server" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([n["id"] for n in json.load(sys.stdin)])')" "['Neu.md']" +echo "=== Ordner" +folders() { api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["folders"]))'; } +"$E" "$PRE store.createFolder(store.notesDir, 'Leer'); store.createFolder(store.notesDir + '/Leer', 'Tief'); 'ok'" >/dev/null +sleep 3 +check "Leere Ordner auf dem Server" "$(folders)" "[('Leer', False), ('Leer/Tief', False), ('Remote', False)]" +push '{"folders": [{"id": "Vom Server/Sub", "updated": "'$(now)'", "deleted": false}]}' +sleep 3 +check "Ordner vom Server angelegt" "$(val 'GLib.file_test(store.notesDir + "/Vom Server/Sub", GLib.FileTest.IS_DIR) + " " + labels(notes).includes("▸ Vom Server")')" "true true" +"$E" "$PRE store.renameFolder(store.notesDir + '/Leer', 'Voll'); 'ok'" >/dev/null +sleep 3 +check "Ordner umbenannt auf dem Server" "$(folders)" "[('Leer', True), ('Leer/Tief', True), ('Remote', False), ('Voll', False), ('Voll/Tief', False), ('Vom Server', False), ('Vom Server/Sub', False)]" +push '{"folders": [{"id": "Vom Server/Sub", "updated": "'$(now)'", "deleted": true}]}' +sleep 3 +check "Ordner vom Server gelöscht" "$(val 'GLib.file_test(store.notesDir + "/Vom Server/Sub", GLib.FileTest.EXISTS) + " " + GLib.file_test(store.notesDir + "/Vom Server", GLib.FileTest.IS_DIR)')" "false true" +"$E" "$PRE store.deleteFolder(store.notesDir + '/Voll/Tief'); 'ok'" >/dev/null +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 "=== Server weg und wieder da" kill $SERVER_PID; sleep 1 "$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null