Compare commits

..

2 Commits

Author SHA1 Message Date
nicwands
ad961fd31a refactor + warning for failed decryptions 2026-04-02 11:45:46 -04:00
nicwands
a0a5cde33a ui tweaks 2026-04-01 10:06:22 -04:00
21 changed files with 544 additions and 204 deletions

View File

@@ -5,6 +5,7 @@ import filesystemPlugin from "@takerofnotes/plugin-filesystem";
import supabasePlugin from "@takerofnotes/plugin-supabase";
import s3Plugin from "@takerofnotes/plugin-s3";
import postgresPlugin from "@takerofnotes/plugin-postgre-sql";
import Ajv from "ajv";
import sodium from "libsodium-wrappers";
import { v4 } from "uuid";
import { Index } from "flexsearch";
@@ -74,13 +75,60 @@ class PluginRegistry {
}));
}
}
const ajv = new Ajv({ allErrors: true, strict: false });
const getDefaultConfig = () => {
return {
activeAdapter: "browser",
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;
return {
async loadConfig() {
@@ -96,6 +144,20 @@ const createConfigManager = (storage) => {
return config;
},
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;
await storage.save(newConfig);
},
@@ -111,8 +173,10 @@ class NotesAPI {
}
this.adapter = adapter;
this.notesCache = /* @__PURE__ */ new Map();
this.categories = /* @__PURE__ */ new Set();
this.encryptionKey = encryptionKey;
this._sodiumReady = false;
this._decryptionFailures = [];
this.index = new Index({
tokenize: "forward"
});
@@ -124,11 +188,10 @@ class NotesAPI {
}
}
_hexToUint8Array(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
return Uint8Array.from(
hex.match(/.{1,2}/g),
(byte) => parseInt(byte, 16)
);
}
_concatUint8Arrays(a, b) {
const result = new Uint8Array(a.length + b.length);
@@ -137,19 +200,11 @@ class NotesAPI {
return result;
}
_uint8ArrayToBase64(bytes) {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
return btoa(String.fromCharCode(...bytes));
}
_base64ToUint8Array(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
_encrypt(note) {
if (!this.encryptionKey) {
@@ -218,18 +273,28 @@ class NotesAPI {
await this._initSodium();
await this.adapter.init();
this.notesCache.clear();
this.categories.clear();
this._decryptionFailures = [];
const encryptedNotes = await this.adapter.getAll();
for (const encryptedNote of encryptedNotes) {
const noteId = encryptedNote.id || "unknown";
try {
const note = this._decrypt(encryptedNote.data || encryptedNote);
this.notesCache.set(note.id, note);
if (note.category) {
this.categories.add(note.category);
}
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);
this._decryptionFailures.push(noteId);
}
}
}
getDecryptionFailures() {
return [...this._decryptionFailures];
}
_extractPlainText(content) {
if (!content) return "";
if (typeof content === "string") return content;
@@ -241,19 +306,14 @@ class NotesAPI {
return extractText(content);
}
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();
return Array.from(this.categories).sort();
}
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 }));
}
getNote(id) {
const note = this.notesCache.get(id);
console.log(id, this.notesCache);
return note ? { ...note } : null;
}
async createNote(metadata = {}, content = "", plainText = "") {
@@ -273,14 +333,32 @@ class NotesAPI {
data: this._encrypt(note)
};
this.notesCache.set(id, note);
if (note.category) {
this.categories.add(note.category);
}
this.index.add(id, note.title + "\n" + plainText);
await this.adapter.create(encryptedNote);
return note;
}
async deleteNote(id) {
const note = this.notesCache.get(id);
const category = note?.category;
await this.adapter.delete(id);
this.notesCache.delete(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 = {}) {
const note = this.notesCache.get(id);
@@ -291,6 +369,7 @@ class NotesAPI {
throw new Error(`Invalid update field: ${key}`);
}
}
const oldCategory = note.category;
const updatedNote = {
...note,
...updates,
@@ -301,6 +380,19 @@ class NotesAPI {
data: this._encrypt(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);
this.index.update(id, updatedNote.title + "\n" + searchText);
await this.adapter.update(encryptedNote);
@@ -319,46 +411,45 @@ const generateEncryptionKey = () => {
crypto.getRandomValues(array);
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();
for (const plugin of plugins) {
registry.register(plugin);
}
const manager = createPluginManager(registry);
const activeConfig = config.adapters?.[config.activeAdapter] || {};
manager.setActivePlugin(config.activeAdapter, activeConfig);
return manager;
return createPluginManager(registry);
};
const initConfigManager = async (runtime) => {
const initConfigManager = async (runtime, pluginManager) => {
let storage;
{
const { createNodeStorage } = await import("./NodeStorage-B8VFtrTS.js");
const filesystemPlugin2 = (await import("@takerofnotes/plugin-filesystem")).default;
storage = createNodeStorage(filesystemPlugin2);
}
return createConfigManager(storage);
return createConfigManager(storage, pluginManager);
};
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 pluginManager = initPluginManager(runtime, plugins, config);
const activeConfig = config.adapters?.[config.activeAdapter] || {};
pluginManager.setActivePlugin(config.activeAdapter, activeConfig);
let notesAPI = null;
let initPromise = null;
const getNotesAPI = async () => {
if (notesAPI) return notesAPI;
if (!initPromise) {
initPromise = (async () => {
const config2 = await configManager.loadConfig();
let encryptionKey = config2?.encryptionKey;
const latestConfig = await configManager.loadConfig();
let encryptionKey = latestConfig?.encryptionKey;
if (!encryptionKey) {
encryptionKey = generateEncryptionKey();
await configManager.setConfig({
...config2,
...latestConfig,
encryptionKey
});
}
const pluginId = config2?.activeAdapter || "filesystem";
const adapterConfig = config2?.adapters?.[pluginId] || {};
const pluginId = latestConfig?.activeAdapter || "filesystem";
const adapterConfig = latestConfig?.adapters?.[pluginId] || {};
const adapter = pluginManager.getAdapter(
pluginId,
adapterConfig

7
package-lock.json generated
View File

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

View File

@@ -41,6 +41,7 @@
"@tiptap/starter-kit": "^3.19.0",
"@tiptap/vue-3": "^3.19.0",
"@vueuse/core": "^14.2.1",
"ajv": "^6.14.0",
"archiver": "^7.0.1",
"dotenv": "^17.3.1",
"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 = () => {
return {
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
return {
@@ -27,6 +89,24 @@ export const createConfigManager = (storage) => {
},
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
await storage.save(newConfig)
},

View File

@@ -1,7 +1,5 @@
export default class IpcAdapter {
constructor() {
this._methods = ['init', 'getAll', 'create', 'update', 'delete']
}
constructor() {}
async init() {
return await window.api.adapterCall('init')

View File

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

View File

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

@@ -12,7 +12,7 @@
<router-link class="menu-item" to="/instructions">
Instructio
</router-link>
<button class="menu-item">Import</button>
<!-- <button class="menu-item">Import</button> -->
<button class="menu-item" @click="handleExport">Export</button>
<router-link class="menu-item" to="/preferences">
Preferences

View File

@@ -5,6 +5,8 @@
<script setup>
import useOpenNote from '@/composables/useOpenNote'
import useNotes from '@/composables/useNotes'
import { useMagicKeys } from '@vueuse/core'
import { watchEffect } from 'vue'
const props = defineProps({ category: String })
const emit = defineEmits(['noteOpened'])
@@ -22,9 +24,12 @@ const onClick = async () => {
openNote(note.id)
emit('noteOpened')
}
</script>
<style lang="scss">
.new-note {
// Add keyboard shortcut
const { ctrl, n } = useMagicKeys()
watchEffect(() => {
if (ctrl.value && n.value) {
onClick()
}
</style>
})
</script>

View File

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

View File

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

View File

@@ -61,8 +61,11 @@ const onUpdate = _debounce(async ({ editor }) => {
onMounted(async () => {
const note = await loadNote(props.id)
if (!note) return
if (note.title !== 'Untitled') {
title.value = note.title || ''
}
editor.value = new Editor({
extensions,
@@ -76,6 +79,7 @@ onMounted(async () => {
await nextTick()
if (titleInput.value) {
titleInput.value.textContent = title.value
titleInput.value.focus()
}
})
onBeforeUnmount(() => {
@@ -101,6 +105,11 @@ const onTitleChange = (e) => {
updateTitle(title.value)
}
const onTitleKeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
editor.value?.commands?.focus()
}
if (title.value.length >= TITLE_CHAR_LIMIT && e.key.length === 1) {
e.preventDefault()
}

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 { getNotesAPI } from '@/libs/getNotesAPI'
import { useEnvironment } from '@/composables/useEnvironment.js'
const categories = ref([])
const searchResults = ref([])
const notesChangeCount = ref(0)
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()
}
const decryptionFailures = ref([])
export default () => {
/* -------------------------
Initialization
--------------------------*/
const { getNotesAPI } = useCore()
// 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 api = await getNotesAPI()
categories.value = api.getCategories()
}
const loadCategoryNotes = async (category = null) => {
const api = await getNotesAPI()
return api.getCategoryNotes(category)
}
const loadNote = async (id) => {
const api = await getNotesAPI()
console.log(id, api)
return api.getNote(id)
}
/* -------------------------
Create
--------------------------*/
// Create
const createNote = async (metadata, content, plainText = '') => {
const api = await getNotesAPI()
const note = await api.createNote(metadata, content, plainText)
@@ -80,9 +43,7 @@ export default () => {
return note
}
/* -------------------------
Update
--------------------------*/
// Update
const updateNote = async (id, updates) => {
const api = await getNotesAPI()
@@ -96,7 +57,6 @@ export default () => {
return note
}
const updateCategory = async (category, update) => {
const notes = await loadCategoryNotes(category)
@@ -107,19 +67,16 @@ export default () => {
await loadCategories()
}
/* -------------------------
Delete
--------------------------*/
// Delete
const deleteNote = async (id) => {
const api = await getNotesAPI()
await api.deleteNote(id)
await loadCategories()
broadcastChange('note-deleted', { id })
getDecryptionFailures()
}
/* -------------------------
Search
--------------------------*/
// Search
const search = async (query) => {
const api = await getNotesAPI()
@@ -134,7 +91,8 @@ export default () => {
return {
categories,
searchResults,
notesChangeCount,
decryptionFailures,
changeCount,
loadCategories,
loadCategoryNotes,

View File

@@ -11,12 +11,9 @@ Tabula = Index/Overview
Program Key Commands
cmd + s = save\
cmd + t = new capitulum\
cmd + n = new nota\
cmd + x = close window\
dbl click = change name / open nota\
paste hyperlink twice = activated url
cmd + x = close window
---
@@ -25,4 +22,9 @@ Text Markdowns
cmd + b = Bold\
cmd + u = underline\
--- = ---------- (ruled line break)\
/_text_/ = Desaturated text
\# = Heading\
\> = Blockquote\
\- = Unordered List\
\1. = Ordered List\
\` = Inline Code\
\``` = Code Block

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

View File

@@ -18,6 +18,6 @@
@mixin p {
font-family: var(--font-mono);
font-weight: 400;
line-height: 1;
line-height: 1.4;
font-size: 12px;
}

View File

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

View File

@@ -14,21 +14,32 @@
</div>
<new-note />
<decryption-warning :failedIds="decryptionFailures" />
</main>
<page-loading v-else />
</template>
<script setup>
import useNotes from '@/composables/useNotes'
import { onMounted, ref, watch } from 'vue'
import DecryptionWarning from '@/components/DecryptionWarning.vue'
import { onMounted, ref, watchEffect, watch } from 'vue'
import CategoryRow from '@/components/CategoryRow.vue'
import NoteRow from '@/components/NoteRow.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 { useMagicKeys } from '@vueuse/core'
import { useRouter } from 'vue-router'
const { categories, loadCategories, loadCategoryNotes, notesChangeCount } =
useNotes()
const router = useRouter()
const {
categories,
loadCategories,
loadCategoryNotes,
changeCount,
decryptionFailures,
} = useNotes()
const notes = ref()
const loaded = ref(false)
@@ -44,15 +55,23 @@ onMounted(async () => {
await refreshNotes()
})
watch(notesChangeCount, async () => {
watch(changeCount, async () => {
await refreshNotes()
})
// New category keyboard shortcut
const { ctrl, t } = useMagicKeys()
watchEffect(() => {
if (ctrl.value && t.value) {
router.push('/category')
}
})
</script>
<style lang="scss">
main.directory {
padding-top: var(--nav-height);
padding-bottom: 30px;
padding-bottom: 60px;
.label {
text-transform: uppercase;