From d21076a7851edf0e364a143c0bcf511dc1f10d24 Mon Sep 17 00:00:00 2001 From: nicwands Date: Tue, 24 Feb 2026 11:18:37 -0500 Subject: [PATCH] plugin system --- out/main/index.js | 174 +++++++++++++++--- src/main/core/BaseNotesAdapter.js | 25 +++ src/main/core/NotesAPI.js | 129 +++++++++++++ src/main/core/PluginConfig.js | 52 ++++++ src/main/core/PluginRegistry.js | 21 +++ src/main/index.js | 44 +++-- .../plugins/filesystem/FileSystemAdapter.js | 65 +++++++ src/main/plugins/filesystem/index.js | 24 +++ 8 files changed, 488 insertions(+), 46 deletions(-) create mode 100644 src/main/core/BaseNotesAdapter.js create mode 100644 src/main/core/NotesAPI.js create mode 100644 src/main/core/PluginConfig.js create mode 100644 src/main/core/PluginRegistry.js create mode 100644 src/main/plugins/filesystem/FileSystemAdapter.js create mode 100644 src/main/plugins/filesystem/index.js diff --git a/out/main/index.js b/out/main/index.js index ba847cb..5010bd2 100644 --- a/out/main/index.js +++ b/out/main/index.js @@ -1,40 +1,84 @@ "use strict"; const utils = require("@electron-toolkit/utils"); const electron = require("electron"); -const fs = require("fs/promises"); const path = require("path"); +const fs = require("fs/promises"); const matter = require("gray-matter"); const flexsearch = require("flexsearch"); const crypto = require("crypto"); -class NotesAPI { +class PluginRegistry { constructor() { - this.notesDir = path.join(electron.app.getPath("userData"), "notes"); - this.notesCache = /* @__PURE__ */ new Map(); - this.index = new flexsearch.Index({ - tokenize: "tolerant", - resolution: 9 - }); + this.plugins = /* @__PURE__ */ new Map(); + } + register(plugin) { + if (!plugin.id) { + throw new Error("Plugin must have an id"); + } + this.plugins.set(plugin.id, plugin); + } + get(id) { + return this.plugins.get(id); + } + list() { + return Array.from(this.plugins.values()); + } +} +class BaseNotesAdapter { + constructor(config = {}) { + this.config = config; + } + async init() { + throw new Error("init() not implemented"); + } + async getAll() { + throw new Error("getAll() not implemented"); + } + async create(note) { + throw new Error("create() not implemented"); + } + async update(note) { + throw new Error("update() not implemented"); + } + async delete(id) { + throw new Error("delete() not implemented"); + } +} +class FileSystemNotesAdapter extends BaseNotesAdapter { + constructor(config) { + super(); + for (const field in config) { + this[field] = config[field]; + } } async init() { await fs.mkdir(this.notesDir, { recursive: true }); - await this._loadAllNotes(); } - async _loadAllNotes() { + async getAll() { const files = await fs.readdir(this.notesDir); + const notes = []; for (const file of files) { if (!file.endsWith(".md")) continue; const fullPath = path.join(this.notesDir, file); const raw = await fs.readFile(fullPath, "utf8"); const parsed = matter(raw); - const note = { + notes.push({ ...parsed.data, content: parsed.content - }; - this.notesCache.set(note.id, note); - this.index.add(note.id, note.title + note.content); + }); } + return notes; } - async _writeNoteFile(note) { + async create(note) { + await this._write(note); + } + async update(note) { + await this._write(note); + } + async delete(id) { + const filePath = path.join(this.notesDir, `${id}.md`); + await fs.unlink(filePath); + } + async _write(note) { const filePath = path.join(this.notesDir, `${note.id}.md`); const fileContent = matter.stringify(note.content, { id: note.id, @@ -45,9 +89,84 @@ class NotesAPI { }); await fs.writeFile(filePath, fileContent, "utf8"); } +} +const filesystemPlugin = { + id: "filesystem", + name: "Local Filesystem", + description: "Stores notes as markdown files locally.", + version: "1.0.0", + configSchema: [ + { + key: "notesDir", + label: "Notes Directory", + type: "directory", + default: path.join(electron.app.getPath("userData"), "notes"), + required: true + } + ], + createAdapter(config) { + return new FileSystemNotesAdapter(config); + } +}; +class PluginConfig { + constructor(defaultPlugin) { + this.defaultPlugin = defaultPlugin; + this.configPath = path.join(electron.app.getPath("userData"), "config.json"); + } + async load() { + let parsed; + try { + const raw = await fs.readFile(this.configPath, "utf8"); + parsed = JSON.parse(raw); + } catch (err) { + parsed = null; + } + if (!parsed || !parsed.activeAdapter) { + const defaultConfig = {}; + for (const field of this.defaultPlugin.configSchema) { + defaultConfig[field.key] = field.default ?? null; + } + parsed = { + activeAdapter: this.defaultPlugin.id, + adapterConfig: defaultConfig + }; + await this.write(parsed); + } + return parsed; + } + async write(configObject) { + const dir = path.dirname(this.configPath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + this.configPath, + JSON.stringify(configObject, null, 2), + "utf8" + ); + } +} +class NotesAPI { + constructor(adapter) { + if (!adapter) { + throw new Error("NotesAPI requires a storage adapter"); + } + this.adapter = adapter; + this.notesCache = /* @__PURE__ */ new Map(); + this.index = new flexsearch.Index({ + tokenize: "tolerant", + resolution: 9 + }); + } + async init() { + await this.adapter.init(); + const notes = await this.adapter.getAll(); + for (const note of notes) { + this.notesCache.set(note.id, note); + this.index.add(note.id, note.title + "\n" + note.content); + } + } /* ----------------------- - Public API - ------------------------*/ + Public API + ------------------------*/ getCategories() { const categories = /* @__PURE__ */ new Set(); for (const note of this.notesCache.values()) { @@ -74,15 +193,13 @@ class NotesAPI { updatedAt: now, content }; - console.log(note); this.notesCache.set(id, note); this.index.add(id, note.title + "\n" + content); - await this._writeNoteFile(note); + await this.adapter.create(note); return note; } async deleteNote(id) { - const filePath = path.join(this.notesDir, `${id}.md`); - await fs.unlink(filePath); + await this.adapter.delete(id); this.notesCache.delete(id); this.index.remove(id); } @@ -92,7 +209,7 @@ class NotesAPI { note.content = content; note.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); this.index.update(id, note.title + "\n" + content); - await this._writeNoteFile(note); + await this.adapter.update(note); return note; } async updateNoteMetadata(id, updates = {}) { @@ -112,7 +229,7 @@ class NotesAPI { } note.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); this.index.update(id, note.title + "\n" + note.content); - await this._writeNoteFile(note); + await this.adapter.update(note); return note; } search(query) { @@ -167,7 +284,7 @@ function createNoteWindow(noteId) { }); } } -electron.app.whenReady().then(() => { +electron.app.whenReady().then(async () => { utils.electronApp.setAppUserModelId("com.electron"); electron.app.on("browser-window-created", (_, window) => { utils.optimizer.watchWindowShortcuts(window); @@ -179,8 +296,13 @@ electron.app.whenReady().then(() => { electron.ipcMain.on("open-note-window", (_, noteId) => { createNoteWindow(noteId); }); - const notesAPI = new NotesAPI(); - notesAPI.init(); + const registry = new PluginRegistry(); + registry.register(filesystemPlugin); + const config = await new PluginConfig(filesystemPlugin).load(); + const plugin = registry.get(config.activeAdapter); + const adapter = plugin.createAdapter(config.adapterConfig); + const notesAPI = new NotesAPI(adapter); + await notesAPI.init(); electron.ipcMain.handle("notesAPI:call", (_, method, args) => { if (!notesAPI[method]) { throw new Error("Invalid method"); diff --git a/src/main/core/BaseNotesAdapter.js b/src/main/core/BaseNotesAdapter.js new file mode 100644 index 0000000..be74ed8 --- /dev/null +++ b/src/main/core/BaseNotesAdapter.js @@ -0,0 +1,25 @@ +export default class BaseNotesAdapter { + constructor(config = {}) { + this.config = config + } + + async init() { + throw new Error('init() not implemented') + } + + async getAll() { + throw new Error('getAll() not implemented') + } + + async create(note) { + throw new Error('create() not implemented') + } + + async update(note) { + throw new Error('update() not implemented') + } + + async delete(id) { + throw new Error('delete() not implemented') + } +} diff --git a/src/main/core/NotesAPI.js b/src/main/core/NotesAPI.js new file mode 100644 index 0000000..de15733 --- /dev/null +++ b/src/main/core/NotesAPI.js @@ -0,0 +1,129 @@ +import { Index } from 'flexsearch' +import crypto from 'crypto' + +export default class NotesAPI { + constructor(adapter) { + if (!adapter) { + throw new Error('NotesAPI requires a storage adapter') + } + + this.adapter = adapter + this.notesCache = new Map() + + this.index = new Index({ + tokenize: 'tolerant', + resolution: 9, + }) + } + + async init() { + await this.adapter.init() + + const notes = await this.adapter.getAll() + + for (const note of notes) { + this.notesCache.set(note.id, note) + this.index.add(note.id, note.title + '\n' + note.content) + } + } + + /* ----------------------- + Public API + ------------------------*/ + getCategories() { + const categories = 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 = '') { + const id = crypto.randomUUID() + const now = new Date().toISOString() + + const note = { + id, + title: metadata.title || 'Untitled', + category: metadata.category || null, + createdAt: now, + updatedAt: now, + content, + } + + this.notesCache.set(id, note) + this.index.add(id, note.title + '\n' + content) + + await this.adapter.create(note) + + return note + } + + async deleteNote(id) { + await this.adapter.delete(id) + + this.notesCache.delete(id) + this.index.remove(id) + } + + async updateNote(id, content) { + const note = this.notesCache.get(id) + if (!note) throw new Error('Note not found') + + note.content = content + note.updatedAt = new Date().toISOString() + + this.index.update(id, note.title + '\n' + content) + + await this.adapter.update(note) + + 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 !== undefined) { + note.title = updates.title + } + + if (updates.category !== undefined) { + note.category = updates.category + } + + note.updatedAt = new Date().toISOString() + + this.index.update(id, note.title + '\n' + note.content) + + await this.adapter.update(note) + + return note + } + + search(query) { + const ids = this.index.search(query) + return ids.map((id) => this.notesCache.get(id)) + } +} diff --git a/src/main/core/PluginConfig.js b/src/main/core/PluginConfig.js new file mode 100644 index 0000000..7b804cf --- /dev/null +++ b/src/main/core/PluginConfig.js @@ -0,0 +1,52 @@ +import fs from 'fs/promises' +import path from 'path' +import { app } from 'electron' + +export default class PluginConfig { + constructor(defaultPlugin) { + this.defaultPlugin = defaultPlugin + + this.configPath = path.join(app.getPath('userData'), 'config.json') + } + + async load() { + let parsed + + try { + const raw = await fs.readFile(this.configPath, 'utf8') + parsed = JSON.parse(raw) + } catch (err) { + parsed = null + } + + if (!parsed || !parsed.activeAdapter) { + const defaultConfig = {} + + for (const field of this.defaultPlugin.configSchema) { + defaultConfig[field.key] = field.default ?? null + } + + parsed = { + activeAdapter: this.defaultPlugin.id, + adapterConfig: defaultConfig, + } + + await this.write(parsed) + } + + return parsed + } + + async write(configObject) { + const dir = path.dirname(this.configPath) + + // Ensure directory exists + await fs.mkdir(dir, { recursive: true }) + + await fs.writeFile( + this.configPath, + JSON.stringify(configObject, null, 2), + 'utf8', + ) + } +} diff --git a/src/main/core/PluginRegistry.js b/src/main/core/PluginRegistry.js new file mode 100644 index 0000000..f7f067a --- /dev/null +++ b/src/main/core/PluginRegistry.js @@ -0,0 +1,21 @@ +export default class PluginRegistry { + constructor() { + this.plugins = new Map() + } + + register(plugin) { + if (!plugin.id) { + throw new Error('Plugin must have an id') + } + + this.plugins.set(plugin.id, plugin) + } + + get(id) { + return this.plugins.get(id) + } + + list() { + return Array.from(this.plugins.values()) + } +} diff --git a/src/main/index.js b/src/main/index.js index 6795288..b3ce091 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1,13 +1,16 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { app, shell, BrowserWindow, ipcMain } from 'electron' -import NotesAPI from './notesAPI' +import PluginRegistry from './core/PluginRegistry.js' +import filesystemPlugin from './plugins/filesystem' +import PluginConfig from './core/PluginConfig.js' +import NotesAPI from './core/NotesAPI.js' import { join } from 'path' const preloadPath = join(__dirname, '../preload/index.js') const rendererPath = join(__dirname, '../renderer/index.html') +// Main window function createWindow() { - // Create the browser window. const mainWindow = new BrowserWindow({ width: 354, height: 549, @@ -28,8 +31,6 @@ function createWindow() { return { action: 'deny' } }) - // HMR for renderer base on electron-vite cli. - // Load the remote URL for development or the local html file for production. if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { @@ -37,6 +38,7 @@ function createWindow() { } } +// Open note in new window function createNoteWindow(noteId) { const noteWindow = new BrowserWindow({ width: 354, @@ -60,26 +62,16 @@ function createNoteWindow(noteId) { } } -// This method will be called when Electron has finished -// initialization and is ready to create browser windows. -// Some APIs can only be used after this event occurs. -app.whenReady().then(() => { - // Set app user model id for windows +app.whenReady().then(async () => { electronApp.setAppUserModelId('com.electron') - // Default open or close DevTools by F12 in development - // and ignore CommandOrControl + R in production. - // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) - // Create main window createWindow() app.on('activate', function () { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) createWindow() }) @@ -88,9 +80,24 @@ app.whenReady().then(() => { createNoteWindow(noteId) }) + // Create plugin registry + const registry = new PluginRegistry() + + // Register built-in plugins + registry.register(filesystemPlugin) + + // Pull plugin config + const config = await new PluginConfig(filesystemPlugin).load() + + // Create instance of active adapter + const plugin = registry.get(config.activeAdapter) + const adapter = plugin.createAdapter(config.adapterConfig) + // Init Notes API - const notesAPI = new NotesAPI() - notesAPI.init() + const notesAPI = new NotesAPI(adapter) + await notesAPI.init() + + // Handle Notes API ipcMain.handle('notesAPI:call', (_, method, args) => { if (!notesAPI[method]) { throw new Error('Invalid method') @@ -99,9 +106,6 @@ app.whenReady().then(() => { }) }) -// Quit when all windows are closed, except on macOS. There, it's common -// for applications and their menu bar to stay active until the user quits -// explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() diff --git a/src/main/plugins/filesystem/FileSystemAdapter.js b/src/main/plugins/filesystem/FileSystemAdapter.js new file mode 100644 index 0000000..2c1d1a2 --- /dev/null +++ b/src/main/plugins/filesystem/FileSystemAdapter.js @@ -0,0 +1,65 @@ +import BaseNotesAdapter from '../../core/BaseNotesAdapter.js' +import matter from 'gray-matter' +import fs from 'fs/promises' +import path from 'path' + +export default class FileSystemNotesAdapter extends BaseNotesAdapter { + constructor(config) { + super() + + for (const field in config) { + this[field] = config[field] + } + } + + async init() { + await fs.mkdir(this.notesDir, { recursive: true }) + } + + async getAll() { + const files = await fs.readdir(this.notesDir) + const notes = [] + + for (const file of files) { + if (!file.endsWith('.md')) continue + + const fullPath = path.join(this.notesDir, file) + const raw = await fs.readFile(fullPath, 'utf8') + const parsed = matter(raw) + + notes.push({ + ...parsed.data, + content: parsed.content, + }) + } + + return notes + } + + async create(note) { + await this._write(note) + } + + async update(note) { + await this._write(note) + } + + async delete(id) { + const filePath = path.join(this.notesDir, `${id}.md`) + await fs.unlink(filePath) + } + + async _write(note) { + const filePath = path.join(this.notesDir, `${note.id}.md`) + + const fileContent = matter.stringify(note.content, { + id: note.id, + title: note.title, + category: note.category ?? null, + createdAt: note.createdAt, + updatedAt: note.updatedAt, + }) + + await fs.writeFile(filePath, fileContent, 'utf8') + } +} diff --git a/src/main/plugins/filesystem/index.js b/src/main/plugins/filesystem/index.js new file mode 100644 index 0000000..3b246c5 --- /dev/null +++ b/src/main/plugins/filesystem/index.js @@ -0,0 +1,24 @@ +import path from 'path' +import { app } from 'electron' +import FileSystemAdapter from './FileSystemAdapter.js' + +export default { + id: 'filesystem', + name: 'Local Filesystem', + description: 'Stores notes as markdown files locally.', + version: '1.0.0', + + configSchema: [ + { + key: 'notesDir', + label: 'Notes Directory', + type: 'directory', + default: path.join(app.getPath('userData'), 'notes'), + required: true, + }, + ], + + createAdapter(config) { + return new FileSystemAdapter(config) + }, +}