From acd311a0d6f4107c644c0e50e5d78d72ffabb41b Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Sun, 31 May 2026 21:06:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E8=BF=AD=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 15 + .prettierignore | 4 + .prettierrc | 13 + CLAUDE.md | 40 ++ README.md | 363 +++++++++++++++ package-lock.json | 17 + package.json | 4 +- src/main/services/FileStorage.js | 198 ++++++++ src/main/services/MetaStore.js | 354 +++++++++++++++ src/main/services/StorageService.js | 425 ++++++++++++++++++ src/main/services/utils.js | 10 + .../src/composables/useNotebookTree.js | 154 +++++++ .../src/composables/useNotebookTreeExample.js | 245 ++++++++++ src/renderer/src/composables/useNotebooks.js | 72 +++ src/renderer/src/composables/useNotes.js | 137 ++++++ .../src/composables/useSidebarTree.js | 258 +++++++++++ src/renderer/src/composables/useTags.js | 75 ++++ src/renderer/src/composables/useWorkspace.js | 91 ++++ src/renderer/src/stores/README.md | 174 +++++++ src/renderer/src/stores/index.js | 6 + src/renderer/src/stores/notebooks.js | 87 ++++ src/renderer/src/stores/notes.js | 173 +++++++ src/renderer/src/stores/tags.js | 94 ++++ src/renderer/src/stores/theme.js | 108 +++++ src/renderer/src/stores/workspace.js | 83 ++++ 25 files changed, 3199 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 src/main/services/FileStorage.js create mode 100644 src/main/services/MetaStore.js create mode 100644 src/main/services/StorageService.js create mode 100644 src/main/services/utils.js create mode 100644 src/renderer/src/composables/useNotebookTree.js create mode 100644 src/renderer/src/composables/useNotebookTreeExample.js create mode 100644 src/renderer/src/composables/useNotebooks.js create mode 100644 src/renderer/src/composables/useNotes.js create mode 100644 src/renderer/src/composables/useSidebarTree.js create mode 100644 src/renderer/src/composables/useTags.js create mode 100644 src/renderer/src/composables/useWorkspace.js create mode 100644 src/renderer/src/stores/README.md create mode 100644 src/renderer/src/stores/index.js create mode 100644 src/renderer/src/stores/notebooks.js create mode 100644 src/renderer/src/stores/notes.js create mode 100644 src/renderer/src/stores/tags.js create mode 100644 src/renderer/src/stores/theme.js create mode 100644 src/renderer/src/stores/workspace.js diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c3d1a7b --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..61cffc3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +out +node_modules +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..baa6e5e --- /dev/null +++ b/.prettierrc @@ -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 +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..be0e5d3 --- /dev/null +++ b/CLAUDE.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c67d70c --- /dev/null +++ b/README.md @@ -0,0 +1,363 @@ +# Tunji - Markdown 编辑器 + +一个受 Typora 启发的 Markdown 编辑器桌面应用,使用 Electron + Vue 3 构建。 + +## 功能特性 + +### 核心功能 +- **Markdown 编辑**:支持实时预览、分屏编辑、源码模式 +- **笔记本管理**:支持多级笔记本结构 +- **标签系统**:灵活的标签管理 +- **主题切换**:浅色、深色、跟随系统 +- **工作区管理**:支持自定义工作区路径 + +### 最新功能:笔记本树形结构 + +#### 功能概述 +使用 PrimeVue Tree 组件实现笔记本的树形结构显示,支持: + +1. **树形结构展示**:笔记本和文档以树形结构展示 +2. **层级关系**:支持父子笔记本关系 +3. **展开/折叠**:可以展开或折叠笔记本节点 +4. **节点选择**:点击笔记本或文档节点可进行选择 +5. **自定义图标**:笔记本使用书本图标,文档使用文件图标 + +#### 使用方式 + +##### 基本使用 +```vue + + + +``` + +##### 高级功能 +```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] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6cb86b3..d2e330f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 95eaa15..aa59034 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/main/services/FileStorage.js b/src/main/services/FileStorage.js new file mode 100644 index 0000000..3dd99ed --- /dev/null +++ b/src/main/services/FileStorage.js @@ -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 + } +} diff --git a/src/main/services/MetaStore.js b/src/main/services/MetaStore.js new file mode 100644 index 0000000..951cde3 --- /dev/null +++ b/src/main/services/MetaStore.js @@ -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 + } + } +} diff --git a/src/main/services/StorageService.js b/src/main/services/StorageService.js new file mode 100644 index 0000000..34d2613 --- /dev/null +++ b/src/main/services/StorageService.js @@ -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$/, '') + } +} diff --git a/src/main/services/utils.js b/src/main/services/utils.js new file mode 100644 index 0000000..a66f133 --- /dev/null +++ b/src/main/services/utils.js @@ -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) + } +} diff --git a/src/renderer/src/composables/useNotebookTree.js b/src/renderer/src/composables/useNotebookTree.js new file mode 100644 index 0000000..ab54e32 --- /dev/null +++ b/src/renderer/src/composables/useNotebookTree.js @@ -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 + } +} \ No newline at end of file diff --git a/src/renderer/src/composables/useNotebookTreeExample.js b/src/renderer/src/composables/useNotebookTreeExample.js new file mode 100644 index 0000000..1b99afd --- /dev/null +++ b/src/renderer/src/composables/useNotebookTreeExample.js @@ -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: + * + * + * + * + */ \ No newline at end of file diff --git a/src/renderer/src/composables/useNotebooks.js b/src/renderer/src/composables/useNotebooks.js new file mode 100644 index 0000000..b4cbf20 --- /dev/null +++ b/src/renderer/src/composables/useNotebooks.js @@ -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 + } +} diff --git a/src/renderer/src/composables/useNotes.js b/src/renderer/src/composables/useNotes.js new file mode 100644 index 0000000..74bb730 --- /dev/null +++ b/src/renderer/src/composables/useNotes.js @@ -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 + } +} diff --git a/src/renderer/src/composables/useSidebarTree.js b/src/renderer/src/composables/useSidebarTree.js new file mode 100644 index 0000000..4d4e1a7 --- /dev/null +++ b/src/renderer/src/composables/useSidebarTree.js @@ -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 + } +} diff --git a/src/renderer/src/composables/useTags.js b/src/renderer/src/composables/useTags.js new file mode 100644 index 0000000..bcaf4be --- /dev/null +++ b/src/renderer/src/composables/useTags.js @@ -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 + } +} diff --git a/src/renderer/src/composables/useWorkspace.js b/src/renderer/src/composables/useWorkspace.js new file mode 100644 index 0000000..589fb32 --- /dev/null +++ b/src/renderer/src/composables/useWorkspace.js @@ -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 + } +} diff --git a/src/renderer/src/stores/README.md b/src/renderer/src/stores/README.md new file mode 100644 index 0000000..807a92e --- /dev/null +++ b/src/renderer/src/stores/README.md @@ -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 + +``` + +## 最佳实践 + +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()` 替代解构的方法 \ No newline at end of file diff --git a/src/renderer/src/stores/index.js b/src/renderer/src/stores/index.js new file mode 100644 index 0000000..7c3754c --- /dev/null +++ b/src/renderer/src/stores/index.js @@ -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' \ No newline at end of file diff --git a/src/renderer/src/stores/notebooks.js b/src/renderer/src/stores/notebooks.js new file mode 100644 index 0000000..7ec9edc --- /dev/null +++ b/src/renderer/src/stores/notebooks.js @@ -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 + } +}) \ No newline at end of file diff --git a/src/renderer/src/stores/notes.js b/src/renderer/src/stores/notes.js new file mode 100644 index 0000000..554da94 --- /dev/null +++ b/src/renderer/src/stores/notes.js @@ -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 + } +}) \ No newline at end of file diff --git a/src/renderer/src/stores/tags.js b/src/renderer/src/stores/tags.js new file mode 100644 index 0000000..e0ba966 --- /dev/null +++ b/src/renderer/src/stores/tags.js @@ -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 + } +}) \ No newline at end of file diff --git a/src/renderer/src/stores/theme.js b/src/renderer/src/stores/theme.js new file mode 100644 index 0000000..61e5257 --- /dev/null +++ b/src/renderer/src/stores/theme.js @@ -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 + } +}) \ No newline at end of file diff --git a/src/renderer/src/stores/workspace.js b/src/renderer/src/stores/workspace.js new file mode 100644 index 0000000..f2751cb --- /dev/null +++ b/src/renderer/src/stores/workspace.js @@ -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 + } +}) \ No newline at end of file