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
|
# Extension: make install | make update
|
||||||
# make update – installierte Extension mit dem Stand aus diesem Ordner überschreiben
|
# Server: make dev (lokal, Port $(PORT)) | make prod (auf dem Server) | make stop | make logs | make down | make test
|
||||||
UUID = desktop@marha.de
|
UUID = desktop@marha.de
|
||||||
SRC = $(CURDIR)/$(UUID)
|
SRC = $(CURDIR)/$(UUID)
|
||||||
DEST = $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
|
DEST = $(HOME)/.local/share/gnome-shell/extensions/$(UUID)
|
||||||
|
VENV = backend/.venv
|
||||||
|
PORT ?= 8040
|
||||||
|
|
||||||
.ONESHELL:
|
.ONESHELL:
|
||||||
.PHONY: install update enable
|
.PHONY: install update enable venv dev prod stop logs down test
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@if [ -e "$(DEST)" ]; then
|
@if [ -e "$(DEST)" ]; then
|
||||||
@@ -35,3 +37,37 @@ update:
|
|||||||
# UUID in die aktiven Extensions eintragen, falls sie fehlt
|
# UUID in die aktiven Extensions eintragen, falls sie fehlt
|
||||||
enable:
|
enable:
|
||||||
@python3 dev/enable.py "$(UUID)"
|
@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ü
|
- `notes.js` – Ordnerbaum, Markdown-Anzeige, Inline-Editor, Kontextmenü
|
||||||
- `widgets.js` – Popover (Grab, Esc, Tab, Klick daneben), Kontextmenü, Datums- und Zeitauswahl
|
- `widgets.js` – Popover (Grab, Esc, Tab, Klick daneben), Kontextmenü, Datums- und Zeitauswahl
|
||||||
- `markdown.js`, `dates.js` – reine Logik, ohne Shell testbar
|
- `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
|
- `dev/` – Test-Werkzeuge
|
||||||
|
|
||||||
## Daten
|
## Daten
|
||||||
@@ -38,6 +40,41 @@ unter allen Fenstern, über dem Wallpaper.
|
|||||||
- `events.json` – Termine
|
- `events.json` – Termine
|
||||||
- `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
|
||||||
|
- `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
|
## 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 # Test-Shell als Fenster starten (zum Selbst-Ausprobieren)
|
||||||
dev/restart.sh headless # Test-Shell unsichtbar, für simulierte Eingaben
|
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/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 dev/seed.js # Testdaten einspielen
|
||||||
dev/eval.sh 'js' # JavaScript in der Test-Shell ausführen (oder Dateipfad)
|
dev/eval.sh 'js' # JavaScript in der Test-Shell ausführen (oder Dateipfad)
|
||||||
dev/shot.sh [datei.png] # Screenshot der Test-Shell
|
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 {CalendarPanel} from './calendar.js';
|
||||||
import {NotesPanel} from './notes.js';
|
import {NotesPanel} from './notes.js';
|
||||||
import {Store} from './store.js';
|
import {Store} from './store.js';
|
||||||
|
import {Sync} from './sync.js';
|
||||||
import {TodoPanel} from './todos.js';
|
import {TodoPanel} from './todos.js';
|
||||||
|
|
||||||
const MARGIN = 24;
|
const MARGIN = 24;
|
||||||
@@ -31,9 +32,14 @@ export default class DesktopExtension extends Extension {
|
|||||||
this._workareaId = global.display.connect('workareas-changed', () => this._layout());
|
this._workareaId = global.display.connect('workareas-changed', () => this._layout());
|
||||||
this._layout();
|
this._layout();
|
||||||
this._restack();
|
this._restack();
|
||||||
|
|
||||||
|
this._sync = new Sync(this._store);
|
||||||
|
this._sync.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
|
this._sync.stop();
|
||||||
|
this._sync = null;
|
||||||
global.display.disconnect(this._restackId);
|
global.display.disconnect(this._restackId);
|
||||||
global.window_manager.disconnect(this._mapId);
|
global.window_manager.disconnect(this._mapId);
|
||||||
global.display.disconnect(this._workareaId);
|
global.display.disconnect(this._workareaId);
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ class NotesPanel extends St.BoxLayout {
|
|||||||
return Clutter.EVENT_STOP;
|
return Clutter.EVENT_STOP;
|
||||||
});
|
});
|
||||||
this.connect('destroy', () => this._stopAutosave());
|
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();
|
this._refresh();
|
||||||
}
|
}
|
||||||
@@ -143,8 +148,10 @@ class NotesPanel extends St.BoxLayout {
|
|||||||
if (this._editing || this._selected?.type !== 'note')
|
if (this._editing || this._selected?.type !== 'note')
|
||||||
return;
|
return;
|
||||||
this._editing = true;
|
this._editing = true;
|
||||||
this._dirty = false;
|
this._store.lockedNote = this._selected.path;
|
||||||
this._editor.set_text(this._store.readNote(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._viewScroll.hide();
|
||||||
this._editScroll.show();
|
this._editScroll.show();
|
||||||
this._editor.clutter_text.grab_key_focus();
|
this._editor.clutter_text.grab_key_focus();
|
||||||
@@ -184,7 +191,9 @@ class NotesPanel extends St.BoxLayout {
|
|||||||
return;
|
return;
|
||||||
this._flush();
|
this._flush();
|
||||||
this._editing = false;
|
this._editing = false;
|
||||||
|
this._store.lockedNote = null;
|
||||||
this._stopAutosave();
|
this._stopAutosave();
|
||||||
|
// Nur die Anzeige wechseln. Ein Neuaufbau des Baums würde die gerade geklickte Zeile zerstören.
|
||||||
this._showSelected();
|
this._showSelected();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +274,7 @@ class NotesPanel extends St.BoxLayout {
|
|||||||
_delete(type, node) {
|
_delete(type, node) {
|
||||||
if (this._selected?.path === node.path) {
|
if (this._selected?.path === node.path) {
|
||||||
this._editing = false;
|
this._editing = false;
|
||||||
|
this._store.lockedNote = null;
|
||||||
this._stopAutosave();
|
this._stopAutosave();
|
||||||
this._selected = null;
|
this._selected = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,44 @@ import Gio from 'gi://Gio';
|
|||||||
import GLib from 'gi://GLib';
|
import GLib from 'gi://GLib';
|
||||||
|
|
||||||
// Ablage: ~/.local/share/desktop-app/
|
// Ablage: ~/.local/share/desktop-app/
|
||||||
// events.json Termine
|
// events.json Termine
|
||||||
// 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
|
||||||
|
// 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();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
function readJson(path) {
|
export function readJson(path, fallback = []) {
|
||||||
try {
|
try {
|
||||||
const [ok, bytes] = GLib.file_get_contents(path);
|
const [ok, bytes] = GLib.file_get_contents(path);
|
||||||
return ok ? JSON.parse(decoder.decode(bytes)) : [];
|
return ok ? JSON.parse(decoder.decode(bytes)) : fallback;
|
||||||
} catch {
|
} catch {
|
||||||
// Datei gibt es beim ersten Start noch nicht
|
// 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));
|
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) {
|
function compareArrays(a, b) {
|
||||||
for (let i = 0; i < a.length; i++) {
|
for (let i = 0; i < a.length; i++) {
|
||||||
if (a[i] < b[i])
|
if (a[i] < b[i])
|
||||||
@@ -67,9 +85,13 @@ export class Store {
|
|||||||
GLib.mkdir_with_parents(this.notesDir, 0o755);
|
GLib.mkdir_with_parents(this.notesDir, 0o755);
|
||||||
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.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._listeners = [];
|
this._listeners = [];
|
||||||
|
// Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an.
|
||||||
|
this.lockedNote = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(callback) {
|
onChange(callback) {
|
||||||
@@ -86,7 +108,7 @@ export class Store {
|
|||||||
// Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate.
|
// Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate.
|
||||||
eventsOn(date) {
|
eventsOn(date) {
|
||||||
return this.events
|
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]));
|
.sort((a, b) => compareArrays([a.start ?? '', a.title], [b.start ?? '', b.title]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,23 +125,45 @@ export class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeEvent(id) {
|
removeEvent(id) {
|
||||||
this.events = this.events.filter(e => e.id !== id);
|
const event = this.events.find(e => e.id === id);
|
||||||
writeJson(this._eventsPath, this.events);
|
this.saveEvent({...event, deleted: true});
|
||||||
this._changed();
|
}
|
||||||
|
|
||||||
|
// ---- 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 ----
|
// ---- Todos ----
|
||||||
|
|
||||||
todosOn(date) {
|
todosOn(date) {
|
||||||
return this.todos
|
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]));
|
.sort((a, b) => compareArrays([a.time ?? '', a.title], [b.time ?? '', b.title]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offene zuerst (terminierte nach Datum, dann allgemeine), erledigte zuletzt.
|
// Offene zuerst (terminierte nach Datum, dann allgemeine), erledigte zuletzt.
|
||||||
sortedTodos() {
|
sortedTodos() {
|
||||||
const key = t => [t.done ? 1 : 0, t.date ? 0 : 1, t.date ?? '', t.time ?? '', t.created ?? ''];
|
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) {
|
saveTodo(todo) {
|
||||||
@@ -140,9 +184,8 @@ export class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeTodo(id) {
|
removeTodo(id) {
|
||||||
this.todos = this.todos.filter(t => t.id !== id);
|
const todo = this.todos.find(t => t.id === id);
|
||||||
writeJson(this._todosPath, this.todos);
|
this.saveTodo({...todo, deleted: true});
|
||||||
this._changed();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Notizen ----
|
// ---- Notizen ----
|
||||||
@@ -151,6 +194,50 @@ export class Store {
|
|||||||
return listFolder(this.notesDir);
|
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) {
|
readNote(path) {
|
||||||
const [, bytes] = GLib.file_get_contents(path);
|
const [, bytes] = GLib.file_get_contents(path);
|
||||||
return decoder.decode(bytes);
|
return decoder.decode(bytes);
|
||||||
@@ -166,6 +253,8 @@ export class Store {
|
|||||||
if (GLib.file_test(path, GLib.FileTest.EXISTS))
|
if (GLib.file_test(path, GLib.FileTest.EXISTS))
|
||||||
throw new Error('Diese Notiz gibt es schon');
|
throw new Error('Diese Notiz gibt es schon');
|
||||||
GLib.file_set_contents(path, '');
|
GLib.file_set_contents(path, '');
|
||||||
|
delete this.deletedNotes[this.relative(path)];
|
||||||
|
writeJson(this._deletedNotesPath, this.deletedNotes);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,20 +273,38 @@ export class Store {
|
|||||||
if (GLib.file_test(target, GLib.FileTest.EXISTS))
|
if (GLib.file_test(target, GLib.FileTest.EXISTS))
|
||||||
throw new Error('Diese Notiz gibt es schon');
|
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);
|
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;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_touch(path) {
|
||||||
|
setModified(Gio.File.new_for_path(path), new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
renameFolder(path, name) {
|
renameFolder(path, name) {
|
||||||
checkName(name);
|
checkName(name);
|
||||||
const target = GLib.build_filenamev([GLib.path_get_dirname(path), name]);
|
const target = GLib.build_filenamev([GLib.path_get_dirname(path), name]);
|
||||||
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 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);
|
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;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteNote(path) {
|
deleteNote(path) {
|
||||||
Gio.File.new_for_path(path).delete(null);
|
Gio.File.new_for_path(path).delete(null);
|
||||||
|
this._rememberDeleted(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
folderEmpty(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"
|
"$DIR/stop.sh"
|
||||||
pgrep -f "^gnome-shell --(nested|headless)" | xargs -r kill 2>/dev/null
|
pgrep -f "^gnome-shell --(nested|headless)" | xargs -r kill 2>/dev/null
|
||||||
rm -rf /tmp/desktop-app-nested/data/desktop-app
|
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
|
sleep 2
|
||||||
(nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &)
|
(nohup "$DIR/nested.sh" "$1" > /tmp/desktop-app-nested.log 2>&1 &)
|
||||||
sleep 9
|
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"
|
check "Kontextmenü offen" "$(val 'labels(grabbed()).join("|")')" "Löschen"
|
||||||
shot "03-context-menu"
|
shot "03-context-menu"
|
||||||
click 'find(grabbed(), "Löschen")'
|
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"
|
echo "=== Woche: ganztägiger Termin, Klick daneben schließt"
|
||||||
click 'cal._alldaySlots[6]'
|
click 'cal._alldaySlots[6]'
|
||||||
@@ -118,7 +118,7 @@ check "Todo bearbeiten zeigt Text" "$(val 'entries(grabbed())[0]')" "Milch kaufe
|
|||||||
key Escape
|
key Escape
|
||||||
rclick 'find(todos, "Milch kaufen")'
|
rclick 'find(todos, "Milch kaufen")'
|
||||||
click 'find(grabbed(), "Löschen")'
|
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'
|
rclickb 'todos'
|
||||||
check "Rechtsklick Todo-Fläche" "$(val 'labels(grabbed()).join("|")')" "Erstellen"
|
check "Rechtsklick Todo-Fläche" "$(val 'labels(grabbed()).join("|")')" "Erstellen"
|
||||||
click 'find(grabbed(), "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