Files
desktop-planer/desktop@marha.de/store.js
2026-09-19 20:59:47 +02:00

321 lines
11 KiB
JavaScript

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);
}
}