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,73 +1,125 @@
"use strict"; "use strict";
const utils = require("@electron-toolkit/utils"); const utils = require("@electron-toolkit/utils");
const electron = require("electron"); const electron = require("electron");
const fs = require("fs"); const fs = require("fs/promises");
const path = require("path"); const path = require("path");
const BASE_DIR = path.join(electron.app.getPath("userData"), "notes-storage"); const matter = require("gray-matter");
const ensureBaseDir = () => { const flexsearch = require("flexsearch");
if (!fs.existsSync(BASE_DIR)) { const crypto = require("crypto");
fs.mkdirSync(BASE_DIR, { recursive: true }); 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
});
} }
}; async init() {
const sanitizeRelativePath = (relativePath) => { await fs.mkdir(this.notesDir, { recursive: true });
const resolved = path.join(BASE_DIR, relativePath); await this._loadAllNotes();
if (!resolved.startsWith(BASE_DIR)) {
throw new Error("Invalid path");
} }
return resolved; async _loadAllNotes() {
}; const files = await fs.readdir(this.notesDir);
const readAllNotesRecursive = (dir = BASE_DIR, base = BASE_DIR) => { for (const file of files) {
const entries = fs.readdirSync(dir, { withFileTypes: true }); if (!file.endsWith(".md")) continue;
let results = []; const fullPath = path.join(this.notesDir, file);
for (const entry of entries) { const raw = await fs.readFile(fullPath, "utf8");
const fullPath = path.join(dir, entry.name); const parsed = matter(raw);
if (entry.isDirectory()) { const note = {
results = results.concat(readAllNotesRecursive(fullPath, base)); ...parsed.data,
} content: parsed.content
if (entry.isFile() && entry.name.endsWith(".md")) { };
const content = fs.readFileSync(fullPath, "utf-8"); this.notesCache.set(note.id, note);
results.push({ this.index.add(note.id, note.title + note.content);
name: entry.name,
path: path.relative(base, fullPath),
content
});
} }
} }
return results; async _writeNoteFile(note) {
}; const filePath = path.join(this.notesDir, `${note.id}.md`);
const createNote = (relativePath, content = "") => { const fileContent = matter.stringify(note.content, {
const fullPath = sanitizeRelativePath(relativePath); id: note.id,
fs.mkdirSync(path.dirname(fullPath), { recursive: true }); title: note.title,
fs.writeFileSync(fullPath, content, "utf-8"); category: note.category ?? null,
return true; createdAt: note.createdAt,
}; updatedAt: note.updatedAt
const createDirectory = (relativePath) => { });
const fullPath = sanitizeRelativePath(relativePath); await fs.writeFile(filePath, fileContent, "utf8");
fs.mkdirSync(fullPath, { recursive: true });
return true;
};
const readNote = (relativePath) => {
const fullPath = sanitizeRelativePath(relativePath);
if (!fs.existsSync(fullPath)) {
createNote(relativePath);
} }
return fs.readFileSync(fullPath, "utf-8"); /* -----------------------
}; Public API
const updateNote = (relativePath, content) => { ------------------------*/
const fullPath = sanitizeRelativePath(relativePath); getCategories() {
if (!fs.existsSync(fullPath)) { const categories = /* @__PURE__ */ new Set();
throw new Error("Note does not exist"); 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"); getCategoryNotes(categoryName) {
return true; return Array.from(this.notesCache.values()).filter((n) => n.category === categoryName).sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
}; }
const notesAPI = { getNote(id) {
readAllNotesRecursive, return this.notesCache.get(id) ?? null;
createNote, }
createDirectory, async createNote(metadata = {}, content = "") {
readNote, const id = crypto.randomUUID();
updateNote 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 preloadPath = path.join(__dirname, "../preload/index.js");
const rendererPath = path.join(__dirname, "../renderer/index.html"); const rendererPath = path.join(__dirname, "../renderer/index.html");
function createWindow() { function createWindow() {
@@ -121,14 +173,15 @@ electron.app.whenReady().then(() => {
utils.optimizer.watchWindowShortcuts(window); utils.optimizer.watchWindowShortcuts(window);
}); });
createWindow(); createWindow();
ensureBaseDir();
electron.app.on("activate", function() { electron.app.on("activate", function() {
if (electron.BrowserWindow.getAllWindows().length === 0) createWindow(); if (electron.BrowserWindow.getAllWindows().length === 0) createWindow();
}); });
electron.ipcMain.on("open-note-window", (_, noteId) => { electron.ipcMain.on("open-note-window", (_, noteId) => {
createNoteWindow(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]) { if (!notesAPI[method]) {
throw new Error("Invalid method"); throw new Error("Invalid method");
} }

152
package-lock.json generated
View File

@@ -21,12 +21,15 @@
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"fecha": "^4.2.3", "fecha": "^4.2.3",
"flexsearch": "^0.8.212",
"gray-matter": "^4.0.3",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lenis": "^1.3.17", "lenis": "^1.3.17",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"sass": "^1.97.3", "sass": "^1.97.3",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"tempus": "^1.0.0-dev.17", "tempus": "^1.0.0-dev.17",
"uuid": "^13.0.0",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
@@ -4942,6 +4945,19 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -4961,6 +4977,18 @@
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
"license": "MIT" "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": { "node_modules/extract-zip": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -5078,6 +5106,34 @@
"node": ">=10" "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": { "node_modules/fontfaceobserver": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", "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==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "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": { "node_modules/gsap": {
"version": "3.14.2", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
@@ -5662,6 +5761,15 @@
"node": ">= 12" "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": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -5878,6 +5986,15 @@
"json-buffer": "3.0.1" "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": { "node_modules/lazy-val": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
@@ -7835,6 +7952,19 @@
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8117,6 +8247,15 @@
"node": ">=8" "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": { "node_modules/sumchecker": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@@ -8548,6 +8687,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/varint": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",

View File

@@ -36,12 +36,15 @@
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"electron-updater": "^6.3.9", "electron-updater": "^6.3.9",
"fecha": "^4.2.3", "fecha": "^4.2.3",
"flexsearch": "^0.8.212",
"gray-matter": "^4.0.3",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"lenis": "^1.3.17", "lenis": "^1.3.17",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"sass": "^1.97.3", "sass": "^1.97.3",
"sass-embedded": "^1.97.3", "sass-embedded": "^1.97.3",
"tempus": "^1.0.0-dev.17", "tempus": "^1.0.0-dev.17",
"uuid": "^13.0.0",
"vue-router": "^5.0.3" "vue-router": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,6 +1,6 @@
import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { app, shell, BrowserWindow, ipcMain } from 'electron' import { app, shell, BrowserWindow, ipcMain } from 'electron'
import notesAPI, { ensureBaseDir } from './notesAPI' import NotesAPI from './notesAPI'
import { join } from 'path' import { join } from 'path'
const preloadPath = join(__dirname, '../preload/index.js') const preloadPath = join(__dirname, '../preload/index.js')
@@ -77,9 +77,6 @@ app.whenReady().then(() => {
// Create main window // Create main window
createWindow() createWindow()
// Ensure data directory is present
ensureBaseDir()
app.on('activate', function () { app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the // 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. // dock icon is clicked and there are no other windows open.
@@ -91,8 +88,10 @@ app.whenReady().then(() => {
createNoteWindow(noteId) createNoteWindow(noteId)
}) })
// Handle calls to Notes API // Init Notes API
ipcMain.handle('notesAPI:call', async (_, method, args) => { const notesAPI = new NotesAPI()
notesAPI.init()
ipcMain.handle('notesAPI:call', (_, method, args) => {
if (!notesAPI[method]) { if (!notesAPI[method]) {
throw new Error('Invalid 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 { app } from 'electron'
import fs from 'fs' import matter from 'gray-matter'
import { join, relative, dirname } from 'path' import { Index } from 'flexsearch'
import crypto from 'crypto'
const BASE_DIR = join(app.getPath('userData'), 'notes-storage') export default class NotesAPI {
export const ensureBaseDir = () => { constructor() {
if (!fs.existsSync(BASE_DIR)) { this.notesDir = path.join(app.getPath('userData'), 'notes')
fs.mkdirSync(BASE_DIR, { recursive: true }) this.notesCache = new Map()
this.index = new Index({
tokenize: 'tolerant',
resolution: 9,
})
} }
}
const sanitizeRelativePath = (relativePath) => { async init() {
const resolved = join(BASE_DIR, relativePath) await fs.mkdir(this.notesDir, { recursive: true })
if (!resolved.startsWith(BASE_DIR)) { await this._loadAllNotes()
throw new Error('Invalid path')
} }
return resolved
}
const readAllNotesRecursive = (dir = BASE_DIR, base = BASE_DIR) => { async _loadAllNotes() {
const entries = fs.readdirSync(dir, { withFileTypes: true }) const files = await fs.readdir(this.notesDir)
let results = []
for (const entry of entries) { for (const file of files) {
const fullPath = join(dir, entry.name) if (!file.endsWith('.md')) continue
if (entry.isDirectory()) { const fullPath = path.join(this.notesDir, file)
results = results.concat(readAllNotesRecursive(fullPath, base)) const raw = await fs.readFile(fullPath, 'utf8')
} const parsed = matter(raw)
if (entry.isFile() && entry.name.endsWith('.md')) { const note = {
const content = fs.readFileSync(fullPath, 'utf-8') ...parsed.data,
content: parsed.content,
}
results.push({ this.notesCache.set(note.id, note)
name: entry.name, this.index.add(note.id, note.title + note.content)
path: relative(base, fullPath),
content,
})
} }
} }
return results async _writeNoteFile(note) {
} const filePath = path.join(this.notesDir, `${note.id}.md`)
const createNote = (relativePath, content = '') => { const fileContent = matter.stringify(note.content, {
const fullPath = sanitizeRelativePath(relativePath) id: note.id,
title: note.title,
category: note.category ?? null,
createdAt: note.createdAt,
updatedAt: note.updatedAt,
})
fs.mkdirSync(dirname(fullPath), { recursive: true }) await fs.writeFile(filePath, fileContent, 'utf8')
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)
} }
return fs.readFileSync(fullPath, 'utf-8') /* -----------------------
} Public API
------------------------*/
getCategories() {
const categories = new Set()
const updateNote = (relativePath, content) => { for (const note of this.notesCache.values()) {
const fullPath = sanitizeRelativePath(relativePath) if (note.category) {
categories.add(note.category)
}
}
if (!fs.existsSync(fullPath)) { return Array.from(categories).sort()
throw new Error('Note does not exist')
} }
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 { async createNote(metadata = {}, content = '') {
readAllNotesRecursive, const id = crypto.randomUUID()
createNote, const now = new Date().toISOString()
createDirectory,
readNote, const note = {
updateNote, 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' import { ref } from 'vue'
const categories = ref([])
const searchResults = ref([])
export default () => { export default () => {
const notes = ref([]) /* -------------------------
const loading = ref(false) Initialization
const error = ref(null) --------------------------*/
async function loadCategories() {
categories.value = await window.notesAPI.call('getCategories')
}
const fetchNotes = async () => { async function loadCategoryNotes(category = null) {
try { return await window.notesAPI.call('getCategoryNotes', category)
loading.value = true }
notes.value = await window.notesAPI.call('readAllNotesRecursive')
} catch (err) { async function loadNote(id) {
error.value = err.message return await window.notesAPI.call('getNote', id)
} finally { }
loading.value = false
/* -------------------------
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 = '') => { searchResults.value = await window.notesAPI.call('search', query)
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)
} }
return { return {
notes, categories,
loading, searchResults,
error,
fetchNotes, loadCategories,
loadCategoryNotes,
loadNote,
createNote, createNote,
createDirectory, updateNoteContent,
readNote, updateNoteMetadata,
updateNote,
search,
} }
} }

View File

@@ -1,11 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import Directory from '../views/Directory.vue' import Directory from '@/views/Directory.vue'
import Editor from '../views/Editor.vue' import Editor from '@/views/Editor.vue'
import Category from '@/views/Category.vue'
const routes = [ const routes = [
{ path: '/', name: 'directory', component: Directory }, { path: '/', name: 'directory', component: Directory },
{ path: '/note/:id', name: 'note', component: Editor }, { path: '/note/:id', name: 'note', component: Editor },
{ path: '/category/:id', name: 'category', component: Category },
] ]
export const router = createRouter({ 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> <template>
<main class="directory layout-block"> <main class="directory layout-block">
<button class="capitula" v-for="(capitula, i) in capitulum"> <category-row
<span class="index">{{ String(i + 1).padStart(2, '0') }}.</span> v-for="(category, i) in categories"
<span class="title h1">{{ capitula.title }}</span> :index="i"
</button> :category="category"
:key="category"
/>
<h2 class="label">Summarium</h2> <h2 class="label">Summarium</h2>
<div class="summarium"> <div class="notes">
<button <note-row v-for="note in notes" :note="note" :key="note.id" />
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> </div>
</main> </main>
</template> </template>
<script setup> <script setup>
import { format } from 'fecha'
import useOpenNote from '@/composables/useOpenNote'
import useNotes from '@/composables/useNotes' 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 = [ const { categories, loadCategories, loadCategoryNotes } = useNotes()
{
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 summarium = [ const notes = ref()
{
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()
onMounted(async () => { onMounted(async () => {
await fetchNotes() await loadCategories()
console.log(notes.value) notes.value = await loadCategoryNotes()
}) })
const formatDate = (date) => {
return format(date, 'MM/DD/YYYY')
}
</script> </script>
<style lang="scss"> <style lang="scss">
.directory { .directory {
padding-top: size-vw(18px); 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 { .label {
text-transform: uppercase; text-transform: uppercase;
margin: size-vw(17px) 0 size-vw(24px); margin: size-vw(17px) 0 size-vw(24px);
@include p; @include p;
} }
.summarium { .notes {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: size-vw(14px); 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> </style>

View File

@@ -35,9 +35,9 @@ import { useRoute } from 'vue-router'
import _debounce from 'lodash/debounce' import _debounce from 'lodash/debounce'
const route = useRoute() 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({ const CustomDocument = Document.extend({
content: 'heading block*', content: 'heading block*',
@@ -45,13 +45,33 @@ const CustomDocument = Document.extend({
const editor = shallowRef() const editor = shallowRef()
const updateFile = _debounce(({ editor }) => { const updateNote = _debounce(async ({ editor }) => {
const markdown = editor.getMarkdown() 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) }, 300)
onMounted(async () => { onMounted(async () => {
const noteFile = await readNote(filePath) const note = await loadNote(id)
lastTitle = note.title
editor.value = new Editor({ editor.value = new Editor({
extensions: [ extensions: [
@@ -73,9 +93,9 @@ onMounted(async () => {
Markdown, Markdown,
Image, Image,
], ],
content: noteFile, content: note.content,
contentType: 'markdown', contentType: 'markdown',
onUpdate: updateFile, onUpdate: updateNote,
}) })
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {