refactor + warning for failed decryptions

This commit is contained in:
nicwands
2026-04-02 11:45:46 -04:00
parent a0a5cde33a
commit ad961fd31a
16 changed files with 505 additions and 187 deletions

View File

@@ -5,6 +5,7 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
import supabasePlugin from "@takerofnotes/plugin-supabase"; import supabasePlugin from "@takerofnotes/plugin-supabase";
import s3Plugin from "@takerofnotes/plugin-s3"; import s3Plugin from "@takerofnotes/plugin-s3";
import postgresPlugin from "@takerofnotes/plugin-postgre-sql"; import postgresPlugin from "@takerofnotes/plugin-postgre-sql";
import Ajv from "ajv";
import sodium from "libsodium-wrappers"; import sodium from "libsodium-wrappers";
import { v4 } from "uuid"; import { v4 } from "uuid";
import { Index } from "flexsearch"; import { Index } from "flexsearch";
@@ -74,13 +75,60 @@ class PluginRegistry {
})); }));
} }
} }
const ajv = new Ajv({ allErrors: true, strict: false });
const getDefaultConfig = () => { const getDefaultConfig = () => {
return { return {
activeAdapter: "browser", activeAdapter: "browser",
theme: "dark" theme: "dark"
}; };
}; };
const createConfigManager = (storage) => { const CONFIG_SCHEMA = {
type: "object",
properties: {
activeAdapter: { type: "string" },
theme: { type: "string", enum: ["dark", "light"] },
encryptionKey: { type: "string" },
adapters: { type: "object" }
},
required: ["activeAdapter"],
additionalProperties: true
};
const validateConfig = ajv.compile(CONFIG_SCHEMA);
const convertSchemaToJson = (schemaArray) => {
const properties = {};
const required = [];
for (const field of schemaArray) {
properties[field.key] = { type: "string" };
if (field.required) {
required.push(field.key);
}
}
return {
type: "object",
properties,
required,
additionalProperties: false
};
};
const validateAdapterConfigs = (adapters, pluginManager) => {
if (!adapters || !pluginManager) return { valid: true, errors: [] };
const errors = [];
for (const [adapterId, adapterConfig] of Object.entries(adapters)) {
const plugin = pluginManager.getPlugin(adapterId);
if (!plugin?.configSchema) continue;
const schema = convertSchemaToJson(plugin.configSchema);
const validate = ajv.compile(schema);
const valid = validate(adapterConfig);
if (!valid) {
const adapterErrors = validate.errors.map(
(e) => `${adapterId}${e.instancePath}: ${e.message}`
);
errors.push(...adapterErrors);
}
}
return { valid: errors.length === 0, errors };
};
const createConfigManager = (storage, pluginManager) => {
let config = null; let config = null;
return { return {
async loadConfig() { async loadConfig() {
@@ -96,6 +144,20 @@ const createConfigManager = (storage) => {
return config; return config;
}, },
async setConfig(newConfig) { async setConfig(newConfig) {
const valid = validateConfig(newConfig);
if (!valid) {
const errors = validateConfig.errors.map((e) => `${e.instancePath || "root"}: ${e.message}`).join("; ");
throw new Error(`Config validation failed: ${errors}`);
}
const adapterValidation = validateAdapterConfigs(
newConfig.adapters,
pluginManager
);
if (!adapterValidation.valid) {
throw new Error(
`Adapter config validation failed: ${adapterValidation.errors.join("; ")}`
);
}
config = newConfig; config = newConfig;
await storage.save(newConfig); await storage.save(newConfig);
}, },
@@ -111,8 +173,10 @@ class NotesAPI {
} }
this.adapter = adapter; this.adapter = adapter;
this.notesCache = /* @__PURE__ */ new Map(); this.notesCache = /* @__PURE__ */ new Map();
this.categories = /* @__PURE__ */ new Set();
this.encryptionKey = encryptionKey; this.encryptionKey = encryptionKey;
this._sodiumReady = false; this._sodiumReady = false;
this._decryptionFailures = [];
this.index = new Index({ this.index = new Index({
tokenize: "forward" tokenize: "forward"
}); });
@@ -124,11 +188,10 @@ class NotesAPI {
} }
} }
_hexToUint8Array(hex) { _hexToUint8Array(hex) {
const bytes = new Uint8Array(hex.length / 2); return Uint8Array.from(
for (let i = 0; i < bytes.length; i++) { hex.match(/.{1,2}/g),
bytes[i] = parseInt(hex.substr(i * 2, 2), 16); (byte) => parseInt(byte, 16)
} );
return bytes;
} }
_concatUint8Arrays(a, b) { _concatUint8Arrays(a, b) {
const result = new Uint8Array(a.length + b.length); const result = new Uint8Array(a.length + b.length);
@@ -137,19 +200,11 @@ class NotesAPI {
return result; return result;
} }
_uint8ArrayToBase64(bytes) { _uint8ArrayToBase64(bytes) {
let binary = ""; return btoa(String.fromCharCode(...bytes));
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
} }
_base64ToUint8Array(base64) { _base64ToUint8Array(base64) {
const binary = atob(base64); const binary = atob(base64);
const bytes = new Uint8Array(binary.length); return Uint8Array.from(binary, (c) => c.charCodeAt(0));
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
} }
_encrypt(note) { _encrypt(note) {
if (!this.encryptionKey) { if (!this.encryptionKey) {
@@ -218,18 +273,28 @@ class NotesAPI {
await this._initSodium(); await this._initSodium();
await this.adapter.init(); await this.adapter.init();
this.notesCache.clear(); this.notesCache.clear();
this.categories.clear();
this._decryptionFailures = [];
const encryptedNotes = await this.adapter.getAll(); const encryptedNotes = await this.adapter.getAll();
for (const encryptedNote of encryptedNotes) { for (const encryptedNote of encryptedNotes) {
const noteId = encryptedNote.id || "unknown";
try { try {
const note = this._decrypt(encryptedNote.data || encryptedNote); const note = this._decrypt(encryptedNote.data || encryptedNote);
this.notesCache.set(note.id, note); this.notesCache.set(note.id, note);
if (note.category) {
this.categories.add(note.category);
}
const searchText = note.plainText || this._extractPlainText(note.content); const searchText = note.plainText || this._extractPlainText(note.content);
this.index.add(note.id, note.title + "\n" + searchText); this.index.add(note.id, note.title + "\n" + searchText);
} catch (error) { } catch (error) {
console.error("Failed to decrypt note:", error); console.error("Failed to decrypt note:", error);
this._decryptionFailures.push(noteId);
} }
} }
} }
getDecryptionFailures() {
return [...this._decryptionFailures];
}
_extractPlainText(content) { _extractPlainText(content) {
if (!content) return ""; if (!content) return "";
if (typeof content === "string") return content; if (typeof content === "string") return content;
@@ -241,19 +306,14 @@ class NotesAPI {
return extractText(content); return extractText(content);
} }
getCategories() { getCategories() {
const categories = /* @__PURE__ */ new Set(); return Array.from(this.categories).sort();
for (const note of this.notesCache.values()) {
if (note.category) {
categories.add(note.category);
}
}
return Array.from(categories).sort();
} }
getCategoryNotes(categoryName = null) { getCategoryNotes(categoryName = null) {
return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)).map((n) => ({ ...n })); return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)).map((n) => ({ ...n }));
} }
getNote(id) { getNote(id) {
const note = this.notesCache.get(id); const note = this.notesCache.get(id);
console.log(id, this.notesCache);
return note ? { ...note } : null; return note ? { ...note } : null;
} }
async createNote(metadata = {}, content = "", plainText = "") { async createNote(metadata = {}, content = "", plainText = "") {
@@ -273,14 +333,32 @@ class NotesAPI {
data: this._encrypt(note) data: this._encrypt(note)
}; };
this.notesCache.set(id, note); this.notesCache.set(id, note);
if (note.category) {
this.categories.add(note.category);
}
this.index.add(id, note.title + "\n" + plainText); this.index.add(id, note.title + "\n" + plainText);
await this.adapter.create(encryptedNote); await this.adapter.create(encryptedNote);
return note; return note;
} }
async deleteNote(id) { async deleteNote(id) {
const note = this.notesCache.get(id);
const category = note?.category;
await this.adapter.delete(id); await this.adapter.delete(id);
this.notesCache.delete(id); this.notesCache.delete(id);
this.index.remove(id); this.index.remove(id);
if (category) {
const notesWithCategory = Array.from(
this.notesCache.values()
).filter((n) => n.category === category);
if (notesWithCategory.length === 0) {
this.categories.delete(category);
}
}
if (this._decryptionFailures.includes(id)) {
this._decryptionFailures = this._decryptionFailures.filter(
(id2) => id2 !== id2
);
}
} }
async updateNote(id, updates = {}) { async updateNote(id, updates = {}) {
const note = this.notesCache.get(id); const note = this.notesCache.get(id);
@@ -291,6 +369,7 @@ class NotesAPI {
throw new Error(`Invalid update field: ${key}`); throw new Error(`Invalid update field: ${key}`);
} }
} }
const oldCategory = note.category;
const updatedNote = { const updatedNote = {
...note, ...note,
...updates, ...updates,
@@ -301,6 +380,19 @@ class NotesAPI {
data: this._encrypt(updatedNote) data: this._encrypt(updatedNote)
}; };
this.notesCache.set(id, updatedNote); this.notesCache.set(id, updatedNote);
if (updates.category !== void 0) {
if (oldCategory) {
const notesWithOldCategory = Array.from(
this.notesCache.values()
).filter((n) => n.category === oldCategory && n.id !== id);
if (notesWithOldCategory.length === 0) {
this.categories.delete(oldCategory);
}
}
if (updatedNote.category) {
this.categories.add(updatedNote.category);
}
}
const searchText = updatedNote.plainText || this._extractPlainText(updatedNote.content); const searchText = updatedNote.plainText || this._extractPlainText(updatedNote.content);
this.index.update(id, updatedNote.title + "\n" + searchText); this.index.update(id, updatedNote.title + "\n" + searchText);
await this.adapter.update(encryptedNote); await this.adapter.update(encryptedNote);
@@ -319,46 +411,45 @@ const generateEncryptionKey = () => {
crypto.getRandomValues(array); crypto.getRandomValues(array);
return Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join(""); return Array.from(array).map((b) => b.toString(16).padStart(2, "0")).join("");
}; };
const initPluginManager = (runtime, plugins, config) => { const initPluginManager = (runtime, plugins) => {
const registry = new PluginRegistry(); const registry = new PluginRegistry();
for (const plugin of plugins) { for (const plugin of plugins) {
registry.register(plugin); registry.register(plugin);
} }
const manager = createPluginManager(registry); return createPluginManager(registry);
const activeConfig = config.adapters?.[config.activeAdapter] || {};
manager.setActivePlugin(config.activeAdapter, activeConfig);
return manager;
}; };
const initConfigManager = async (runtime) => { const initConfigManager = async (runtime, pluginManager) => {
let storage; let storage;
{ {
const { createNodeStorage } = await import("./NodeStorage-B8VFtrTS.js"); const { createNodeStorage } = await import("./NodeStorage-B8VFtrTS.js");
const filesystemPlugin2 = (await import("@takerofnotes/plugin-filesystem")).default; const filesystemPlugin2 = (await import("@takerofnotes/plugin-filesystem")).default;
storage = createNodeStorage(filesystemPlugin2); storage = createNodeStorage(filesystemPlugin2);
} }
return createConfigManager(storage); return createConfigManager(storage, pluginManager);
}; };
const initializeCore = async (runtime, { plugins }) => { const initializeCore = async (runtime, { plugins }) => {
const configManager = await initConfigManager(); const pluginManager = initPluginManager(runtime, plugins);
const configManager = await initConfigManager(runtime, pluginManager);
const config = await configManager.loadConfig(); const config = await configManager.loadConfig();
const pluginManager = initPluginManager(runtime, plugins, config); const activeConfig = config.adapters?.[config.activeAdapter] || {};
pluginManager.setActivePlugin(config.activeAdapter, activeConfig);
let notesAPI = null; let notesAPI = null;
let initPromise = null; let initPromise = null;
const getNotesAPI = async () => { const getNotesAPI = async () => {
if (notesAPI) return notesAPI; if (notesAPI) return notesAPI;
if (!initPromise) { if (!initPromise) {
initPromise = (async () => { initPromise = (async () => {
const config2 = await configManager.loadConfig(); const latestConfig = await configManager.loadConfig();
let encryptionKey = config2?.encryptionKey; let encryptionKey = latestConfig?.encryptionKey;
if (!encryptionKey) { if (!encryptionKey) {
encryptionKey = generateEncryptionKey(); encryptionKey = generateEncryptionKey();
await configManager.setConfig({ await configManager.setConfig({
...config2, ...latestConfig,
encryptionKey encryptionKey
}); });
} }
const pluginId = config2?.activeAdapter || "filesystem"; const pluginId = latestConfig?.activeAdapter || "filesystem";
const adapterConfig = config2?.adapters?.[pluginId] || {}; const adapterConfig = latestConfig?.adapters?.[pluginId] || {};
const adapter = pluginManager.getAdapter( const adapter = pluginManager.getAdapter(
pluginId, pluginId,
adapterConfig adapterConfig

7
package-lock.json generated
View File

@@ -25,6 +25,7 @@
"@tiptap/starter-kit": "^3.19.0", "@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0", "@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"ajv": "^6.14.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
@@ -4917,7 +4918,6 @@
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -7245,7 +7245,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-fifo": { "node_modules/fast-fifo": {
@@ -7258,7 +7257,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-builder": { "node_modules/fast-xml-builder": {
@@ -8192,7 +8190,6 @@
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stringify-safe": { "node_modules/json-stringify-safe": {
@@ -9756,7 +9753,6 @@
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -11243,7 +11239,6 @@
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"punycode": "^2.1.0" "punycode": "^2.1.0"

View File

@@ -41,6 +41,7 @@
"@tiptap/starter-kit": "^3.19.0", "@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0", "@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"ajv": "^6.14.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",

View File

@@ -1,3 +1,7 @@
import Ajv from 'ajv'
const ajv = new Ajv({ allErrors: true, strict: false })
const getDefaultConfig = () => { const getDefaultConfig = () => {
return { return {
activeAdapter: 'browser', activeAdapter: 'browser',
@@ -5,7 +9,65 @@ const getDefaultConfig = () => {
} }
} }
export const createConfigManager = (storage) => { const CONFIG_SCHEMA = {
type: 'object',
properties: {
activeAdapter: { type: 'string' },
theme: { type: 'string', enum: ['dark', 'light'] },
encryptionKey: { type: 'string' },
adapters: { type: 'object' },
},
required: ['activeAdapter'],
additionalProperties: true,
}
const validateConfig = ajv.compile(CONFIG_SCHEMA)
const convertSchemaToJson = (schemaArray) => {
const properties = {}
const required = []
for (const field of schemaArray) {
properties[field.key] = { type: 'string' }
if (field.required) {
required.push(field.key)
}
}
return {
type: 'object',
properties,
required,
additionalProperties: false,
}
}
const validateAdapterConfigs = (adapters, pluginManager) => {
if (!adapters || !pluginManager) return { valid: true, errors: [] }
const errors = []
for (const [adapterId, adapterConfig] of Object.entries(adapters)) {
const plugin = pluginManager.getPlugin(adapterId)
if (!plugin?.configSchema) continue
const schema = convertSchemaToJson(plugin.configSchema)
const validate = ajv.compile(schema)
const valid = validate(adapterConfig)
if (!valid) {
const adapterErrors = validate.errors.map(
(e) => `${adapterId}${e.instancePath}: ${e.message}`,
)
errors.push(...adapterErrors)
}
}
return { valid: errors.length === 0, errors }
}
export const createConfigManager = (storage, pluginManager) => {
let config = null let config = null
return { return {
@@ -27,6 +89,24 @@ export const createConfigManager = (storage) => {
}, },
async setConfig(newConfig) { async setConfig(newConfig) {
const valid = validateConfig(newConfig)
if (!valid) {
const errors = validateConfig.errors
.map((e) => `${e.instancePath || 'root'}: ${e.message}`)
.join('; ')
throw new Error(`Config validation failed: ${errors}`)
}
const adapterValidation = validateAdapterConfigs(
newConfig.adapters,
pluginManager,
)
if (!adapterValidation.valid) {
throw new Error(
`Adapter config validation failed: ${adapterValidation.errors.join('; ')}`,
)
}
config = newConfig config = newConfig
await storage.save(newConfig) await storage.save(newConfig)
}, },

View File

@@ -10,8 +10,10 @@ export default class NotesAPI {
this.adapter = adapter this.adapter = adapter
this.notesCache = new Map() this.notesCache = new Map()
this.categories = new Set()
this.encryptionKey = encryptionKey this.encryptionKey = encryptionKey
this._sodiumReady = false this._sodiumReady = false
this._decryptionFailures = []
this.index = new Index({ this.index = new Index({
tokenize: 'forward', tokenize: 'forward',
@@ -26,11 +28,9 @@ export default class NotesAPI {
} }
_hexToUint8Array(hex) { _hexToUint8Array(hex) {
const bytes = new Uint8Array(hex.length / 2) return Uint8Array.from(hex.match(/.{1,2}/g), (byte) =>
for (let i = 0; i < bytes.length; i++) { parseInt(byte, 16),
bytes[i] = parseInt(hex.substr(i * 2, 2), 16) )
}
return bytes
} }
_concatUint8Arrays(a, b) { _concatUint8Arrays(a, b) {
@@ -41,20 +41,12 @@ export default class NotesAPI {
} }
_uint8ArrayToBase64(bytes) { _uint8ArrayToBase64(bytes) {
let binary = '' return btoa(String.fromCharCode(...bytes))
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
} }
_base64ToUint8Array(base64) { _base64ToUint8Array(base64) {
const binary = atob(base64) const binary = atob(base64)
const bytes = new Uint8Array(binary.length) return Uint8Array.from(binary, (c) => c.charCodeAt(0))
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes
} }
_encrypt(note) { _encrypt(note) {
@@ -142,23 +134,34 @@ export default class NotesAPI {
await this._initSodium() await this._initSodium()
await this.adapter.init() await this.adapter.init()
this.notesCache.clear() this.notesCache.clear()
this.categories.clear()
this._decryptionFailures = []
const encryptedNotes = await this.adapter.getAll() const encryptedNotes = await this.adapter.getAll()
for (const encryptedNote of encryptedNotes) { for (const encryptedNote of encryptedNotes) {
const noteId = encryptedNote.id || 'unknown'
try { try {
const note = this._decrypt(encryptedNote.data || encryptedNote) const note = this._decrypt(encryptedNote.data || encryptedNote)
this.notesCache.set(note.id, note) this.notesCache.set(note.id, note)
if (note.category) {
this.categories.add(note.category)
}
const searchText = const searchText =
note.plainText || this._extractPlainText(note.content) note.plainText || this._extractPlainText(note.content)
this.index.add(note.id, note.title + '\n' + searchText) this.index.add(note.id, note.title + '\n' + searchText)
} catch (error) { } catch (error) {
console.error('Failed to decrypt note:', error) console.error('Failed to decrypt note:', error)
this._decryptionFailures.push(noteId)
} }
} }
} }
getDecryptionFailures() {
return [...this._decryptionFailures]
}
_extractPlainText(content) { _extractPlainText(content) {
if (!content) return '' if (!content) return ''
if (typeof content === 'string') return content if (typeof content === 'string') return content
@@ -173,15 +176,7 @@ export default class NotesAPI {
} }
getCategories() { getCategories() {
const categories = new Set() return Array.from(this.categories).sort()
for (const note of this.notesCache.values()) {
if (note.category) {
categories.add(note.category)
}
}
return Array.from(categories).sort()
} }
getCategoryNotes(categoryName = null) { getCategoryNotes(categoryName = null) {
@@ -193,6 +188,7 @@ export default class NotesAPI {
getNote(id) { getNote(id) {
const note = this.notesCache.get(id) const note = this.notesCache.get(id)
return note ? { ...note } : null return note ? { ...note } : null
} }
@@ -216,6 +212,9 @@ export default class NotesAPI {
} }
this.notesCache.set(id, note) this.notesCache.set(id, note)
if (note.category) {
this.categories.add(note.category)
}
this.index.add(id, note.title + '\n' + plainText) this.index.add(id, note.title + '\n' + plainText)
await this.adapter.create(encryptedNote) await this.adapter.create(encryptedNote)
@@ -224,10 +223,30 @@ export default class NotesAPI {
} }
async deleteNote(id) { async deleteNote(id) {
const note = this.notesCache.get(id)
const category = note?.category
await this.adapter.delete(id) await this.adapter.delete(id)
this.notesCache.delete(id) this.notesCache.delete(id)
this.index.remove(id) this.index.remove(id)
// Delete category if needed
if (category) {
const notesWithCategory = Array.from(
this.notesCache.values(),
).filter((n) => n.category === category)
if (notesWithCategory.length === 0) {
this.categories.delete(category)
}
}
// Update decryption failures
if (this._decryptionFailures.includes(id)) {
this._decryptionFailures = this._decryptionFailures.filter(
(id) => id !== id,
)
}
} }
async updateNote(id, updates = {}) { async updateNote(id, updates = {}) {
@@ -242,6 +261,8 @@ export default class NotesAPI {
} }
} }
const oldCategory = note.category
const updatedNote = { const updatedNote = {
...note, ...note,
...updates, ...updates,
@@ -255,6 +276,20 @@ export default class NotesAPI {
this.notesCache.set(id, updatedNote) this.notesCache.set(id, updatedNote)
if (updates.category !== undefined) {
if (oldCategory) {
const notesWithOldCategory = Array.from(
this.notesCache.values(),
).filter((n) => n.category === oldCategory && n.id !== id)
if (notesWithOldCategory.length === 0) {
this.categories.delete(oldCategory)
}
}
if (updatedNote.category) {
this.categories.add(updatedNote.category)
}
}
const searchText = const searchText =
updatedNote.plainText || this._extractPlainText(updatedNote.content) updatedNote.plainText || this._extractPlainText(updatedNote.content)

View File

@@ -18,7 +18,7 @@ const generateEncryptionKey = () => {
.join('') .join('')
} }
const initPluginManager = (runtime, plugins, config) => { const initPluginManager = (runtime, plugins) => {
if (runtime === 'electron-renderer') return createPluginManagerClient() if (runtime === 'electron-renderer') return createPluginManagerClient()
const registry = new PluginRegistry() const registry = new PluginRegistry()
@@ -27,14 +27,10 @@ const initPluginManager = (runtime, plugins, config) => {
registry.register(plugin) registry.register(plugin)
} }
const manager = createPluginManager(registry) return createPluginManager(registry)
const activeConfig = config.adapters?.[config.activeAdapter] || {}
manager.setActivePlugin(config.activeAdapter, activeConfig)
return manager
} }
const initConfigManager = async (runtime) => { const initConfigManager = async (runtime, pluginManager) => {
if (runtime === 'electron-renderer') return createConfigManagerClient() if (runtime === 'electron-renderer') return createConfigManagerClient()
let storage let storage
@@ -48,35 +44,41 @@ const initConfigManager = async (runtime) => {
storage = createWebStorage() storage = createWebStorage()
} }
return createConfigManager(storage) return createConfigManager(storage, pluginManager)
} }
export const initializeCore = async (runtime, { plugins }) => { export const initializeCore = async (runtime, { plugins }) => {
const configManager = await initConfigManager(runtime) const pluginManager = initPluginManager(runtime, plugins)
const configManager = await initConfigManager(runtime, pluginManager)
const config = await configManager.loadConfig() const config = await configManager.loadConfig()
const pluginManager = initPluginManager(runtime, plugins, config)
// NotesAPI bootstrap // Set active plugin
const activeConfig = config.adapters?.[config.activeAdapter] || {}
pluginManager.setActivePlugin(config.activeAdapter, activeConfig)
// Create API instance
let notesAPI = null let notesAPI = null
let initPromise = null let initPromise = null
const getNotesAPI = async () => { const getNotesAPI = async () => {
if (notesAPI) return notesAPI if (notesAPI) return notesAPI
if (!initPromise) { if (!initPromise) {
initPromise = (async () => { initPromise = (async () => {
let encryptionKey = config?.encryptionKey // Get fresh config to ensure adapters are populated from main process
const latestConfig = await configManager.loadConfig()
let encryptionKey = latestConfig?.encryptionKey
if (!encryptionKey) { if (!encryptionKey) {
encryptionKey = generateEncryptionKey() encryptionKey = generateEncryptionKey()
await configManager.setConfig({ await configManager.setConfig({
...config, ...latestConfig,
encryptionKey, encryptionKey,
}) })
} }
const pluginId = config?.activeAdapter || 'filesystem' const pluginId = latestConfig?.activeAdapter || 'filesystem'
const adapterConfig = config?.adapters?.[pluginId] || {} const adapterConfig = latestConfig?.adapters?.[pluginId] || {}
const adapter = pluginManager.getAdapter( const adapter = pluginManager.getAdapter(
pluginId, pluginId,

View File

@@ -0,0 +1,103 @@
<template>
<div v-if="failedIds.length && visible" class="decryption-warning">
<div class="warning">
<button class="warning-text" @click="detailsOpen = !detailsOpen">
<strong>{{ failedIds.length }}&nbsp;</strong>
<span>
{{ failedIds.length === 1 ? 'note could' : 'notes could' }}
not be decrypted
</span>
</button>
<button class="close-button" @click="visible = false"></button>
</div>
<div v-show="detailsOpen" class="details">
<span>Failed note IDs:</span>
<ul class="id-list">
<li v-for="id in failedIds" :key="id">{{ id }}</li>
</ul>
<p class="instructions">
These notes may have been encrypted with a different key. Either
change your encryption key in preferences, or delete these
notes.
</p>
<button class="delete-button" @click="onDeleteNotes">
Delete Notes (Permanent)
</button>
</div>
</div>
</template>
<script setup>
import useNotes from '@/composables/useNotes'
import { ref } from 'vue'
const props = defineProps({
failedIds: {
type: Array,
required: true,
},
})
const visible = ref(true)
const detailsOpen = ref(false)
const { deleteNote } = useNotes()
const onDeleteNotes = async () => {
await Promise.all(props.failedIds.map((id) => deleteNote(id)))
visible.value = false
}
</script>
<style lang="scss" scoped>
.decryption-warning {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: var(--theme-bg);
border-top: 1px solid var(--grey-100);
padding: 10px var(--layout-margin);
.warning {
display: flex;
justify-content: space-between;
gap: 10px;
.warning-text {
&:hover {
color: var(--theme-accent);
}
}
}
.details {
margin-top: 20px;
ul {
list-style-type: none;
margin-top: 10px;
color: var(--grey-100);
}
.instructions {
margin-top: 10px;
}
.delete-button {
display: block;
margin: 20px auto 0;
border: 1px solid var(--theme-fg);
padding: 0.2em 0.5em;
border-radius: 0.2em;
&:hover {
color: var(--theme-accent);
border-color: var(--theme-accent);
}
}
}
}
</style>

View File

@@ -63,6 +63,7 @@ const moveActive = computed(() => route.query.move === props.note.id)
.title { .title {
white-space: break-spaces; white-space: break-spaces;
word-break: break-word; word-break: break-word;
@include line-clamp(3);
} }
.action { .action {
opacity: 0; opacity: 0;

View File

@@ -78,6 +78,7 @@ watch(
width: 8px; width: 8px;
will-change: transform; will-change: transform;
border-left: 1px solid var(--grey-100); border-left: 1px solid var(--grey-100);
background: var(--theme-bg);
.inner { .inner {
height: 100%; height: 100%;

View File

@@ -61,6 +61,7 @@ const onUpdate = _debounce(async ({ editor }) => {
onMounted(async () => { onMounted(async () => {
const note = await loadNote(props.id) const note = await loadNote(props.id)
if (!note) return
if (note.title !== 'Untitled') { if (note.title !== 'Untitled') {
title.value = note.title || '' title.value = note.title || ''

View File

@@ -0,0 +1,52 @@
import { useEnvironment } from '@/composables/useEnvironment.js'
import useCore from '@/composables/useCore'
import _omit from 'lodash/omit'
import { ref } from 'vue'
let listenersInitialized = false
const changeCount = ref(0)
export default () => {
const environment = useEnvironment()
const { getNotesAPI } = useCore()
const setupListeners = () => {
if (environment !== 'electron' || listenersInitialized) return
listenersInitialized = true
const updateCacheCount = async (note) => {
const api = await getNotesAPI()
await api.updateNote(
note.id,
_omit(note, ['id', 'createdAt', 'updatedAt']),
)
changeCount.value++
}
window.api.onNoteCreated(updateCacheCount)
window.api.onNoteUpdated(updateCacheCount)
window.api.onPluginChanged(async () => {
const api = await getNotesAPI()
await api.init()
changeCount.value++
})
// Todo update cache
window.api.onNoteDeleted(() => {
changeCount.value++
})
}
const broadcastChange = (event, data) => {
if (environment !== 'electron') return
window.api.notifyNoteChanged(event, data)
}
return {
setupListeners,
broadcastChange,
changeCount,
}
}

View File

@@ -1,77 +1,40 @@
import _omit from 'lodash/omit' import useNoteListeners from '@/composables/useNoteListeners'
import useCore from '@/composables/useCore'
import { ref } from 'vue' import { ref } from 'vue'
import { getNotesAPI } from '@/libs/getNotesAPI'
import { useEnvironment } from '@/composables/useEnvironment.js'
const categories = ref([]) const categories = ref([])
const searchResults = ref([]) const searchResults = ref([])
const notesChangeCount = ref(0) const decryptionFailures = ref([])
let listenersInitialized = false
const environment = useEnvironment()
const setupListeners = () => {
if (listenersInitialized || typeof window === 'undefined') return
listenersInitialized = true
const updateCacheCount = async (note) => {
const api = await getNotesAPI()
await api.updateNote(
note.id,
_omit(note, ['id', 'createdAt', 'updatedAt']),
)
notesChangeCount.value++
}
window.api.onNoteCreated(updateCacheCount)
window.api.onNoteUpdated(updateCacheCount)
window.api.onPluginChanged(async () => {
const api = await getNotesAPI()
await api.init()
notesChangeCount.value++
})
// Todo update cache
window.api.onNoteDeleted(() => {
notesChangeCount.value++
})
}
const broadcastChange = (event, data) => {
if (environment === 'electron') {
window.api.notifyNoteChanged(event, data)
}
}
if (environment === 'electron') {
setupListeners()
}
export default () => { export default () => {
/* ------------------------- const { getNotesAPI } = useCore()
Initialization
--------------------------*/ // Change listeners for electron
const { setupListeners, broadcastChange, changeCount } = useNoteListeners()
setupListeners()
const getDecryptionFailures = async () => {
const api = await getNotesAPI()
decryptionFailures.value = api.getDecryptionFailures()
}
getDecryptionFailures()
// Load
const loadCategories = async () => { const loadCategories = async () => {
const api = await getNotesAPI() const api = await getNotesAPI()
categories.value = api.getCategories() categories.value = api.getCategories()
} }
const loadCategoryNotes = async (category = null) => { const loadCategoryNotes = async (category = null) => {
const api = await getNotesAPI() const api = await getNotesAPI()
return api.getCategoryNotes(category) return api.getCategoryNotes(category)
} }
const loadNote = async (id) => { const loadNote = async (id) => {
const api = await getNotesAPI() const api = await getNotesAPI()
console.log(id, api)
return api.getNote(id) return api.getNote(id)
} }
/* ------------------------- // Create
Create
--------------------------*/
const createNote = async (metadata, content, plainText = '') => { const createNote = async (metadata, content, plainText = '') => {
const api = await getNotesAPI() const api = await getNotesAPI()
const note = await api.createNote(metadata, content, plainText) const note = await api.createNote(metadata, content, plainText)
@@ -80,9 +43,7 @@ export default () => {
return note return note
} }
/* ------------------------- // Update
Update
--------------------------*/
const updateNote = async (id, updates) => { const updateNote = async (id, updates) => {
const api = await getNotesAPI() const api = await getNotesAPI()
@@ -96,7 +57,6 @@ export default () => {
return note return note
} }
const updateCategory = async (category, update) => { const updateCategory = async (category, update) => {
const notes = await loadCategoryNotes(category) const notes = await loadCategoryNotes(category)
@@ -107,19 +67,16 @@ export default () => {
await loadCategories() await loadCategories()
} }
/* ------------------------- // Delete
Delete
--------------------------*/
const deleteNote = async (id) => { const deleteNote = async (id) => {
const api = await getNotesAPI() const api = await getNotesAPI()
await api.deleteNote(id) await api.deleteNote(id)
await loadCategories() await loadCategories()
broadcastChange('note-deleted', { id }) broadcastChange('note-deleted', { id })
getDecryptionFailures()
} }
/* ------------------------- // Search
Search
--------------------------*/
const search = async (query) => { const search = async (query) => {
const api = await getNotesAPI() const api = await getNotesAPI()
@@ -134,7 +91,8 @@ export default () => {
return { return {
categories, categories,
searchResults, searchResults,
notesChangeCount, decryptionFailures,
changeCount,
loadCategories, loadCategories,
loadCategoryNotes, loadCategoryNotes,

View File

@@ -1,18 +0,0 @@
import useCore from '@/composables/useCore'
let notesAPI = null
let initPromise = null
export const getNotesAPI = async () => {
if (notesAPI) return notesAPI
if (!initPromise) {
initPromise = (async () => {
const { getNotesAPI } = useCore()
notesAPI = await getNotesAPI()
return notesAPI
})()
}
return initPromise
}

View File

@@ -1,4 +1,9 @@
import { createRouter, createWebHashHistory } from 'vue-router' import {
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router'
import { useEnvironment } from '@/composables/useEnvironment.ts'
import Directory from '@/views/Directory.vue' import Directory from '@/views/Directory.vue'
import Note from '@/views/Note.vue' import Note from '@/views/Note.vue'
@@ -8,6 +13,8 @@ import Instructions from '@/views/Instructions.vue'
import Search from '@/views/Search.vue' import Search from '@/views/Search.vue'
import Preferences from '@/views/Preferences.vue' import Preferences from '@/views/Preferences.vue'
const environment = useEnvironment()
const routes = [ const routes = [
{ path: '/', name: 'directory', component: Directory }, { path: '/', name: 'directory', component: Directory },
{ path: '/note/:id', name: 'note', component: Note }, { path: '/note/:id', name: 'note', component: Note },
@@ -19,6 +26,7 @@ const routes = [
] ]
export const router = createRouter({ export const router = createRouter({
history: createWebHashHistory(), history:
environment === 'web' ? createWebHistory() : createWebHashHistory(),
routes, routes,
}) })

View File

@@ -27,22 +27,22 @@ const route = useRoute()
const id = route.params?.id const id = route.params?.id
const router = useRouter() const router = useRouter()
const {
categories,
loadCategoryNotes,
updateCategory,
notesChangeCount,
loadCategories,
} = useNotes()
const notes = ref() const notes = ref()
async function refreshNotes() { const refreshNotes = async () => {
if (id) { if (id) {
notes.value = await loadCategoryNotes(id) notes.value = await loadCategoryNotes(id)
} }
} }
const {
categories,
loadCategoryNotes,
updateCategory,
loadCategories,
changeCount,
} = useNotes(refreshNotes)
onMounted(async () => { onMounted(async () => {
await refreshNotes() await refreshNotes()
@@ -52,7 +52,7 @@ onMounted(async () => {
} }
}) })
watch(notesChangeCount, async () => { watch(changeCount, async () => {
await refreshNotes() await refreshNotes()
}) })

View File

@@ -14,24 +14,32 @@
</div> </div>
<new-note /> <new-note />
<decryption-warning :failedIds="decryptionFailures" />
</main> </main>
<page-loading v-else /> <page-loading v-else />
</template> </template>
<script setup> <script setup>
import useNotes from '@/composables/useNotes' import DecryptionWarning from '@/components/DecryptionWarning.vue'
import { onMounted, ref, watch, watchEffect } from 'vue' import { onMounted, ref, watchEffect, watch } from 'vue'
import CategoryRow from '@/components/CategoryRow.vue' import CategoryRow from '@/components/CategoryRow.vue'
import NoteRow from '@/components/NoteRow.vue'
import PageLoading from '@/components/PageLoading.vue' import PageLoading from '@/components/PageLoading.vue'
import NoteRow from '@/components/NoteRow.vue'
import useNotes from '@/composables/useNotes'
import NewNote from '@/components/NewNote.vue' import NewNote from '@/components/NewNote.vue'
import { useMagicKeys } from '@vueuse/core' import { useMagicKeys } from '@vueuse/core'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
useNotes()
const router = useRouter() const router = useRouter()
const {
categories,
loadCategories,
loadCategoryNotes,
changeCount,
decryptionFailures,
} = useNotes()
const notes = ref() const notes = ref()
const loaded = ref(false) const loaded = ref(false)
@@ -47,7 +55,7 @@ onMounted(async () => {
await refreshNotes() await refreshNotes()
}) })
watch(notesChangeCount, async () => { watch(changeCount, async () => {
await refreshNotes() await refreshNotes()
}) })
@@ -63,7 +71,7 @@ watchEffect(() => {
<style lang="scss"> <style lang="scss">
main.directory { main.directory {
padding-top: var(--nav-height); padding-top: var(--nav-height);
padding-bottom: 30px; padding-bottom: 60px;
.label { .label {
text-transform: uppercase; text-transform: uppercase;