update
This commit is contained in:
@@ -41,6 +41,7 @@ unter allen Fenstern, über dem Wallpaper.
|
|||||||
- `todos.json` – Todos
|
- `todos.json` – Todos
|
||||||
- `notes/` – Notizen als `.md`, Ordner beliebig verschachtelt (Obsidian kann den Ordner öffnen)
|
- `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
|
- `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.json` – Server-Zugang, siehe Sync
|
||||||
- `sync-state.json` – letzter Server-Stand
|
- `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 <SYNC_TOKEN>`):
|
Server-API (alle Aufrufe mit `Authorization: Bearer <SYNC_TOKEN>`):
|
||||||
|
|
||||||
- `POST /api/sync` – `{"since": <cursor>, "events": [...], "todos": [...], "notes": [...]}`; Antwort: Änderungen seit `since` plus neuer `cursor`
|
- `POST /api/sync` – `{"since": <cursor>, "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
|
- `GET /api/events`, `/api/todos`, `/api/notes` – aktueller Stand ohne Gelöschte, für andere Anwendungen
|
||||||
|
|
||||||
### Server starten
|
### Server starten
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ DB_PATH = Path(os.getenv("DB_PATH", DATA_DIR / "desktop.db"))
|
|||||||
# Jeder Client schickt diesen Token als "Authorization: Bearer <Token>"
|
# Jeder Client schickt diesen Token als "Authorization: Bearer <Token>"
|
||||||
SYNC_TOKEN = os.getenv("SYNC_TOKEN", "")
|
SYNC_TOKEN = os.getenv("SYNC_TOKEN", "")
|
||||||
|
|
||||||
COLLECTIONS = ("events", "todos", "notes")
|
COLLECTIONS = ("events", "todos", "notes", "folders")
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class SyncIn(BaseModel):
|
|||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
todos: list[dict] = []
|
todos: list[dict] = []
|
||||||
notes: list[dict] = []
|
notes: list[dict] = []
|
||||||
|
folders: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ def test_tombstone(client):
|
|||||||
assert client.get("/api/notes", headers=AUTH).json() == []
|
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):
|
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": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "a"}])
|
||||||
sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}])
|
sync(client, events=[{"id": "e2", "updated": "2026-09-19T10:00:00.000Z", "title": "b"}])
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import GLib from 'gi://GLib';
|
|||||||
// todos.json Todos
|
// todos.json Todos
|
||||||
// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt
|
// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt
|
||||||
// deleted-notes.json gelöschte Notizen mit Zeitpunkt, damit der Sync sie auch woanders löscht
|
// 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
|
// 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.
|
// 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._eventsPath = GLib.build_filenamev([this.dir, 'events.json']);
|
||||||
this._todosPath = GLib.build_filenamev([this.dir, 'todos.json']);
|
this._todosPath = GLib.build_filenamev([this.dir, 'todos.json']);
|
||||||
this._deletedNotesPath = GLib.build_filenamev([this.dir, 'deleted-notes.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.events = readJson(this._eventsPath);
|
||||||
this.todos = readJson(this._todosPath);
|
this.todos = readJson(this._todosPath);
|
||||||
this.deletedNotes = readJson(this._deletedNotesPath, {});
|
this.deletedNotes = readJson(this._deletedNotesPath, {});
|
||||||
|
this.folders = readJson(this._foldersPath, {});
|
||||||
this._listeners = [];
|
this._listeners = [];
|
||||||
// Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an.
|
// Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an.
|
||||||
this.lockedNote = null;
|
this.lockedNote = null;
|
||||||
@@ -238,6 +241,46 @@ export class Store {
|
|||||||
writeJson(this._deletedNotesPath, this.deletedNotes);
|
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) {
|
readNote(path) {
|
||||||
const [, bytes] = GLib.file_get_contents(path);
|
const [, bytes] = GLib.file_get_contents(path);
|
||||||
return decoder.decode(bytes);
|
return decoder.decode(bytes);
|
||||||
@@ -264,6 +307,7 @@ export class Store {
|
|||||||
if (GLib.file_test(path, GLib.FileTest.EXISTS))
|
if (GLib.file_test(path, GLib.FileTest.EXISTS))
|
||||||
throw new Error('Diesen Ordner gibt es schon');
|
throw new Error('Diesen Ordner gibt es schon');
|
||||||
Gio.File.new_for_path(path).make_directory(null);
|
Gio.File.new_for_path(path).make_directory(null);
|
||||||
|
this._setFolder(this.relative(path));
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,16 +333,25 @@ export class Store {
|
|||||||
if (GLib.file_test(target, GLib.FileTest.EXISTS))
|
if (GLib.file_test(target, GLib.FileTest.EXISTS))
|
||||||
throw new Error('Diesen Ordner gibt es schon');
|
throw new Error('Diesen Ordner gibt es schon');
|
||||||
const notes = [];
|
const notes = [];
|
||||||
|
const folders = [path];
|
||||||
const collect = folder => {
|
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));
|
notes.push(...folder.notes.map(n => n.path));
|
||||||
};
|
};
|
||||||
collect(listFolder(path));
|
collect(listFolder(path));
|
||||||
Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null);
|
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) {
|
for (const note of notes) {
|
||||||
this._rememberDeleted(note);
|
this._rememberDeleted(note);
|
||||||
this._touch(target + note.slice(path.length));
|
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;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,5 +369,6 @@ export class Store {
|
|||||||
if (!this.folderEmpty(path))
|
if (!this.folderEmpty(path))
|
||||||
throw new Error('Der Ordner ist nicht leer');
|
throw new Error('Der Ordner ist nicht leer');
|
||||||
Gio.File.new_for_path(path).delete(null);
|
Gio.File.new_for_path(path).delete(null);
|
||||||
|
this._setFolder(this.relative(path), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export class Sync {
|
|||||||
this._store = store;
|
this._store = store;
|
||||||
this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null);
|
this._config = readJson(GLib.build_filenamev([store.dir, 'sync.json']), null);
|
||||||
this._statePath = GLib.build_filenamev([store.dir, 'sync-state.json']);
|
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._session = new Soup.Session({timeout: 10});
|
||||||
this._busy = false;
|
this._busy = false;
|
||||||
this._failing = false;
|
this._failing = false;
|
||||||
@@ -81,6 +82,7 @@ export class Sync {
|
|||||||
events: changed('events', store.events),
|
events: changed('events', store.events),
|
||||||
todos: changed('todos', store.todos),
|
todos: changed('todos', store.todos),
|
||||||
notes,
|
notes,
|
||||||
|
folders: changed('folders', store.listFolders()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +117,16 @@ export class Sync {
|
|||||||
}
|
}
|
||||||
for (const note of payload.notes)
|
for (const note of payload.notes)
|
||||||
pushed.notes[note.id] = note.updated;
|
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,
|
// 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.
|
// dann rückt der Stand nicht vor und der Server liefert sie beim nächsten Lauf erneut.
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ sleep 1
|
|||||||
sleep 3
|
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']"
|
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"
|
echo "=== Server weg und wieder da"
|
||||||
kill $SERVER_PID; sleep 1
|
kill $SERVER_PID; sleep 1
|
||||||
"$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null
|
"$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null
|
||||||
|
|||||||
Reference in New Issue
Block a user