config system + move api to frontend
This commit is contained in:
@@ -5,9 +5,6 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
|
||||
import supabasePlugin from "@takerofnotes/plugin-supabase";
|
||||
import fs from "fs/promises";
|
||||
import path, { join } from "path";
|
||||
import { Index } from "flexsearch";
|
||||
import crypto from "crypto";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import __cjs_mod__ from "node:module";
|
||||
const __filename = import.meta.filename;
|
||||
const __dirname = import.meta.dirname;
|
||||
@@ -26,16 +23,21 @@ class PluginRegistry {
|
||||
return this.plugins.get(id);
|
||||
}
|
||||
list() {
|
||||
return Array.from(this.plugins.values());
|
||||
return Array.from(this.plugins.values()).map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
configSchema: plugin.configSchema
|
||||
}));
|
||||
}
|
||||
}
|
||||
const USER_DATA_STRING = "__DEFAULT_USER_DATA__";
|
||||
class PluginConfig {
|
||||
class Config {
|
||||
constructor(defaultPlugin) {
|
||||
this.defaultPlugin = defaultPlugin;
|
||||
this.configPath = path.join(app.getPath("userData"), "config.json");
|
||||
}
|
||||
// Helper to replace placeholders with dynamic values, recursively
|
||||
// Helper to replace placeholders with dynamic values
|
||||
_resolveDefaults(config) {
|
||||
if (Array.isArray(config)) {
|
||||
return config.map((item) => this._resolveDefaults(item));
|
||||
@@ -65,9 +67,10 @@ class PluginConfig {
|
||||
defaultConfig[field.key] = field.default ?? null;
|
||||
}
|
||||
parsed = {
|
||||
activeAdapter: this.defaultPlugin.id,
|
||||
adapterConfig: defaultConfig
|
||||
...parsed ? parsed : {},
|
||||
activeAdapter: this.defaultPlugin.id
|
||||
};
|
||||
parsed.adapters[this.defaultPlugin.id] = defaultConfig;
|
||||
await this.write(parsed);
|
||||
} else {
|
||||
parsed.adapterConfig = this._resolveDefaults(parsed.adapterConfig);
|
||||
@@ -88,206 +91,6 @@ class PluginConfig {
|
||||
);
|
||||
}
|
||||
}
|
||||
class NotesAPI {
|
||||
constructor(adapter, encryptionKey = null) {
|
||||
if (!adapter) {
|
||||
throw new Error("NotesAPI requires a storage adapter");
|
||||
}
|
||||
this.adapter = adapter;
|
||||
this.notesCache = /* @__PURE__ */ new Map();
|
||||
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY;
|
||||
this._sodiumReady = false;
|
||||
this.index = new Index({
|
||||
tokenize: "forward"
|
||||
});
|
||||
}
|
||||
async _initSodium() {
|
||||
if (!this._sodiumReady) {
|
||||
await sodium.ready;
|
||||
this._sodiumReady = true;
|
||||
}
|
||||
}
|
||||
_encrypt(note) {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error("Encryption key not set");
|
||||
}
|
||||
const key = Buffer.from(this.encryptionKey, "hex");
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
"Encryption key must be 64 hex characters (32 bytes)"
|
||||
);
|
||||
}
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
||||
const message = JSON.stringify(note);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
Buffer.from(message),
|
||||
nonce,
|
||||
key
|
||||
);
|
||||
const combined = Buffer.concat([nonce, ciphertext]);
|
||||
return combined.toString("base64");
|
||||
}
|
||||
_decrypt(encryptedData) {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error("Encryption key not set");
|
||||
}
|
||||
const key = Buffer.from(this.encryptionKey, "hex");
|
||||
if (key.length !== 32) {
|
||||
throw new Error(
|
||||
"Encryption key must be 64 hex characters (32 bytes)"
|
||||
);
|
||||
}
|
||||
let combined;
|
||||
try {
|
||||
combined = Buffer.from(encryptedData, "base64");
|
||||
} catch (e) {
|
||||
throw new Error("Invalid encrypted data: not valid base64");
|
||||
}
|
||||
if (combined.length < sodium.crypto_secretbox_NONCEBYTES + sodium.crypto_secretbox_MACBYTES) {
|
||||
throw new Error("Invalid encrypted data: too short");
|
||||
}
|
||||
const nonce = combined.slice(0, sodium.crypto_secretbox_NONCEBYTES);
|
||||
const ciphertext = combined.slice(sodium.crypto_secretbox_NONCEBYTES);
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = sodium.crypto_secretbox_open_easy(
|
||||
ciphertext,
|
||||
nonce,
|
||||
key
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error("Decryption failed: wrong key or corrupted data");
|
||||
}
|
||||
if (!decrypted) {
|
||||
throw new Error("Decryption failed: no data returned");
|
||||
}
|
||||
const decryptedStr = Buffer.from(decrypted).toString("utf8");
|
||||
try {
|
||||
return JSON.parse(decryptedStr);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Decryption succeeded but invalid JSON: ${decryptedStr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
async init() {
|
||||
await this._initSodium();
|
||||
await this.adapter.init();
|
||||
const encryptedNotes = await this.adapter.getAll();
|
||||
for (const encryptedNote of encryptedNotes) {
|
||||
try {
|
||||
const note = this._decrypt(encryptedNote.data || encryptedNote);
|
||||
this.notesCache.set(note.id, note);
|
||||
const searchText = note.plainText || this._extractPlainText(note.content);
|
||||
this.index.add(note.id, note.title + "\n" + searchText);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt note:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
_extractPlainText(content) {
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
const extractText = (node) => {
|
||||
if (typeof node === "string") return node;
|
||||
if (!node || !node.content) return "";
|
||||
return node.content.map(extractText).join(" ");
|
||||
};
|
||||
return extractText(content);
|
||||
}
|
||||
/* -----------------------
|
||||
Public API
|
||||
------------------------*/
|
||||
getCategories() {
|
||||
const categories = /* @__PURE__ */ new Set();
|
||||
for (const note of this.notesCache.values()) {
|
||||
if (note.category) {
|
||||
categories.add(note.category);
|
||||
}
|
||||
}
|
||||
return Array.from(categories).sort();
|
||||
}
|
||||
getCategoryNotes(categoryName) {
|
||||
return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
}
|
||||
getNote(id) {
|
||||
return this.notesCache.get(id) ?? null;
|
||||
}
|
||||
async createNote(metadata = {}, content = "", plainText = "") {
|
||||
const id = crypto.randomUUID();
|
||||
const now = (/* @__PURE__ */ new Date()).toISOString();
|
||||
const note = {
|
||||
id,
|
||||
title: metadata.title || "Untitled",
|
||||
category: metadata.category || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
content,
|
||||
plainText
|
||||
};
|
||||
const encryptedNote = {
|
||||
id: note.id,
|
||||
data: this._encrypt(note)
|
||||
};
|
||||
this.notesCache.set(id, note);
|
||||
this.index.add(id, note.title + "\n" + plainText);
|
||||
await this.adapter.create(encryptedNote);
|
||||
return note;
|
||||
}
|
||||
async deleteNote(id) {
|
||||
await this.adapter.delete(id);
|
||||
this.notesCache.delete(id);
|
||||
this.index.remove(id);
|
||||
}
|
||||
async updateNote(id, content, plainText = "") {
|
||||
const note = this.notesCache.get(id);
|
||||
if (!note) throw new Error("Note not found");
|
||||
note.content = content;
|
||||
note.plainText = plainText;
|
||||
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
||||
const encryptedNote = {
|
||||
id: note.id,
|
||||
data: this._encrypt(note)
|
||||
};
|
||||
this.index.update(id, note.title + "\n" + plainText);
|
||||
await this.adapter.update(encryptedNote);
|
||||
return note;
|
||||
}
|
||||
async updateNoteMetadata(id, updates = {}) {
|
||||
const note = this.notesCache.get(id);
|
||||
if (!note) throw new Error("Note not found");
|
||||
const allowedFields = ["title", "category"];
|
||||
for (const key of Object.keys(updates)) {
|
||||
if (!allowedFields.includes(key)) {
|
||||
throw new Error(`Invalid metadata field: ${key}`);
|
||||
}
|
||||
}
|
||||
if (updates.title !== void 0) {
|
||||
note.title = updates.title;
|
||||
}
|
||||
if (updates.category !== void 0) {
|
||||
note.category = updates.category;
|
||||
}
|
||||
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
||||
const encryptedNote = {
|
||||
id: note.id,
|
||||
data: this._encrypt(note)
|
||||
};
|
||||
this.index.update(
|
||||
id,
|
||||
note.title + "\n" + (note.plainText || this._extractPlainText(note.content))
|
||||
);
|
||||
await this.adapter.update(encryptedNote);
|
||||
return note;
|
||||
}
|
||||
search(query) {
|
||||
const ids = this.index.search(query, {
|
||||
limit: 50,
|
||||
suggest: true
|
||||
});
|
||||
return ids.map((id) => this.notesCache.get(id));
|
||||
}
|
||||
}
|
||||
const preloadPath = join(__dirname, "../preload/index.mjs");
|
||||
const rendererPath = join(__dirname, "../renderer/index.html");
|
||||
function createWindow() {
|
||||
@@ -343,34 +146,44 @@ app.whenReady().then(async () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register(filesystemPlugin);
|
||||
registry.register(supabasePlugin);
|
||||
const config = await new PluginConfig(filesystemPlugin).load();
|
||||
const plugin = registry.get(config.activeAdapter);
|
||||
const adapter = plugin.createAdapter(config.adapterConfig);
|
||||
const notesAPI = new NotesAPI(
|
||||
adapter,
|
||||
"729a0d21d783654c68f1a0123e2a0e986350de536b5324f1f35876ea12ffeaf5"
|
||||
);
|
||||
await notesAPI.init();
|
||||
const config = new Config(filesystemPlugin);
|
||||
const initialConfig = await config.load();
|
||||
const setActivePlugin = async (pluginId) => {
|
||||
const currentConfig = await config.load();
|
||||
await config.write({ ...currentConfig, activeAdapter: pluginId });
|
||||
const plugin = registry.get(pluginId);
|
||||
const adapterConfig = currentConfig.adapters[pluginId] || {};
|
||||
const adapter = plugin.createAdapter(adapterConfig);
|
||||
await adapter.init();
|
||||
ipcMain.removeHandler("adapter:call");
|
||||
ipcMain.handle("adapter:call", async (_, method, args) => {
|
||||
if (!adapter[method]) {
|
||||
throw new Error(`Invalid adapter method: ${method}`);
|
||||
}
|
||||
return await adapter[method](...args);
|
||||
});
|
||||
return true;
|
||||
};
|
||||
await setActivePlugin(initialConfig.activeAdapter);
|
||||
ipcMain.handle("getConfig", async () => {
|
||||
return await config.load();
|
||||
});
|
||||
ipcMain.handle("setConfig", async (_, newConfig) => {
|
||||
await config.write(newConfig);
|
||||
});
|
||||
ipcMain.handle("listPlugins", async () => {
|
||||
return registry.list();
|
||||
});
|
||||
ipcMain.handle("setActivePlugin", async (_, pluginId) => {
|
||||
return await setActivePlugin(pluginId);
|
||||
});
|
||||
const broadcastNoteChange = (event, data) => {
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send(event, data);
|
||||
});
|
||||
};
|
||||
ipcMain.handle("notesAPI:call", async (_, method, args) => {
|
||||
if (!notesAPI[method]) {
|
||||
throw new Error("Invalid method");
|
||||
}
|
||||
const result = await notesAPI[method](...args);
|
||||
if (method === "createNote") {
|
||||
broadcastNoteChange("note-created", result);
|
||||
} else if (method === "updateNote") {
|
||||
broadcastNoteChange("note-updated", result);
|
||||
} else if (method === "updateNoteMetadata") {
|
||||
broadcastNoteChange("note-updated", result);
|
||||
} else if (method === "deleteNote") {
|
||||
broadcastNoteChange("note-deleted", { id: args[0] });
|
||||
}
|
||||
return result;
|
||||
ipcMain.on("note-changed", (_, event, data) => {
|
||||
broadcastNoteChange(event, data);
|
||||
});
|
||||
electronApp.setAppUserModelId("com.electron");
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
|
||||
Reference in New Issue
Block a user