search WIP

This commit is contained in:
nicwands
2026-03-10 12:10:52 -04:00
parent 77b8ad2dcd
commit efc9c73751
8 changed files with 185 additions and 24 deletions

View File

@@ -98,8 +98,7 @@ class NotesAPI {
this.encryptionKey = encryptionKey || process.env.NOTES_ENCRYPTION_KEY;
this._sodiumReady = false;
this.index = new Index({
tokenize: "tolerant",
resolution: 9
tokenize: "forward"
});
}
async _initSodium() {
@@ -179,12 +178,23 @@ class NotesAPI {
try {
const note = this._decrypt(encryptedNote.data || encryptedNote);
this.notesCache.set(note.id, note);
this.index.add(note.id, note.title + "\n" + note.content);
const searchText = note.plainText || this._extractPlainText(note.content);
this.index.add(note.id, note.title + "\n" + searchText);
} catch (error) {
console.error("Failed to decrypt note:", error);
}
}
}
_extractPlainText(content) {
if (!content) return "";
if (typeof content === "string") return content;
const extractText = (node) => {
if (typeof node === "string") return node;
if (!node || !node.content) return "";
return node.content.map(extractText).join(" ");
};
return extractText(content);
}
/* -----------------------
Public API
------------------------*/
@@ -203,7 +213,7 @@ class NotesAPI {
getNote(id) {
return this.notesCache.get(id) ?? null;
}
async createNote(metadata = {}, content = "") {
async createNote(metadata = {}, content = "", plainText = "") {
const id = crypto.randomUUID();
const now = (/* @__PURE__ */ new Date()).toISOString();
const note = {
@@ -212,14 +222,15 @@ class NotesAPI {
category: metadata.category || null,
createdAt: now,
updatedAt: now,
content
content,
plainText
};
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.notesCache.set(id, note);
this.index.add(id, note.title + "\n" + content);
this.index.add(id, note.title + "\n" + plainText);
await this.adapter.create(encryptedNote);
return note;
}
@@ -228,16 +239,17 @@ class NotesAPI {
this.notesCache.delete(id);
this.index.remove(id);
}
async updateNote(id, content) {
async updateNote(id, content, plainText = "") {
const note = this.notesCache.get(id);
if (!note) throw new Error("Note not found");
note.content = content;
note.plainText = plainText;
note.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
const encryptedNote = {
id: note.id,
data: this._encrypt(note)
};
this.index.update(id, note.title + "\n" + content);
this.index.update(id, note.title + "\n" + plainText);
await this.adapter.update(encryptedNote);
return note;
}
@@ -261,12 +273,18 @@ class NotesAPI {
id: note.id,
data: this._encrypt(note)
};
this.index.update(id, note.title + "\n" + note.content);
this.index.update(
id,
note.title + "\n" + (note.plainText || this._extractPlainText(note.content))
);
await this.adapter.update(encryptedNote);
return note;
}
search(query) {
const ids = this.index.search(query);
const ids = this.index.search(query, {
limit: 50,
suggest: true
});
return ids.map((id) => this.notesCache.get(id));
}
}

View File

@@ -14,8 +14,7 @@ export default class NotesAPI {
this._sodiumReady = false
this.index = new Index({
tokenize: 'tolerant',
resolution: 9,
tokenize: 'forward',
})
}
@@ -118,13 +117,28 @@ export default class NotesAPI {
const note = this._decrypt(encryptedNote.data || encryptedNote)
this.notesCache.set(note.id, note)
this.index.add(note.id, note.title + '\n' + note.content)
const searchText =
note.plainText || this._extractPlainText(note.content)
this.index.add(note.id, note.title + '\n' + searchText)
} catch (error) {
console.error('Failed to decrypt note:', error)
}
}
}
_extractPlainText(content) {
if (!content) return ''
if (typeof content === 'string') return content
const extractText = (node) => {
if (typeof node === 'string') return node
if (!node || !node.content) return ''
return node.content.map(extractText).join(' ')
}
return extractText(content)
}
/* -----------------------
Public API
------------------------*/
@@ -150,7 +164,7 @@ export default class NotesAPI {
return this.notesCache.get(id) ?? null
}
async createNote(metadata = {}, content = '') {
async createNote(metadata = {}, content = '', plainText = '') {
const id = crypto.randomUUID()
const now = new Date().toISOString()
@@ -161,6 +175,7 @@ export default class NotesAPI {
createdAt: now,
updatedAt: now,
content,
plainText,
}
const encryptedNote = {
@@ -169,7 +184,7 @@ export default class NotesAPI {
}
this.notesCache.set(id, note)
this.index.add(id, note.title + '\n' + content)
this.index.add(id, note.title + '\n' + plainText)
await this.adapter.create(encryptedNote)
@@ -183,11 +198,12 @@ export default class NotesAPI {
this.index.remove(id)
}
async updateNote(id, content) {
async updateNote(id, content, plainText = '') {
const note = this.notesCache.get(id)
if (!note) throw new Error('Note not found')
note.content = content
note.plainText = plainText
note.updatedAt = new Date().toISOString()
const encryptedNote = {
@@ -195,7 +211,7 @@ export default class NotesAPI {
data: this._encrypt(note),
}
this.index.update(id, note.title + '\n' + content)
this.index.update(id, note.title + '\n' + plainText)
await this.adapter.update(encryptedNote)
@@ -228,7 +244,12 @@ export default class NotesAPI {
data: this._encrypt(note),
}
this.index.update(id, note.title + '\n' + note.content)
this.index.update(
id,
note.title +
'\n' +
(note.plainText || this._extractPlainText(note.content)),
)
await this.adapter.update(encryptedNote)
@@ -236,7 +257,10 @@ export default class NotesAPI {
}
search(query) {
const ids = this.index.search(query)
const ids = this.index.search(query, {
limit: 50,
suggest: true,
})
return ids.map((id) => this.notesCache.get(id))
}
}

View File

@@ -1,6 +1,8 @@
<template>
<nav class="nav layout-block">
<button @click="toggleMenu">Menu</button>
<router-link to="/search">Search</router-link>
</nav>
</template>
@@ -29,6 +31,9 @@ onMounted(() => {
<style lang="scss">
.nav {
display: flex;
align-items: center;
gap: 10px;
padding-top: 9px;
color: var(--grey-100);
}

View File

@@ -23,6 +23,7 @@ const formatDate = (date) => {
.note-row {
grid-template-columns: auto 1fr;
display: grid;
width: 100%;
gap: 20px;
cursor: pointer;

View File

@@ -33,8 +33,9 @@ const { loadNote, updateNoteContent, updateNoteMetadata } = useNotes()
const updateNote = _debounce(async ({ editor }) => {
const json = editor.getJSON()
const text = editor.getText()
await updateNoteContent(props.id, json)
await updateNoteContent(props.id, json, text)
updateTitle(editor)
}, 300)

View File

@@ -44,8 +44,13 @@ export default () => {
/* -------------------------
Create
--------------------------*/
async function createNote(metadata, content) {
const note = await window.notesAPI.call('createNote', metadata, content)
async function createNote(metadata, content, plainText = '') {
const note = await window.notesAPI.call(
'createNote',
metadata,
content,
plainText,
)
await loadCategories()
return note
}
@@ -53,8 +58,13 @@ export default () => {
/* -------------------------
Update
--------------------------*/
async function updateNoteContent(id, content) {
const note = await window.notesAPI.call('updateNote', id, content)
async function updateNoteContent(id, content, plainText = '') {
const note = await window.notesAPI.call(
'updateNote',
id,
content,
plainText,
)
return note
}

View File

@@ -5,6 +5,7 @@ import Note from '@/views/Note.vue'
import CreateCategory from '@/views/CreateCategory.vue'
import Category from '@/views/Category.vue'
import Instructions from '@/views/Instructions.vue'
import Search from '@/views/Search.vue'
const routes = [
{ path: '/', name: 'directory', component: Directory },
@@ -12,6 +13,7 @@ const routes = [
{ path: '/category', name: 'create-category', component: CreateCategory },
{ path: '/category/:id', name: 'category', component: Category },
{ path: '/instructions', name: 'instructions', component: Instructions },
{ path: '/search', name: 'search', component: Search },
]
export const router = createRouter({

View File

@@ -0,0 +1,100 @@
<template>
<main class="search layout-block">
<router-link class="back" to="/"><- Back</router-link>
<form @submit.prevent="onSearch">
<div class="input-wrap">
<input
v-model="query"
type="text"
placeholder="Search"
ref="searchInput"
/>
</div>
</form>
<div class="results">
<note-row
v-for="note in searchResults"
:key="note.id"
:note="note"
/>
</div>
</main>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import useNotes from '@/composables/useNotes'
import NoteRow from '@/components/NoteRow.vue'
const query = ref('')
const searchInput = ref()
const { search, searchResults } = useNotes()
onMounted(async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
searchInput.value?.focus()
})
const onSearch = async () => {
await search(query.value)
}
</script>
<style lang="scss">
main.search {
.back {
display: block;
opacity: 0.25;
margin-top: 9px;
}
.input-wrap {
margin-top: 19px;
position: relative;
input {
display: block;
width: 100%;
position: relative;
padding: 5px 15px 6px;
background: var(--theme-bg);
--clip-start: 16px;
clip-path: polygon(
var(--clip-start) 1px,
calc(100% - var(--clip-start)) 1px,
calc(100% - 1.5px) 50%,
calc(100% - var(--clip-start)) calc(100% - 1px),
var(--clip-start) calc(100% - 1px),
1.5px 50%
);
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--theme-fg);
--clip-start: 15px;
clip-path: polygon(
var(--clip-start) 0,
calc(100% - var(--clip-start)) 0,
100% 50%,
calc(100% - var(--clip-start)) 100%,
var(--clip-start) 100%,
0% 50%
);
}
}
.results {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
}
</style>