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";
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
View File

@@ -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",

View File

@@ -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": {

View File

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

View File

@@ -1,88 +1,159 @@
import fs from 'fs/promises'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { join, relative, dirname } from 'path'
import matter from 'gray-matter'
import { Index } from 'flexsearch'
import crypto from 'crypto'
const BASE_DIR = join(app.getPath('userData'), 'notes-storage')
export const ensureBaseDir = () => {
if (!fs.existsSync(BASE_DIR)) {
fs.mkdirSync(BASE_DIR, { recursive: true })
}
}
export default class NotesAPI {
constructor() {
this.notesDir = path.join(app.getPath('userData'), 'notes')
this.notesCache = new Map()
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))
}
}

View File

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

View File

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

View File

@@ -1,48 +1,75 @@
import { ref } from 'vue'
const categories = ref([])
const searchResults = ref([])
export default () => {
const notes = ref([])
const loading = ref(false)
const error = ref(null)
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,
}
}

View File

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

View File

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

View File

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

View File

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