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

@@ -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

@@ -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,35 +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 () => {
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) {
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

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

@@ -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

@@ -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,24 +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, watchEffect } 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)
@@ -47,7 +55,7 @@ onMounted(async () => {
await refreshNotes()
})
watch(notesChangeCount, async () => {
watch(changeCount, async () => {
await refreshNotes()
})
@@ -63,7 +71,7 @@ watchEffect(() => {
<style lang="scss">
main.directory {
padding-top: var(--nav-height);
padding-bottom: 30px;
padding-bottom: 60px;
.label {
text-transform: uppercase;