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 // 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(); export function readJson(path, fallback = []) { try { const [ok, bytes] = GLib.file_get_contents(path); return ok ? JSON.parse(decoder.decode(bytes)) : fallback; } catch { // Datei gibt es beim ersten Start noch nicht return fallback; } } 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]) return -1; if (a[i] > b[i]) return 1; } return 0; } function listFolder(path) { const node = {name: GLib.path_get_basename(path), path, folders: [], notes: []}; const dir = Gio.File.new_for_path(path); const children = dir.enumerate_children('standard::name,standard::type', Gio.FileQueryInfoFlags.NONE, null); let info; while ((info = children.next_file(null))) { const name = info.get_name(); if (name.startsWith('.')) continue; const child = GLib.build_filenamev([path, name]); if (info.get_file_type() === Gio.FileType.DIRECTORY) node.folders.push(listFolder(child)); else if (name.endsWith('.md')) node.notes.push({name: name.slice(0, -3), path: child}); } children.close(null); node.folders.sort((a, b) => a.name.localeCompare(b.name)); node.notes.sort((a, b) => a.name.localeCompare(b.name)); return node; } function checkName(name) { if (!name) throw new Error('Name fehlt'); if (name.includes('/') || name.startsWith('.')) throw new Error('Name darf kein "/" enthalten und nicht mit "." beginnen'); } export class Store { constructor() { this.dir = GLib.build_filenamev([GLib.get_user_data_dir(), 'desktop-app']); this.notesDir = GLib.build_filenamev([this.dir, 'notes']); 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) { this._listeners.push(callback); } _changed() { for (const callback of this._listeners) callback(); } // ---- Termine ---- // Termine, die den Tag berühren. Mehrtägige laufen von date bis endDate. eventsOn(date) { return this.events .filter(e => !e.deleted && e.date <= date && date <= (e.endDate ?? e.date)) .sort((a, b) => compareArrays([a.start ?? '', a.title], [b.start ?? '', b.title])); } saveEvent(event) { const now = new Date().toISOString(); if (!event.id) { this.events.push({...event, id: GLib.uuid_string_random(), created: now, updated: now}); } else { const index = this.events.findIndex(e => e.id === event.id); this.events[index] = {...event, updated: now}; } writeJson(this._eventsPath, this.events); this._changed(); } removeEvent(id) { 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.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.filter(t => !t.deleted).sort((a, b) => compareArrays(key(a), key(b))); } saveTodo(todo) { const now = new Date().toISOString(); if (!todo.id) { this.todos.push({...todo, done: false, id: GLib.uuid_string_random(), created: now, updated: now}); } else { const index = this.todos.findIndex(t => t.id === todo.id); this.todos[index] = {...todo, updated: now}; } writeJson(this._todosPath, this.todos); this._changed(); } toggleTodo(id) { const todo = this.todos.find(t => t.id === id); this.saveTodo({...todo, done: !todo.done}); } removeTodo(id) { const todo = this.todos.find(t => t.id === id); this.saveTodo({...todo, deleted: true}); } // ---- Notizen ---- tree() { 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); } writeNote(path, text) { GLib.file_set_contents(path, text); } createNote(folder, name) { checkName(name); const path = GLib.build_filenamev([folder, `${name}.md`]); 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; } createFolder(parent, name) { checkName(name); const path = GLib.build_filenamev([parent, name]); if (GLib.file_test(path, GLib.FileTest.EXISTS)) throw new Error('Diesen Ordner gibt es schon'); Gio.File.new_for_path(path).make_directory(null); return path; } renameNote(path, name) { checkName(name); const target = GLib.build_filenamev([GLib.path_get_dirname(path), `${name}.md`]); 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) { const node = listFolder(path); return !node.folders.length && !node.notes.length; } deleteFolder(path) { if (!this.folderEmpty(path)) throw new Error('Der Ordner ist nicht leer'); Gio.File.new_for_path(path).delete(null); } }