This commit is contained in:
team3
2026-09-19 22:22:51 +02:00
parent 7da689889c
commit e1b532b35c
7 changed files with 70 additions and 17 deletions

View File

@@ -47,7 +47,7 @@ sync-config:
echo "Sync-Zugang vorhanden: $(SYNC_JSON)" echo "Sync-Zugang vorhanden: $(SYNC_JSON)"
elif [ -f .env ] && grep -q "^SYNC_TOKEN=.\+" .env; then elif [ -f .env ] && grep -q "^SYNC_TOKEN=.\+" .env; then
mkdir -p "$(dir $(SYNC_JSON))" mkdir -p "$(dir $(SYNC_JSON))"
printf '{"url": "$(SYNC_URL)", "token": "%s", "interval": 5}\n' "$$(grep ^SYNC_TOKEN= .env | cut -d= -f2-)" > "$(SYNC_JSON)" printf '{"url": "$(SYNC_URL)", "token": "%s"}\n' "$$(grep ^SYNC_TOKEN= .env | cut -d= -f2-)" > "$(SYNC_JSON)"
chmod 600 "$(SYNC_JSON)" chmod 600 "$(SYNC_JSON)"
echo "Sync-Zugang angelegt: $(SYNC_JSON)" echo "Sync-Zugang angelegt: $(SYNC_JSON)"
else else

View File

@@ -50,18 +50,20 @@ Gelöschte Termine und Todos bleiben mit `deleted: true` in der Datei und werden
## Sync ## Sync
Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht alle paar Sekunden ab, Der Server (`backend/`, FastAPI + SQLite) hält den Kern. Jedes Gerät gleicht regelmäßig ab und sofort
und sofort nach jeder eigenen Änderung: eigene Änderungen hoch, fremde Änderungen runter. Bei Konflikten gewinnt der Eintrag mit dem nach jeder eigenen Änderung: eigene Änderungen hoch, fremde Änderungen runter. Der Takt richtet sich nach
der Aktivität: nach einer Änderung (eigene oder vom Server) eine Minute lang jede Sekunde, dann alle
5 Sekunden, nach 5 Minuten Ruhe alle 10 Sekunden. Bei Konflikten gewinnt der Eintrag mit dem
neueren `updated` (Notizen: Änderungszeit der Datei, ganze Datei). Offline geht weiter, beim 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. 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`: Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`:
``` ```
{"url": "https://planer.marha.de", "token": "<SYNC_TOKEN aus .env>", "interval": 5} {"url": "https://planer.marha.de", "token": "<SYNC_TOKEN aus .env>"}
``` ```
Ohne diese Datei läuft die Extension rein lokal. Ohne diese Datei läuft die Extension rein lokal. Optional `"interval"`: Ruhetakt in Sekunden statt 10.
Server-API (alle Aufrufe mit `Authorization: Bearer <SYNC_TOKEN>`): Server-API (alle Aufrufe mit `Authorization: Bearer <SYNC_TOKEN>`):

View File

