full notes file system architecture

This commit is contained in:
nicwands
2026-02-23 16:55:38 -05:00
parent 9ac9d73b0a
commit 0ab0620da8
12 changed files with 666 additions and 293 deletions

View File

@@ -1,6 +1,6 @@
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import notesAPI, { ensureBaseDir } from './notesAPI'
import NotesAPI from './notesAPI'
import { join } from 'path'
const preloadPath = join(__dirname, '../preload/index.js')
@@ -77,9 +77,6 @@ app.whenReady().then(() => {
// Create main window
createWindow()
// Ensure data directory is present
ensureBaseDir()
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
@@ -91,8 +88,10 @@ app.whenReady().then(() => {
createNoteWindow(noteId)
})
// Handle calls to Notes API
ipcMain.handle('notesAPI:call', async (_, method, args) => {
// Init Notes API
const notesAPI = new NotesAPI()
notesAPI.init()
ipcMain.handle('notesAPI:call', (_, method, args) => {
if (!notesAPI[method]) {
throw new Error('Invalid method')
}

View File

@@ -1,88 +1,159 @@
import fs from 'fs/promises'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { join, relative, dirname } from 'path'
import matter from 'gray-matter'
import { Index } from 'flexsearch'
import crypto from 'crypto'
const BASE_DIR = join(app.getPath('userData'), 'notes-storage')
export const ensureBaseDir = () => {
if (!fs.existsSync(BASE_DIR)) {
fs.mkdirSync(BASE_DIR, { recursive: true })
export default class NotesAPI {
constructor() {
this.notesDir = path.join(app.getPath('userData'), 'notes')
this.notesCache = new Map()
this.index = new Index({
tokenize: 'tolerant',
resolution: 9,
})
}
}
const sanitizeRelativePath = (relativePath) => {
const resolved = join(BASE_DIR, relativePath)
if (!resolved.startsWith(BASE_DIR)) {
throw new Error('Invalid path')
async init() {
await fs.mkdir(this.notesDir, { recursive: true })
await this._loadAllNotes()
}
return resolved
}
const readAllNotesRecursive = (dir = BASE_DIR, base = BASE_DIR) => {
const entries = fs.readdirSync(dir, { withFileTypes: true })
let results = []
async _loadAllNotes() {
const files = await fs.readdir(this.notesDir)
for (const entry of entries) {
const fullPath = join(dir, entry.name)
for (const file of files) {
if (!file.endsWith('.md')) continue
if (entry.isDirectory()) {
results = results.concat(readAllNotesRecursive(fullPath, base))
}
const fullPath = path.join(this.notesDir, file)
const raw = await fs.readFile(fullPath, 'utf8')
const parsed = matter(raw)
if (entry.isFile() && entry.name.endsWith('.md')) {
const content = fs.readFileSync(fullPath, 'utf-8')
const note = {
...parsed.data,
content: parsed.content,
}
results.push({
name: entry.name,
path: relative(base, fullPath),
content,
})
this.notesCache.set(note.id, note)
this.index.add(note.id, note.title + note.content)
}
}
return results
}
async _writeNoteFile(note) {
const filePath = path.join(this.notesDir, `${note.id}.md`)
const createNote = (relativePath, content = '') => {
const fullPath = sanitizeRelativePath(relativePath)
const fileContent = matter.stringify(note.content, {
id: note.id,
title: note.title,
category: note.category ?? null,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
})
fs.mkdirSync(dirname(fullPath), { recursive: true })
fs.writeFileSync(fullPath, content, 'utf-8')
return true
}
const createDirectory = (relativePath) => {
const fullPath = sanitizeRelativePath(relativePath)
fs.mkdirSync(fullPath, { recursive: true })
return true
}
const readNote = (relativePath) => {
const fullPath = sanitizeRelativePath(relativePath)
if (!fs.existsSync(fullPath)) {
createNote(relativePath)
await fs.writeFile(filePath, fileContent, 'utf8')
}
return fs.readFileSync(fullPath, 'utf-8')
}
/* -----------------------
Public API
------------------------*/
getCategories() {
const categories = new Set()
const updateNote = (relativePath, content) => {
const fullPath = sanitizeRelativePath(relativePath)
for (const note of this.notesCache.values()) {
if (note.category) {
categories.add(note.category)
}
}
if (!fs.existsSync(fullPath)) {
throw new Error('Note does not exist')
return Array.from(categories).sort()
}
fs.writeFileSync(fullPath, content, 'utf-8')
getCategoryNotes(categoryName) {
return Array.from(this.notesCache.values())
.filter((n) => n.category === categoryName)
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
}
return true
}
getNote(id) {
return this.notesCache.get(id) ?? null
}
export default {
readAllNotesRecursive,
createNote,
createDirectory,
readNote,
updateNote,
async createNote(metadata = {}, content = '') {
const id = crypto.randomUUID()
const now = new Date().toISOString()
const note = {
id,
title: metadata.title || 'Untitled',
category: metadata.category || null,
createdAt: now,
updatedAt: now,
content,
}
console.log(note)
this.notesCache.set(id, note)
this.index.add(id, note.title + '\n' + content)
await this._writeNoteFile(note)
return note
}
async deleteNote(id) {
const filePath = path.join(this.notesDir, `${id}.md`)
await fs.unlink(filePath)
this.notesCache.delete(id)
this.index.remove(id)
}
async updateNote(id, content) {
const note = this.notesCache.get(id)
if (!note) throw new Error('Note not found')
note.content = content
note.updatedAt = new Date().toISOString()
this.index.update(id, note.title + '\n' + content)
await this._writeNoteFile(note)
return note
}
async updateNoteMetadata(id, updates = {}) {
const note = this.notesCache.get(id)
if (!note) throw new Error('Note not found')
const allowedFields = ['title', 'category']
for (const key of Object.keys(updates)) {
if (!allowedFields.includes(key)) {
throw new Error(`Invalid metadata field: ${key}`)
}
}
if (updates.title !== undefined) {
note.title = updates.title
}
if (updates.category !== undefined) {
note.category = updates.category
}
note.updatedAt = new Date().toISOString()
this.index.update(id, note.title + '\n' + note.content)
await this._writeNoteFile(note)
return note
}
search(query) {
const ids = this.index.search(query)
return ids.map((id) => this.notesCache.get(id))
}
}

View File

@@ -0,0 +1,42 @@
<template>
<router-link class="category-row" :to="`/category/${category}`">
<span class="index">{{ String(index + 1).padStart(2, '0') }}.</span>
<span class="title h1">{{ category }}</span>
</router-link>
</template>
<script setup>
const props = defineProps({ index: Number, category: String })
</script>
<style lang="scss">
.category-row {
display: grid;
grid-template-columns: size-vw(26px) 1fr;
width: 100%;
position: relative;
padding: size-vw(5px) 0 size-vw(15px);
cursor: pointer;
.index {
margin-top: size-vw(19px);
}
.title {
display: block;
width: 100%;
@include line-clamp(2);
}
&::after {
content: '----------------------------------------';
position: absolute;
bottom: 0;
left: 0;
}
&.router-link-exact-active {
cursor: default;
}
&:hover:not(.router-link-exact-active) {
color: var(--theme-accent);
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<button class="note-row" @click="openNote(note.id)">
<span class="date">{{ formatDate(note.createdAt) }}</span>
<span class="title bold">{{ note.title }}</span>
</button>
</template>
<script setup>
import useOpenNote from '@/composables/useOpenNote'
import { format } from 'fecha'
const props = defineProps({ note: Object })
const { openNote } = useOpenNote()
const formatDate = (date) => {
const d = new Date(date)
return format(d, 'MM/DD/YYYY')
}
</script>
<style lang="scss">
.note-row {
grid-template-columns: auto 1fr;
display: grid;
gap: size-vw(20px);
cursor: pointer;
.title {
width: size-vw(159px);
position: relative;
&::after {
content: '(open)';
position: absolute;
bottom: 0;
right: 0;
transform: translateX(100%);
font-weight: 700;
opacity: 0;
}
}
&:hover {
color: var(--theme-accent);
.title::after {
opacity: 1;
}
}
}
</style>

View File

@@ -1,48 +1,75 @@
import { ref } from 'vue'
const categories = ref([])
const searchResults = ref([])
export default () => {
const notes = ref([])
const loading = ref(false)
const error = ref(null)
/* -------------------------
Initialization
--------------------------*/
async function loadCategories() {
categories.value = await window.notesAPI.call('getCategories')
}
const fetchNotes = async () => {
try {
loading.value = true
notes.value = await window.notesAPI.call('readAllNotesRecursive')
} catch (err) {
error.value = err.message
} finally {
loading.value = false
async function loadCategoryNotes(category = null) {
return await window.notesAPI.call('getCategoryNotes', category)
}
async function loadNote(id) {
return await window.notesAPI.call('getNote', id)
}
/* -------------------------
Create
--------------------------*/
async function createNote(metadata, content) {
const note = await window.notesAPI.call('createNote', metadata, content)
await loadCategories()
return note
}
/* -------------------------
Update
--------------------------*/
async function updateNoteContent(id, content) {
const note = await window.notesAPI.call('updateNote', id, content)
return note
}
async function updateNoteMetadata(id, updates) {
const note = await window.notesAPI.call(
'updateNoteMetadata',
id,
updates,
)
await loadCategories()
return note
}
/* -------------------------
Search
--------------------------*/
async function search(query) {
if (!query) {
searchResults.value = []
return
}
}
const createNote = async (path, content = '') => {
await window.notesAPI.call('createNote', path, content)
await fetchNotes()
}
const createDirectory = async (path) => {
await window.notesAPI.call('createDirectory', path)
await fetchNotes()
}
const readNote = async (path) => {
console.log(path)
return await window.notesAPI.call('readNote', path)
}
const updateNote = async (path, content) => {
return await window.notesAPI.call('updateNote', path, content)
searchResults.value = await window.notesAPI.call('search', query)
}
return {
notes,
loading,
error,
fetchNotes,
categories,
searchResults,
loadCategories,
loadCategoryNotes,
loadNote,
createNote,
createDirectory,
readNote,
updateNote,
updateNoteContent,
updateNoteMetadata,
search,
}
}

View File

@@ -1,11 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'
import Directory from '../views/Directory.vue'
import Editor from '../views/Editor.vue'
import Directory from '@/views/Directory.vue'
import Editor from '@/views/Editor.vue'
import Category from '@/views/Category.vue'
const routes = [
{ path: '/', name: 'directory', component: Directory },
{ path: '/note/:id', name: 'note', component: Editor },
{ path: '/category/:id', name: 'category', component: Category },
]
export const router = createRouter({

View File

@@ -0,0 +1,53 @@
<template>
<main class="category layout-block">
<router-link class="back" to="/"><- Go Back</router-link>
<category-row :index="categoryIndex" :category="id" />
<div class="notes">
<note-row v-for="note in notes" :note="note" :key="note.id" />
</div>
</main>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import useNotes from '@/composables/useNotes'
import NoteRow from '@/components/NoteRow.vue'
import CategoryRow from '../components/CategoryRow.vue'
const route = useRoute()
const id = route.params.id
const { categories, loadCategoryNotes } = useNotes()
const notes = ref()
onMounted(async () => {
notes.value = await loadCategoryNotes(id)
})
const categoryIndex = computed(() => {
return categories.value?.findIndex((category) => category === id) || 0
})
</script>
<style lang="scss">
.category {
.back {
display: block;
opacity: 0.25;
margin-top: size-vw(9px);
}
.category-row {
margin-top: size-vw(4px);
}
.notes {
display: flex;
flex-direction: column;
gap: size-vw(14px);
margin-top: size-vw(9px);
}
}
</style>

View File

@@ -1,149 +1,49 @@
<template>
<main class="directory layout-block">
<button class="capitula" v-for="(capitula, i) in capitulum">
<span class="index">{{ String(i + 1).padStart(2, '0') }}.</span>
<span class="title h1">{{ capitula.title }}</span>
</button>
<category-row
v-for="(category, i) in categories"
:index="i"
:category="category"
:key="category"
/>
<h2 class="label">Summarium</h2>
<div class="summarium">
<button
class="summaria"
v-for="summaria in summarium"
@click="openNote(summaria.slug)"
>
<span class="date">{{ formatDate(summaria.date) }}</span>
<span class="title bold">{{ summaria.title }}</span>
</button>
<div class="notes">
<note-row v-for="note in notes" :note="note" :key="note.id" />
</div>
</main>
</template>
<script setup>
import { format } from 'fecha'
import useOpenNote from '@/composables/useOpenNote'
import useNotes from '@/composables/useNotes'
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import CategoryRow from '@/components/CategoryRow.vue'
import NoteRow from '@/components/NoteRow.vue'
const capitulum = [
{
slug: 'category-name',
date: new Date(),
title: 'Category Name',
},
{
slug: 'alternate-tuning',
date: new Date(),
title: 'Alternate Tuning',
},
{
slug: 'hungarian-citizenship',
date: new Date(),
title: 'Hungarian Citizenship Application and More and More',
},
]
const { categories, loadCategories, loadCategoryNotes } = useNotes()
const summarium = [
{
slug: 'birthday-dinner',
date: new Date(),
title: 'Birthday Dinner Recipe to make for Gina',
},
{
slug: 'to-do-reminders',
date: new Date(),
title: 'To-Do Reminders',
},
{
slug: 'client-feedback',
date: new Date(),
title: 'Client Feedback',
},
]
const { openNote } = useOpenNote()
const { notes, fetchNotes } = useNotes()
const notes = ref()
onMounted(async () => {
await fetchNotes()
console.log(notes.value)
await loadCategories()
notes.value = await loadCategoryNotes()
})
const formatDate = (date) => {
return format(date, 'MM/DD/YYYY')
}
</script>
<style lang="scss">
.directory {
padding-top: size-vw(18px);
.capitula {
display: grid;
grid-template-columns: size-vw(26px) 1fr;
width: 100%;
position: relative;
padding: size-vw(5px) 0 size-vw(15px);
cursor: pointer;
.index {
margin-top: size-vw(19px);
}
.title {
display: block;
width: 100%;
@include line-clamp(2);
}
&::after {
content: '----------------------------------------';
position: absolute;
bottom: 0;
left: 0;
}
&:hover {
color: var(--theme-accent);
}
}
.label {
text-transform: uppercase;
margin: size-vw(17px) 0 size-vw(24px);
@include p;
}
.summarium {
.notes {
display: flex;
flex-direction: column;
gap: size-vw(14px);
.summaria {
grid-template-columns: auto 1fr;
display: grid;
gap: size-vw(20px);
cursor: pointer;
.title {
width: size-vw(159px);
position: relative;
&::after {
content: '(open)';
position: absolute;
bottom: 0;
right: 0;
transform: translateX(100%);
font-weight: 700;
opacity: 0;
}
}
&:hover {
color: var(--theme-accent);
.title::after {
opacity: 1;
}
}
}
}
}
</style>

View File

@@ -35,9 +35,9 @@ import { useRoute } from 'vue-router'
import _debounce from 'lodash/debounce'
const route = useRoute()
const filePath = `/${route.params.id}.md`
const id = route.params.id
const { readNote, updateNote } = useNotes()
const { loadNote, updateNoteContent, updateNoteMetadata } = useNotes()
const CustomDocument = Document.extend({
content: 'heading block*',
@@ -45,13 +45,33 @@ const CustomDocument = Document.extend({
const editor = shallowRef()
const updateFile = _debounce(({ editor }) => {
const updateNote = _debounce(async ({ editor }) => {
const markdown = editor.getMarkdown()
updateNote(filePath, markdown)
await updateNoteContent(id, markdown)
updateTitle(editor)
}, 300)
// Track title updates for file
let lastTitle
const updateTitle = _debounce(async (editor) => {
const doc = editor.state.doc
const firstNode = doc.firstChild
if (!firstNode || firstNode.type.name !== 'heading') return
const newTitle = firstNode.textContent.trim() || 'Untitled'
if (newTitle === lastTitle) return
lastTitle = newTitle
await updateNoteMetadata(id, { title: newTitle })
}, 300)
onMounted(async () => {
const noteFile = await readNote(filePath)
const note = await loadNote(id)
lastTitle = note.title
editor.value = new Editor({
extensions: [
@@ -73,9 +93,9 @@ onMounted(async () => {
Markdown,
Image,
],
content: noteFile,
content: note.content,
contentType: 'markdown',
onUpdate: updateFile,
onUpdate: updateNote,
})
})
onBeforeUnmount(() => {