update
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# Gemeinsamer Token für alle Geräte. Steht auf jedem Gerät in ~/.local/share/desktop-app/sync.json
|
||||
SYNC_TOKEN=
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
backend/.venv/
|
||||
backend/data/
|
||||
data/
|
||||
.env
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/app ./app
|
||||
|
||||
# Datenbank liegt im Volume, nicht im Image
|
||||
ENV DATA_DIR=/data
|
||||
RUN mkdir -p /data
|
||||
VOLUME /data
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
42
Makefile
42
Makefile
@@ -1,11 +1,13 @@
|
||||
# make install – Extension installieren, falls noch nicht vorhanden, und aktivieren
|
||||
# make update – installierte Extension mit dem Stand aus diesem Ordner überschreiben
|
||||
# Extension: make install | make update
|
||||
# Server: make dev (lokal, Port $(PORT)) | make prod (auf dem Server) | make stop | make logs | make down | make test
|
||||
UUID = desktop@marha.de
|
||||
SRC = $(CURDIR)/$(UUID)
|
||||
DEST = $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
|
||||
VENV = backend/.venv
|
||||
PORT ?= 8040
|
||||
|
||||
.ONESHELL:
|
||||
.PHONY: install update enable
|
||||
.PHONY: install update enable venv dev prod stop logs down test
|
||||
|
||||
install:
|
||||
@if [ -e "$(DEST)" ]; then
|
||||
@@ -35,3 +37,37 @@ update:
|
||||
# UUID in die aktiven Extensions eintragen, falls sie fehlt
|
||||
enable:
|
||||
@python3 dev/enable.py "$(UUID)"
|
||||
|
||||
# ── Server ───────────────────────────────────────────────────────────────────
|
||||
venv: $(VENV)/bin/uvicorn
|
||||
|
||||
$(VENV)/bin/uvicorn: backend/requirements.txt
|
||||
python3 -m venv $(VENV)
|
||||
$(VENV)/bin/pip install -q -r backend/requirements.txt
|
||||
|
||||
# Lokal entwickeln. Token kommt aus .env (Vorlage: .env.example).
|
||||
dev: venv
|
||||
@[ -f .env ] || { echo ".env fehlt. Vorlage: cp .env.example .env, dann SYNC_TOKEN setzen."; exit 1; }
|
||||
set -a; . ./.env; set +a
|
||||
echo "Server: http://localhost:$(PORT)"
|
||||
cd backend && .venv/bin/uvicorn app.main:app --reload --port $(PORT)
|
||||
|
||||
# Produktion (auf dem Server ausführen, im Projektverzeichnis)
|
||||
prod:
|
||||
@[ -f .env ] || { echo ".env fehlt. Vorlage: cp .env.example .env, dann SYNC_TOKEN setzen."; exit 1; }
|
||||
docker compose up -d --build
|
||||
echo "läuft: https://planer.marha.de"
|
||||
|
||||
# [u] statt u: so trifft das Muster nicht die eigene Shell, die es im Aufruf trägt
|
||||
stop:
|
||||
-pkill -f "$(CURDIR)/$(VENV)/bin/python3 .venv/bin/[u]vicorn" 2>/dev/null
|
||||
echo "gestoppt."
|
||||
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
test: venv
|
||||
cd backend && .venv/bin/python -m pytest -q
|
||||
|
||||
40
README.md
40
README.md
@@ -28,7 +28,9 @@ unter allen Fenstern, über dem Wallpaper.
|
||||
- `notes.js` – Ordnerbaum, Markdown-Anzeige, Inline-Editor, Kontextmenü
|
||||
- `widgets.js` – Popover (Grab, Esc, Tab, Klick daneben), Kontextmenü, Datums- und Zeitauswahl
|
||||
- `markdown.js`, `dates.js` – reine Logik, ohne Shell testbar
|
||||
- `store.js` – Ablage
|
||||
- `store.js` – Ablage, Tombstones, Übernahme vom Server
|
||||
- `sync.js` – Abgleich mit dem Server (Soup, Timer)
|
||||
- `backend/` – Sync-Server (FastAPI, SQLite), `Dockerfile`, `docker-compose.yml`
|
||||
- `dev/` – Test-Werkzeuge
|
||||
|
||||
## Daten
|
||||
@@ -38,6 +40,41 @@ unter allen Fenstern, über dem Wallpaper.
|
||||
- `events.json` – Termine
|
||||
- `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
|
||||
- `sync.json` – Server-Zugang, siehe Sync
|
||||
- `sync-state.json` – letzter Server-Stand
|
||||
|
||||
Gelöschte Termine und Todos bleiben mit `deleted: true` in der Datei und werden nur ausgeblendet.
|
||||
|
||||
## Sync
|
||||
|
||||
Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht alle paar Sekunden ab:
|
||||
eigene Änderungen hoch, fremde Änderungen runter. Bei Konflikten gewinnt der Eintrag mit dem
|
||||
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.
|
||||
|
||||
Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`:
|
||||
|
||||
```
|
||||
{"url": "https://planer.marha.de", "token": "<SYNC_TOKEN aus .env>", "interval": 5}
|
||||
```
|
||||
|
||||
Ohne diese Datei läuft die Extension rein lokal.
|
||||
|
||||
Server-API (alle Aufrufe mit `Authorization: Bearer <SYNC_TOKEN>`):
|
||||
|
||||
- `POST /api/sync` – `{"since": <cursor>, "events": [...], "todos": [...], "notes": [...]}`; 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
|
||||
|
||||
```
|
||||
cp .env.example .env # SYNC_TOKEN setzen
|
||||
make dev # lokal, http://localhost:8040 (anderer Port: make dev PORT=8050)
|
||||
make prod # auf dem Server: Docker Compose hinter Traefik, https://planer.marha.de
|
||||
make stop | make logs | make down
|
||||
make test # Server-Tests
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -56,6 +93,7 @@ gjs -m dev/tests/run.js # Unit-Tests für dates.js und markdown.js
|
||||
dev/restart.sh # Test-Shell als Fenster starten (zum Selbst-Ausprobieren)
|
||||
dev/restart.sh headless # Test-Shell unsichtbar, für simulierte Eingaben
|
||||
dev/tests/input.sh # Ablauf-Tests mit echter Maus und Tastatur (headless)
|
||||
dev/tests/sync.sh # Sync-Test: lokaler Server plus headless Shell, beide Richtungen
|
||||
dev/eval.sh dev/seed.js # Testdaten einspielen
|
||||
dev/eval.sh 'js' # JavaScript in der Test-Shell ausführen (oder Dateipfad)
|
||||
dev/shot.sh [datei.png] # Screenshot der Test-Shell
|
||||
|
||||
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
12
backend/app/config.py
Normal file
12
backend/app/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", ROOT / "data"))
|
||||
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")
|
||||
82
backend/app/db.py
Normal file
82
backend/app/db.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .config import DB_PATH
|
||||
|
||||
# Ein Eintrag pro Termin, Todo oder Notiz. Gelöschte bleiben als Tombstone erhalten,
|
||||
# sonst würde ein anderes Gerät sie wieder hochladen.
|
||||
# updated: Zeitstempel des Geräts, entscheidet bei Konflikten (neuester gewinnt).
|
||||
# seq: Server-Zähler, damit Clients "alles seit meinem letzten Stand" abholen können.
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
collection TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
updated TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
data TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
PRIMARY KEY (collection, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS items_seq ON items(seq);
|
||||
"""
|
||||
|
||||
|
||||
@contextmanager
|
||||
def connect():
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def cursor(conn):
|
||||
return conn.execute("SELECT COALESCE(MAX(seq), 0) FROM items").fetchone()[0]
|
||||
|
||||
|
||||
def merge(conn, collection, item):
|
||||
"""Übernimmt den Eintrag, wenn er neuer ist als der gespeicherte.
|
||||
|
||||
Gibt (True, None) zurück, wenn er übernommen wurde, sonst (False, gespeicherter Eintrag),
|
||||
damit der Client den gültigen Stand bekommt.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT updated, data FROM items WHERE collection = ? AND id = ?", (collection, item["id"])
|
||||
).fetchone()
|
||||
if row and row["updated"] >= item["updated"]:
|
||||
return False, json.loads(row["data"])
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO items (collection, id, updated, deleted, data, seq) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
collection,
|
||||
item["id"],
|
||||
item["updated"],
|
||||
1 if item.get("deleted") else 0,
|
||||
json.dumps(item, ensure_ascii=False),
|
||||
cursor(conn) + 1,
|
||||
),
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
def since(conn, collection, seq):
|
||||
rows = conn.execute(
|
||||
"SELECT data FROM items WHERE collection = ? AND seq > ? ORDER BY seq", (collection, seq)
|
||||
)
|
||||
return [json.loads(r["data"]) for r in rows]
|
||||
|
||||
|
||||
def alive(conn, collection):
|
||||
rows = conn.execute(
|
||||
"SELECT data FROM items WHERE collection = ? AND deleted = 0 ORDER BY id", (collection,)
|
||||
)
|
||||
return [json.loads(r["data"]) for r in rows]
|
||||
91
backend/app/main.py
Normal file
91
backend/app/main.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import db
|
||||
from .config import COLLECTIONS, SYNC_TOKEN
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("app")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
if not SYNC_TOKEN:
|
||||
log.warning("SYNC_TOKEN ist leer, jede Anfrage wird abgelehnt.")
|
||||
db.init()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Desktop Sync", lifespan=lifespan)
|
||||
|
||||
|
||||
def check_token(authorization: str):
|
||||
if not SYNC_TOKEN or authorization != f"Bearer {SYNC_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Token fehlt oder ist falsch")
|
||||
|
||||
|
||||
class SyncIn(BaseModel):
|
||||
since: int = 0
|
||||
events: list[dict] = []
|
||||
todos: list[dict] = []
|
||||
notes: list[dict] = []
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/sync")
|
||||
def sync(body: SyncIn, authorization: str = Header(default="")):
|
||||
"""Nimmt Änderungen des Clients an und gibt alles zurück, was sich seit seinem Stand geändert hat.
|
||||
|
||||
Konflikte: der Eintrag mit dem neueren updated gewinnt. Verliert der Client, bekommt er den
|
||||
Server-Stand zurück. Die Antwort enthält auch die gerade angenommenen Einträge; der Client
|
||||
erkennt sie am gleichen updated und überspringt sie.
|
||||
"""
|
||||
check_token(authorization)
|
||||
with db.connect() as conn:
|
||||
accepted = 0
|
||||
rejected = {collection: [] for collection in COLLECTIONS}
|
||||
for collection in COLLECTIONS:
|
||||
for item in getattr(body, collection):
|
||||
ok, stored = db.merge(conn, collection, item)
|
||||
if ok:
|
||||
accepted += 1
|
||||
else:
|
||||
rejected[collection].append(stored)
|
||||
out = {}
|
||||
for collection in COLLECTIONS:
|
||||
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]
|
||||
out["cursor"] = db.cursor(conn)
|
||||
if accepted:
|
||||
log.info("sync: %d Einträge übernommen", accepted)
|
||||
return out
|
||||
|
||||
|
||||
# Lesezugriff für andere Anwendungen
|
||||
@app.get("/api/events")
|
||||
def events(authorization: str = Header(default="")):
|
||||
check_token(authorization)
|
||||
with db.connect() as conn:
|
||||
return db.alive(conn, "events")
|
||||
|
||||
|
||||
@app.get("/api/todos")
|
||||
def todos(authorization: str = Header(default="")):
|
||||
check_token(authorization)
|
||||
with db.connect() as conn:
|
||||
return db.alive(conn, "todos")
|
||||
|
||||
|
||||
@app.get("/api/notes")
|
||||
def notes(authorization: str = Header(default="")):
|
||||
check_token(authorization)
|
||||
with db.connect() as conn:
|
||||
return db.alive(conn, "notes")
|
||||
2
backend/pytest.ini
Normal file
2
backend/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
pytest==8.3.4
|
||||
85
backend/tests/test_sync.py
Normal file
85
backend/tests/test_sync.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import importlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
os.environ["DB_PATH"] = str(tmp_path / "test.db")
|
||||
os.environ["SYNC_TOKEN"] = "geheim"
|
||||
from app import config, db, main
|
||||
importlib.reload(config)
|
||||
importlib.reload(db)
|
||||
importlib.reload(main)
|
||||
with TestClient(main.app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
AUTH = {"Authorization": "Bearer geheim"}
|
||||
|
||||
|
||||
def sync(client, since=0, **collections):
|
||||
body = {"since": since, **collections}
|
||||
r = client.post("/api/sync", json=body, headers=AUTH)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_health_ohne_token(client):
|
||||
assert client.get("/api/health").json() == {"ok": True}
|
||||
|
||||
|
||||
def test_falscher_token(client):
|
||||
assert client.post("/api/sync", json={}).status_code == 401
|
||||
assert client.post("/api/sync", json={}, headers={"Authorization": "Bearer falsch"}).status_code == 401
|
||||
assert client.get("/api/events").status_code == 401
|
||||
|
||||
|
||||
def test_push_und_pull(client):
|
||||
a = sync(client, events=[{"id": "e1", "updated": "2026-09-19T10:00:00.000Z", "title": "Zahnarzt"}])
|
||||
assert a["cursor"] == 1
|
||||
assert [e["id"] for e in a["events"]] == ["e1"]
|
||||
|
||||
# Anderes Gerät ohne Stand bekommt alles
|
||||
b = sync(client)
|
||||
assert [e["title"] for e in b["events"]] == ["Zahnarzt"]
|
||||
assert b["cursor"] == 1
|
||||
|
||||
# Mit aktuellem Stand kommt nichts Neues
|
||||
c = sync(client, since=1)
|
||||
assert c["events"] == [] and c["cursor"] == 1
|
||||
|
||||
|
||||
def test_neuester_gewinnt(client):
|
||||
sync(client, todos=[{"id": "t1", "updated": "2026-09-19T10:00:00.000Z", "title": "alt"}])
|
||||
sync(client, todos=[{"id": "t1", "updated": "2026-09-19T09:00:00.000Z", "title": "älter"}])
|
||||
assert client.get("/api/todos", headers=AUTH).json()[0]["title"] == "alt"
|
||||
r = sync(client, since=0, todos=[{"id": "t1", "updated": "2026-09-19T11:00:00.000Z", "title": "neu"}])
|
||||
assert r["todos"][0]["title"] == "neu"
|
||||
assert r["cursor"] == 2
|
||||
|
||||
|
||||
def test_abgelehnte_aenderung_bekommt_serverstand(client):
|
||||
sync(client, todos=[{"id": "t1", "updated": "2026-09-19T10:00:00.000Z", "title": "server"}])
|
||||
r = sync(client, since=1, todos=[{"id": "t1", "updated": "2026-09-19T09:00:00.000Z", "title": "client"}])
|
||||
assert [t["title"] for t in r["todos"]] == ["server"]
|
||||
assert r["cursor"] == 1
|
||||
|
||||
|
||||
def test_tombstone(client):
|
||||
sync(client, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:00:00.000Z", "content": "x"}])
|
||||
r = sync(client, since=1, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:05:00.000Z", "deleted": True}])
|
||||
assert r["notes"][0]["deleted"] is True
|
||||
assert client.get("/api/notes", headers=AUTH).json() == []
|
||||
# Ein verspäteter älterer Stand belebt die Notiz nicht wieder
|
||||
sync(client, notes=[{"id": "Plan.md", "updated": "2026-09-19T10:01:00.000Z", "content": "y"}])
|
||||
assert client.get("/api/notes", headers=AUTH).json() == []
|
||||
|
||||
|
||||
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"}])
|
||||
r = sync(client, since=1)
|
||||
assert [e["id"] for e in r["events"]] == ["e2"]
|
||||
@@ -7,6 +7,7 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import {CalendarPanel} from './calendar.js';
|
||||
import {NotesPanel} from './notes.js';
|
||||
import {Store} from './store.js';
|
||||
import {Sync} from './sync.js';
|
||||
import {TodoPanel} from './todos.js';
|
||||
|
||||
const MARGIN = 24;
|
||||
@@ -31,9 +32,14 @@ export default class DesktopExtension extends Extension {
|
||||
this._workareaId = global.display.connect('workareas-changed', () => this._layout());
|
||||
this._layout();
|
||||
this._restack();
|
||||
|
||||
this._sync = new Sync(this._store);
|
||||
this._sync.start();
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._sync.stop();
|
||||
this._sync = null;
|
||||
global.display.disconnect(this._restackId);
|
||||
global.window_manager.disconnect(this._mapId);
|
||||
global.display.disconnect(this._workareaId);
|
||||
|
||||
@@ -61,6 +61,11 @@ class NotesPanel extends St.BoxLayout {
|
||||
return Clutter.EVENT_STOP;
|
||||
});
|
||||
this.connect('destroy', () => this._stopAutosave());
|
||||
// Änderungen vom Sync. Während des Tippens bleibt der Editor, wie er ist.
|
||||
store.onChange(() => {
|
||||
if (!this._editing)
|
||||
this._refresh();
|
||||
});
|
||||
|
||||
this._refresh();
|
||||
}
|
||||
@@ -143,8 +148,10 @@ class NotesPanel extends St.BoxLayout {
|
||||
if (this._editing || this._selected?.type !== 'note')
|
||||
return;
|
||||
this._editing = true;
|
||||
this._dirty = false;
|
||||
this._store.lockedNote = this._selected.path;
|
||||
this._editor.set_text(this._store.readNote(this._selected.path));
|
||||
// Erst nach set_text, das löst selbst text-changed aus. Sonst gilt jedes Öffnen als Änderung.
|
||||
this._dirty = false;
|
||||
this._viewScroll.hide();
|
||||
this._editScroll.show();
|
||||
this._editor.clutter_text.grab_key_focus();
|
||||
@@ -184,7 +191,9 @@ class NotesPanel extends St.BoxLayout {
|
||||
return;
|
||||
this._flush();
|
||||
this._editing = false;
|
||||
this._store.lockedNote = null;
|
||||
this._stopAutosave();
|
||||
// Nur die Anzeige wechseln. Ein Neuaufbau des Baums würde die gerade geklickte Zeile zerstören.
|
||||
this._showSelected();
|
||||
}
|
||||
|
||||
@@ -265,6 +274,7 @@ class NotesPanel extends St.BoxLayout {
|
||||
_delete(type, node) {
|
||||
if (this._selected?.path === node.path) {
|
||||
this._editing = false;
|
||||
this._store.lockedNote = null;
|
||||
this._stopAutosave();
|
||||
this._selected = null;
|
||||
}
|
||||
|
||||
@@ -2,26 +2,44 @@ import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
|
||||
// Ablage: ~/.local/share/desktop-app/
|
||||
// events.json Termine
|
||||
// todos.json Todos
|
||||
// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt
|
||||
// events.json Termine
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function readJson(path) {
|
||||
export function readJson(path, fallback = []) {
|
||||
try {
|
||||
const [ok, bytes] = GLib.file_get_contents(path);
|
||||
return ok ? JSON.parse(decoder.decode(bytes)) : [];
|
||||
return ok ? JSON.parse(decoder.decode(bytes)) : fallback;
|
||||
} catch {
|
||||
// Datei gibt es beim ersten Start noch nicht
|
||||
return [];
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(path, data) {
|
||||
export function writeJson(path, data) {
|
||||
GLib.file_set_contents(path, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// Änderungszeit einer Datei als ISO-String mit Millisekunden.
|
||||
function modifiedIso(file) {
|
||||
const info = file.query_info('time::modified,time::modified-usec', Gio.FileQueryInfoFlags.NONE, null);
|
||||
const usec = info.get_attribute_uint32('time::modified-usec');
|
||||
return new Date(Number(info.get_attribute_uint64('time::modified')) * 1000 + Math.floor(usec / 1000)).toISOString();
|
||||
}
|
||||
|
||||
function setModified(file, iso) {
|
||||
const ms = new Date(iso).getTime();
|
||||
const info = new Gio.FileInfo();
|
||||
info.set_attribute_uint64('time::modified', Math.floor(ms / 1000));
|
||||
info.set_attribute_uint32('time::modified-usec', (ms % 1000) * 1000);
|
||||
file.set_attributes_from_info(info, Gio.FileQueryInfoFlags.NONE, null);
|
||||
}
|
||||
|
||||
function compareArrays(a, b) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] < b[i])
|
||||
@@ -67,9 +85,13 @@ export class Store {
|
||||
GLib.mkdir_with_parents(this.notesDir, 0o755);
|
||||
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.events = readJson(this._eventsPath);
|
||||
this.todos = readJson(this._todosPath);
|
||||
this.deletedNotes = readJson(this._deletedNotesPath, {});
|
||||
this._listeners = [];
|
||||
// Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an.
|
||||
this.lockedNote = null;
|
||||
}
|
||||
|
||||
onChange(callback) {
|
||||
@@ -86,7 +108,7 @@ export class Store {
|
||||
// Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate.
|
||||
eventsOn(date) {
|
||||
return this.events
|
||||
.filter(e => e.date <= date && date <= (e.endDate ?? e.date))
|
||||
.filter(e => !e.deleted && e.date <= date && date <= (e.endDate ?? e.date))
|
||||
.sort((a, b) => compareArrays([a.start ?? '', a.title], [b.start ?? '', b.title]));
|
||||
}
|
||||
|
||||
@@ -103,23 +125,45 @@ export class Store {
|
||||
}
|
||||
|
||||
removeEvent(id) {
|
||||
this.events = this.events.filter(e => e.id !== id);
|
||||
writeJson(this._eventsPath, this.events);
|
||||
this._changed();
|
||||
const event = this.events.find(e => e.id === id);
|
||||
this.saveEvent({...event, deleted: true});
|
||||
}
|
||||
|
||||
// ---- Sync: Änderungen von außen übernehmen, neuester Stand gewinnt ----
|
||||
|
||||
// Gibt true zurück, wenn sich lokal etwas geändert hat.
|
||||
applyRemote(collection, items) {
|
||||
const list = this[collection];
|
||||
let changed = false;
|
||||
for (const item of items) {
|
||||
const index = list.findIndex(e => e.id === item.id);
|
||||
if (index >= 0 && list[index].updated >= item.updated)
|
||||
continue;
|
||||
if (index >= 0)
|
||||
list[index] = item;
|
||||
else
|
||||
list.push(item);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
writeJson(collection === 'events' ? this._eventsPath : this._todosPath, list);
|
||||
this._changed();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// ---- Todos ----
|
||||
|
||||
todosOn(date) {
|
||||
return this.todos
|
||||
.filter(t => t.date === date)
|
||||
.filter(t => !t.deleted && t.date === date)
|
||||
.sort((a, b) => compareArrays([a.time ?? '', a.title], [b.time ?? '', b.title]));
|
||||
}
|
||||
|
||||
// Offene zuerst (terminierte nach Datum, dann allgemeine), erledigte zuletzt.
|
||||
sortedTodos() {
|
||||
const key = t => [t.done ? 1 : 0, t.date ? 0 : 1, t.date ?? '', t.time ?? '', t.created ?? ''];
|
||||
return [...this.todos].sort((a, b) => compareArrays(key(a), key(b)));
|
||||
return this.todos.filter(t => !t.deleted).sort((a, b) => compareArrays(key(a), key(b)));
|
||||
}
|
||||
|
||||
saveTodo(todo) {
|
||||
@@ -140,9 +184,8 @@ export class Store {
|
||||
}
|
||||
|
||||
removeTodo(id) {
|
||||
this.todos = this.todos.filter(t => t.id !== id);
|
||||
writeJson(this._todosPath, this.todos);
|
||||
this._changed();
|
||||
const todo = this.todos.find(t => t.id === id);
|
||||
this.saveTodo({...todo, deleted: true});
|
||||
}
|
||||
|
||||
// ---- Notizen ----
|
||||
@@ -151,6 +194,50 @@ export class Store {
|
||||
return listFolder(this.notesDir);
|
||||
}
|
||||
|
||||
// Pfad relativ zum Notizordner, dient dem Sync als id.
|
||||
relative(path) {
|
||||
return path.slice(this.notesDir.length + 1);
|
||||
}
|
||||
|
||||
absolute(relative) {
|
||||
return GLib.build_filenamev([this.notesDir, relative]);
|
||||
}
|
||||
|
||||
// Alle Notizen mit Änderungszeit, für den Sync.
|
||||
listNotes() {
|
||||
const out = [];
|
||||
const walk = folder => {
|
||||
folder.folders.forEach(walk);
|
||||
for (const note of folder.notes)
|
||||
out.push({id: this.relative(note.path), updated: modifiedIso(Gio.File.new_for_path(note.path))});
|
||||
};
|
||||
walk(this.tree());
|
||||
return out;
|
||||
}
|
||||
|
||||
// Notiz vom Server übernehmen. Die Änderungszeit wird übernommen, damit sie nicht als neu gilt.
|
||||
writeRemoteNote(relative, content, updated) {
|
||||
const path = this.absolute(relative);
|
||||
GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o755);
|
||||
GLib.file_set_contents(path, content);
|
||||
setModified(Gio.File.new_for_path(path), updated);
|
||||
delete this.deletedNotes[relative];
|
||||
writeJson(this._deletedNotesPath, this.deletedNotes);
|
||||
this._changed();
|
||||
}
|
||||
|
||||
deleteRemoteNote(relative) {
|
||||
const path = this.absolute(relative);
|
||||
if (GLib.file_test(path, GLib.FileTest.EXISTS))
|
||||
Gio.File.new_for_path(path).delete(null);
|
||||
this._changed();
|
||||
}
|
||||
|
||||
_rememberDeleted(path) {
|
||||
this.deletedNotes[this.relative(path)] = new Date().toISOString();
|
||||
writeJson(this._deletedNotesPath, this.deletedNotes);
|
||||
}
|
||||
|
||||
readNote(path) {
|
||||
const [, bytes] = GLib.file_get_contents(path);
|
||||
return decoder.decode(bytes);
|
||||
@@ -166,6 +253,8 @@ export class Store {
|
||||
if (GLib.file_test(path, GLib.FileTest.EXISTS))
|
||||
throw new Error('Diese Notiz gibt es schon');
|
||||
GLib.file_set_contents(path, '');
|
||||
delete this.deletedNotes[this.relative(path)];
|
||||
writeJson(this._deletedNotesPath, this.deletedNotes);
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -184,20 +273,38 @@ export class Store {
|
||||
if (GLib.file_test(target, GLib.FileTest.EXISTS))
|
||||
throw new Error('Diese Notiz gibt es schon');
|
||||
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
|
||||
this._rememberDeleted(path);
|
||||
this._touch(target);
|
||||
return target;
|
||||
}
|
||||
|
||||
_touch(path) {
|
||||
setModified(Gio.File.new_for_path(path), new Date().toISOString());
|
||||
}
|
||||
|
||||
renameFolder(path, name) {
|
||||
checkName(name);
|
||||
const target = GLib.build_filenamev([GLib.path_get_dirname(path), name]);
|
||||
if (GLib.file_test(target, GLib.FileTest.EXISTS))
|
||||
throw new Error('Diesen Ordner gibt es schon');
|
||||
const notes = [];
|
||||
const collect = folder => {
|
||||
folder.folders.forEach(collect);
|
||||
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);
|
||||
for (const note of notes) {
|
||||
this._rememberDeleted(note);
|
||||
this._touch(target + note.slice(path.length));
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
deleteNote(path) {
|
||||
Gio.File.new_for_path(path).delete(null);
|
||||
this._rememberDeleted(path);
|
||||
}
|
||||
|
||||
folderEmpty(path) {
|
||||
|
||||
147
desktop@marha.de/sync.js
Normal file
147
desktop@marha.de/sync.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import Gio from 'gi://Gio';
|
||||
import GLib from 'gi://GLib';
|
||||
import Soup from 'gi://Soup';
|
||||
|
||||
import {readJson, writeJson} from './store.js';
|
||||
|
||||
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
|
||||
// {"url": "https://planer.marha.de", "token": "...", "interval": 5}
|
||||
// Alle paar Sekunden: eigene Änderungen seit dem letzten Lauf hochladen, Änderungen des Servers
|
||||
// seit dem letzten Stand holen. Bei Konflikten gewinnt der Eintrag mit dem neueren updated.
|
||||
// sync-state.json merkt sich den Server-Stand (cursor) und was zuletzt übertragen wurde (pushed).
|
||||
|
||||
const DEFAULT_INTERVAL = 5;
|
||||
|
||||
export class Sync {
|
||||
constructor(store) {
|
||||
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._session = new Soup.Session({timeout: 10});
|
||||
this._busy = false;
|
||||
this._failing = false;
|
||||
this._timerId = null;
|
||||
}
|
||||
|
||||
get active() {
|
||||
return Boolean(this._config?.url && this._config?.token);
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.active)
|
||||
return;
|
||||
const interval = this._config.interval ?? DEFAULT_INTERVAL;
|
||||
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => {
|
||||
this._cycle().catch(e => this._fail(e.message));
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
});
|
||||
this._cycle().catch(e => this._fail(e.message));
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._timerId) {
|
||||
GLib.source_remove(this._timerId);
|
||||
this._timerId = null;
|
||||
}
|
||||
this._session.abort();
|
||||
}
|
||||
|
||||
async _cycle() {
|
||||
if (this._busy)
|
||||
return;
|
||||
this._busy = true;
|
||||
try {
|
||||
const payload = this._collect();
|
||||
const response = await this._post(payload);
|
||||
this._apply(response, payload);
|
||||
if (this._failing) {
|
||||
this._failing = false;
|
||||
console.log('desktop-app sync: Verbindung wieder da');
|
||||
}
|
||||
} finally {
|
||||
this._busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Alles, was seit dem letzten Lauf lokal geändert wurde.
|
||||
_collect() {
|
||||
const pushed = this._state.pushed;
|
||||
const store = this._store;
|
||||
const changed = (collection, items) => items.filter(item => pushed[collection][item.id] !== item.updated);
|
||||
const notes = changed('notes', store.listNotes()).map(({id, updated}) => ({id, updated, content: store.readNote(store.absolute(id))}));
|
||||
for (const [id, updated] of Object.entries(store.deletedNotes)) {
|
||||
if (pushed.notes[id] !== updated)
|
||||
notes.push({id, updated, deleted: true});
|
||||
}
|
||||
return {
|
||||
since: this._state.cursor,
|
||||
events: changed('events', store.events),
|
||||
todos: changed('todos', store.todos),
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
_post(payload) {
|
||||
const message = Soup.Message.new('POST', `${this._config.url}/api/sync`);
|
||||
message.request_headers.append('Authorization', `Bearer ${this._config.token}`);
|
||||
message.set_request_body_from_bytes('application/json', new GLib.Bytes(new TextEncoder().encode(JSON.stringify(payload))));
|
||||
return new Promise((resolve, reject) => {
|
||||
this._session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => {
|
||||
try {
|
||||
const bytes = session.send_and_read_finish(result);
|
||||
if (message.get_status() !== Soup.Status.OK)
|
||||
throw new Error(`HTTP ${message.get_status()} ${message.get_reason_phrase()}`);
|
||||
resolve(JSON.parse(new TextDecoder().decode(bytes.get_data())));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Antwort einarbeiten. Was der Server zurückschickt und wir gerade hochgeladen haben, kennt er jetzt.
|
||||
_apply(response, payload) {
|
||||
const store = this._store;
|
||||
const pushed = this._state.pushed;
|
||||
for (const collection of ['events', 'todos']) {
|
||||
for (const item of payload[collection])
|
||||
pushed[collection][item.id] = item.updated;
|
||||
store.applyRemote(collection, response[collection]);
|
||||
for (const item of response[collection])
|
||||
pushed[collection][item.id] = item.updated;
|
||||
}
|
||||
for (const note of payload.notes)
|
||||
pushed.notes[note.id] = note.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.
|
||||
const local = new Map(store.listNotes().map(n => [n.id, n.updated]));
|
||||
let skipped = false;
|
||||
for (const note of response.notes) {
|
||||
if (store.lockedNote === store.absolute(note.id)) {
|
||||
skipped = true;
|
||||
continue;
|
||||
}
|
||||
const mine = local.get(note.id) ?? store.deletedNotes[note.id];
|
||||
if (mine && mine >= note.updated) {
|
||||
pushed.notes[note.id] = mine;
|
||||
continue;
|
||||
}
|
||||
if (note.deleted)
|
||||
store.deleteRemoteNote(note.id);
|
||||
else
|
||||
store.writeRemoteNote(note.id, note.content, note.updated);
|
||||
pushed.notes[note.id] = note.updated;
|
||||
}
|
||||
if (!skipped)
|
||||
this._state.cursor = response.cursor;
|
||||
writeJson(this._statePath, this._state);
|
||||
}
|
||||
|
||||
_fail(message) {
|
||||
if (!this._failing)
|
||||
console.log(`desktop-app sync: ${message}`);
|
||||
this._failing = true;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@ DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
"$DIR/stop.sh"
|
||||
pgrep -f "^gnome-shell --(nested|headless)" | xargs -r kill 2>/dev/null
|
||||
rm -rf /tmp/desktop-app-nested/data/desktop-app
|
||||
# Mit SYNC_URL bekommt die Test-Shell einen Server-Zugang (Token "test", alle 2 Sekunden)
|
||||
if [ -n "$SYNC_URL" ]; then
|
||||
mkdir -p /tmp/desktop-app-nested/data/desktop-app
|
||||
echo "{\"url\": \"$SYNC_URL\", \"token\": \"test\", \"interval\": 2}" > /tmp/desktop-app-nested/data/desktop-app/sync.json
|
||||
fi
|
||||
sleep 2
|
||||
(nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &)
|
||||
sleep 9
|
||||
|
||||
@@ -82,7 +82,7 @@ rclick 'cal._blocks.find(b => b.event.title === "Sport").block'
|
||||
check "Kontextmenü offen" "$(val 'labels(grabbed()).join("|")')" "Löschen"
|
||||
shot "03-context-menu"
|
||||
click 'find(grabbed(), "Löschen")'
|
||||
check "Termin gelöscht" "$(val 'store.events.filter(e => e.title === "Sport").length')" "0"
|
||||
check "Termin gelöscht (Tombstone bleibt)" "$(val 'store.events.filter(e => e.title === "Sport").map(e => e.deleted).join(",") + " " + store.eventsOn("2026-09-18").filter(e => e.title === "Sport").length')" "true 0"
|
||||
|
||||
echo "=== Woche: ganztägiger Termin, Klick daneben schließt"
|
||||
click 'cal._alldaySlots[6]'
|
||||
@@ -118,7 +118,7 @@ check "Todo bearbeiten zeigt Text" "$(val 'entries(grabbed())[0]')" "Milch kaufe
|
||||
key Escape
|
||||
rclick 'find(todos, "Milch kaufen")'
|
||||
click 'find(grabbed(), "Löschen")'
|
||||
check "Todo gelöscht" "$(val 'store.todos.filter(t => t.title === "Milch kaufen").length')" "0"
|
||||
check "Todo gelöscht (Tombstone bleibt)" "$(val 'store.todos.filter(t => t.title === "Milch kaufen").map(t => t.deleted).join(",") + " " + store.sortedTodos().filter(t => t.title === "Milch kaufen").length')" "true 0"
|
||||
rclickb 'todos'
|
||||
check "Rechtsklick Todo-Fläche" "$(val 'labels(grabbed()).join("|")')" "Erstellen"
|
||||
click 'find(grabbed(), "Erstellen")'
|
||||
|
||||
130
dev/tests/sync.sh
Executable file
130
dev/tests/sync.sh
Executable file
@@ -0,0 +1,130 @@
|
||||
#!/bin/bash
|
||||
# Sync-Test: lokaler Server plus headless Shell, Änderungen in beide Richtungen: dev/tests/sync.sh
|
||||
DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ROOT=$(cd "$DIR/.." && pwd)
|
||||
E="$DIR/eval.sh"
|
||||
OUT=/tmp/desktop-app-nested/out.txt
|
||||
PORT=8041
|
||||
URL="http://localhost:$PORT"
|
||||
SERVER_DATA=/tmp/desktop-app-sync-server
|
||||
AUTH="Authorization: Bearer test"
|
||||
FAILED=0
|
||||
|
||||
PRE='const ext = Main.extensionManager.lookup("desktop@marha.de").stateObj;
|
||||
const notes = ext._root.get_child_at_index(0);
|
||||
const todos = ext._root.get_child_at_index(1).get_child_at_index(1);
|
||||
const store = ext._store;
|
||||
const read = p => new TextDecoder().decode(GLib.file_get_contents(p)[1]);
|
||||
const exists = p => GLib.file_test(p, GLib.FileTest.EXISTS);
|
||||
const state = () => JSON.parse(read(store.dir + "/sync-state.json"));
|
||||
const St = imports.gi.St;
|
||||
const labels = a => { const out = []; const walk = n => { if (n instanceof St.Label && n.text) out.push(n.text); n.get_children().forEach(walk); }; walk(a); return out; };
|
||||
'
|
||||
val() {
|
||||
"$E" "$PRE GLib.file_set_contents('$OUT', String($1)); 'w'" | grep -v "^OK '\"w\"')$"
|
||||
cat "$OUT"
|
||||
}
|
||||
api() { curl -s -H "$AUTH" -H "Content-Type: application/json" "$@"; }
|
||||
now() { date -u +%Y-%m-%dT%H:%M:%S.000Z; }
|
||||
push() { api -X POST "$URL/api/sync" -d "$1" >/dev/null; }
|
||||
check() {
|
||||
local name=$1 actual=$2 expected=$3
|
||||
if [ "$actual" == "$expected" ]; then
|
||||
echo "ok $name"
|
||||
else
|
||||
echo "FAIL $name"; echo " ist: $actual"; echo " soll: $expected"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
}
|
||||
cleanup() {
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
"$DIR/stop.sh"
|
||||
pkill -f "^gnome-shell --(nested|headless)" 2>/dev/null
|
||||
wait 2>/dev/null
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== Server starten"
|
||||
rm -rf "$SERVER_DATA"
|
||||
(cd "$ROOT/backend" && DATA_DIR="$SERVER_DATA" SYNC_TOKEN=test .venv/bin/uvicorn app.main:app --port $PORT > /tmp/desktop-app-sync-server.log 2>&1 &)
|
||||
for i in $(seq 1 30); do curl -s "$URL/api/health" >/dev/null && break; sleep 0.5; done
|
||||
SERVER_PID=$(pgrep -f "uvicorn app.main:app --port $PORT")
|
||||
check "Server läuft" "$(curl -s $URL/api/health)" '{"ok":true}'
|
||||
|
||||
echo "=== Shell mit Sync starten"
|
||||
SYNC_URL="$URL" "$DIR/restart.sh" headless >/dev/null
|
||||
check "Sync aktiv" "$(val 'ext._sync.active')" "true"
|
||||
|
||||
echo "=== Lokal → Server"
|
||||
"$E" "$PRE store.saveEvent({title: 'Zahnarzt', date: '2026-09-21', start: '09:00', end: '10:00', endDate: null}); store.saveTodo({title: 'Einkaufen', date: null, time: null}); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
check "Termin auf dem Server" "$(api "$URL/api/events" | python3 -c 'import json,sys; print([e["title"] for e in json.load(sys.stdin)])')" "['Zahnarzt']"
|
||||
check "Todo auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["title"] for t in json.load(sys.stdin)])')" "['Einkaufen']"
|
||||
check "Stand gemerkt" "$(val 'state().cursor > 0')" "true"
|
||||
|
||||
echo "=== Server → lokal"
|
||||
push '{"todos": [{"id": "r1", "updated": "2026-09-19T10:00:00.000Z", "title": "Vom Handy", "date": null, "time": null, "done": false}], "notes": [{"id": "Remote/Idee.md", "updated": "2026-09-19T10:00:00.000Z", "content": "# Vom Server"}]}'
|
||||
sleep 3
|
||||
check "Todo vom Server in der Liste" "$(val 'store.sortedTodos().map(t => t.title).sort().join(",")')" "Einkaufen,Vom Handy"
|
||||
check "Todo vom Server im Panel" "$(val 'labels(todos).includes("Vom Handy")')" "true"
|
||||
check "Notiz vom Server als Datei" "$(val 'exists(store.notesDir + "/Remote/Idee.md") + " " + JSON.stringify(read(store.notesDir + "/Remote/Idee.md"))')" 'true "# Vom Server"'
|
||||
check "Notiz vom Server im Baum" "$(val 'labels(notes).includes("▸ Remote")')" "true"
|
||||
check "Übernommene Notiz wird nicht zurückgeschickt" "$(val 'state().pushed.notes["Remote/Idee.md"]')" "2026-09-19T10:00:00.000Z"
|
||||
|
||||
echo "=== Konflikt: neuester gewinnt"
|
||||
push '{"todos": [{"id": "r1", "updated": "2026-09-19T09:00:00.000Z", "title": "Älter", "date": null, "time": null, "done": false}]}'
|
||||
sleep 3
|
||||
check "Älterer Stand verliert" "$(val 'store.todos.find(t => t.id === "r1").title')" "Vom Handy"
|
||||
push '{"todos": [{"id": "r1", "updated": "'$(now)'", "title": "Neuer", "date": null, "time": null, "done": false}]}'
|
||||
sleep 3
|
||||
check "Neuerer Stand gewinnt" "$(val 'store.todos.find(t => t.id === "r1").title')" "Neuer"
|
||||
sleep 1
|
||||
"$E" "$PRE store.toggleTodo('r1'); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
check "Lokale Änderung ist neuer und landet auf dem Server" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print([t["done"] for t in json.load(sys.stdin) if t["id"] == "r1"])')" "[True]"
|
||||
push '{"todos": [{"id": "r1", "updated": "2026-09-19T09:30:00.000Z", "title": "Verspätet", "date": null, "time": null, "done": false}]}'
|
||||
sleep 3
|
||||
check "Verspäteter älterer Stand ändert nichts" "$(val 'store.todos.find(t => t.id === "r1").title + " " + store.todos.find(t => t.id === "r1").done')" "Neuer true"
|
||||
|
||||
echo "=== Löschen in beide Richtungen"
|
||||
"$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Einkaufen').id); store.deleteNote(store.notesDir + '/Remote/Idee.md'); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
check "Gelöschtes Todo auf dem Server weg" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print(sorted(t["title"] for t in json.load(sys.stdin)))')" "['Neuer']"
|
||||
check "Gelöschte Notiz auf dem Server weg" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([n["id"] for n in json.load(sys.stdin)])')" "[]"
|
||||
"$E" "$PRE store.createNote(store.notesDir, 'Lokal'); store.writeNote(store.notesDir + '/Lokal.md', 'lokal'); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
check "Lokale Notiz auf dem Server" "$(api "$URL/api/notes" | python3 -c 'import json,sys; print([(n["id"], n["content"]) for n in json.load(sys.stdin)])')" "[('Lokal.md', 'lokal')]"
|
||||
push '{"notes": [{"id": "Lokal.md", "updated": "'$(now)'", "deleted": true}]}'
|
||||
sleep 3
|
||||
check "Vom Server gelöschte Notiz lokal weg" "$(val 'exists(store.notesDir + "/Lokal.md")')" "false"
|
||||
|
||||
echo "=== Offene Notiz bleibt unangetastet"
|
||||
"$E" "$PRE store.createNote(store.notesDir, 'Offen'); store.writeNote(store.notesDir + '/Offen.md', 'meins'); notes._select({type: 'note', path: store.notesDir + '/Offen.md', name: 'Offen'}); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
"$E" "$PRE notes._startEdit(); 'ok'" >/dev/null
|
||||
CURSOR=$(val 'state().cursor')
|
||||
push '{"notes": [{"id": "Offen.md", "updated": "'$(now)'", "content": "vom server"}]}'
|
||||
sleep 3
|
||||
check "Datei während des Tippens unverändert" "$(val 'read(store.notesDir + "/Offen.md") + " " + (state().cursor === '"$CURSOR"')')" "meins true"
|
||||
"$E" "$PRE notes._stopEdit(); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
check "Nach dem Schließen übernommen" "$(val 'read(store.notesDir + "/Offen.md") + " " + notes._content.text')" "vom server vom server"
|
||||
|
||||
echo "=== Umbenennen"
|
||||
sleep 1
|
||||
"$E" "$PRE store.renameNote(store.notesDir + '/Offen.md', 'Neu'); 'ok'" >/dev/null
|
||||
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 "=== Server weg und wieder da"
|
||||
kill $SERVER_PID; sleep 1
|
||||
"$E" "$PRE store.saveTodo({title: 'Offline', date: null, time: null}); 'ok'" >/dev/null
|
||||
sleep 3
|
||||
(cd "$ROOT/backend" && DATA_DIR="$SERVER_DATA" SYNC_TOKEN=test .venv/bin/uvicorn app.main:app --port $PORT >> /tmp/desktop-app-sync-server.log 2>&1 &)
|
||||
for i in $(seq 1 30); do curl -s "$URL/api/health" >/dev/null && break; sleep 0.5; done
|
||||
SERVER_PID=$(pgrep -f "uvicorn app.main:app --port $PORT")
|
||||
sleep 4
|
||||
check "Offline-Änderung nachgeliefert" "$(api "$URL/api/todos" | python3 -c 'import json,sys; print(sorted(t["title"] for t in json.load(sys.stdin)))')" "['Neuer', 'Offline']"
|
||||
check "Keine JS-Fehler" "$(grep -c 'JS ERROR' /tmp/desktop-app-nested.log)" "0"
|
||||
echo
|
||||
if [ $FAILED -eq 0 ]; then echo "alle ok"; else echo "$FAILED FEHLER"; grep -n "JS ERROR" -A5 /tmp/desktop-app-nested.log | head -30; grep "desktop-app sync" /tmp/desktop-app-nested.log | tail -5; fi
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
services:
|
||||
desktop:
|
||||
build:
|
||||
context: .
|
||||
container_name: desktop
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Traefik routet erst ab "healthy" — ohne dichten Start-Takt ist die Seite anfangs 404
|
||||
start_period: 30s
|
||||
start_interval: 2s
|
||||
networks:
|
||||
- web
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.desktopapp.rule=Host(`planer.marha.de`)"
|
||||
- "traefik.http.routers.desktopapp.entrypoints=websecure"
|
||||
- "traefik.http.routers.desktopapp.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.desktopapp.loadbalancer.server.port=8000"
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
Reference in New Issue
Block a user