update
This commit is contained in:
2
Makefile
2
Makefile
@@ -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
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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>`):
|
||||||
|
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 &)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user