@@ -135,6 +135,9 @@ class NotesPanel extends St.BoxLayout {
} }
this._noteTitle.text = selected.name; this._noteTitle.text = selected.name;
this._content.clutter_text.set_markup(renderMarkdown(text)); this._content.clutter_text.set_markup(renderMarkdown(text));
// Clutter merkt sich die Textattribute des vorigen Inhalts für die Höhenberechnung.
// Ohne diesen Reset wird die Notiz mit größeren Überschriften unten abgeschnitten.
this._content.clutter_text.set_attributes(null);
} else { } else {
this._noteTitle.text = ''; this._noteTitle.text = '';
this._content.text = ''; this._content.text = '';

View File

@@ -6,11 +6,19 @@ import {readJson, writeJson} from './store.js';
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner: // Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
// {"url": "https://planer.marha.de", "token": "...", "interval": 5} // {"url": "https://planer.marha.de", "token": "...", "interval": 5}
// Alle paar Sekunden und kurz nach jeder eigenen Änderung: eigene Änderungen hochladen, // Kurz nach jeder eigenen Änderung und in einem Takt, der sich nach der Aktivität richtet:
// Änderungen des Servers seit dem letzten Stand holen. Bei Konflikten gewinnt der Eintrag mit dem neueren updated. // eigene Änderungen 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). // sync-state.json merkt sich den Server-Stand (cursor) und was zuletzt übertragen wurde (pushed).
const DEFAULT_INTERVAL = 5; // Takt nach der letzten Aktivität (eigene Änderung oder Änderung vom Server):
// erste Minute jede Sekunde, dann alle 5 Sekunden, nach 5 Minuten Ruhe alle 10 Sekunden.
// "interval" in sync.json ersetzt den Ruhetakt.
const INTERVALS = [
{after: 0, seconds: 1},
{after: 60, seconds: 5},
{after: 5 * 60, seconds: 10},
];
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen // Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
const SOON_MS = 500; const SOON_MS = 500;
@@ -32,6 +40,8 @@ export class Sync {
this._timerId = null; this._timerId = null;
this._soonId = null; this._soonId = null;
this._applying = false; this._applying = false;
// Beim Start gilt: lange nichts passiert
this._lastActivity = 0;
} }
get active() { get active() {
@@ -41,18 +51,40 @@ export class Sync {
start() { start() {
if (!this.active) if (!this.active)
return; 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;
});
// Eigene Änderungen gehen sofort raus, nicht erst beim nächsten Takt // Eigene Änderungen gehen sofort raus, nicht erst beim nächsten Takt
this._store.onChange(() => this._scheduleSoon()); this._store.onChange(() => this._scheduleSoon());
this._cycle().catch(e => this._fail(e.message)); this._cycle().catch(e => this._fail(e.message));
this._scheduleNext();
}
// Aktueller Takt in Sekunden, je nachdem wie lange die letzte Aktivität her ist.
get interval() {
const idle = (GLib.get_monotonic_time() - this._lastActivity) / 1e6;
const step = [...INTERVALS].reverse().find(s => idle >= s.after);
const idleInterval = this._config.interval ?? INTERVALS.at(-1).seconds;
return step === INTERVALS.at(-1) ? idleInterval : step.seconds;
}
_touchActivity() {
this._lastActivity = GLib.get_monotonic_time();
}
// Nächsten Lauf planen. Der Abstand wird jedes Mal neu bestimmt.
_scheduleNext() {
if (this._stopped)
return;
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this.interval, () => {
this._timerId = null;
this._cycle().catch(e => this._fail(e.message)).finally(() => this._scheduleNext());
return GLib.SOURCE_REMOVE;
});
} }
_scheduleSoon() { _scheduleSoon() {
if (this._stopped || this._applying || this._soonId) if (this._stopped || this._applying)
return;
this._touchActivity();
if (this._soonId)
return; return;
this._soonId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SOON_MS, () => { this._soonId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SOON_MS, () => {
this._soonId = null; this._soonId = null;
@@ -82,6 +114,9 @@ export class Sync {
try { try {
const payload = this._collect(); const payload = this._collect();
const response = await this._post(payload); const response = await this._post(payload);
// Kam etwas vom Server, ist das andere Gerät gerade aktiv: schneller nachfragen
if (['events', 'todos', 'notes', 'folders'].some(c => (response[c] ?? []).length))
this._touchActivity();
// Übernommene Änderungen lösen selbst onChange aus, das soll keinen neuen Lauf starten // Übernommene Änderungen lösen selbst onChange aus, das soll keinen neuen Lauf starten
this._applying = true; this._applying = true;
try { try {

View File

@@ -4,10 +4,10 @@ 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) # Mit SYNC_URL bekommt die Test-Shell einen Server-Zugang (Token "test")
if [ -n "$SYNC_URL" ]; then if [ -n "$SYNC_URL" ]; then
mkdir -p /tmp/desktop-app-nested/data/desktop-app 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 echo "{\"url\": \"$SYNC_URL\", \"token\": \"test\"}" > /tmp/desktop-app-nested/data/desktop-app/sync.json
fi 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 &)

View File

@@ -149,6 +149,7 @@ click 'cal._viewButtons.month'
check "Fokusverlust beendet den Editor, Anzeige gerendert" "$(val '[notes._editing, notes._viewScroll.visible, notes._content.text].join("|")')" "false|true|Hallo check "Fokusverlust beendet den Editor, Anzeige gerendert" "$(val '[notes._editing, notes._viewScroll.visible, notes._content.text].join("|")')" "false|true|Hallo
Erste Notiz" Erste Notiz"
check "Notiz mit Überschrift wird nicht abgeschnitten" "$(val '(l => Math.round(l.get_preferred_height(l.width)[1]) >= l.clutter_text.get_layout().get_pixel_size()[1] && l.get_allocation_box().get_height() >= l.clutter_text.get_layout().get_pixel_size()[1])(notes._content)')" "true"
click 'notes._viewScroll' click 'notes._viewScroll'
check "Klick in die Fläche startet den Editor" "$(val '[notes._editing, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true" check "Klick in die Fläche startet den Editor" "$(val '[notes._editing, global.stage.get_key_focus() === notes._editor.clutter_text].join(" ")')" "true true"
type_ " mehr" type_ " mehr"

View File

@@ -54,6 +54,7 @@ check "Server läuft" "$(curl -s $URL/api/health)" '{"ok":true}'
echo "=== Shell mit Sync starten" echo "=== Shell mit Sync starten"
SYNC_URL="$URL" "$DIR/restart.sh" headless >/dev/null SYNC_URL="$URL" "$DIR/restart.sh" headless >/dev/null
check "Sync aktiv" "$(val 'ext._sync.active')" "true" check "Sync aktiv" "$(val 'ext._sync.active')" "true"
check "Ohne Aktivität Ruhetakt 10 s" "$(val 'ext._sync.interval')" "10"
echo "=== Lokal → Server" 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 "$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
@@ -61,11 +62,12 @@ 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 "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 "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" check "Stand gemerkt" "$(val 'state().cursor > 0')" "true"
check "Nach eigener Änderung Takt 1 s" "$(val 'ext._sync.interval')" "1"
"$E" "$PRE store.saveTodo({title: 'Sofort', date: null, time: null}); 'ok'" >/dev/null "$E" "$PRE store.saveTodo({title: 'Sofort', date: null, time: null}); 'ok'" >/dev/null
START=$(date +%s%N) START=$(date +%s%N)
for i in $(seq 1 40); do api "$URL/api/todos" | grep -q '"Sofort"' && break; sleep 0.1; done for i in $(seq 1 40); do api "$URL/api/todos" | grep -q '"Sofort"' && break; sleep 0.1; done
MS=$(( ($(date +%s%N) - START) / 1000000 )) MS=$(( ($(date +%s%N) - START) / 1000000 ))
check "Eigene Änderung ist vor dem nächsten Takt (2 s) auf dem Server" "$([ $MS -lt 1500 ] && echo "schnell" || echo "langsam: ${MS}ms")" "schnell" check "Eigene Änderung ist sofort auf dem Server" "$([ $MS -lt 1500 ] && echo "schnell" || echo "langsam: ${MS}ms")" "schnell"
"$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Sofort').id); 'ok'" >/dev/null "$E" "$PRE store.removeTodo(store.todos.find(t => t.title === 'Sofort').id); 'ok'" >/dev/null
sleep 2 sleep 2
@@ -73,6 +75,16 @@ 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"}]}' 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 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 in der Liste" "$(val 'store.sortedTodos().map(t => t.title).sort().join(",")')" "Einkaufen,Vom Handy"
"$E" "$PRE ext._sync._lastActivity = GLib.get_monotonic_time() - 70e6; 'ok'" >/dev/null
check "Nach 70 s Ruhe Takt 5 s" "$(val 'ext._sync.interval')" "5"
push '{"todos": [{"id": "r2", "updated": "2026-09-19T10:00:00.000Z", "title": "Weckt auf", "date": null, "time": null, "done": false}]}'
sleep 7
check "Änderung vom Server setzt Takt auf 1 s" "$(val 'store.todos.some(t => t.id === "r2") + " " + ext._sync.interval')" "true 1"
"$E" "$PRE ext._sync._lastActivity = GLib.get_monotonic_time() - 400e6; 'ok'" >/dev/null
check "Nach 5 min Ruhe Takt 10 s" "$(val 'ext._sync.interval')" "10"
# Aufräumen: Test-Todo weg, damit wieder schnell abgeglichen wird
"$E" "$PRE store.removeTodo('r2'); 'ok'" >/dev/null
sleep 2
check "Todo vom Server im Panel" "$(val 'labels(todos).includes("Vom Handy")')" "true" 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 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 "Notiz vom Server im Baum" "$(val 'labels(notes).includes("▸ Remote")')" "true"