local file saving/reading

This commit is contained in:
nicwands
2026-02-23 13:43:04 -05:00
parent 660b0825c5
commit 7a670aab92
10 changed files with 321 additions and 72 deletions

View File

@@ -1,6 +1,10 @@
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import notes from './notesStorage'
import { join } from 'path'
const preloadPath = join(__dirname, '../preload/index.js')
const rendererPath = join(__dirname, '../renderer/index.html')
function createWindow() {
// Create the browser window.
@@ -10,7 +14,7 @@ function createWindow() {
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
preload: preloadPath,
sandbox: false,
},
})
@@ -29,7 +33,7 @@ function createWindow() {
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
mainWindow.loadFile(rendererPath)
}
}
@@ -39,7 +43,7 @@ function createNoteWindow(noteId) {
height: 549,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, 'preload.js'),
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
@@ -50,7 +54,7 @@ function createNoteWindow(noteId) {
`${process.env['ELECTRON_RENDERER_URL']}/note/${noteId}`,
)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'), {
mainWindow.loadFile(rendererPath, {
path: `/notes/${noteId}`,
})
}
@@ -70,10 +74,12 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
console.log(app.getPath('userData'))
// Create main window
createWindow()
// Ensure data directory is present
notes.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.
@@ -84,6 +90,23 @@ app.whenReady().then(() => {
ipcMain.on('open-note-window', (_, noteId) => {
createNoteWindow(noteId)
})
// File access
ipcMain.handle('notes:list', () => {
return notes.readAllNotesRecursive()
})
ipcMain.handle('notes:create', (_, { path, content }) => {
return notes.createNote(path, content)
})
ipcMain.handle('notes:createDir', (_, path) => {
return notes.createDirectory(path)
})
ipcMain.handle('notes:read', (_, path) => {
return notes.readNote(path)
})
ipcMain.handle('notes:update', (_, { path, content }) => {
return notes.updateNote(path, content)
})
})
// Quit when all windows are closed, except on macOS. There, it's common

91
src/main/notesStorage.js Normal file
View File

@@ -0,0 +1,91 @@
import { app } from 'electron'
import fs from 'fs'
import { join, relative, dirname } from 'path'
const BASE_DIR = join(app.getPath('userData'), 'notes-storage')
export const ensureBaseDir = () => {
if (!fs.existsSync(BASE_DIR)) {
fs.mkdirSync(BASE_DIR, { recursive: true })
}
}
export const sanitizeRelativePath = (relativePath) => {
const resolved = join(BASE_DIR, relativePath)
if (!resolved.startsWith(BASE_DIR)) {
throw new Error('Invalid path')
}
return resolved
}
export 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,
})
}
}
return results
}
export const createNote = (relativePath, content = '') => {
const fullPath = sanitizeRelativePath(relativePath)
fs.mkdirSync(dirname(fullPath), { recursive: true })
fs.writeFileSync(fullPath, content, 'utf-8')
return true
}
export const createDirectory = (relativePath) => {
const fullPath = sanitizeRelativePath(relativePath)
fs.mkdirSync(fullPath, { recursive: true })
return true
}
export const readNote = (relativePath) => {
const fullPath = sanitizeRelativePath(relativePath)
if (!fs.existsSync(fullPath)) {
createNote(relativePath)
}
return fs.readFileSync(fullPath, 'utf-8')
}
export 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
}
export default {
ensureBaseDir,
sanitizeRelativePath,
readAllNotesRecursive,
createNote,
createDirectory,
readNote,
updateNote,
}

View File

@@ -1,24 +1,25 @@
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// Custom APIs for renderer
const api = {
openNoteWindow: (noteId) => {
ipcRenderer.send('open-note-window', noteId)
},
listNotes: () => ipcRenderer.invoke('notes:list'),
createNote: (path, content) =>
ipcRenderer.invoke('notes:create', { path, content }),
createNoteDir: (path) => ipcRenderer.invoke('notes:createDir', path),
readNote: (path) => ipcRenderer.invoke('notes:read', path),
updateNote: (path, content) =>
ipcRenderer.invoke('notes:update', { path, content }),
}
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
window.electron = electronAPI
window.api = api
}

View File

@@ -0,0 +1,48 @@
import { ref } from 'vue'
export default () => {
const notes = ref([])
const loading = ref(false)
const error = ref(null)
const fetchNotes = async () => {
try {
loading.value = true
notes.value = await window.api.listNotes()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
const createNote = async (path, content = '') => {
await window.api.createNote(path, content)
await fetchNotes()
}
const createDirectory = async (path) => {
await window.api.createNoteDir(path)
await fetchNotes()
}
const readNote = async (path) => {
console.log(path)
return await window.api.readNote(path)
}
const updateNote = async (path, content) => {
return await window.api.updateNote(path, content)
}
return {
notes,
loading,
error,
fetchNotes,
createNote,
createDirectory,
readNote,
updateNote,
}
}

View File

@@ -1,10 +1,6 @@
<template>
<main class="directory layout-block">
<button
class="capitula"
v-for="(capitula, i) in capitulum"
@click="openNote(capitula.slug)"
>
<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>

View File

@@ -22,62 +22,64 @@
</template>
<script setup>
import { onBeforeUnmount } from 'vue'
import { onBeforeUnmount, onMounted, shallowRef } from 'vue'
import { Editor, EditorContent } from '@tiptap/vue-3'
import { Markdown } from '@tiptap/markdown'
import Image from '@tiptap/extension-image'
import Document from '@tiptap/extension-document'
import { Placeholder } from '@tiptap/extensions'
import StarterKit from '@tiptap/starter-kit'
import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
// import SvgIconHr from '../svg/icon/Hr.vue'
import { BubbleMenu } from '@tiptap/vue-3/menus'
import useNotes from '@/composables/useNotes'
import { useRoute } from 'vue-router'
import _debounce from 'lodash/debounce'
// Initial markdown string
const initialMarkdown = `# My Document
const route = useRoute()
const filePath = `/${route.params.id}.md`
This is a paragraph.
# Section
- Item one
- Item two
---
# Header Three
[Link](https://google.com)
`
// const initialMarkdown = ``
const { readNote, updateNote } = useNotes()
const CustomDocument = Document.extend({
content: 'heading block*',
})
const editor = new Editor({
extensions: [
CustomDocument,
StarterKit.configure({
document: false,
heading: { levels: [1] },
trailingNode: {
node: 'paragraph',
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'Title'
}
},
}),
Markdown,
Image,
],
content: initialMarkdown,
contentType: 'markdown',
const editor = shallowRef()
const updateFile = _debounce(({ editor }) => {
const markdown = editor.getMarkdown()
updateNote(filePath, markdown)
}, 300)
onMounted(async () => {
const noteFile = await readNote(filePath)
editor.value = new Editor({
extensions: [
CustomDocument,
StarterKit.configure({
document: false,
heading: { levels: [1] },
trailingNode: {
node: 'paragraph',
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'Title'
}
},
}),
Markdown,
Image,
],
content: noteFile,
contentType: 'markdown',
onUpdate: updateFile,
})
})
onBeforeUnmount(() => {
editor.destroy()
editor.value?.destroy?.()
})
</script>