feat: 功能迭代
This commit is contained in:
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
out
|
||||
node_modules
|
||||
*.md
|
||||
13
.prettierrc
Normal file
13
.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 160,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"vueIndentScriptAndStyle": false,
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
40
CLAUDE.md
Normal file
40
CLAUDE.md
Normal file
@@ -0,0 +1,40 @@
|
||||
o# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Tunji is a Markdown editor desktop app inspired by Typora, built with Electron + Vue 3. The UI is in Chinese (Simplified). The app is in early development — all data is mock/hardcoded with no persistence layer yet.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run dev` — Start development with hot reload (electron-vite dev)
|
||||
- `npm run build` — Production build to `out/`
|
||||
- `npm run preview` — Preview production build
|
||||
- `npm run package` — Package with electron-builder
|
||||
|
||||
No test framework is installed.
|
||||
|
||||
## Architecture
|
||||
|
||||
This is an **electron-vite** project with three build targets configured in `electron.vite.config.mjs`:
|
||||
|
||||
- **Main process** (`src/main/index.js`) — BrowserWindow creation, IPC for theme sync, titleBarOverlay color management
|
||||
- **Preload** (`src/main/indexpreload.js`) — Exposes `window.electronAPI` (platform, setTheme) via contextBridge
|
||||
- **Renderer** (`src/renderer/`) — Vue 3 SPA, path alias `@` → `src/renderer/src`
|
||||
|
||||
### Key Tech Decisions
|
||||
|
||||
- **Tailwind CSS v4** — uses `@tailwindcss/vite` plugin and `@theme` directive syntax (not `tailwind.config.js`)
|
||||
- **PrimeVue** — component library with Aura theme preset; dark mode selector is `.dark`
|
||||
- **Icons** — use `@lucide/vue` for all icons, not PrimeVue's built-in icon components
|
||||
- **Theme system** — `composables/useTheme.js` manages light/dark/system modes, persisted to localStorage (`tunji-theme`), synced to Electron main process via IPC for titleBarOverlay colors. Dark palette is Catppuccin Mocha-inspired.
|
||||
- **Markdown rendering** — unified/remark-parse/remark-gfm/remark-rehype/rehype-stringify pipeline in EditorPanel.vue
|
||||
|
||||
### Component Layout
|
||||
|
||||
`App.vue` orchestrates three panels: `SideBar` (navigation, search, notebooks, tags, theme toggle) → `NotesOverview` (card/list grid of notes) → `EditorPanel` (markdown editor with edit/preview/split modes, formatting toolbar, auto-generated outline from headings). `ButtonIcon.vue` is a shared icon button component.
|
||||
|
||||
### Styling
|
||||
|
||||
Custom CSS properties are defined in `src/renderer/src/assets/styles/main.css` using Tailwind v4's `@theme` syntax for primary (indigo), neutral, semantic, and app-level colors. Light and dark mode palettes are both defined there.
|
||||
363
README.md
Normal file
363
README.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Tunji - Markdown 编辑器
|
||||
|
||||
一个受 Typora 启发的 Markdown 编辑器桌面应用,使用 Electron + Vue 3 构建。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 核心功能
|
||||
- **Markdown 编辑**:支持实时预览、分屏编辑、源码模式
|
||||
- **笔记本管理**:支持多级笔记本结构
|
||||
- **标签系统**:灵活的标签管理
|
||||
- **主题切换**:浅色、深色、跟随系统
|
||||
- **工作区管理**:支持自定义工作区路径
|
||||
|
||||
### 最新功能:笔记本树形结构
|
||||
|
||||
#### 功能概述
|
||||
使用 PrimeVue Tree 组件实现笔记本的树形结构显示,支持:
|
||||
|
||||
1. **树形结构展示**:笔记本和文档以树形结构展示
|
||||
2. **层级关系**:支持父子笔记本关系
|
||||
3. **展开/折叠**:可以展开或折叠笔记本节点
|
||||
4. **节点选择**:点击笔记本或文档节点可进行选择
|
||||
5. **自定义图标**:笔记本使用书本图标,文档使用文件图标
|
||||
|
||||
#### 使用方式
|
||||
|
||||
##### 基本使用
|
||||
```vue
|
||||
<script setup>
|
||||
import { useNotebookTree } from '@/composables/useNotebookTree'
|
||||
|
||||
const { notebookTree, expandedKeys, loadNotebooks, loadNotes } = useNotebookTree()
|
||||
|
||||
onMounted(async () => {
|
||||
await loadNotebooks()
|
||||
await loadNotes()
|
||||
})
|
||||
|
||||
function onNodeSelect(event) {
|
||||
console.log('Selected node:', event.node)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tree
|
||||
:value="notebookTree"
|
||||
:expandedKeys="expandedKeys"
|
||||
selectionMode="single"
|
||||
@node-select="onNodeSelect"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
##### 高级功能
|
||||
```javascript
|
||||
import { useFilteredNotebookTree } from '@/composables/useNotebookTreeExample'
|
||||
|
||||
const filterText = ref('')
|
||||
const { filteredTree } = useFilteredNotebookTree(filterText)
|
||||
```
|
||||
|
||||
#### 技术实现
|
||||
|
||||
##### 数据结构
|
||||
```javascript
|
||||
// 树节点格式
|
||||
{
|
||||
key: 'notebook-{id}',
|
||||
label: '节点名称',
|
||||
data: {
|
||||
type: 'notebook' | 'note',
|
||||
id: '节点ID',
|
||||
name: '笔记本名称',
|
||||
title: '文档标题',
|
||||
notebook_id: '所属笔记本ID'
|
||||
},
|
||||
icon: 'pi pi-book' | 'pi pi-file',
|
||||
children: [],
|
||||
selectable: true
|
||||
}
|
||||
```
|
||||
|
||||
##### 核心逻辑
|
||||
- **树形结构构建**:将扁平的笔记本列表转换为树形结构
|
||||
- **展开状态管理**:默认展开所有笔记本节点
|
||||
- **节点选择处理**:支持笔记本和文档节点的选择
|
||||
|
||||
##### 样式定制
|
||||
```css
|
||||
.notebook-tree :deep(.p-tree-node-content) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.notebook-tree :deep(.p-tree-node-selected) {
|
||||
background-color: var(--emerald-50);
|
||||
color: var(--emerald-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main/ # Electron 主进程
|
||||
│ ├── index.js # 主窗口创建
|
||||
│ ├── indexpreload.js # 预加载脚本
|
||||
│ └── services/ # 服务层
|
||||
│ ├── StorageService.js # 统一存储服务
|
||||
│ ├── MetaStore.js # SQLite 元数据存储
|
||||
│ └── FileStorage.js # 文件存储
|
||||
├── renderer/ # Vue 渲染进程
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Vue 组件
|
||||
│ │ │ ├── SideBar.vue # 侧边栏组件
|
||||
│ │ │ ├── NotesOverview.vue # 笔记概览
|
||||
│ │ │ └── EditorPanel.vue # 编辑器面板
|
||||
│ │ ├── composables/ # Vue 组合式函数
|
||||
│ │ │ ├── useNotebookTree.js # 笔记本树形结构
|
||||
│ │ │ ├── useNotebookTreeExample.js # 使用示例
|
||||
│ │ │ ├── useNotebooks.js # 笔记本管理
|
||||
│ │ │ ├── useNotes.js # 笔记管理
|
||||
│ │ │ └── useTheme.js # 主题管理
|
||||
│ │ ├── stores/ # Pinia 状态管理
|
||||
│ │ │ ├── notebooks.js # 笔记本状态
|
||||
│ │ │ ├── notes.js # 笔记状态
|
||||
│ │ │ ├── tags.js # 标签状态
|
||||
│ │ │ └── theme.js # 主题状态
|
||||
│ │ └── assets/ # 静态资源
|
||||
│ └── index.html # 入口 HTML
|
||||
└── electron.vite.config.mjs # Electron Vite 配置
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
- Node.js >= 16
|
||||
- npm >= 8
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 打包应用
|
||||
```bash
|
||||
npm run package
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端框架
|
||||
- **Vue 3**:渐进式 JavaScript 框架
|
||||
- **Pinia**:Vue 状态管理库
|
||||
- **PrimeVue**:Vue UI 组件库
|
||||
- **Tailwind CSS v4**:实用优先的 CSS 框架
|
||||
|
||||
### 桌面框架
|
||||
- **Electron**:跨平台桌面应用框架
|
||||
- **electron-vite**:Electron 构建工具
|
||||
|
||||
### 数据存储
|
||||
- **SQLite (sql.js)**:轻量级数据库
|
||||
- **文件系统**:Markdown 文件存储
|
||||
|
||||
### 开发工具
|
||||
- **Vite**:前端构建工具
|
||||
- **ESLint**:代码检查工具
|
||||
- **Prettier**:代码格式化工具
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 1. 笔记本管理
|
||||
- 创建、重命名、删除笔记本
|
||||
- 支持多级笔记本结构
|
||||
- 树形结构展示
|
||||
|
||||
### 2. 笔记管理
|
||||
- 创建、编辑、删除笔记
|
||||
- Markdown 实时预览
|
||||
- 支持多种编辑模式
|
||||
|
||||
### 3. 标签系统
|
||||
- 创建、删除标签
|
||||
- 为笔记添加标签
|
||||
- 按标签筛选笔记
|
||||
|
||||
### 4. 主题系统
|
||||
- 浅色主题
|
||||
- 深色主题
|
||||
- 跟随系统主题
|
||||
|
||||
### 5. 工作区管理
|
||||
- 自定义工作区路径
|
||||
- 工作区初始化
|
||||
- 数据导入导出
|
||||
|
||||
## 组件说明
|
||||
|
||||
### SideBar.vue
|
||||
侧边栏组件,包含:
|
||||
- 搜索功能
|
||||
- 导航菜单
|
||||
- 笔记本树形结构
|
||||
- 标签列表
|
||||
- 主题切换
|
||||
- 设置入口
|
||||
|
||||
### NotesOverview.vue
|
||||
笔记概览组件,显示:
|
||||
- 笔记列表
|
||||
- 笔记预览
|
||||
- 筛选和排序功能
|
||||
|
||||
### EditorPanel.vue
|
||||
编辑器面板,提供:
|
||||
- Markdown 编辑
|
||||
- 实时预览
|
||||
- 分屏模式
|
||||
- 工具栏
|
||||
|
||||
## 状态管理
|
||||
|
||||
### notebooks.js
|
||||
笔记本状态管理:
|
||||
- 笔记本列表
|
||||
- 创建、重命名、删除操作
|
||||
- 笔记本查询
|
||||
|
||||
### notes.js
|
||||
笔记状态管理:
|
||||
- 笔记列表
|
||||
- 创建、编辑、删除操作
|
||||
- 笔记搜索
|
||||
|
||||
### tags.js
|
||||
标签状态管理:
|
||||
- 标签列表
|
||||
- 创建、删除操作
|
||||
- 标签关联
|
||||
|
||||
### theme.js
|
||||
主题状态管理:
|
||||
- 主题切换
|
||||
- 主题持久化
|
||||
- 系统主题同步
|
||||
|
||||
## 组合式函数
|
||||
|
||||
### useNotebookTree.js
|
||||
笔记本树形结构:
|
||||
- 树形数据构建
|
||||
- 展开状态管理
|
||||
- 节点查找功能
|
||||
|
||||
### useNotebooks.js
|
||||
笔记本管理:
|
||||
- 笔记本 CRUD 操作
|
||||
- 笔记本查询
|
||||
- 笔记本排序
|
||||
|
||||
### useNotes.js
|
||||
笔记管理:
|
||||
- 笔记 CRUD 操作
|
||||
- 笔记搜索
|
||||
- 笔记筛选
|
||||
|
||||
### useTheme.js
|
||||
主题管理:
|
||||
- 主题切换
|
||||
- 主题持久化
|
||||
- 系统主题检测
|
||||
|
||||
## 存储架构
|
||||
|
||||
### MetaStore.js
|
||||
SQLite 元数据存储:
|
||||
- 笔记本元数据
|
||||
- 笔记元数据
|
||||
- 标签元数据
|
||||
- 关联关系
|
||||
|
||||
### FileStorage.js
|
||||
文件存储:
|
||||
- Markdown 文件读写
|
||||
- 文件目录管理
|
||||
- 文件同步
|
||||
|
||||
### StorageService.js
|
||||
统一存储服务:
|
||||
- 协调 MetaStore 和 FileStorage
|
||||
- 提供统一 API
|
||||
- 数据一致性保证
|
||||
|
||||
## 构建配置
|
||||
|
||||
### electron.vite.config.mjs
|
||||
Electron Vite 配置:
|
||||
- 主进程构建
|
||||
- 预加载脚本构建
|
||||
- 渲染进程构建
|
||||
|
||||
### package.json
|
||||
项目配置:
|
||||
- 依赖管理
|
||||
- 脚本命令
|
||||
- 构建配置
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 开发环境
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
```bash
|
||||
npm run build
|
||||
npm run package
|
||||
```
|
||||
|
||||
### 平台支持
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
|
||||
## 贡献指南
|
||||
|
||||
### 开发规范
|
||||
- 使用 Vue 3 Composition API
|
||||
- 遵循 ESLint 规则
|
||||
- 使用 Prettier 格式化代码
|
||||
|
||||
### 提交规范
|
||||
- 使用 Conventional Commits
|
||||
- 每个提交解决一个问题
|
||||
- 提交前运行测试
|
||||
|
||||
### 代码审查
|
||||
- 至少一人审查
|
||||
- 通过所有测试
|
||||
- 无代码异味
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
||||
## 联系方式
|
||||
- 项目地址:[GitHub Repository]
|
||||
- 问题反馈:[Issues]
|
||||
- 文档:[Documentation]
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"autoprefixer": "^10.5.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"vite": "^7.3.3"
|
||||
}
|
||||
},
|
||||
@@ -6816,6 +6817,22 @@
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/primeicons": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/primeicons/-/primeicons-7.0.0.tgz",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-builder"
|
||||
"package": "electron-builder",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"keywords": [
|
||||
"markdown",
|
||||
@@ -43,6 +44,7 @@
|
||||
"autoprefixer": "^10.5.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"postcss": "^8.5.15",
|
||||
"prettier": "^3.8.3",
|
||||
"vite": "^7.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
198
src/main/services/FileStorage.js
Normal file
198
src/main/services/FileStorage.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, readdirSync } from 'fs'
|
||||
import { join, dirname, extname, relative } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
import matter from 'gray-matter'
|
||||
|
||||
/**
|
||||
* FileStorage - Manages .md files on the file system
|
||||
* Each note is stored as a .md file with YAML frontmatter for metadata.
|
||||
*/
|
||||
export class FileStorage {
|
||||
constructor(workspacePath) {
|
||||
this.workspacePath = workspacePath
|
||||
this.notebooksDir = join(workspacePath, 'notebooks')
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!existsSync(this.notebooksDir)) {
|
||||
mkdirSync(this.notebooksDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new .md file for a note
|
||||
* @param {Object} options
|
||||
* @param {string} options.id - Note UUID
|
||||
* @param {string} options.title - Note title
|
||||
* @param {string} options.content - Markdown content (without frontmatter)
|
||||
* @param {string} options.notebookName - Notebook folder name
|
||||
* @param {string[]} options.tags - Tag names
|
||||
* @returns {{ filePath: string, absolutePath: string }}
|
||||
*/
|
||||
createFile({ id, title, content = '', notebookName = '默认笔记本', tags = [] }) {
|
||||
const dir = this._ensureNotebookDir(notebookName)
|
||||
const fileName = this._sanitizeFileName(title) || '无标题'
|
||||
const filePath = this._getUniqueFilePath(dir, fileName)
|
||||
const absolutePath = join(dir, filePath)
|
||||
const relativePath = join('notebooks', notebookName, filePath)
|
||||
|
||||
const frontmatter = {
|
||||
id,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
tags
|
||||
}
|
||||
|
||||
const fileContent = matter.stringify(content, frontmatter)
|
||||
writeFileSync(absolutePath, fileContent, 'utf-8')
|
||||
|
||||
return { filePath: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a .md file and parse its frontmatter
|
||||
* @param {string} relativePath - Path relative to workspace
|
||||
* @returns {{ meta: Object, content: string } | null}
|
||||
*/
|
||||
readFile(relativePath) {
|
||||
const absolutePath = join(this.workspacePath, relativePath)
|
||||
if (!existsSync(absolutePath)) return null
|
||||
|
||||
const raw = readFileSync(absolutePath, 'utf-8')
|
||||
const parsed = matter(raw)
|
||||
|
||||
return {
|
||||
meta: parsed.data,
|
||||
content: parsed.content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a .md file's content and/or frontmatter
|
||||
* @param {string} relativePath - Path relative to workspace
|
||||
* @param {Object} options
|
||||
* @param {string} options.content - New markdown content
|
||||
* @param {Object} options.meta - Frontmatter updates
|
||||
*/
|
||||
updateFile(relativePath, { content, meta }) {
|
||||
const absolutePath = join(this.workspacePath, relativePath)
|
||||
if (!existsSync(absolutePath)) return false
|
||||
|
||||
const existing = this.readFile(relativePath)
|
||||
if (!existing) return false
|
||||
|
||||
const newMeta = { ...existing.meta, ...meta, updated: new Date().toISOString() }
|
||||
const newContent = content !== undefined ? content : existing.content
|
||||
|
||||
const fileContent = matter.stringify(newContent, newMeta)
|
||||
writeFileSync(absolutePath, fileContent, 'utf-8')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a .md file
|
||||
* @param {string} relativePath - Path relative to workspace
|
||||
*/
|
||||
deleteFile(relativePath) {
|
||||
const absolutePath = join(this.workspacePath, relativePath)
|
||||
if (existsSync(absolutePath)) {
|
||||
unlinkSync(absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename/move a note file
|
||||
* @param {string} oldPath - Current relative path
|
||||
* @param {string} newTitle - New title (used for filename)
|
||||
* @returns {string} New relative path
|
||||
*/
|
||||
renameFile(oldPath, newTitle) {
|
||||
const absoluteOld = join(this.workspacePath, oldPath)
|
||||
if (!existsSync(absoluteOld)) return oldPath
|
||||
|
||||
const dir = dirname(absoluteOld)
|
||||
const fileName = this._sanitizeFileName(newTitle) || '无标题'
|
||||
const newFileName = this._getUniqueFilePath(dir, fileName, absoluteOld)
|
||||
const absoluteNew = join(dir, newFileName)
|
||||
|
||||
renameSync(absoluteOld, absoluteNew)
|
||||
const relativePath = relative(this.workspacePath, absoluteNew)
|
||||
return relativePath
|
||||
}
|
||||
|
||||
/**
|
||||
* List all .md files in a notebook directory (recursively)
|
||||
* @param {string} notebookName - Notebook folder name
|
||||
* @returns {string[]} Array of relative paths
|
||||
*/
|
||||
listFiles(notebookName) {
|
||||
const dir = join(this.notebooksDir, notebookName)
|
||||
if (!existsSync(dir)) return []
|
||||
return this._listMdFilesRecursive(dir)
|
||||
}
|
||||
|
||||
/**
|
||||
* List all .md files in the workspace
|
||||
* @returns {string[]} Array of relative paths
|
||||
*/
|
||||
listAllFiles() {
|
||||
if (!existsSync(this.notebooksDir)) return []
|
||||
return this._listMdFilesRecursive(this.notebooksDir)
|
||||
}
|
||||
|
||||
// ==================== Internal Helpers ====================
|
||||
|
||||
_ensureNotebookDir(notebookName) {
|
||||
const dir = join(this.notebooksDir, notebookName)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a title into a valid filename
|
||||
* Removes characters not allowed in file names
|
||||
*/
|
||||
_sanitizeFileName(title) {
|
||||
if (!title) return ''
|
||||
return title
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '') // Remove invalid chars
|
||||
.replace(/\s+/g, ' ') // Normalize spaces
|
||||
.trim()
|
||||
.substring(0, 200) // Limit length
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a unique filename, appending (1), (2), etc. if needed
|
||||
*/
|
||||
_getUniqueFilePath(dir, baseName, excludePath = null) {
|
||||
const ext = '.md'
|
||||
let candidate = `${baseName}${ext}`
|
||||
let counter = 1
|
||||
const fullPath = join(dir, candidate)
|
||||
|
||||
while (existsSync(fullPath) && fullPath !== excludePath) {
|
||||
candidate = `${baseName} (${counter})${ext}`
|
||||
counter++
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively list all .md files
|
||||
*/
|
||||
_listMdFilesRecursive(dir, results = []) {
|
||||
const entries = readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
this._listMdFilesRecursive(fullPath, results)
|
||||
} else if (extname(entry.name) === '.md') {
|
||||
results.push(relative(this.workspacePath, fullPath))
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
354
src/main/services/MetaStore.js
Normal file
354
src/main/services/MetaStore.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import initSqlJs from 'sql.js'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { mkdirSync } from 'fs'
|
||||
|
||||
/**
|
||||
* MetaStore - SQLite-based metadata storage for notes
|
||||
* Uses sql.js (WASM) to avoid native compilation requirements.
|
||||
* Database is loaded into memory and persisted to disk on changes.
|
||||
*/
|
||||
export class MetaStore {
|
||||
constructor(dbPath) {
|
||||
this.dbPath = dbPath
|
||||
this.db = null
|
||||
this._dirty = false
|
||||
this._saveTimer = null
|
||||
}
|
||||
|
||||
async init() {
|
||||
const SQL = await initSqlJs()
|
||||
|
||||
if (existsSync(this.dbPath)) {
|
||||
const buffer = readFileSync(this.dbPath)
|
||||
this.db = new SQL.Database(buffer)
|
||||
this._migrate()
|
||||
} else {
|
||||
this.db = new SQL.Database()
|
||||
this._createTables()
|
||||
this._seedDefaults()
|
||||
this._save()
|
||||
}
|
||||
|
||||
// Enable WAL-like behavior for better concurrency
|
||||
this.db.run('PRAGMA journal_mode=DELETE')
|
||||
this.db.run('PRAGMA foreign_keys=ON')
|
||||
}
|
||||
|
||||
_createTables() {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS notebooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES notebooks(id) ON DELETE SET NULL
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
notebook_id TEXT NOT NULL,
|
||||
word_count INTEGER DEFAULT 0,
|
||||
is_pinned INTEGER DEFAULT 0,
|
||||
is_favorited INTEGER DEFAULT 0,
|
||||
is_trashed INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (notebook_id) REFERENCES notebooks(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
note_id TEXT NOT NULL,
|
||||
tag_id TEXT NOT NULL,
|
||||
PRIMARY KEY (note_id, tag_id),
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_notebook ON notes(notebook_id)
|
||||
`)
|
||||
this.db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_trashed ON notes(is_trashed)
|
||||
`)
|
||||
this.db.run(`
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at)
|
||||
`)
|
||||
}
|
||||
|
||||
_migrate() {
|
||||
// Add is_favorited column if it doesn't exist (for existing databases)
|
||||
try {
|
||||
const columns = this._all("PRAGMA table_info(notes)")
|
||||
const hasFavorited = columns.some(c => c.name === 'is_favorited')
|
||||
if (!hasFavorited) {
|
||||
this.db.run('ALTER TABLE notes ADD COLUMN is_favorited INTEGER DEFAULT 0')
|
||||
this._markDirty()
|
||||
}
|
||||
} catch (e) {
|
||||
// Table might not exist yet, ignore
|
||||
}
|
||||
}
|
||||
|
||||
_seedDefaults() {
|
||||
const now = new Date().toISOString()
|
||||
// Create a default notebook
|
||||
this.db.run(
|
||||
'INSERT INTO notebooks (id, name, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
['default', '默认笔记本', null, 0, now]
|
||||
)
|
||||
}
|
||||
|
||||
_save() {
|
||||
if (this._saveTimer) clearTimeout(this._saveTimer)
|
||||
this._saveTimer = setTimeout(() => {
|
||||
const data = this.db.export()
|
||||
const dir = dirname(this.dbPath)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(this.dbPath, Buffer.from(data))
|
||||
this._dirty = false
|
||||
}, 100) // debounce 100ms
|
||||
}
|
||||
|
||||
_markDirty() {
|
||||
this._dirty = true
|
||||
this._save()
|
||||
}
|
||||
|
||||
// --- Helper: run a write query and auto-save ---
|
||||
_run(sql, params = []) {
|
||||
this.db.run(sql, params)
|
||||
this._markDirty()
|
||||
}
|
||||
|
||||
// --- Helper: get all rows ---
|
||||
_all(sql, params = []) {
|
||||
const stmt = this.db.prepare(sql)
|
||||
if (params.length) stmt.bind(params)
|
||||
const rows = []
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.getAsObject())
|
||||
}
|
||||
stmt.free()
|
||||
return rows
|
||||
}
|
||||
|
||||
// --- Helper: get one row ---
|
||||
_get(sql, params = []) {
|
||||
const rows = this._all(sql, params)
|
||||
return rows.length > 0 ? rows[0] : null
|
||||
}
|
||||
|
||||
// ==================== Notes ====================
|
||||
|
||||
createNote({ id, title, filePath, notebookId, wordCount = 0 }) {
|
||||
const now = new Date().toISOString()
|
||||
this._run(
|
||||
'INSERT INTO notes (id, title, file_path, notebook_id, word_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, title, filePath, notebookId, wordCount, now, now]
|
||||
)
|
||||
return this.getNote(id)
|
||||
}
|
||||
|
||||
getNote(id) {
|
||||
const note = this._get('SELECT * FROM notes WHERE id = ?', [id])
|
||||
if (!note) return null
|
||||
note.tags = this._getNoteTags(id)
|
||||
return note
|
||||
}
|
||||
|
||||
getNoteByPath(filePath) {
|
||||
const note = this._get('SELECT * FROM notes WHERE file_path = ?', [filePath])
|
||||
if (!note) return null
|
||||
note.tags = this._getNoteTags(note.id)
|
||||
return note
|
||||
}
|
||||
|
||||
updateNote(id, { title, notebookId, wordCount, isPinned, isFavorited, isTrashed, filePath }) {
|
||||
const fields = []
|
||||
const params = []
|
||||
|
||||
if (title !== undefined) { fields.push('title = ?'); params.push(title) }
|
||||
if (filePath !== undefined) { fields.push('file_path = ?'); params.push(filePath) }
|
||||
if (notebookId !== undefined) { fields.push('notebook_id = ?'); params.push(notebookId) }
|
||||
if (wordCount !== undefined) { fields.push('word_count = ?'); params.push(wordCount) }
|
||||
if (isPinned !== undefined) { fields.push('is_pinned = ?'); params.push(isPinned ? 1 : 0) }
|
||||
if (isFavorited !== undefined) { fields.push('is_favorited = ?'); params.push(isFavorited ? 1 : 0) }
|
||||
if (isTrashed !== undefined) { fields.push('is_trashed = ?'); params.push(isTrashed ? 1 : 0) }
|
||||
|
||||
if (fields.length === 0) return
|
||||
|
||||
fields.push('updated_at = ?')
|
||||
params.push(new Date().toISOString())
|
||||
params.push(id)
|
||||
|
||||
this._run(`UPDATE notes SET ${fields.join(', ')} WHERE id = ?`, params)
|
||||
}
|
||||
|
||||
deleteNote(id) {
|
||||
this._run('DELETE FROM notes WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
listNotes({ notebookId, tagId, isTrashed = 0, isFavorited, search, sort = 'updated_at' } = {}) {
|
||||
let sql = 'SELECT n.* FROM notes n'
|
||||
const params = []
|
||||
const conditions = ['n.is_trashed = ?']
|
||||
params.push(isTrashed ? 1 : 0)
|
||||
|
||||
if (isFavorited !== undefined) {
|
||||
conditions.push('n.is_favorited = ?')
|
||||
params.push(isFavorited ? 1 : 0)
|
||||
}
|
||||
|
||||
if (tagId) {
|
||||
sql += ' INNER JOIN note_tags nt ON n.id = nt.note_id'
|
||||
conditions.push('nt.tag_id = ?')
|
||||
params.push(tagId)
|
||||
}
|
||||
|
||||
if (notebookId) {
|
||||
conditions.push('n.notebook_id = ?')
|
||||
params.push(notebookId)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push('(n.title LIKE ?)')
|
||||
params.push(`%${search}%`)
|
||||
}
|
||||
|
||||
sql += ` WHERE ${conditions.join(' AND ')}`
|
||||
|
||||
const validSorts = ['updated_at', 'created_at', 'title', 'word_count']
|
||||
const sortCol = validSorts.includes(sort) ? sort : 'updated_at'
|
||||
sql += ` GROUP BY n.id ORDER BY n.is_pinned DESC, n.${sortCol} DESC`
|
||||
|
||||
const notes = this._all(sql, params)
|
||||
// Attach tags to each note
|
||||
for (const note of notes) {
|
||||
note.tags = this._getNoteTags(note.id)
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
_getNoteTags(noteId) {
|
||||
return this._all(
|
||||
`SELECT t.id, t.name FROM tags t
|
||||
INNER JOIN note_tags nt ON t.id = nt.tag_id
|
||||
WHERE nt.note_id = ?`,
|
||||
[noteId]
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Notebooks ====================
|
||||
|
||||
createNotebook({ id, name, parentId = null, sortOrder = 0 }) {
|
||||
const now = new Date().toISOString()
|
||||
this._run(
|
||||
'INSERT INTO notebooks (id, name, parent_id, sort_order, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, name, parentId, sortOrder, now]
|
||||
)
|
||||
return this.getNotebook(id)
|
||||
}
|
||||
|
||||
getNotebook(id) {
|
||||
return this._get('SELECT * FROM notebooks WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
listNotebooks() {
|
||||
return this._all('SELECT * FROM notebooks ORDER BY sort_order, name')
|
||||
}
|
||||
|
||||
renameNotebook(id, name) {
|
||||
this._run('UPDATE notebooks SET name = ? WHERE id = ?', [name, id])
|
||||
}
|
||||
|
||||
deleteNotebook(id) {
|
||||
// Move notes to default notebook before deleting
|
||||
this._run("UPDATE notes SET notebook_id = 'default' WHERE notebook_id = ?", [id])
|
||||
this._run('DELETE FROM notebooks WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
// ==================== Tags ====================
|
||||
|
||||
createTag({ id, name }) {
|
||||
this._run('INSERT INTO tags (id, name) VALUES (?, ?)', [id, name])
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
getTag(id) {
|
||||
return this._get('SELECT * FROM tags WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
listTags() {
|
||||
return this._all('SELECT t.*, COUNT(nt.note_id) as note_count FROM tags t LEFT JOIN note_tags nt ON t.id = nt.tag_id GROUP BY t.id ORDER BY t.name')
|
||||
}
|
||||
|
||||
deleteTag(id) {
|
||||
this._run('DELETE FROM tags WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
addTagToNote(noteId, tagId) {
|
||||
this._run('INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)', [noteId, tagId])
|
||||
}
|
||||
|
||||
removeTagFromNote(noteId, tagId) {
|
||||
this._run('DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?', [noteId, tagId])
|
||||
}
|
||||
|
||||
setNoteTags(noteId, tagIds) {
|
||||
this._run('DELETE FROM note_tags WHERE note_id = ?', [noteId])
|
||||
for (const tagId of tagIds) {
|
||||
this._run('INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)', [noteId, tagId])
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Search ====================
|
||||
|
||||
search(query) {
|
||||
return this._all(
|
||||
"SELECT * FROM notes WHERE title LIKE ? AND is_trashed = 0 ORDER BY updated_at DESC",
|
||||
[`%${query}%`]
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Stats ====================
|
||||
|
||||
getStats() {
|
||||
const noteCount = this._get('SELECT COUNT(*) as count FROM notes WHERE is_trashed = 0')
|
||||
const notebookCount = this._get('SELECT COUNT(*) as count FROM notebooks')
|
||||
const tagCount = this._get('SELECT COUNT(*) as count FROM tags')
|
||||
return {
|
||||
notes: noteCount?.count || 0,
|
||||
notebooks: notebookCount?.count || 0,
|
||||
tags: tagCount?.count || 0
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
close() {
|
||||
if (this._saveTimer) clearTimeout(this._saveTimer)
|
||||
// Final save
|
||||
if (this.db) {
|
||||
const data = this.db.export()
|
||||
writeFileSync(this.dbPath, Buffer.from(data))
|
||||
this.db.close()
|
||||
this.db = null
|
||||
}
|
||||
}
|
||||
}
|
||||
425
src/main/services/StorageService.js
Normal file
425
src/main/services/StorageService.js
Normal file
@@ -0,0 +1,425 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, renameSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { MetaStore } from './MetaStore'
|
||||
import { FileStorage } from './FileStorage'
|
||||
|
||||
/**
|
||||
* StorageService - Unified storage layer combining FileStorage + MetaStore
|
||||
* Coordinates between .md files (content) and SQLite (metadata).
|
||||
*/
|
||||
export class StorageService {
|
||||
constructor() {
|
||||
this.metaStore = null
|
||||
this.fileStorage = null
|
||||
this.workspacePath = null
|
||||
this._listeners = new Set()
|
||||
}
|
||||
|
||||
async init(workspacePath) {
|
||||
this.workspacePath = workspacePath
|
||||
|
||||
// Ensure .tunji directory exists
|
||||
const tunjiDir = join(workspacePath, '.tunji')
|
||||
if (!existsSync(tunjiDir)) {
|
||||
mkdirSync(tunjiDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Initialize sub-services
|
||||
this.metaStore = new MetaStore(join(tunjiDir, 'tunji.db'))
|
||||
await this.metaStore.init()
|
||||
|
||||
this.fileStorage = new FileStorage(workspacePath)
|
||||
this.fileStorage.init()
|
||||
|
||||
// Sync file system with database on startup
|
||||
await this._syncFileSystem()
|
||||
}
|
||||
|
||||
// ==================== Notes ====================
|
||||
|
||||
/**
|
||||
* Create a new note
|
||||
*/
|
||||
async createNote({ title = '无标题', content = '', notebookId = 'default', tags = [] } = {}) {
|
||||
const id = nanoid(12)
|
||||
const notebook = this.metaStore.getNotebook(notebookId)
|
||||
const notebookName = notebook ? notebook.name : '默认笔记本'
|
||||
|
||||
// Create .md file
|
||||
const { filePath } = this.fileStorage.createFile({
|
||||
id,
|
||||
title,
|
||||
content,
|
||||
notebookName,
|
||||
tags
|
||||
})
|
||||
|
||||
// Create database record
|
||||
const note = this.metaStore.createNote({
|
||||
id,
|
||||
title,
|
||||
filePath,
|
||||
notebookId,
|
||||
wordCount: this._countWords(content)
|
||||
})
|
||||
|
||||
// Set tags in junction table
|
||||
if (tags.length > 0) {
|
||||
this._ensureTags(tags)
|
||||
const tagRecords = tags.map(name => {
|
||||
const t = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
|
||||
return t?.id
|
||||
}).filter(Boolean)
|
||||
this.metaStore.setNoteTags(id, tagRecords)
|
||||
}
|
||||
|
||||
return this.getNote(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single note with full content
|
||||
*/
|
||||
async getNote(id) {
|
||||
const note = this.metaStore.getNote(id)
|
||||
if (!note) return null
|
||||
|
||||
// Read content from file
|
||||
const fileData = this.fileStorage.readFile(note.file_path)
|
||||
if (fileData) {
|
||||
note.content = fileData.content
|
||||
// Sync title from frontmatter if different
|
||||
if (fileData.meta.tags && fileData.meta.tags.length > 0) {
|
||||
note.tags = fileData.meta.tags.map(name => ({ id: name, name }))
|
||||
}
|
||||
} else {
|
||||
note.content = ''
|
||||
}
|
||||
|
||||
// Get notebook name
|
||||
const notebook = this.metaStore.getNotebook(note.notebook_id)
|
||||
note.notebook_name = notebook ? notebook.name : '未知'
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a note's content and/or metadata
|
||||
*/
|
||||
async updateNote(id, { title, content, notebookId, tags, isPinned, isFavorited, isTrashed }) {
|
||||
const note = this.metaStore.getNote(id)
|
||||
if (!note) return null
|
||||
|
||||
const updates = {}
|
||||
|
||||
// Update file content if provided
|
||||
if (content !== undefined || title !== undefined || tags !== undefined) {
|
||||
const meta = {}
|
||||
if (tags !== undefined) meta.tags = tags
|
||||
|
||||
this.fileStorage.updateFile(note.file_path, {
|
||||
content: content !== undefined ? content : undefined,
|
||||
meta
|
||||
})
|
||||
|
||||
if (content !== undefined) {
|
||||
updates.wordCount = this._countWords(content)
|
||||
}
|
||||
|
||||
// If title changed, rename the file
|
||||
if (title !== undefined && title !== note.title) {
|
||||
const oldPath = note.file_path
|
||||
const notebook = this.metaStore.getNotebook(note.notebook_id)
|
||||
const notebookName = notebook ? notebook.name : '默认笔记本'
|
||||
const newRelativePath = `notebooks/${notebookName}/${this.fileStorage._sanitizeFileName(title) || '无标题'}.md`
|
||||
|
||||
// Only rename if the path would actually change
|
||||
if (newRelativePath !== oldPath) {
|
||||
const absoluteOld = join(this.workspacePath, oldPath)
|
||||
const absoluteNew = join(this.workspacePath, newRelativePath)
|
||||
if (existsSync(absoluteOld)) {
|
||||
const dir = dirname(absoluteNew)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
renameSync(absoluteOld, absoluteNew)
|
||||
updates.filePath = newRelativePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates.title = title !== undefined ? title : note.title
|
||||
}
|
||||
|
||||
if (notebookId !== undefined) updates.notebookId = notebookId
|
||||
if (isPinned !== undefined) updates.isPinned = isPinned
|
||||
if (isFavorited !== undefined) updates.isFavorited = isFavorited
|
||||
if (isTrashed !== undefined) updates.isTrashed = isTrashed
|
||||
|
||||
// Update database
|
||||
this.metaStore.updateNote(id, updates)
|
||||
|
||||
// Update tags in junction table
|
||||
if (tags !== undefined) {
|
||||
this._ensureTags(tags)
|
||||
const tagRecords = tags.map(name => {
|
||||
const t = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
|
||||
return t?.id
|
||||
}).filter(Boolean)
|
||||
this.metaStore.setNoteTags(id, tagRecords)
|
||||
}
|
||||
|
||||
return this.getNote(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a note (moves to trash, or permanently if already trashed)
|
||||
*/
|
||||
async deleteNote(id, permanent = false) {
|
||||
const note = this.metaStore.getNote(id)
|
||||
if (!note) return false
|
||||
|
||||
if (permanent) {
|
||||
this.fileStorage.deleteFile(note.file_path)
|
||||
this.metaStore.deleteNote(id)
|
||||
} else {
|
||||
this.metaStore.updateNote(id, { isTrashed: true })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a note from trash
|
||||
*/
|
||||
async restoreNote(id) {
|
||||
this.metaStore.updateNote(id, { isTrashed: false })
|
||||
return this.getNote(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* List notes with optional filters
|
||||
*/
|
||||
async listNotes(filter = {}) {
|
||||
return this.metaStore.listNotes(filter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search notes by query
|
||||
*/
|
||||
async searchNotes(query) {
|
||||
if (!query || !query.trim()) return []
|
||||
return this.metaStore.search(query.trim())
|
||||
}
|
||||
|
||||
// ==================== Notebooks ====================
|
||||
|
||||
async createNotebook({ name, parentId = null }) {
|
||||
const id = nanoid(12)
|
||||
const notebook = this.metaStore.createNotebook({ id, name, parentId })
|
||||
|
||||
// Create the directory on disk
|
||||
this.fileStorage._ensureNotebookDir(name)
|
||||
|
||||
return notebook
|
||||
}
|
||||
|
||||
async listNotebooks() {
|
||||
return this.metaStore.listNotebooks()
|
||||
}
|
||||
|
||||
async renameNotebook(id, name) {
|
||||
const notebook = this.metaStore.getNotebook(id)
|
||||
if (!notebook) return null
|
||||
|
||||
// Rename directory on disk
|
||||
const oldDir = join(this.workspacePath, 'notebooks', notebook.name)
|
||||
const newDir = join(this.workspacePath, 'notebooks', name)
|
||||
if (existsSync(oldDir) && !existsSync(newDir)) {
|
||||
renameSync(oldDir, newDir)
|
||||
|
||||
// Update file paths in database for all notes in this notebook
|
||||
const notes = this.metaStore.listNotes({ notebookId: id })
|
||||
for (const note of notes) {
|
||||
const oldPath = note.file_path
|
||||
const newPath = oldPath.replace(`notebooks/${notebook.name}/`, `notebooks/${name}/`)
|
||||
this.metaStore.updateNote(note.id, { filePath: newPath })
|
||||
}
|
||||
}
|
||||
|
||||
this.metaStore.renameNotebook(id, name)
|
||||
return this.metaStore.getNotebook(id)
|
||||
}
|
||||
|
||||
async deleteNotebook(id) {
|
||||
if (id === 'default') return false // Cannot delete default notebook
|
||||
this.metaStore.deleteNotebook(id)
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== Tags ====================
|
||||
|
||||
async createTag(name) {
|
||||
const existing = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
|
||||
if (existing) return { id: existing.id, name }
|
||||
|
||||
const id = nanoid(8)
|
||||
return this.metaStore.createTag({ id, name })
|
||||
}
|
||||
|
||||
async listTags() {
|
||||
return this.metaStore.listTags()
|
||||
}
|
||||
|
||||
async deleteTag(id) {
|
||||
this.metaStore.deleteTag(id)
|
||||
return true
|
||||
}
|
||||
|
||||
async addTagToNote(noteId, tagName) {
|
||||
this._ensureTags([tagName])
|
||||
const tag = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [tagName])
|
||||
if (!tag) return false
|
||||
this.metaStore.addTagToNote(noteId, tag.id)
|
||||
return true
|
||||
}
|
||||
|
||||
async removeTagFromNote(noteId, tagName) {
|
||||
const tag = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [tagName])
|
||||
if (!tag) return false
|
||||
this.metaStore.removeTagFromNote(noteId, tag.id)
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================== Utilities ====================
|
||||
|
||||
/**
|
||||
* Get workspace statistics
|
||||
*/
|
||||
async getStats() {
|
||||
return this.metaStore.getStats()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save all pending changes
|
||||
*/
|
||||
flush() {
|
||||
if (this.metaStore) {
|
||||
this.metaStore._save()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close and cleanup
|
||||
*/
|
||||
close() {
|
||||
if (this.metaStore) {
|
||||
this.metaStore.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Internal ====================
|
||||
|
||||
/**
|
||||
* Ensure tag records exist for given tag names
|
||||
*/
|
||||
_ensureTags(tagNames) {
|
||||
for (const name of tagNames) {
|
||||
const existing = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
|
||||
if (!existing) {
|
||||
this.metaStore.createTag({ id: nanoid(8), name })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count words in content (Chinese + English)
|
||||
*/
|
||||
_countWords(text) {
|
||||
if (!text) return 0
|
||||
// Count Chinese characters individually, English words by spaces
|
||||
const chinese = (text.match(/[一-鿿]/g) || []).length
|
||||
const english = text.replace(/[一-鿿]/g, '').trim().split(/\s+/).filter(w => w.length > 0).length
|
||||
return chinese + english
|
||||
}
|
||||
|
||||
/**
|
||||
* On startup: scan file system and sync with database
|
||||
* - Add new files found on disk but not in DB
|
||||
* - Update modified files
|
||||
* - Remove DB entries for deleted files
|
||||
*/
|
||||
async _syncFileSystem() {
|
||||
const filesOnDisk = this.fileStorage.listAllFiles()
|
||||
const filesInDb = this.metaStore.listNotes().map(n => n.file_path)
|
||||
const dbFileSet = new Set(filesInDb)
|
||||
|
||||
// Build a set of existing note IDs to avoid UNIQUE constraint violations
|
||||
const existingIds = new Set(this.metaStore.listNotes().map(n => n.id))
|
||||
|
||||
for (const filePath of filesOnDisk) {
|
||||
if (!dbFileSet.has(filePath)) {
|
||||
// New file found on disk - import it
|
||||
const fileData = this.fileStorage.readFile(filePath)
|
||||
if (fileData) {
|
||||
// Always generate a fresh ID to avoid UNIQUE constraint conflicts
|
||||
// with IDs that may already exist in the database
|
||||
let id = fileData.meta.id
|
||||
if (!id || existingIds.has(id)) {
|
||||
id = nanoid(12)
|
||||
}
|
||||
existingIds.add(id)
|
||||
|
||||
const title = fileData.meta.title || this._titleFromPath(filePath)
|
||||
const tags = fileData.meta.tags || []
|
||||
|
||||
// Determine notebook from path
|
||||
const pathParts = filePath.split('/')
|
||||
const notebookName = pathParts.length > 1 ? pathParts[1] : '默认笔记本'
|
||||
let notebook = this.metaStore._get('SELECT id FROM notebooks WHERE name = ?', [notebookName])
|
||||
if (!notebook) {
|
||||
const nbId = nanoid(12)
|
||||
this.metaStore.createNotebook({ id: nbId, name: notebookName })
|
||||
notebook = { id: nbId }
|
||||
}
|
||||
|
||||
this.metaStore.createNote({
|
||||
id,
|
||||
title,
|
||||
filePath,
|
||||
notebookId: notebook.id,
|
||||
wordCount: this._countWords(fileData.content)
|
||||
})
|
||||
|
||||
if (tags.length > 0) {
|
||||
this._ensureTags(tags)
|
||||
const tagRecords = tags.map(name => {
|
||||
const t = this.metaStore._get('SELECT id FROM tags WHERE name = ?', [name])
|
||||
return t?.id
|
||||
}).filter(Boolean)
|
||||
this.metaStore.setNoteTags(id, tagRecords)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove DB entries for files that no longer exist on disk
|
||||
for (const dbFile of filesInDb) {
|
||||
const absolutePath = join(this.workspacePath, dbFile)
|
||||
if (!existsSync(absolutePath)) {
|
||||
const note = this.metaStore.getNoteByPath(dbFile)
|
||||
if (note) {
|
||||
this.metaStore.deleteNote(note.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a title from file path
|
||||
*/
|
||||
_titleFromPath(filePath) {
|
||||
const parts = filePath.split('/')
|
||||
const fileName = parts[parts.length - 1]
|
||||
return fileName.replace(/\.md$/, '')
|
||||
}
|
||||
}
|
||||
10
src/main/services/utils.js
Normal file
10
src/main/services/utils.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Debounce a function call
|
||||
*/
|
||||
export function debounce(fn, delay) {
|
||||
let timer = null
|
||||
return function (...args) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => fn.apply(this, args), delay)
|
||||
}
|
||||
}
|
||||
154
src/renderer/src/composables/useNotebookTree.js
Normal file
154
src/renderer/src/composables/useNotebookTree.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { computed } from 'vue'
|
||||
import { useNotebooksStore } from '../stores/notebooks'
|
||||
import { useNotesStore } from '../stores/notes'
|
||||
|
||||
/**
|
||||
* Composable for building a tree structure of notebooks and notes
|
||||
* for use with PrimeVue Tree component
|
||||
*/
|
||||
export function useNotebookTree() {
|
||||
const notebooksStore = useNotebooksStore()
|
||||
const notesStore = useNotesStore()
|
||||
|
||||
const notebooks = computed(() => notebooksStore.notebooks)
|
||||
const notes = computed(() => notesStore.notes)
|
||||
|
||||
/**
|
||||
* Build tree structure from flat notebooks and notes lists
|
||||
*/
|
||||
const notebookTree = computed(() => {
|
||||
// Create a map for quick lookup
|
||||
const notebookMap = new Map()
|
||||
notebooks.value.forEach(nb => {
|
||||
notebookMap.set(nb.id, {
|
||||
key: `notebook-${nb.id}`,
|
||||
label: nb.name,
|
||||
data: { type: 'notebook', id: nb.id, name: nb.name },
|
||||
icon: 'pi pi-book',
|
||||
children: [],
|
||||
selectable: true
|
||||
})
|
||||
})
|
||||
|
||||
// Build tree structure
|
||||
const rootNodes = []
|
||||
notebooks.value.forEach(nb => {
|
||||
const node = notebookMap.get(nb.id)
|
||||
if (nb.parent_id && notebookMap.has(nb.parent_id)) {
|
||||
// Add as child to parent
|
||||
notebookMap.get(nb.parent_id).children.push(node)
|
||||
} else {
|
||||
// Add as root node
|
||||
rootNodes.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// Add notes to their respective notebooks
|
||||
notes.value.forEach(note => {
|
||||
if (note.is_trashed) return // Skip trashed notes
|
||||
|
||||
const noteNode = {
|
||||
key: `note-${note.id}`,
|
||||
label: note.title || '无标题',
|
||||
data: { type: 'note', id: note.id, title: note.title, notebook_id: note.notebook_id },
|
||||
icon: 'pi pi-file',
|
||||
selectable: true
|
||||
}
|
||||
|
||||
const parentNode = notebookMap.get(note.notebook_id)
|
||||
if (parentNode) {
|
||||
parentNode.children.push(noteNode)
|
||||
} else {
|
||||
// If notebook not found, add to root (shouldn't happen normally)
|
||||
rootNodes.push(noteNode)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort children: notebooks first, then notes
|
||||
const sortChildren = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.sort((a, b) => {
|
||||
// Notebooks first, then notes
|
||||
if (a.data.type === 'notebook' && b.data.type !== 'notebook') return -1
|
||||
if (a.data.type !== 'notebook' && b.data.type === 'notebook') return 1
|
||||
// Within same type, sort alphabetically
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
// Recursively sort children
|
||||
sortChildren(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sortChildren(rootNodes)
|
||||
|
||||
return rootNodes
|
||||
})
|
||||
|
||||
/**
|
||||
* Flatten tree for searching or other purposes
|
||||
*/
|
||||
const flatNotebooks = computed(() => {
|
||||
const result = []
|
||||
const flatten = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.data.type === 'notebook') {
|
||||
result.push(node.data)
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
flatten(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
flatten(notebookTree.value)
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Find a notebook node by ID
|
||||
*/
|
||||
function findNotebookNode(notebookId) {
|
||||
const find = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.data.type === 'notebook' && node.data.id === notebookId) {
|
||||
return node
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = find(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return find(notebookTree.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notebook IDs for expansion
|
||||
*/
|
||||
const expandedKeys = computed(() => {
|
||||
const keys = {}
|
||||
const collectKeys = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.data.type === 'notebook') {
|
||||
keys[node.key] = true
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
collectKeys(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
collectKeys(notebookTree.value)
|
||||
return keys
|
||||
})
|
||||
|
||||
return {
|
||||
notebookTree,
|
||||
flatNotebooks,
|
||||
findNotebookNode,
|
||||
expandedKeys,
|
||||
loadNotebooks: notebooksStore.loadNotebooks,
|
||||
loadNotes: notesStore.loadNotes
|
||||
}
|
||||
}
|
||||
245
src/renderer/src/composables/useNotebookTreeExample.js
Normal file
245
src/renderer/src/composables/useNotebookTreeExample.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Example usage of useNotebookTree composable
|
||||
* This file shows how to use the notebook tree in other components
|
||||
*/
|
||||
|
||||
import { useNotebookTree } from './useNotebookTree'
|
||||
|
||||
/**
|
||||
* Example 1: Basic usage in a component
|
||||
*/
|
||||
export function useNotebookTreeExample() {
|
||||
const {
|
||||
notebookTree,
|
||||
flatNotebooks,
|
||||
findNotebookNode,
|
||||
expandedKeys,
|
||||
loadNotebooks,
|
||||
loadNotes
|
||||
} = useNotebookTree()
|
||||
|
||||
// Load data on component mount
|
||||
async function initialize() {
|
||||
await Promise.all([
|
||||
loadNotebooks(),
|
||||
loadNotes()
|
||||
])
|
||||
}
|
||||
|
||||
// Find a specific notebook
|
||||
function findNotebook(notebookId) {
|
||||
return findNotebookNode(notebookId)
|
||||
}
|
||||
|
||||
// Get all notebook IDs for expansion
|
||||
function getAllNotebookKeys() {
|
||||
return Object.keys(expandedKeys.value)
|
||||
}
|
||||
|
||||
// Get flat list of all notebooks
|
||||
function getAllNotebooks() {
|
||||
return flatNotebooks.value
|
||||
}
|
||||
|
||||
return {
|
||||
notebookTree,
|
||||
flatNotebooks,
|
||||
expandedKeys,
|
||||
initialize,
|
||||
findNotebook,
|
||||
getAllNotebookKeys,
|
||||
getAllNotebooks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 2: Custom tree filtering
|
||||
*/
|
||||
export function useFilteredNotebookTree(filterText) {
|
||||
const { notebookTree, flatNotebooks } = useNotebookTree()
|
||||
|
||||
const filteredTree = computed(() => {
|
||||
if (!filterText.value) {
|
||||
return notebookTree.value
|
||||
}
|
||||
|
||||
const filter = filterText.value.toLowerCase()
|
||||
|
||||
const filterNodes = (nodes) => {
|
||||
return nodes.filter(node => {
|
||||
// Check if node label matches filter
|
||||
const labelMatch = node.label.toLowerCase().includes(filter)
|
||||
|
||||
// Check if any children match
|
||||
const childrenMatch = node.children && filterNodes(node.children).length > 0
|
||||
|
||||
// Include node if label matches or has matching children
|
||||
return labelMatch || childrenMatch
|
||||
}).map(node => {
|
||||
// If node has children, filter them recursively
|
||||
if (node.children) {
|
||||
return {
|
||||
...node,
|
||||
children: filterNodes(node.children)
|
||||
}
|
||||
}
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
return filterNodes(notebookTree.value)
|
||||
})
|
||||
|
||||
return {
|
||||
filteredTree,
|
||||
flatNotebooks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 3: Tree statistics
|
||||
*/
|
||||
export function useNotebookTreeStats() {
|
||||
const { notebookTree, flatNotebooks } = useNotebookTree()
|
||||
|
||||
const stats = computed(() => {
|
||||
let notebookCount = 0
|
||||
let noteCount = 0
|
||||
|
||||
const countNodes = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.data.type === 'notebook') {
|
||||
notebookCount++
|
||||
} else if (node.data.type === 'note') {
|
||||
noteCount++
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
countNodes(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
countNodes(notebookTree.value)
|
||||
|
||||
return {
|
||||
notebooks: notebookCount,
|
||||
notes: noteCount,
|
||||
total: notebookCount + noteCount
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
stats,
|
||||
flatNotebooks
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example 4: Tree navigation helpers
|
||||
*/
|
||||
export function useNotebookTreeNavigation() {
|
||||
const { notebookTree, findNotebookNode } = useNotebookTree()
|
||||
|
||||
// Get parent notebook of a note
|
||||
function getParentNotebook(noteId) {
|
||||
const findParent = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.data.type === 'note' && child.data.id === noteId) {
|
||||
return node.data
|
||||
}
|
||||
}
|
||||
const found = findParent(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return findParent(notebookTree.value)
|
||||
}
|
||||
|
||||
// Get all notes in a notebook
|
||||
function getNotesInNotebook(notebookId) {
|
||||
const node = findNotebookNode(notebookId)
|
||||
if (!node || !node.children) return []
|
||||
|
||||
return node.children
|
||||
.filter(child => child.data.type === 'note')
|
||||
.map(child => child.data)
|
||||
}
|
||||
|
||||
// Get all child notebooks
|
||||
function getChildNotebooks(notebookId) {
|
||||
const node = findNotebookNode(notebookId)
|
||||
if (!node || !node.children) return []
|
||||
|
||||
return node.children
|
||||
.filter(child => child.data.type === 'notebook')
|
||||
.map(child => child.data)
|
||||
}
|
||||
|
||||
// Get notebook path (breadcrumb)
|
||||
function getNotebookPath(notebookId) {
|
||||
const path = []
|
||||
|
||||
const findPath = (nodes, targetId) => {
|
||||
for (const node of nodes) {
|
||||
if (node.data.type === 'notebook' && node.data.id === targetId) {
|
||||
path.push(node.data)
|
||||
return true
|
||||
}
|
||||
|
||||
if (node.children && findPath(node.children, targetId)) {
|
||||
if (node.data.type === 'notebook') {
|
||||
path.push(node.data)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
findPath(notebookTree.value, notebookId)
|
||||
return path.reverse()
|
||||
}
|
||||
|
||||
return {
|
||||
getParentNotebook,
|
||||
getNotesInNotebook,
|
||||
getChildNotebooks,
|
||||
getNotebookPath
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example component usage:
|
||||
*
|
||||
* <script setup>
|
||||
* import { useNotebookTreeExample } from '@/composables/useNotebookTreeExample'
|
||||
*
|
||||
* const {
|
||||
* notebookTree,
|
||||
* flatNotebooks,
|
||||
* expandedKeys,
|
||||
* initialize,
|
||||
* findNotebook,
|
||||
* getAllNotebooks
|
||||
* } = useNotebookTreeExample()
|
||||
*
|
||||
* onMounted(() => {
|
||||
* initialize()
|
||||
* })
|
||||
* </script>
|
||||
*
|
||||
* <template>
|
||||
* <Tree
|
||||
* :value="notebookTree"
|
||||
* :expandedKeys="expandedKeys"
|
||||
* selectionMode="single"
|
||||
* @node-select="onNodeSelect"
|
||||
* />
|
||||
* </template>
|
||||
*/
|
||||
72
src/renderer/src/composables/useNotebooks.js
Normal file
72
src/renderer/src/composables/useNotebooks.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const notebooks = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* Composable for notebook management
|
||||
* Wraps IPC calls to the main process StorageService
|
||||
*/
|
||||
export function useNotebooks() {
|
||||
|
||||
async function loadNotebooks() {
|
||||
loading.value = true
|
||||
try {
|
||||
notebooks.value = await window.electronAPI.notebooks.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to load notebooks:', err)
|
||||
notebooks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNotebook(name) {
|
||||
try {
|
||||
const notebook = await window.electronAPI.notebooks.create({ name })
|
||||
if (notebook) {
|
||||
notebooks.value.push(notebook)
|
||||
}
|
||||
return notebook
|
||||
} catch (err) {
|
||||
console.error('Failed to create notebook:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function renameNotebook(id, name) {
|
||||
try {
|
||||
const notebook = await window.electronAPI.notebooks.rename(id, name)
|
||||
if (notebook) {
|
||||
const idx = notebooks.value.findIndex(nb => nb.id === id)
|
||||
if (idx !== -1) {
|
||||
notebooks.value[idx] = notebook
|
||||
}
|
||||
}
|
||||
return notebook
|
||||
} catch (err) {
|
||||
console.error('Failed to rename notebook:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotebook(id) {
|
||||
try {
|
||||
await window.electronAPI.notebooks.delete(id)
|
||||
notebooks.value = notebooks.value.filter(nb => nb.id !== id)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete notebook:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notebooks,
|
||||
loading,
|
||||
loadNotebooks,
|
||||
createNotebook,
|
||||
renameNotebook,
|
||||
deleteNotebook
|
||||
}
|
||||
}
|
||||
137
src/renderer/src/composables/useNotes.js
Normal file
137
src/renderer/src/composables/useNotes.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const notes = ref([])
|
||||
const currentNote = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* Composable for note CRUD operations
|
||||
* Wraps IPC calls to the main process StorageService
|
||||
*/
|
||||
export function useNotes() {
|
||||
|
||||
async function loadNotes(filter = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
notes.value = await window.electronAPI.notes.list(filter)
|
||||
} catch (err) {
|
||||
console.error('Failed to load notes:', err)
|
||||
notes.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote(data = {}) {
|
||||
try {
|
||||
const note = await window.electronAPI.notes.create(data)
|
||||
if (note) {
|
||||
notes.value.unshift(note)
|
||||
}
|
||||
return note
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getNote(id) {
|
||||
try {
|
||||
const note = await window.electronAPI.notes.get(id)
|
||||
currentNote.value = note
|
||||
return note
|
||||
} catch (err) {
|
||||
console.error('Failed to get note:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNote(id, data) {
|
||||
try {
|
||||
const note = await window.electronAPI.notes.update(id, data)
|
||||
if (note) {
|
||||
// Update in local list
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx] = { ...notes.value[idx], ...note }
|
||||
}
|
||||
// Update current note if it's the one being edited
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = note
|
||||
}
|
||||
}
|
||||
return note
|
||||
} catch (err) {
|
||||
console.error('Failed to update note:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(id, permanent = false) {
|
||||
try {
|
||||
await window.electronAPI.notes.delete(id, permanent)
|
||||
if (permanent) {
|
||||
notes.value = notes.value.filter(n => n.id !== id)
|
||||
} else {
|
||||
// Move to trash - update local state
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx].is_trashed = 1
|
||||
}
|
||||
}
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreNote(id) {
|
||||
try {
|
||||
await window.electronAPI.notes.restore(id)
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx].is_trashed = 0
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to restore note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function searchNotes(query) {
|
||||
try {
|
||||
return await window.electronAPI.notes.search(query)
|
||||
} catch (err) {
|
||||
console.error('Failed to search notes:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentNote(note) {
|
||||
currentNote.value = note
|
||||
}
|
||||
|
||||
function clearCurrentNote() {
|
||||
currentNote.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
currentNote,
|
||||
loading,
|
||||
loadNotes,
|
||||
createNote,
|
||||
getNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
restoreNote,
|
||||
searchNotes,
|
||||
setCurrentNote,
|
||||
clearCurrentNote
|
||||
}
|
||||
}
|
||||
258
src/renderer/src/composables/useSidebarTree.js
Normal file
258
src/renderer/src/composables/useSidebarTree.js
Normal file
@@ -0,0 +1,258 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useNotebooksStore } from '../stores/notebooks'
|
||||
import { useNotesStore } from '../stores/notes'
|
||||
|
||||
/**
|
||||
* Composable for building a unified tree structure
|
||||
* combining notes navigation, notebooks, and tags
|
||||
* for use with PrimeVue Tree component
|
||||
*/
|
||||
export function useSidebarTree(tagsRef) {
|
||||
const notebooksStore = useNotebooksStore()
|
||||
const notesStore = useNotesStore()
|
||||
|
||||
const notebooks = computed(() => notebooksStore.notebooks)
|
||||
const notes = computed(() => notesStore.notes)
|
||||
const tags = computed(() => tagsRef?.value || [])
|
||||
|
||||
/**
|
||||
* Build notes navigation nodes (static items)
|
||||
*/
|
||||
function buildNotesNavNodes() {
|
||||
return [
|
||||
{
|
||||
key: 'nav-all',
|
||||
label: '所有笔记',
|
||||
data: { type: 'nav', id: 'all' },
|
||||
selectable: true,
|
||||
leaf: true
|
||||
},
|
||||
{
|
||||
key: 'nav-favorites',
|
||||
label: '收藏夹',
|
||||
data: { type: 'nav', id: 'favorites' },
|
||||
selectable: true,
|
||||
leaf: true
|
||||
},
|
||||
{
|
||||
key: 'nav-trash',
|
||||
label: '回收站',
|
||||
data: { type: 'nav', id: 'trash' },
|
||||
selectable: true,
|
||||
leaf: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build notebook tree nodes with nested notes
|
||||
*/
|
||||
function buildNotebookNodes() {
|
||||
// Create a map for quick lookup
|
||||
const notebookMap = new Map()
|
||||
notebooks.value.forEach(nb => {
|
||||
notebookMap.set(nb.id, {
|
||||
key: `notebook-${nb.id}`,
|
||||
label: nb.name,
|
||||
data: { type: 'notebook', id: nb.id, name: nb.name },
|
||||
children: [],
|
||||
selectable: true
|
||||
})
|
||||
})
|
||||
|
||||
// Build tree structure
|
||||
const rootNodes = []
|
||||
notebooks.value.forEach(nb => {
|
||||
const node = notebookMap.get(nb.id)
|
||||
if (nb.parent_id && notebookMap.has(nb.parent_id)) {
|
||||
// Add as child to parent
|
||||
notebookMap.get(nb.parent_id).children.push(node)
|
||||
} else {
|
||||
// Add as root node
|
||||
rootNodes.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// Add notes to their respective notebooks
|
||||
notes.value.forEach(note => {
|
||||
if (note.is_trashed) return // Skip trashed notes
|
||||
|
||||
const noteNode = {
|
||||
key: `note-${note.id}`,
|
||||
label: note.title || '无标题',
|
||||
data: { type: 'note', id: note.id, title: note.title, notebook_id: note.notebook_id },
|
||||
selectable: true
|
||||
}
|
||||
|
||||
const parentNode = notebookMap.get(note.notebook_id)
|
||||
if (parentNode) {
|
||||
parentNode.children.push(noteNode)
|
||||
} else {
|
||||
// If notebook not found, add to root (shouldn't happen normally)
|
||||
rootNodes.push(noteNode)
|
||||
}
|
||||
})
|
||||
|
||||
// Sort children: notebooks first, then notes
|
||||
const sortChildren = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.sort((a, b) => {
|
||||
// Notebooks first, then notes
|
||||
if (a.data.type === 'notebook' && b.data.type !== 'notebook') return -1
|
||||
if (a.data.type !== 'notebook' && b.data.type === 'notebook') return 1
|
||||
// Within same type, sort alphabetically
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
// Recursively sort children
|
||||
sortChildren(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sortChildren(rootNodes)
|
||||
|
||||
return rootNodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tag nodes
|
||||
*/
|
||||
function buildTagNodes() {
|
||||
return tags.value.map(tag => ({
|
||||
key: `tag-${tag.id}`,
|
||||
label: tag.name,
|
||||
data: { type: 'tag', id: tag.id, name: tag.name, note_count: tag.note_count || 0 },
|
||||
selectable: true,
|
||||
leaf: true
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build complete unified tree structure
|
||||
*/
|
||||
const sidebarTree = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 'group-notes',
|
||||
label: '笔记',
|
||||
data: { type: 'group', id: 'notes' },
|
||||
selectable: false,
|
||||
children: buildNotesNavNodes()
|
||||
},
|
||||
{
|
||||
key: 'group-notebooks',
|
||||
label: '笔记本',
|
||||
data: { type: 'group', id: 'notebooks' },
|
||||
selectable: false,
|
||||
children: buildNotebookNodes()
|
||||
},
|
||||
{
|
||||
key: 'group-tags',
|
||||
label: '标签',
|
||||
data: { type: 'group', id: 'tags' },
|
||||
selectable: false,
|
||||
children: buildTagNodes()
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* Flatten notebooks for searching or other purposes
|
||||
*/
|
||||
const flatNotebooks = computed(() => {
|
||||
const result = []
|
||||
const flatten = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.data.type === 'notebook') {
|
||||
result.push(node.data)
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
flatten(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Only flatten the notebooks group
|
||||
const notebooksGroup = sidebarTree.value.find(g => g.data.id === 'notebooks')
|
||||
if (notebooksGroup) {
|
||||
flatten(notebooksGroup.children)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
/**
|
||||
* Find a notebook node by ID
|
||||
*/
|
||||
function findNotebookNode(notebookId) {
|
||||
const find = (nodes) => {
|
||||
for (const node of nodes) {
|
||||
if (node.data.type === 'notebook' && node.data.id === notebookId) {
|
||||
return node
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const found = find(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
const notebooksGroup = sidebarTree.value.find(g => g.data.id === 'notebooks')
|
||||
return notebooksGroup ? find(notebooksGroup.children) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all expandable node keys for expansion state
|
||||
*/
|
||||
const expandedKeys = ref({})
|
||||
|
||||
// Initialize expanded keys when sidebarTree changes
|
||||
watch(sidebarTree, (newTree) => {
|
||||
const keys = {}
|
||||
|
||||
// Expand all group nodes by default
|
||||
keys['group-notes'] = true
|
||||
keys['group-notebooks'] = true
|
||||
keys['group-tags'] = true
|
||||
|
||||
// Expand all notebook nodes by default
|
||||
const collectNotebookKeys = (nodes) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.data.type === 'notebook') {
|
||||
keys[node.key] = true
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
collectNotebookKeys(node.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
const notebooksGroup = newTree.find(g => g.data.id === 'notebooks')
|
||||
if (notebooksGroup) {
|
||||
collectNotebookKeys(notebooksGroup.children)
|
||||
}
|
||||
|
||||
expandedKeys.value = keys
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* Toggle expanded state of a node
|
||||
*/
|
||||
function toggleNodeExpanded(nodeKey) {
|
||||
const newKeys = { ...expandedKeys.value }
|
||||
if (newKeys[nodeKey]) {
|
||||
delete newKeys[nodeKey]
|
||||
} else {
|
||||
newKeys[nodeKey] = true
|
||||
}
|
||||
expandedKeys.value = newKeys
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarTree,
|
||||
flatNotebooks,
|
||||
findNotebookNode,
|
||||
expandedKeys,
|
||||
toggleNodeExpanded,
|
||||
loadNotebooks: notebooksStore.loadNotebooks,
|
||||
loadNotes: notesStore.loadNotes
|
||||
}
|
||||
}
|
||||
75
src/renderer/src/composables/useTags.js
Normal file
75
src/renderer/src/composables/useTags.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const tags = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
/**
|
||||
* Composable for tag management
|
||||
* Wraps IPC calls to the main process StorageService
|
||||
*/
|
||||
export function useTags() {
|
||||
|
||||
async function loadTags() {
|
||||
loading.value = true
|
||||
try {
|
||||
tags.value = await window.electronAPI.tags.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags:', err)
|
||||
tags.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTag(name) {
|
||||
try {
|
||||
const tag = await window.electronAPI.tags.create(name)
|
||||
if (tag && !tags.value.find(t => t.id === tag.id)) {
|
||||
tags.value.push(tag)
|
||||
}
|
||||
return tag
|
||||
} catch (err) {
|
||||
console.error('Failed to create tag:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag(id) {
|
||||
try {
|
||||
await window.electronAPI.tags.delete(id)
|
||||
tags.value = tags.value.filter(t => t.id !== id)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tag:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function addTagToNote(noteId, tagName) {
|
||||
try {
|
||||
return await window.electronAPI.tags.addToNote(noteId, tagName)
|
||||
} catch (err) {
|
||||
console.error('Failed to add tag to note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTagFromNote(noteId, tagName) {
|
||||
try {
|
||||
return await window.electronAPI.tags.removeFromNote(noteId, tagName)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove tag from note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tags,
|
||||
loading,
|
||||
loadTags,
|
||||
createTag,
|
||||
deleteTag,
|
||||
addTagToNote,
|
||||
removeTagFromNote
|
||||
}
|
||||
}
|
||||
91
src/renderer/src/composables/useWorkspace.js
Normal file
91
src/renderer/src/composables/useWorkspace.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Workspace 管理 composable
|
||||
* 处理首次使用引导和存储文件夹选择
|
||||
*/
|
||||
|
||||
// 全局状态(所有组件共享)
|
||||
const isReady = ref(false)
|
||||
const workspacePath = ref(null)
|
||||
const loading = ref(false)
|
||||
let listenerRegistered = false
|
||||
|
||||
/**
|
||||
* 检查 workspace 状态(通过 IPC 查询主进程)
|
||||
*/
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const status = await window.electronAPI.workspace.getStatus()
|
||||
isReady.value = status.ready
|
||||
workspacePath.value = status.path
|
||||
} catch (err) {
|
||||
console.error('Failed to check workspace status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开系统文件夹选择对话框
|
||||
* @returns {object|null} { canceled, path } 或 null
|
||||
*/
|
||||
async function selectFolder() {
|
||||
try {
|
||||
return await window.electronAPI.workspace.selectFolder()
|
||||
} catch (err) {
|
||||
console.error('Failed to select folder:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 workspace 路径并初始化存储服务
|
||||
* @param {string} path - 要设置的文件夹路径
|
||||
* @returns {boolean} 是否成功
|
||||
*/
|
||||
async function initWorkspace(path) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await window.electronAPI.workspace.setPath(path)
|
||||
if (result && result.success) {
|
||||
isReady.value = true
|
||||
workspacePath.value = result.path
|
||||
return true
|
||||
}
|
||||
console.error('Failed to init workspace:', result?.error || 'unknown error')
|
||||
return false
|
||||
} catch (err) {
|
||||
console.error('Failed to init workspace:', err)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听主进程推送的 workspace 状态变更
|
||||
*/
|
||||
function listenStatusChanges() {
|
||||
if (listenerRegistered) return
|
||||
listenerRegistered = true
|
||||
|
||||
window.electronAPI.workspace.onStatusChange((status) => {
|
||||
isReady.value = status.ready
|
||||
workspacePath.value = status.path
|
||||
})
|
||||
}
|
||||
|
||||
export function useWorkspace() {
|
||||
// 注册状态变更监听
|
||||
listenStatusChanges()
|
||||
// 主动查询一次当前状态(防止 did-finish-load 事件在监听注册前触发)
|
||||
checkStatus()
|
||||
|
||||
return {
|
||||
isReady,
|
||||
workspacePath,
|
||||
loading,
|
||||
checkStatus,
|
||||
selectFolder,
|
||||
initWorkspace
|
||||
}
|
||||
}
|
||||
174
src/renderer/src/stores/README.md
Normal file
174
src/renderer/src/stores/README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Pinia Stores 使用指南
|
||||
|
||||
本项目使用 Pinia 作为全局状态管理解决方案,替代了之前的 composables 模式。
|
||||
|
||||
## 可用的 Stores
|
||||
|
||||
### 1. useNotesStore - 笔记管理
|
||||
```javascript
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
|
||||
// 状态
|
||||
notesStore.notes // 所有笔记列表
|
||||
notesStore.currentNote // 当前选中的笔记
|
||||
notesStore.loading // 加载状态
|
||||
|
||||
// Getters
|
||||
notesStore.activeNotes // 未删除的笔记
|
||||
notesStore.trashedNotes // 已删除的笔记
|
||||
notesStore.hasCurrentNote // 是否有选中的笔记
|
||||
|
||||
// 方法
|
||||
await notesStore.loadNotes({ isTrashed: 0 }) // 加载笔记
|
||||
await notesStore.createNote({ title, content, notebookId }) // 创建笔记
|
||||
await notesStore.getNote(id) // 获取笔记详情
|
||||
await notesStore.updateNote(id, { title, content }) // 更新笔记
|
||||
await notesStore.deleteNote(id, permanent) // 删除笔记
|
||||
await notesStore.restoreNote(id) // 恢复已删除笔记
|
||||
await notesStore.searchNotes(query) // 搜索笔记
|
||||
notesStore.setCurrentNote(note) // 设置当前笔记
|
||||
notesStore.clearCurrentNote() // 清空当前笔记
|
||||
```
|
||||
|
||||
### 2. useNotebooksStore - 笔记本管理
|
||||
```javascript
|
||||
import { useNotebooksStore } from '@/stores/notebooks'
|
||||
|
||||
const notebooksStore = useNotebooksStore()
|
||||
|
||||
// 状态
|
||||
notebooksStore.notebooks // 所有笔记本列表
|
||||
notebooksStore.loading // 加载状态
|
||||
|
||||
// Getters
|
||||
notebooksStore.notebookCount // 笔记本数量
|
||||
notebooksStore.defaultNotebook // 默认笔记本
|
||||
|
||||
// 方法
|
||||
await notebooksStore.loadNotebooks() // 加载笔记本
|
||||
await notebooksStore.createNotebook(name) // 创建笔记本
|
||||
await notebooksStore.renameNotebook(id, name) // 重命名笔记本
|
||||
await notebooksStore.deleteNotebook(id) // 删除笔记本
|
||||
notebooksStore.getNotebookById(id) // 根据 ID 获取笔记本
|
||||
```
|
||||
|
||||
### 3. useTagsStore - 标签管理
|
||||
```javascript
|
||||
import { useTagsStore } from '@/stores/tags'
|
||||
|
||||
const tagsStore = useTagsStore()
|
||||
|
||||
// 状态
|
||||
tagsStore.tags // 所有标签列表
|
||||
tagsStore.loading // 加载状态
|
||||
|
||||
// Getters
|
||||
tagsStore.tagCount // 标签数量
|
||||
tagsStore.tagNames // 所有标签名称数组
|
||||
|
||||
// 方法
|
||||
await tagsStore.loadTags() // 加载标签
|
||||
await tagsStore.createTag(name) // 创建标签
|
||||
await tagsStore.deleteTag(id) // 删除标签
|
||||
await tagsStore.addTagToNote(noteId, tagName) // 为笔记添加标签
|
||||
await tagsStore.removeTagFromNote(noteId, tagName) // 从笔记移除标签
|
||||
tagsStore.getTagById(id) // 根据 ID 获取标签
|
||||
tagsStore.getTagByName(name) // 根据名称获取标签
|
||||
```
|
||||
|
||||
### 4. useWorkspaceStore - 工作区管理
|
||||
```javascript
|
||||
import { useWorkspaceStore } from '@/stores/workspace'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
|
||||
// 状态
|
||||
workspaceStore.isReady // 工作区是否就绪
|
||||
workspaceStore.workspacePath // 工作区路径
|
||||
workspaceStore.loading // 加载状态
|
||||
|
||||
// Getters
|
||||
workspaceStore.hasWorkspace // 是否有工作区
|
||||
|
||||
// 方法
|
||||
workspaceStore.initialize() // 初始化(自动调用)
|
||||
await workspaceStore.checkStatus() // 检查状态
|
||||
await workspaceStore.selectFolder() // 选择文件夹
|
||||
await workspaceStore.initWorkspace(path) // 初始化工作区
|
||||
```
|
||||
|
||||
### 5. useThemeStore - 主题管理
|
||||
```javascript
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
// 状态
|
||||
themeStore.theme // 当前主题 ('light' | 'dark' | 'system')
|
||||
|
||||
// Getters
|
||||
themeStore.resolvedTheme // 解析后的主题 ('light' | 'dark')
|
||||
themeStore.isDark // 是否为暗色模式
|
||||
themeStore.themeLabel // 主题显示名称
|
||||
|
||||
// 方法
|
||||
themeStore.initialize() // 初始化(需要在应用启动时调用)
|
||||
themeStore.toggleTheme() // 切换主题
|
||||
themeStore.setTheme('dark') // 设置指定主题
|
||||
themeStore.cleanup() // 清理监听器
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
|
||||
### 选项式 API
|
||||
```javascript
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
import { mapState, mapActions } from 'pinia'
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
...mapState(useNotesStore, ['notes', 'currentNote', 'loading'])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(useNotesStore, ['loadNotes', 'createNote'])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 组合式 API (推荐)
|
||||
```javascript
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useNotesStore } from '@/stores/notes'
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
|
||||
// 使用计算属性保持响应性
|
||||
const notes = computed(() => notesStore.notes)
|
||||
const loading = computed(() => notesStore.loading)
|
||||
|
||||
// 直接调用 actions
|
||||
async function handleCreate() {
|
||||
await notesStore.createNote({ title: '新笔记', content: '' })
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **单一数据源**: 每个 store 管理一个特定领域的状态
|
||||
2. **响应式**: 使用 `computed` 包装 store 的状态以保持响应性
|
||||
3. **异步操作**: 所有 API 调用都应在 actions 中进行
|
||||
4. **错误处理**: 在 actions 中处理错误并记录日志
|
||||
5. **初始化**: 某些 store 需要调用 `initialize()` 方法(如 themeStore、workspaceStore)
|
||||
|
||||
## 从 Composables 迁移
|
||||
|
||||
旧的 composables 仍然存在但已不推荐使用。迁移步骤:
|
||||
|
||||
1. 将 `import { useXxx } from '@/composables/xxx'` 改为 `import { useXxxStore } from '@/stores/xxx'`
|
||||
2. 将 `const { data, method } = useXxx()` 改为 `const store = useXxxStore()`
|
||||
3. 使用 `computed(() => store.data)` 保持响应性
|
||||
4. 直接调用 `store.method()` 替代解构的方法
|
||||
6
src/renderer/src/stores/index.js
Normal file
6
src/renderer/src/stores/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all stores
|
||||
export { useNotesStore } from './notes'
|
||||
export { useNotebooksStore } from './notebooks'
|
||||
export { useTagsStore } from './tags'
|
||||
export { useWorkspaceStore } from './workspace'
|
||||
export { useThemeStore } from './theme'
|
||||
87
src/renderer/src/stores/notebooks.js
Normal file
87
src/renderer/src/stores/notebooks.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useNotebooksStore = defineStore('notebooks', () => {
|
||||
// State
|
||||
const notebooks = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Getters
|
||||
const notebookCount = computed(() => notebooks.value.length)
|
||||
const defaultNotebook = computed(() => notebooks.value.find(nb => nb.id === 'default'))
|
||||
|
||||
// Actions
|
||||
async function loadNotebooks() {
|
||||
loading.value = true
|
||||
try {
|
||||
notebooks.value = await window.electronAPI.notebooks.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to load notebooks:', err)
|
||||
notebooks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNotebook(data) {
|
||||
try {
|
||||
const params = typeof data === 'string' ? { name: data } : data
|
||||
const notebook = await window.electronAPI.notebooks.create(params)
|
||||
if (notebook) {
|
||||
notebooks.value.push(notebook)
|
||||
}
|
||||
return notebook
|
||||
} catch (err) {
|
||||
console.error('Failed to create notebook:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function renameNotebook(id, name) {
|
||||
try {
|
||||
const notebook = await window.electronAPI.notebooks.rename(id, name)
|
||||
if (notebook) {
|
||||
const idx = notebooks.value.findIndex(nb => nb.id === id)
|
||||
if (idx !== -1) {
|
||||
notebooks.value[idx] = notebook
|
||||
}
|
||||
}
|
||||
return notebook
|
||||
} catch (err) {
|
||||
console.error('Failed to rename notebook:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotebook(id) {
|
||||
try {
|
||||
await window.electronAPI.notebooks.delete(id)
|
||||
notebooks.value = notebooks.value.filter(nb => nb.id !== id)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete notebook:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getNotebookById(id) {
|
||||
return notebooks.value.find(nb => nb.id === id)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
notebooks,
|
||||
loading,
|
||||
|
||||
// Getters
|
||||
notebookCount,
|
||||
defaultNotebook,
|
||||
|
||||
// Actions
|
||||
loadNotebooks,
|
||||
createNotebook,
|
||||
renameNotebook,
|
||||
deleteNotebook,
|
||||
getNotebookById
|
||||
}
|
||||
})
|
||||
173
src/renderer/src/stores/notes.js
Normal file
173
src/renderer/src/stores/notes.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useNotesStore = defineStore('notes', () => {
|
||||
// State
|
||||
const notes = ref([])
|
||||
const currentNote = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Getters
|
||||
const activeNotes = computed(() => notes.value.filter(n => !n.is_trashed))
|
||||
const trashedNotes = computed(() => notes.value.filter(n => n.is_trashed))
|
||||
const favoritedNotes = computed(() => notes.value.filter(n => n.is_favorited && !n.is_trashed))
|
||||
const hasCurrentNote = computed(() => currentNote.value !== null)
|
||||
|
||||
// Actions
|
||||
async function loadNotes(filter = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
notes.value = await window.electronAPI.notes.list(filter)
|
||||
} catch (err) {
|
||||
console.error('Failed to load notes:', err)
|
||||
notes.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createNote(data = {}) {
|
||||
try {
|
||||
const note = await window.electronAPI.notes.create(data)
|
||||
if (note) {
|
||||
notes.value.unshift(note)
|
||||
}
|
||||
return note
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getNote(id) {
|
||||
try {
|
||||
const note = await window.electronAPI.notes.get(id)
|
||||
currentNote.value = note
|
||||
return note
|
||||
} catch (err) {
|
||||
console.error('Failed to get note:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNote(id, data) {
|
||||
try {
|
||||
const note = await window.electronAPI.notes.update(id, data)
|
||||
if (note) {
|
||||
// Update in local list
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx] = { ...notes.value[idx], ...note }
|
||||
}
|
||||
// Update current note if it's the one being edited
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = note
|
||||
}
|
||||
}
|
||||
return note
|
||||
} catch (err) {
|
||||
console.error('Failed to update note:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNote(id, permanent = false) {
|
||||
try {
|
||||
await window.electronAPI.notes.delete(id, permanent)
|
||||
if (permanent) {
|
||||
notes.value = notes.value.filter(n => n.id !== id)
|
||||
} else {
|
||||
// Move to trash - update local state
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx].is_trashed = 1
|
||||
}
|
||||
}
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = null
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreNote(id) {
|
||||
try {
|
||||
await window.electronAPI.notes.restore(id)
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx].is_trashed = 0
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to restore note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function searchNotes(query) {
|
||||
try {
|
||||
return await window.electronAPI.notes.search(query)
|
||||
} catch (err) {
|
||||
console.error('Failed to search notes:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite(id) {
|
||||
try {
|
||||
const note = notes.value.find(n => n.id === id)
|
||||
if (!note) return null
|
||||
const newValue = note.is_favorited ? 0 : 1
|
||||
const updated = await window.electronAPI.notes.update(id, { isFavorited: newValue })
|
||||
if (updated) {
|
||||
const idx = notes.value.findIndex(n => n.id === id)
|
||||
if (idx !== -1) {
|
||||
notes.value[idx] = { ...notes.value[idx], ...updated }
|
||||
}
|
||||
if (currentNote.value?.id === id) {
|
||||
currentNote.value = updated
|
||||
}
|
||||
}
|
||||
return updated
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentNote(note) {
|
||||
currentNote.value = note
|
||||
}
|
||||
|
||||
function clearCurrentNote() {
|
||||
currentNote.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
notes,
|
||||
currentNote,
|
||||
loading,
|
||||
|
||||
// Getters
|
||||
activeNotes,
|
||||
trashedNotes,
|
||||
favoritedNotes,
|
||||
hasCurrentNote,
|
||||
|
||||
// Actions
|
||||
loadNotes,
|
||||
createNote,
|
||||
getNote,
|
||||
updateNote,
|
||||
deleteNote,
|
||||
restoreNote,
|
||||
searchNotes,
|
||||
toggleFavorite,
|
||||
setCurrentNote,
|
||||
clearCurrentNote
|
||||
}
|
||||
})
|
||||
94
src/renderer/src/stores/tags.js
Normal file
94
src/renderer/src/stores/tags.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useTagsStore = defineStore('tags', () => {
|
||||
// State
|
||||
const tags = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// Getters
|
||||
const tagCount = computed(() => tags.value.length)
|
||||
const tagNames = computed(() => tags.value.map(t => t.name))
|
||||
|
||||
// Actions
|
||||
async function loadTags() {
|
||||
loading.value = true
|
||||
try {
|
||||
tags.value = await window.electronAPI.tags.list()
|
||||
} catch (err) {
|
||||
console.error('Failed to load tags:', err)
|
||||
tags.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTag(name) {
|
||||
try {
|
||||
const tag = await window.electronAPI.tags.create(name)
|
||||
if (tag && !tags.value.find(t => t.id === tag.id)) {
|
||||
tags.value.push(tag)
|
||||
}
|
||||
return tag
|
||||
} catch (err) {
|
||||
console.error('Failed to create tag:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTag(id) {
|
||||
try {
|
||||
await window.electronAPI.tags.delete(id)
|
||||
tags.value = tags.value.filter(t => t.id !== id)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete tag:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function addTagToNote(noteId, tagName) {
|
||||
try {
|
||||
return await window.electronAPI.tags.addToNote(noteId, tagName)
|
||||
} catch (err) {
|
||||
console.error('Failed to add tag to note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTagFromNote(noteId, tagName) {
|
||||
try {
|
||||
return await window.electronAPI.tags.removeFromNote(noteId, tagName)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove tag from note:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getTagById(id) {
|
||||
return tags.value.find(t => t.id === id)
|
||||
}
|
||||
|
||||
function getTagByName(name) {
|
||||
return tags.value.find(t => t.name === name)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
tags,
|
||||
loading,
|
||||
|
||||
// Getters
|
||||
tagCount,
|
||||
tagNames,
|
||||
|
||||
// Actions
|
||||
loadTags,
|
||||
createTag,
|
||||
deleteTag,
|
||||
addTagToNote,
|
||||
removeTagFromNote,
|
||||
getTagById,
|
||||
getTagByName
|
||||
}
|
||||
})
|
||||
108
src/renderer/src/stores/theme.js
Normal file
108
src/renderer/src/stores/theme.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'tunji-theme'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
// State
|
||||
const theme = ref('system')
|
||||
let mediaQuery = null
|
||||
let mediaHandler = null
|
||||
|
||||
// Getters
|
||||
const resolvedTheme = computed(() => {
|
||||
if (theme.value === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
return theme.value
|
||||
})
|
||||
|
||||
const isDark = computed(() => resolvedTheme.value === 'dark')
|
||||
|
||||
const themeLabel = computed(() => {
|
||||
const labels = {
|
||||
light: '浅色',
|
||||
dark: '深色',
|
||||
system: '跟随系统'
|
||||
}
|
||||
return labels[theme.value] || '跟随系统'
|
||||
})
|
||||
|
||||
// Actions
|
||||
function applyTheme(dark) {
|
||||
const html = document.documentElement
|
||||
if (dark) {
|
||||
html.classList.add('dark')
|
||||
} else {
|
||||
html.classList.remove('dark')
|
||||
}
|
||||
|
||||
// 通知 Electron 主进程更新标题栏颜色
|
||||
if (window.electronAPI?.setTheme) {
|
||||
window.electronAPI.setTheme(dark ? 'dark' : 'light')
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const order = ['light', 'dark', 'system']
|
||||
const idx = order.indexOf(theme.value)
|
||||
theme.value = order[(idx + 1) % order.length]
|
||||
}
|
||||
|
||||
function setTheme(t) {
|
||||
theme.value = t
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
// 从 localStorage 恢复主题
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved && ['light', 'dark', 'system'].includes(saved)) {
|
||||
theme.value = saved
|
||||
}
|
||||
|
||||
// 应用当前主题
|
||||
applyTheme(isDark.value)
|
||||
|
||||
// 监听系统主题变化
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaHandler = (e) => {
|
||||
if (theme.value === 'system') {
|
||||
applyTheme(e.matches)
|
||||
}
|
||||
}
|
||||
mediaQuery.addEventListener('change', mediaHandler)
|
||||
|
||||
// 监听 theme 变化并持久化 + 应用
|
||||
watch(theme, (newTheme) => {
|
||||
localStorage.setItem(STORAGE_KEY, newTheme)
|
||||
applyTheme(isDark.value)
|
||||
})
|
||||
|
||||
// 监听 resolvedTheme 变化(system 模式下系统偏好改变时触发)
|
||||
watch(resolvedTheme, (dark) => {
|
||||
applyTheme(dark === 'dark')
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (mediaQuery && mediaHandler) {
|
||||
mediaQuery.removeEventListener('change', mediaHandler)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
theme,
|
||||
|
||||
// Getters
|
||||
resolvedTheme,
|
||||
isDark,
|
||||
themeLabel,
|
||||
|
||||
// Actions
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
initialize,
|
||||
cleanup
|
||||
}
|
||||
})
|
||||
83
src/renderer/src/stores/workspace.js
Normal file
83
src/renderer/src/stores/workspace.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
// State
|
||||
const isReady = ref(false)
|
||||
const workspacePath = ref(null)
|
||||
const loading = ref(false)
|
||||
let listenerRegistered = false
|
||||
|
||||
// Getters
|
||||
const hasWorkspace = computed(() => isReady.value && workspacePath.value !== null)
|
||||
|
||||
// Actions
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const status = await window.electronAPI.workspace.getStatus()
|
||||
isReady.value = status.ready
|
||||
workspacePath.value = status.path
|
||||
} catch (err) {
|
||||
console.error('Failed to check workspace status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function selectFolder() {
|
||||
try {
|
||||
return await window.electronAPI.workspace.selectFolder()
|
||||
} catch (err) {
|
||||
console.error('Failed to select folder:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function initWorkspace(path) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await window.electronAPI.workspace.setPath(path)
|
||||
if (result && result.success) {
|
||||
isReady.value = true
|
||||
workspacePath.value = result.path
|
||||
return true
|
||||
}
|
||||
console.error('Failed to init workspace:', result?.error || 'unknown error')
|
||||
return false
|
||||
} catch (err) {
|
||||
console.error('Failed to init workspace:', err)
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function listenStatusChanges() {
|
||||
if (listenerRegistered) return
|
||||
listenerRegistered = true
|
||||
|
||||
window.electronAPI.workspace.onStatusChange((status) => {
|
||||
isReady.value = status.ready
|
||||
workspacePath.value = status.path
|
||||
})
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
listenStatusChanges()
|
||||
checkStatus()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
isReady,
|
||||
workspacePath,
|
||||
loading,
|
||||
|
||||
// Getters
|
||||
hasWorkspace,
|
||||
|
||||
// Actions
|
||||
checkStatus,
|
||||
selectFolder,
|
||||
initWorkspace,
|
||||
initialize
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user