update
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user