This commit is contained in:
team3
2026-09-19 20:59:47 +02:00
parent 45a6ecf654
commit 4548178a7c
20 changed files with 832 additions and 23 deletions

2
.env.example Normal file
View 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
View File

@@ -0,0 +1,6 @@
backend/.venv/
backend/data/
data/
.env
__pycache__/
.pytest_cache/

15
Dockerfile Normal file
View 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"]

View File

@@ -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

View File

@@ -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
View File

12
backend/app/config.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
[pytest]
testpaths = tests

4
backend/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
httpx==0.28.1
pytest==8.3.4

View 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"]

View File

@@ -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);

View File

@@ -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;
} }

View File

@@ -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
View 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;
}
}

View File

@@ -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

View File

@@ -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
View 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
View 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