full notes file system architecture
This commit is contained in:
@@ -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 sanitizeRelativePath = (relativePath) => {
|
||||
const resolved = path.join(BASE_DIR, relativePath);
|
||||
if (!resolved.startsWith(BASE_DIR)) {
|
||||
throw new Error("Invalid path");
|
||||
}
|
||||
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
|
||||
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
|
||||
});
|
||||
}
|
||||
async init() {
|
||||
await fs.mkdir(this.notesDir, { recursive: true });
|
||||
await this._loadAllNotes();
|
||||
}
|
||||
return results;
|
||||
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
|
||||
};
|
||||
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);
|
||||
this.notesCache.set(note.id, note);
|
||||
this.index.add(note.id, note.title + note.content);
|
||||
}
|
||||
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");
|
||||
}
|
||||
fs.writeFileSync(fullPath, content, "utf-8");
|
||||
return true;
|
||||
};
|
||||
const notesAPI = {
|
||||
readAllNotesRecursive,
|
||||
createNote,
|
||||
createDirectory,
|
||||
readNote,
|
||||
updateNote
|
||||
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");
|
||||
}
|
||||
/* -----------------------
|
||||
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();
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
||||
152
package-lock.json
generated
152
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
const sanitizeRelativePath = (relativePath) => {
|
||||
const resolved = join(BASE_DIR, relativePath)
|
||||
if (!resolved.startsWith(BASE_DIR)) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
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 = 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: relative(base, fullPath),
|
||||
content,
|
||||
this.index = new Index({
|
||||
tokenize: 'tolerant',
|
||||
resolution: 9,
|
||||
})
|
||||
}
|
||||
|
||||
async init() {
|
||||
await fs.mkdir(this.notesDir, { recursive: true })
|
||||
await this._loadAllNotes()
|
||||
}
|
||||
|
||||
return results
|
||||
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,
|
||||
}
|
||||
|
||||
const createNote = (relativePath, content = '') => {
|
||||
const fullPath = sanitizeRelativePath(relativePath)
|
||||
|
||||
fs.mkdirSync(dirname(fullPath), { recursive: true })
|
||||
fs.writeFileSync(fullPath, content, 'utf-8')
|
||||
|
||||
return true
|
||||
this.notesCache.set(note.id, note)
|
||||
this.index.add(note.id, note.title + note.content)
|
||||
}
|
||||
}
|
||||
|
||||
const createDirectory = (relativePath) => {
|
||||
const fullPath = sanitizeRelativePath(relativePath)
|
||||
fs.mkdirSync(fullPath, { recursive: true })
|
||||
return true
|
||||
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')
|
||||
}
|
||||
|
||||
const readNote = (relativePath) => {
|
||||
const fullPath = sanitizeRelativePath(relativePath)
|
||||
/* -----------------------
|
||||
Public API
|
||||
------------------------*/
|
||||
getCategories() {
|
||||
const categories = new Set()
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
createNote(relativePath)
|
||||
for (const note of this.notesCache.values()) {
|
||||
if (note.category) {
|
||||
categories.add(note.category)
|
||||
}
|
||||
}
|
||||
|
||||
return fs.readFileSync(fullPath, 'utf-8')
|
||||
return Array.from(categories).sort()
|
||||
}
|
||||
|
||||
const updateNote = (relativePath, content) => {
|
||||
const fullPath = sanitizeRelativePath(relativePath)
|
||||
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
throw new Error('Note does not exist')
|
||||
getCategoryNotes(categoryName) {
|
||||
return Array.from(this.notesCache.values())
|
||||
.filter((n) => n.category === categoryName)
|
||||
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
|
||||
}
|
||||
|
||||
fs.writeFileSync(fullPath, content, 'utf-8')
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
42
src/renderer/src/components/CategoryRow.vue
Normal file
42
src/renderer/src/components/CategoryRow.vue
Normal 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>
|
||||
51
src/renderer/src/components/NoteRow.vue
Normal file
51
src/renderer/src/components/NoteRow.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
const fetchNotes = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
notes.value = await window.notesAPI.call('readAllNotesRecursive')
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
/* -------------------------
|
||||
Initialization
|
||||
--------------------------*/
|
||||
async function loadCategories() {
|
||||
categories.value = await window.notesAPI.call('getCategories')
|
||||
}
|
||||
|
||||
const createNote = async (path, content = '') => {
|
||||
await window.notesAPI.call('createNote', path, content)
|
||||
await fetchNotes()
|
||||
async function loadCategoryNotes(category = null) {
|
||||
return await window.notesAPI.call('getCategoryNotes', category)
|
||||
}
|
||||
|
||||
const createDirectory = async (path) => {
|
||||
await window.notesAPI.call('createDirectory', path)
|
||||
await fetchNotes()
|
||||
async function loadNote(id) {
|
||||
return await window.notesAPI.call('getNote', id)
|
||||
}
|
||||
|
||||
const readNote = async (path) => {
|
||||
console.log(path)
|
||||
return await window.notesAPI.call('readNote', path)
|
||||
/* -------------------------
|
||||
Create
|
||||
--------------------------*/
|
||||
async function createNote(metadata, content) {
|
||||
const note = await window.notesAPI.call('createNote', metadata, content)
|
||||
await loadCategories()
|
||||
return note
|
||||
}
|
||||
|
||||
const updateNote = async (path, content) => {
|
||||
return await window.notesAPI.call('updateNote', path, content)
|
||||
/* -------------------------
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
53
src/renderer/src/views/Category.vue
Normal file
53
src/renderer/src/views/Category.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user