375 lines
13 KiB
JavaScript
375 lines
13 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
|
|
// folders.json Ordner mit Zeitpunkt und deleted-Flag, damit auch leere Ordner mitlaufen
|
|
// 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._foldersPath = GLib.build_filenamev([this.dir, 'folders.json']);
|
|
this.events = readJson(this._eventsPath);
|
|
this.todos = readJson(this._todosPath);
|
|
this.deletedNotes = readJson(this._deletedNotesPath, {});
|
|
this.folders = readJson(this._foldersPath, {});
|
|
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);
|
|
}
|
|
|
|
// ---- Ordner für den Sync ----
|
|
|
|
_setFolder(relative, deleted = false) {
|
|
this.folders[relative] = {updated: new Date().toISOString(), deleted};
|
|
writeJson(this._foldersPath, this.folders);
|
|
}
|
|
|
|
// Alle Ordner als Einträge. Vorhandene Ordner ohne Eintrag (z.B. von Hand angelegt) kommen dazu.
|
|
listFolders() {
|
|
let added = false;
|
|
const walk = folder => {
|
|
for (const sub of folder.folders) {
|
|
const relative = this.relative(sub.path);
|
|
if (!this.folders[relative] || this.folders[relative].deleted) {
|
|
this.folders[relative] = {updated: new Date().toISOString(), deleted: false};
|
|
added = true;
|
|
}
|
|
walk(sub);
|
|
}
|
|
};
|
|
walk(this.tree());
|
|
if (added)
|
|
writeJson(this._foldersPath, this.folders);
|
|
return Object.entries(this.folders).map(([id, {updated, deleted}]) => ({id, updated, deleted}));
|
|
}
|
|
|
|
// Ordner vom Server übernehmen. Gelöscht wird nur, wenn er leer ist.
|
|
applyRemoteFolder(record) {
|
|
const path = this.absolute(record.id);
|
|
if (record.deleted) {
|
|
if (GLib.file_test(path, GLib.FileTest.IS_DIR) && this.folderEmpty(path))
|
|
Gio.File.new_for_path(path).delete(null);
|
|
} else {
|
|
GLib.mkdir_with_parents(path, 0o755);
|
|
}
|
|
this.folders[record.id] = {updated: record.updated, deleted: Boolean(record.deleted)};
|
|
writeJson(this._foldersPath, this.folders);
|
|
this._changed();
|
|
}
|
|
|
|
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);
|
|
this._setFolder(this.relative(path));
|
|
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 folders = [path];
|
|
const collect = folder => {
|
|
for (const sub of folder.folders) {
|
|
folders.push(sub.path);
|
|
collect(sub);
|
|
}
|
|
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);
|
|
// Für den Sync ist das löschen plus neu anlegen, für Ordner wie für Notizen
|
|
for (const note of notes) {
|
|
this._rememberDeleted(note);
|
|
this._touch(target + note.slice(path.length));
|
|
}
|
|
for (const folder of folders) {
|
|
this._setFolder(this.relative(folder), true);
|
|
this._setFolder(this.relative(target + folder.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);
|
|
this._setFolder(this.relative(path), true);
|
|
}
|
|
}
|