From 0ab0620da86cf95f1ba9a8db5fd25a6abeb4e6c0 Mon Sep 17 00:00:00 2001 From: nicwands Date: Mon, 23 Feb 2026 16:55:38 -0500 Subject: [PATCH] full notes file system architecture --- out/main/index.js | 177 +++++++++++------ package-lock.json | 152 +++++++++++++++ package.json | 3 + src/main/index.js | 11 +- src/main/notesAPI.js | 199 +++++++++++++------- src/renderer/src/components/CategoryRow.vue | 42 +++++ src/renderer/src/components/NoteRow.vue | 51 +++++ src/renderer/src/composables/useNotes.js | 99 ++++++---- src/renderer/src/plugins/router.js | 6 +- src/renderer/src/views/Category.vue | 53 ++++++ src/renderer/src/views/Directory.vue | 132 ++----------- src/renderer/src/views/Editor.vue | 34 +++- 12 files changed, 666 insertions(+), 293 deletions(-) create mode 100644 src/renderer/src/components/CategoryRow.vue create mode 100644 src/renderer/src/components/NoteRow.vue create mode 100644 src/renderer/src/views/Category.vue diff --git a/out/main/index.js b/out/main/index.js index 0e84aae..ba847cb 100644 --- a/out/main/index.js +++ b/out/main/index.js @@ -1,73 +1,125 @@ "use strict"; const utils = require("@electron-toolkit/utils"); const electron = require("electron"); -const fs = require("fs"); +const fs = require("fs/promises"); const path = require("path"); -const BASE_DIR = path.join(electron.app.getPath("userData"), "notes-storage"); -const ensureBaseDir = () => { - if (!fs.existsSync(BASE_DIR)) { - fs.mkdirSync(BASE_DIR, { recursive: true }); +const matter = require("gray-matter"); +const flexsearch = require("flexsearch"); +const crypto = require("crypto"); +class NotesAPI { + constructor() { + this.notesDir = path.join(electron.app.getPath("userData"), "notes"); + this.notesCache = /* @__PURE__ */ new Map(); + this.index = new flexsearch.Index({ + tokenize: "tolerant", + resolution: 9 + }); } -}; -const sanitizeRelativePath = (relativePath) => { - const resolved = path.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 = []; - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results = results.concat(readAllNotesRecursive(fullPath, base)); - } - if (entry.isFile() && entry.name.endsWith(".md")) { - const content = fs.readFileSync(fullPath, "utf-8"); - results.push({ - name: entry.name, - path: path.relative(base, fullPath), - content - }); + async _loadAllNotes() { + const files = await fs.readdir(this.notesDir); + for (const file of files) { + if (!file.endsWith(".md")) continue; + const fullPath = path.join(this.notesDir, file); + const raw = await fs.readFile(fullPath, "utf8"); + const parsed = matter(raw); + const note = { + ...parsed.data, + content: parsed.content + }; + this.notesCache.set(note.id, note); + this.index.add(note.id, note.title + note.content); } } - return results; -}; -const createNote = (relativePath, content = "") => { - const fullPath = sanitizeRelativePath(relativePath); - fs.mkdirSync(path.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); + async _writeNoteFile(note) { + const filePath = path.join(this.notesDir, `${note.id}.md`); + const fileContent = matter.stringify(note.content, { + id: note.id, + title: note.title, + category: note.category ?? null, + createdAt: note.createdAt, + updatedAt: note.updatedAt + }); + await fs.writeFile(filePath, fileContent, "utf8"); } - return fs.readFileSync(fullPath, "utf-8"); -}; -const updateNote = (relativePath, content) => { - const fullPath = sanitizeRelativePath(relativePath); - if (!fs.existsSync(fullPath)) { - throw new Error("Note does not exist"); + /* ----------------------- + Public API + ------------------------*/ + 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(); } - fs.writeFileSync(fullPath, content, "utf-8"); - return true; -}; -const notesAPI = { - readAllNotesRecursive, - createNote, - createDirectory, - readNote, - updateNote -}; + getCategoryNotes(categoryName) { + return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + } + getNote(id) { + return this.notesCache.get(id) ?? null; + } + async createNote(metadata = {}, content = "") { + const id = crypto.randomUUID(); + const now = (/* @__PURE__ */ 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 = (/* @__PURE__ */ 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 !== void 0) { + note.title = updates.title; + } + if (updates.category !== void 0) { + note.category = updates.category; + } + note.updatedAt = (/* @__PURE__ */ 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)); + } +} const preloadPath = path.join(__dirname, "../preload/index.js"); const rendererPath = path.join(__dirname, "../renderer/index.html"); function createWindow() { @@ -121,14 +173,15 @@ electron.app.whenReady().then(() => { utils.optimizer.watchWindowShortcuts(window); }); createWindow(); - ensureBaseDir(); electron.app.on("activate", function() { if (electron.BrowserWindow.getAllWindows().length === 0) createWindow(); }); electron.ipcMain.on("open-note-window", (_, noteId) => { createNoteWindow(noteId); }); - electron.ipcMain.handle("notesAPI:call", async (_, method, args) => { + const notesAPI = new NotesAPI(); + notesAPI.init(); + electron.ipcMain.handle("notesAPI:call", (_, method, args) => { if (!notesAPI[method]) { throw new Error("Invalid method"); } diff --git a/package-lock.json b/package-lock.json index 6abec00..170dcbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,15 @@ "@vueuse/core": "^14.2.1", "electron-updater": "^6.3.9", "fecha": "^4.2.3", + "flexsearch": "^0.8.212", + "gray-matter": "^4.0.3", "gsap": "^3.14.2", "lenis": "^1.3.17", "lodash": "^4.17.23", "sass": "^1.97.3", "sass-embedded": "^1.97.3", "tempus": "^1.0.0-dev.17", + "uuid": "^13.0.0", "vue-router": "^5.0.3" }, "devDependencies": { @@ -4942,6 +4945,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", @@ -4961,6 +4977,18 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -5078,6 +5106,34 @@ "node": ">=10" } }, + "node_modules/flexsearch": { + "version": "0.8.212", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.212.tgz", + "integrity": "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/ts-thomas" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=GEVR88FC9BWRW" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/flexsearch" + }, + { + "type": "patreon", + "url": "https://patreon.com/user?u=96245532" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/ts-thomas" + } + ], + "license": "Apache-2.0" + }, "node_modules/fontfaceobserver": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", @@ -5409,6 +5465,49 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gray-matter/node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/gsap": { "version": "3.14.2", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", @@ -5662,6 +5761,15 @@ "node": ">= 12" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5878,6 +5986,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", @@ -7835,6 +7952,19 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -8117,6 +8247,15 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -8548,6 +8687,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", diff --git a/package.json b/package.json index c20fc48..800bdb7 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,15 @@ "@vueuse/core": "^14.2.1", "electron-updater": "^6.3.9", "fecha": "^4.2.3", + "flexsearch": "^0.8.212", + "gray-matter": "^4.0.3", "gsap": "^3.14.2", "lenis": "^1.3.17", "lodash": "^4.17.23", "sass": "^1.97.3", "sass-embedded": "^1.97.3", "tempus": "^1.0.0-dev.17", + "uuid": "^13.0.0", "vue-router": "^5.0.3" }, "devDependencies": { diff --git a/src/main/index.js b/src/main/index.js index 3520f46..6795288 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -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') } diff --git a/src/main/notesAPI.js b/src/main/notesAPI.js index 31e7f9d..5951f00 100644 --- a/src/main/notesAPI.js +++ b/src/main/notesAPI.js @@ -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)) + } } diff --git a/src/renderer/src/components/CategoryRow.vue b/src/renderer/src/components/CategoryRow.vue new file mode 100644 index 0000000..94c4578 --- /dev/null +++ b/src/renderer/src/components/CategoryRow.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/renderer/src/components/NoteRow.vue b/src/renderer/src/components/NoteRow.vue new file mode 100644 index 0000000..23b5820 --- /dev/null +++ b/src/renderer/src/components/NoteRow.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/renderer/src/composables/useNotes.js b/src/renderer/src/composables/useNotes.js index 2e02710..ce074f4 100644 --- a/src/renderer/src/composables/useNotes.js +++ b/src/renderer/src/composables/useNotes.js @@ -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, } } diff --git a/src/renderer/src/plugins/router.js b/src/renderer/src/plugins/router.js index 90fee9e..46d0d98 100644 --- a/src/renderer/src/plugins/router.js +++ b/src/renderer/src/plugins/router.js @@ -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({ diff --git a/src/renderer/src/views/Category.vue b/src/renderer/src/views/Category.vue new file mode 100644 index 0000000..e54adeb --- /dev/null +++ b/src/renderer/src/views/Category.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/renderer/src/views/Directory.vue b/src/renderer/src/views/Directory.vue index 0a5428c..d88326f 100644 --- a/src/renderer/src/views/Directory.vue +++ b/src/renderer/src/views/Directory.vue @@ -1,149 +1,49 @@ diff --git a/src/renderer/src/views/Editor.vue b/src/renderer/src/views/Editor.vue index 5666a71..90e60f0 100644 --- a/src/renderer/src/views/Editor.vue +++ b/src/renderer/src/views/Editor.vue @@ -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(() => {