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

View File

@@ -7,6 +7,7 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {CalendarPanel} from './calendar.js';
import {NotesPanel} from './notes.js';
import {Store} from './store.js';
import {Sync} from './sync.js';
import {TodoPanel} from './todos.js';
const MARGIN = 24;
@@ -31,9 +32,14 @@ export default class DesktopExtension extends Extension {
this._workareaId = global.display.connect('workareas-changed', () => this._layout());
this._layout();
this._restack();
this._sync = new Sync(this._store);
this._sync.start();
}
disable() {
this._sync.stop();
this._sync = null;
global.display.disconnect(this._restackId);
global.window_manager.disconnect(this._mapId);
global.display.disconnect(this._workareaId);

View File

@@ -61,6 +61,11 @@ class NotesPanel extends St.BoxLayout {
return Clutter.EVENT_STOP;
});
this.connect('destroy', () => this._stopAutosave());
// Änderungen vom Sync. Während des Tippens bleibt der Editor, wie er ist.
store.onChange(() => {
if (!this._editing)
this._refresh();
});
this._refresh();
}
@@ -143,8 +148,10 @@ class NotesPanel extends St.BoxLayout {
if (this._editing || this._selected?.type !== 'note')
return;
this._editing = true;
this._dirty = false;
this._store.lockedNote = this._selected.path;
this._editor.set_text(this._store.readNote(this._selected.path));
// Erst nach set_text, das löst selbst text-changed aus. Sonst gilt jedes Öffnen als Änderung.
this._dirty = false;
this._viewScroll.hide();
this._editScroll.show();
this._editor.clutter_text.grab_key_focus();
@@ -184,7 +191,9 @@ class NotesPanel extends St.BoxLayout {
return;
this._flush();
this._editing = false;
this._store.lockedNote = null;
this._stopAutosave();
// Nur die Anzeige wechseln. Ein Neuaufbau des Baums würde die gerade geklickte Zeile zerstören.
this._showSelected();
}
@@ -265,6 +274,7 @@ class NotesPanel extends St.BoxLayout {
_delete(type, node) {
if (this._selected?.path === node.path) {
this._editing = false;
this._store.lockedNote = null;
this._stopAutosave();
this._selected = null;
}

View File

@@ -2,26 +2,44 @@ import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
// Ablage: ~/.local/share/desktop-app/
// events.json Termine
// todos.json Todos
// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt
// events.json Termine
// todos.json Todos
// notes/ Notizen als .md-Dateien, Ordner beliebig verschachtelt
// deleted-notes.json gelöschte Notizen mit Zeitpunkt, damit der Sync sie auch woanders löscht
// sync.json Server-Zugang (url, token), siehe sync.js
// Gelöschte Termine und Todos bleiben mit deleted: true in der Liste (Tombstone) und werden nur ausgeblendet.
const decoder = new TextDecoder();
function readJson(path) {
export function readJson(path, fallback = []) {
try {
const [ok, bytes] = GLib.file_get_contents(path);
return ok ? JSON.parse(decoder.decode(bytes)) : [];
return ok ? JSON.parse(decoder.decode(bytes)) : fallback;
} catch {
// Datei gibt es beim ersten Start noch nicht
return [];
return fallback;
}
}
function writeJson(path, data) {
export function writeJson(path, data) {
GLib.file_set_contents(path, JSON.stringify(data, null, 2));
}
// Änderungszeit einer Datei als ISO-String mit Millisekunden.
function modifiedIso(file) {
const info = file.query_info('time::modified,time::modified-usec', Gio.FileQueryInfoFlags.NONE, null);
const usec = info.get_attribute_uint32('time::modified-usec');
return new Date(Number(info.get_attribute_uint64('time::modified')) * 1000 + Math.floor(usec / 1000)).toISOString();
}
function setModified(file, iso) {
const ms = new Date(iso).getTime();
const info = new Gio.FileInfo();
info.set_attribute_uint64('time::modified', Math.floor(ms / 1000));
info.set_attribute_uint32('time::modified-usec', (ms % 1000) * 1000);
file.set_attributes_from_info(info, Gio.FileQueryInfoFlags.NONE, null);
}
function compareArrays(a, b) {
for (let i = 0; i < a.length; i++) {
if (a[i] < b[i])
@@ -67,9 +85,13 @@ export class Store {
GLib.mkdir_with_parents(this.notesDir, 0o755);
this._eventsPath = GLib.build_filenamev([this.dir, 'events.json']);
this._todosPath = GLib.build_filenamev([this.dir, 'todos.json']);
this._deletedNotesPath = GLib.build_filenamev([this.dir, 'deleted-notes.json']);
this.events = readJson(this._eventsPath);
this.todos = readJson(this._todosPath);
this.deletedNotes = readJson(this._deletedNotesPath, {});
this._listeners = [];
// Notiz, die gerade im Editor offen ist. Der Sync fasst sie nicht an.
this.lockedNote = null;
}
onChange(callback) {
@@ -86,7 +108,7 @@ export class Store {
// Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate.
eventsOn(date) {
return this.events
.filter(e => e.date <= date && date <= (e.endDate ?? e.date))
.filter(e => !e.deleted && e.date <= date && date <= (e.endDate ?? e.date))
.sort((a, b) => compareArrays([a.start ?? '', a.title], [b.start ?? '', b.title]));
}
@@ -103,23 +125,45 @@ export class Store {
}
removeEvent(id) {
this.events = this.events.filter(e => e.id !== id);
writeJson(this._eventsPath, this.events);
this._changed();
const event = this.events.find(e => e.id === id);
this.saveEvent({...event, deleted: true});
}
// ---- Sync: Änderungen von außen übernehmen, neuester Stand gewinnt ----
// Gibt true zurück, wenn sich lokal etwas geändert hat.
applyRemote(collection, items) {
const list = this[collection];
let changed = false;
for (const item of items) {
const index = list.findIndex(e => e.id === item.id);
if (index >= 0 && list[index].updated >= item.updated)
continue;
if (index >= 0)
list[index] = item;
else
list.push(item);
changed = true;
}
if (changed) {
writeJson(collection === 'events' ? this._eventsPath : this._todosPath, list);
this._changed();
}
return changed;
}
// ---- Todos ----
todosOn(date) {
return this.todos
.filter(t => t.date === date)
.filter(t => !t.deleted && t.date === date)
.sort((a, b) => compareArrays([a.time ?? '', a.title], [b.time ?? '', b.title]));
}
// Offene zuerst (terminierte nach Datum, dann allgemeine), erledigte zuletzt.
sortedTodos() {
const key = t => [t.done ? 1 : 0, t.date ? 0 : 1, t.date ?? '', t.time ?? '', t.created ?? ''];
return [...this.todos].sort((a, b) => compareArrays(key(a), key(b)));
return this.todos.filter(t => !t.deleted).sort((a, b) => compareArrays(key(a), key(b)));
}
saveTodo(todo) {
@@ -140,9 +184,8 @@ export class Store {
}
removeTodo(id) {
this.todos = this.todos.filter(t => t.id !== id);
writeJson(this._todosPath, this.todos);
this._changed();
const todo = this.todos.find(t => t.id === id);
this.saveTodo({...todo, deleted: true});
}
// ---- Notizen ----
@@ -151,6 +194,50 @@ export class Store {
return listFolder(this.notesDir);
}
// Pfad relativ zum Notizordner, dient dem Sync als id.
relative(path) {
return path.slice(this.notesDir.length + 1);
}
absolute(relative) {
return GLib.build_filenamev([this.notesDir, relative]);
}
// Alle Notizen mit Änderungszeit, für den Sync.
listNotes() {
const out = [];
const walk = folder => {
folder.folders.forEach(walk);
for (const note of folder.notes)
out.push({id: this.relative(note.path), updated: modifiedIso(Gio.File.new_for_path(note.path))});
};
walk(this.tree());
return out;
}
// Notiz vom Server übernehmen. Die Änderungszeit wird übernommen, damit sie nicht als neu gilt.
writeRemoteNote(relative, content, updated) {
const path = this.absolute(relative);
GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o755);
GLib.file_set_contents(path, content);
setModified(Gio.File.new_for_path(path), updated);
delete this.deletedNotes[relative];
writeJson(this._deletedNotesPath, this.deletedNotes);
this._changed();
}
deleteRemoteNote(relative) {
const path = this.absolute(relative);
if (GLib.file_test(path, GLib.FileTest.EXISTS))
Gio.File.new_for_path(path).delete(null);
this._changed();
}
_rememberDeleted(path) {
this.deletedNotes[this.relative(path)] = new Date().toISOString();
writeJson(this._deletedNotesPath, this.deletedNotes);
}
readNote(path) {
const [, bytes] = GLib.file_get_contents(path);
return decoder.decode(bytes);
@@ -166,6 +253,8 @@ export class Store {
if (GLib.file_test(path, GLib.FileTest.EXISTS))
throw new Error('Diese Notiz gibt es schon');
GLib.file_set_contents(path, '');
delete this.deletedNotes[this.relative(path)];
writeJson(this._deletedNotesPath, this.deletedNotes);
return path;
}
@@ -184,20 +273,38 @@ export class Store {
if (GLib.file_test(target, GLib.FileTest.EXISTS))
throw new Error('Diese Notiz gibt es schon');
Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null);
// Für den Sync ist das löschen plus neu anlegen
this._rememberDeleted(path);
this._touch(target);
return target;
}
_touch(path) {
setModified(Gio.File.new_for_path(path), new Date().toISOString());
}
renameFolder(path, name) {
checkName(name);
const target = GLib.build_filenamev([GLib.path_get_dirname(path), name]);
if (GLib.file_test(target, GLib.FileTest.EXISTS))
throw new Error('Diesen Ordner gibt es schon');
const notes = [];
const collect = folder => {
folder.folders.forEach(collect);
notes.push(...folder.notes.map(n => n.path));
};
collect(listFolder(path));
Gio.File.new_for_path(path).move(Gio.File.new_for_path(target), Gio.FileCopyFlags.NONE, null, null);
for (const note of notes) {
this._rememberDeleted(note);
this._touch(target + note.slice(path.length));
}
return target;
}
deleteNote(path) {
Gio.File.new_for_path(path).delete(null);
this._rememberDeleted(path);
}
folderEmpty(path) {

147
desktop@marha.de/sync.js Normal file
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;
}
}