From 9c534a920ddfd65b032575e5a1c4b72969aecabe Mon Sep 17 00:00:00 2001 From: MikiVL Date: Sun, 3 May 2026 01:16:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(M5+):=20=E5=AF=BC=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=8A=9F=E8=83=BD=E3=80=81=E6=80=A7=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E3=80=81=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出:Markdown / Word(.docx) / PDF(打印) / 纯文本,编辑器标题栏下拉菜单 导入:.md / .txt / .docx(mammoth) / .pdf(pdfjs-dist),侧边栏底部按钮 Store:createNote 支持 init 参数,filteredNotes 增加缓存层 测试:vitest 23 个单元测试(utils + filterNotes 逻辑) 构建:vite manualChunks 分包优化 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + README.md | 168 +++-- package-lock.json | 963 ++++++++++++++++++++++++++- package.json | 10 +- src/components/editor/Editor.tsx | 4 + src/components/editor/ExportMenu.tsx | 122 ++++ src/components/sidebar/Sidebar.tsx | 56 +- src/index.css | 25 + src/lib/export.ts | 236 +++++++ src/lib/import.ts | 366 ++++++++++ src/stores/appStore.ts | 26 +- src/test/store.test.ts | 159 +++++ src/test/utils.test.ts | 84 +++ vite.config.ts | 17 + vitest.config.ts | 7 + 15 files changed, 2174 insertions(+), 74 deletions(-) create mode 100644 src/components/editor/ExportMenu.tsx create mode 100644 src/lib/export.ts create mode 100644 src/lib/import.ts create mode 100644 src/test/store.test.ts create mode 100644 src/test/utils.test.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index a547bf3..8b6ee54 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,8 @@ dist-ssr *.njsproj *.sln *.sw? + +# Secrets & runtime config +.env +models.json + diff --git a/README.md b/README.md index 7dbf7eb..0c589c8 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,121 @@ -# React + TypeScript + Vite +# StudyNote -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +基于 Vite + React + TypeScript 构建的现代化 AI 笔记应用,提供富文本编辑、灵活的文件夹组织,以及由 Claude API 驱动的 AI 写作辅助能力。 -Currently, two official plugins are available: +## 技术栈 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +| 层次 | 选型 | +|------|------| +| 构建工具 | Vite 5 | +| 前端框架 | React 19 + TypeScript | +| 编辑器 | TipTap v3 | +| 样式 | Tailwind CSS v4 | +| 状态管理 | Zustand v5 | +| 本地存储 | IndexedDB(Dexie.js) | +| AI 接入 | Anthropic SDK(claude-sonnet-4-6) | +| 后端代理 | Hono(Node.js,端口 3001) | +| 动画 | Framer Motion | +| 拖拽 | @dnd-kit | -## React Compiler +## 快速开始 -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +```bash +# 1. 克隆仓库 +git clone https://git.muchen.fan/MikiVL/studynote.git +cd studynote -## Expanding the ESLint configuration +# 2. 安装依赖 +npm install -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +# 3. 配置 API Key +cp .env.example .env +# 编辑 .env,填入你的 ANTHROPIC_API_KEY 和 ANTHROPIC_BASE_URL -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +# 4. 启动开发服务器(同时启动前端 + AI 代理) +npm run dev ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +浏览器访问 `http://localhost:5173` -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +## 功能一览 + +### 富文本编辑 +- 标题(H1–H4)、段落、引用块、代码块(含语法高亮) +- 粗体、斜体、下划线、删除线、行内代码、高亮 +- 有序/无序列表、任务列表(Checkbox) +- 表格、图片插入 +- `/` 斜杠命令菜单快速插入内容块 +- 浮动工具栏(选中文字后出现) +- 自动保存(防抖 1s) + +### 笔记组织 +- 多级文件夹嵌套 +- 笔记标签(多标签、按标签过滤) +- 收藏标记 +- 全文搜索(标题 + 正文 + 标签) +- 排序:按修改时间、创建时间、标题 +- 拖拽移动笔记到文件夹 +- 笔记/文件夹重命名、删除 + +### AI 功能 +- **AI 续写**:点击 ⚡ 按钮,基于当前内容流式续写 +- **AI 润色**:选中文字 → 浮动工具栏 → 润色,预览后替换 +- **AI 摘要**:选中段落或全文生成摘要 +- **翻译成英文**:选中文字 → 浮动工具栏 → 翻译 +- **AI 问答**:可拖拽/缩放浮窗,针对当前笔记自由提问 +- **模型管理**:支持添加多个 OpenAI 兼容模型,一键切换 + +### 界面体验 +- 亮色 / 暗色主题切换 +- 专注模式(隐藏侧边栏,全屏写作) +- 弹窗缩放动画(Framer Motion) + +## 键盘快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Cmd/Ctrl + N` | 新建笔记 | +| `Cmd/Ctrl + \` | 切换专注模式 | +| `Cmd/Ctrl + Shift + J` | 打开 AI 助手 | +| `Cmd/Ctrl + B` | 粗体 | +| `Cmd/Ctrl + I` | 斜体 | +| `Cmd/Ctrl + Z` | 撤销 | +| `Cmd/Ctrl + Shift + Z` | 重做 | +| `/` | 命令菜单 | +| `Esc` | 退出专注模式 | + +## 项目结构 -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +``` +studynote/ +├── src/ +│ ├── components/ +│ │ ├── editor/ # TipTap 编辑器、浮动工具栏、欢迎页 +│ │ ├── sidebar/ # 左侧导航栏 +│ │ └── ai/ # AI 面板、模型管理弹窗 +│ ├── stores/ # Zustand store +│ ├── db/ # Dexie.js IndexedDB 封装 +│ ├── lib/ # 工具函数、AI 流式请求 +│ └── test/ # Vitest 单元测试 +├── server/ # Hono AI 代理服务 +├── .env.example # 环境变量模板 +└── vite.config.ts +``` + +## 环境变量 + +复制 `.env.example` 为 `.env` 并填写: + +``` +ANTHROPIC_API_KEY=sk-ant-... +ANTHROPIC_BASE_URL=https://api.anthropic.com +``` + +> `.env` 和运行时生成的 `models.json` 已加入 `.gitignore`,不会被提交。 + +## 构建 + +```bash +npm run build # 生产构建,输出到 dist/ +npm run preview # 本地预览生产构建 ``` diff --git a/package-lock.json b/package-lock.json index aa26d69..955d0c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,11 +44,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dexie": "^4.4.2", + "docx": "^9.6.1", "dotenv": "^17.4.2", "framer-motion": "^12.38.0", "hono": "^4.12.16", "lowlight": "^3.3.0", "lucide-react": "^1.14.0", + "mammoth": "^1.12.0", + "pdfjs-dist": "^5.7.284", "react": "^19.2.5", "react-dom": "^19.2.5", "tailwind-merge": "^3.5.0", @@ -72,7 +75,8 @@ "tsx": "^4.21.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" } }, "node_modules/@anthropic-ai/sdk": { @@ -1153,6 +1157,271 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2318,6 +2587,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", @@ -3255,6 +3531,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3594,6 +3888,128 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -3660,6 +4076,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3672,6 +4097,16 @@ "node": ">=10" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", @@ -3719,6 +4154,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", @@ -3732,6 +4187,12 @@ "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -3800,6 +4261,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3918,6 +4389,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4008,6 +4485,62 @@ "integrity": "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==", "license": "Apache-2.0" }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/docx": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", + "integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^25.2.3", + "hash.js": "^1.1.7", + "jszip": "^3.10.1", + "nanoid": "^5.1.3", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/docx/node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -4020,6 +4553,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.349", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", @@ -4048,6 +4590,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -4285,6 +4834,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4295,6 +4854,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4535,6 +5104,16 @@ "node": ">=8" } }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4580,6 +5159,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4590,6 +5175,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4623,6 +5214,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4707,6 +5304,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4731,6 +5340,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5026,6 +5644,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, "node_modules/lowlight": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", @@ -5070,6 +5699,36 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -5141,6 +5800,23 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5197,6 +5873,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5207,6 +5889,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5217,6 +5908,25 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5283,6 +5993,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prosemirror-changeset": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", @@ -5512,6 +6228,21 @@ } } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5589,6 +6320,21 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5605,6 +6351,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5641,6 +6393,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5651,6 +6410,35 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5726,6 +6514,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -5743,6 +6548,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5849,6 +6664,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -5949,6 +6770,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -6027,6 +6854,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -6049,6 +6966,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -6077,6 +7011,33 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 0d767dd..08b5be5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "dev:server": "tsx --watch server/index.ts", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -48,11 +50,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dexie": "^4.4.2", + "docx": "^9.6.1", "dotenv": "^17.4.2", "framer-motion": "^12.38.0", "hono": "^4.12.16", "lowlight": "^3.3.0", "lucide-react": "^1.14.0", + "mammoth": "^1.12.0", + "pdfjs-dist": "^5.7.284", "react": "^19.2.5", "react-dom": "^19.2.5", "tailwind-merge": "^3.5.0", @@ -76,6 +81,7 @@ "tsx": "^4.21.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.2", - "vite": "^8.0.10" + "vite": "^8.0.10", + "vitest": "^4.1.5" } } diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 7ef4303..51a8ca9 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -30,6 +30,7 @@ import { useAppStore } from '../../stores/appStore' import { countWords } from '../../lib/utils' import { streamAI } from '../../lib/ai' import { WelcomeView } from './WelcomeView' +import { ExportMenu } from './ExportMenu' const lowlight = createLowlight(common) @@ -385,6 +386,9 @@ export function Editor() { style={{ color: activeNote.starred ? '#f59e0b' : 'var(--text-faint)' }} /> +
+ +
+ + {open && ( +
+ {[ + { + label: 'Markdown', + icon: , + onClick: () => { exportMarkdown(title, content); setOpen(false) }, + }, + { + label: 'Word (.docx)', + icon: , + onClick: handleDocx, + }, + { + label: 'PDF (打印)', + icon: , + onClick: () => { exportPDF(); setOpen(false) }, + }, + { + label: '纯文本', + icon: , + onClick: () => { exportTxt(title, content); setOpen(false) }, + }, + ].map(item => ( + + ))} +
+ )} + + ) +} diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 1d29b97..2f62073 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -4,9 +4,10 @@ import { Search, Plus, Star, FileText, Folder, FolderOpen, ChevronRight, ChevronDown, Trash2, Edit2, Moon, Sun, FolderPlus, Hash, BookOpen, - ArrowUpDown, Check, X, Bot, + ArrowUpDown, Check, X, Bot, Upload, } from 'lucide-react' import { ModelSettingsModal } from '../ai/ModelSettingsModal' +import { importMarkdown, importTxt, importDocx, importPDF } from '../../lib/import' import { DndContext, DragOverlay, useDraggable, useDroppable, useSensor, useSensors, MouseSensor, TouchSensor, @@ -35,6 +36,7 @@ export function Sidebar() { const [noteEditValue, setNoteEditValue] = useState('') const [sortMenuOpen, setSortMenuOpen] = useState(false) const [modelModalOpen, setModelModalOpen] = useState(false) + const [localSearch, setLocalSearch] = useState(searchQuery) const [contextMenu, setContextMenu] = useState<{ type: 'note' | 'folder' id: string @@ -53,6 +55,11 @@ export function Sidebar() { const newFolderRef = useRef(null) const displayed = filteredNotes() + useEffect(() => { + const t = setTimeout(() => setSearch(localSearch), 300) + return () => clearTimeout(t) + }, [localSearch, setSearch]) + useEffect(() => { if (editingFolderId && editInputRef.current) editInputRef.current.focus() }, [editingFolderId]) @@ -104,6 +111,31 @@ export function Sidebar() { await createNote(folderId) } + const importFileRef = useRef(null) + + const handleImportFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + const folderId = typeof activeFolderId === 'string' && activeFolderId !== 'all' && activeFolderId !== 'starred' + ? activeFolderId + : null + const ext = file.name.split('.').pop()?.toLowerCase() ?? '' + let result: { title: string; content: string } + if (ext === 'md' || ext === 'markdown') { + result = importMarkdown(await file.text()) + } else if (ext === 'txt') { + result = importTxt(await file.text()) + } else if (ext === 'docx') { + result = await importDocx(await file.arrayBuffer()) + } else if (ext === 'pdf') { + result = await importPDF(await file.arrayBuffer()) + } else { + return + } + await createNote(folderId, result) + } + const handleDragStart = (event: DragStartEvent) => { setDraggingNoteId(event.active.id as string) } @@ -182,8 +214,8 @@ export function Sidebar() { > setSearch(e.target.value)} + value={localSearch} + onChange={e => setLocalSearch(e.target.value)} placeholder="搜索笔记…" className="bg-transparent outline-none text-sm w-full" style={{ color: 'var(--text)' }} @@ -356,6 +388,24 @@ export function Sidebar() { AI 模型 + + diff --git a/src/index.css b/src/index.css index d6b5159..36ead80 100644 --- a/src/index.css +++ b/src/index.css @@ -285,3 +285,28 @@ border-radius: 1px; animation: ai-blink 0.8s ease infinite; } + +/* ── Print / PDF export ── */ +@media print { + aside, [data-ai-panel], .floating-toolbar, .editor-toolbar, + .toolbar-btn, [class*="sidebar"] { + display: none !important; + } + .ProseMirror { + padding: 0 !important; + min-height: unset !important; + } + body { + overflow: visible !important; + background: white !important; + color: black !important; + } + #root { + display: block !important; + height: auto !important; + } + .flex-1.flex.flex-col.min-w-0.h-full { + display: block !important; + height: auto !important; + } +} diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..51bfd22 --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,236 @@ +import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, UnderlineType } from 'docx' +import { extractTextFromJSON } from './utils' + +// ── Shared ──────────────────────────────────────────────────────────────────── + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + setTimeout(() => URL.revokeObjectURL(url), 10000) +} + +// ── TipTap JSON types ───────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TipTapNode = any + +// ── Markdown export ─────────────────────────────────────────────────────────── + +function inlineToMd(node: TipTapNode): string { + if (node.type === 'hardBreak') return ' \n' + if (node.type === 'image') return `![${node.attrs?.alt ?? ''}](${node.attrs?.src ?? ''})` + if (node.type !== 'text') return '' + + let text: string = node.text ?? '' + const marks: string[] = (node.marks ?? []).map((m: TipTapNode) => m.type) + const linkMark = (node.marks ?? []).find((m: TipTapNode) => m.type === 'link') + + if (marks.includes('code')) return `\`${text}\`` + if (linkMark) text = `[${text}](${linkMark.attrs?.href ?? ''})` + if (marks.includes('bold')) text = `**${text}**` + if (marks.includes('italic')) text = `*${text}*` + if (marks.includes('strike')) text = `~~${text}~~` + if (marks.includes('underline')) text = `${text}` + if (marks.includes('highlight')) text = `==${text}==` + return text +} + +function inlinesToMd(nodes: TipTapNode[]): string { + return (nodes ?? []).map(inlineToMd).join('') +} + +function nodeToMd(node: TipTapNode, listDepth = 0, listIndex = { n: 1 }): string { + const indent = ' '.repeat(listDepth) + switch (node.type) { + case 'heading': { + const level = node.attrs?.level ?? 1 + return `${'#'.repeat(level)} ${inlinesToMd(node.content)}\n\n` + } + case 'paragraph': { + const text = inlinesToMd(node.content ?? []) + return text ? `${text}\n\n` : '\n' + } + case 'blockquote': + return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n).replace(/^/gm, '> ')).join('') + '\n' + case 'codeBlock': { + const lang = node.attrs?.language ?? '' + const code = (node.content ?? []).map((n: TipTapNode) => n.text ?? '').join('') + return `\`\`\`${lang}\n${code}\n\`\`\`\n\n` + } + case 'bulletList': + return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n, listDepth, listIndex)).join('') + '\n' + case 'orderedList': { + const idx = { n: 1 } + return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n, listDepth, idx)).join('') + '\n' + } + case 'listItem': { + const children = node.content ?? [] + const first = children[0] + const firstText = first ? inlinesToMd(first.content ?? []) : '' + const rest = children.slice(1).map((n: TipTapNode) => nodeToMd(n, listDepth + 1, { n: 1 })).join('') + return `${indent}- ${firstText}\n${rest}` + } + case 'taskList': + return (node.content ?? []).map((n: TipTapNode) => nodeToMd(n, listDepth, listIndex)).join('') + '\n' + case 'taskItem': { + const checked = node.attrs?.checked ? 'x' : ' ' + const children = node.content ?? [] + const first = children[0] + const firstText = first ? inlinesToMd(first.content ?? []) : '' + const rest = children.slice(1).map((n: TipTapNode) => nodeToMd(n, listDepth + 1, { n: 1 })).join('') + return `${indent}- [${checked}] ${firstText}\n${rest}` + } + case 'table': { + const rows: TipTapNode[] = node.content ?? [] + if (!rows.length) return '' + const cells = (row: TipTapNode) => + (row.content ?? []).map((cell: TipTapNode) => inlinesToMd((cell.content?.[0]?.content) ?? [])).join(' | ') + const header = `| ${cells(rows[0])} |` + const sep = `| ${(rows[0].content ?? []).map(() => '---').join(' | ')} |` + const body = rows.slice(1).map((r: TipTapNode) => `| ${cells(r)} |`).join('\n') + return `${header}\n${sep}\n${body}\n\n` + } + case 'horizontalRule': + return '---\n\n' + case 'image': + return `![${node.attrs?.alt ?? ''}](${node.attrs?.src ?? ''})\n\n` + default: + return '' + } +} + +export function exportMarkdown(title: string, content: string): void { + let md = `# ${title}\n\n` + try { + const doc = JSON.parse(content) + md += (doc.content ?? []).map((n: TipTapNode) => nodeToMd(n)).join('') + } catch { + md += content + } + downloadBlob(new Blob([md], { type: 'text/markdown;charset=utf-8' }), `${title}.md`) +} + +// ── Plain text export ───────────────────────────────────────────────────────── + +export function exportTxt(title: string, content: string): void { + const text = `${title}\n\n${extractTextFromJSON(content)}` + downloadBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), `${title}.txt`) +} + +// ── PDF export ──────────────────────────────────────────────────────────────── + +export function exportPDF(): void { + window.print() +} + +// ── DOCX export ─────────────────────────────────────────────────────────────── + +function inlinesToDocxRuns(nodes: TipTapNode[]): TextRun[] { + return (nodes ?? []).flatMap((node: TipTapNode): TextRun[] => { + if (node.type === 'hardBreak') return [new TextRun({ break: 1 })] + if (node.type !== 'text') return [] + const marks: string[] = (node.marks ?? []).map((m: TipTapNode) => m.type) + return [new TextRun({ + text: node.text ?? '', + bold: marks.includes('bold'), + italics: marks.includes('italic'), + strike: marks.includes('strike'), + underline: marks.includes('underline') ? { type: UnderlineType.SINGLE } : undefined, + font: marks.includes('code') ? 'Courier New' : undefined, + })] + }) +} + +function nodesToDocxParagraphs(nodes: TipTapNode[]): Paragraph[] { + return (nodes ?? []).flatMap((node: TipTapNode): Paragraph[] => { + switch (node.type) { + case 'heading': + return [new Paragraph({ + heading: ([ + HeadingLevel.HEADING_1, + HeadingLevel.HEADING_2, + HeadingLevel.HEADING_3, + HeadingLevel.HEADING_4, + ])[(node.attrs?.level ?? 1) - 1] ?? HeadingLevel.HEADING_1, + children: inlinesToDocxRuns(node.content ?? []), + })] + case 'paragraph': + return [new Paragraph({ children: inlinesToDocxRuns(node.content ?? []) })] + case 'blockquote': + return nodesToDocxParagraphs(node.content ?? []).map(p => { + // indent blockquote + return new Paragraph({ + indent: { left: 720 }, + children: (p as Paragraph & { options: { children: TextRun[] } }).options?.children ?? [], + }) + }) + case 'codeBlock': { + const code = (node.content ?? []).map((n: TipTapNode) => n.text ?? '').join('') + return code.split('\n').map(line => new Paragraph({ + children: [new TextRun({ text: line, font: 'Courier New' })], + })) + } + case 'bulletList': + return (node.content ?? []).flatMap((item: TipTapNode) => { + const first = item.content?.[0] + return [new Paragraph({ + bullet: { level: 0 }, + children: inlinesToDocxRuns(first?.content ?? []), + })] + }) + case 'orderedList': + return (node.content ?? []).flatMap((item: TipTapNode, i: number) => { + const first = item.content?.[0] + return [new Paragraph({ + numbering: { reference: 'default-numbering', level: 0 }, + children: [new TextRun(`${i + 1}. `), ...inlinesToDocxRuns(first?.content ?? [])], + })] + }) + case 'taskList': + return (node.content ?? []).flatMap((item: TipTapNode) => { + const checked = item.attrs?.checked ? '☑' : '☐' + const first = item.content?.[0] + return [new Paragraph({ + children: [new TextRun(`${checked} `), ...inlinesToDocxRuns(first?.content ?? [])], + })] + }) + case 'horizontalRule': + return [new Paragraph({ + border: { bottom: { color: 'auto', space: 1, style: 'single', size: 6 } }, + children: [], + alignment: AlignmentType.CENTER, + })] + case 'image': + // Images require async fetch; skip in sync path + return [new Paragraph({ children: [new TextRun(`[图片: ${node.attrs?.src ?? ''}]`)] })] + default: + return [] + } + }) +} + +export async function exportDocx(title: string, content: string): Promise { + let bodyParagraphs: Paragraph[] = [] + try { + const doc = JSON.parse(content) + bodyParagraphs = nodesToDocxParagraphs(doc.content ?? []) + } catch { + bodyParagraphs = [new Paragraph({ children: [new TextRun(content)] })] + } + + const docx = new Document({ + sections: [{ + properties: {}, + children: [ + new Paragraph({ heading: HeadingLevel.TITLE, children: [new TextRun(title)] }), + ...bodyParagraphs, + ], + }], + }) + + const blob = await Packer.toBlob(docx) + downloadBlob(blob, `${title}.docx`) +} diff --git a/src/lib/import.ts b/src/lib/import.ts new file mode 100644 index 0000000..f356f4e --- /dev/null +++ b/src/lib/import.ts @@ -0,0 +1,366 @@ +import mammoth from 'mammoth' +import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist' + +GlobalWorkerOptions.workerSrc = + 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/5.7.284/pdf.worker.min.mjs' + +// ── Types ───────────────────────────────────────────────────────────────────── + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type TipTapNode = any + +export interface ImportResult { + title: string + content: string // TipTap JSON string +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeDoc(nodes: TipTapNode[]): string { + return JSON.stringify({ type: 'doc', content: nodes.length ? nodes : [{ type: 'paragraph' }] }) +} + +function textNode(text: string, marks: TipTapNode[] = []): TipTapNode { + const n: TipTapNode = { type: 'text', text } + if (marks.length) n.marks = marks + return n +} + +function paragraph(children: TipTapNode[]): TipTapNode { + return { type: 'paragraph', content: children.length ? children : undefined } +} + +// ── Markdown inline parser ──────────────────────────────────────────────────── + +function parseInline(raw: string): TipTapNode[] { + const nodes: TipTapNode[] = [] + // Regex order matters: code first, then links, then bold/italic combos + const re = + /(`[^`]+`)|(\*\*\*(.+?)\*\*\*)|(\*\*(.+?)\*\*)|(\*(.+?)\*)|(__(.+?)__)|(_(.+?)_)|(~~(.+?)~~)|(==(.+?)==)|(\[([^\]]+)\]\(([^)]+)\))|((.+?)<\/u>)/g + let last = 0 + let m: RegExpExecArray | null + while ((m = re.exec(raw)) !== null) { + if (m.index > last) nodes.push(textNode(raw.slice(last, m.index))) + if (m[1]) { + // `code` + nodes.push(textNode(m[1].slice(1, -1), [{ type: 'code' }])) + } else if (m[2]) { + // ***bold+italic*** + nodes.push(textNode(m[3], [{ type: 'bold' }, { type: 'italic' }])) + } else if (m[4]) { + // **bold** + nodes.push(textNode(m[5], [{ type: 'bold' }])) + } else if (m[6]) { + // *italic* + nodes.push(textNode(m[7], [{ type: 'italic' }])) + } else if (m[8]) { + // __bold__ + nodes.push(textNode(m[9], [{ type: 'bold' }])) + } else if (m[10]) { + // _italic_ + nodes.push(textNode(m[11], [{ type: 'italic' }])) + } else if (m[12]) { + // ~~strike~~ + nodes.push(textNode(m[13], [{ type: 'strike' }])) + } else if (m[14]) { + // ==highlight== + nodes.push(textNode(m[15], [{ type: 'highlight' }])) + } else if (m[16]) { + // [text](url) + nodes.push(textNode(m[17], [{ type: 'link', attrs: { href: m[18] } }])) + } else if (m[19]) { + // underline + nodes.push(textNode(m[20], [{ type: 'underline' }])) + } + last = m.index + m[0].length + } + if (last < raw.length) nodes.push(textNode(raw.slice(last))) + return nodes +} + +// ── Markdown block parser ───────────────────────────────────────────────────── + +export function importMarkdown(text: string): ImportResult { + const lines = text.split('\n') + const nodes: TipTapNode[] = [] + let title = '' + let i = 0 + + while (i < lines.length) { + const line = lines[i] + + // Heading + const headingMatch = line.match(/^(#{1,4})\s+(.*)/) + if (headingMatch) { + const level = headingMatch[1].length + const content = parseInline(headingMatch[2]) + if (!title && level === 1) title = headingMatch[2] + nodes.push({ type: 'heading', attrs: { level }, content }) + i++ + continue + } + + // Fenced code block + const fenceMatch = line.match(/^```(\w*)/) + if (fenceMatch) { + const lang = fenceMatch[1] + const codeLines: string[] = [] + i++ + while (i < lines.length && !lines[i].startsWith('```')) { + codeLines.push(lines[i]) + i++ + } + i++ // skip closing ``` + nodes.push({ + type: 'codeBlock', + attrs: { language: lang || null }, + content: [{ type: 'text', text: codeLines.join('\n') }], + }) + continue + } + + // Blockquote + if (line.startsWith('> ')) { + const quoteLines: string[] = [] + while (i < lines.length && lines[i].startsWith('> ')) { + quoteLines.push(lines[i].slice(2)) + i++ + } + nodes.push({ + type: 'blockquote', + content: [paragraph(parseInline(quoteLines.join(' ')))], + }) + continue + } + + // Horizontal rule + if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) { + nodes.push({ type: 'horizontalRule' }) + i++ + continue + } + + // Task list item + const taskMatch = line.match(/^(\s*)- \[([x ])\] (.*)/) + if (taskMatch) { + const items: TipTapNode[] = [] + let j = i + while (j < lines.length) { + const tm = lines[j].match(/^(\s*)- \[([x ])\] (.*)/) + if (!tm) break + items.push({ + type: 'taskItem', + attrs: { checked: tm[2] === 'x' }, + content: [paragraph(parseInline(tm[3]))], + }) + j++ + } + nodes.push({ type: 'taskList', content: items }) + i = j + continue + } + + // Bullet list + const bulletMatch = line.match(/^(\s*)[-*+] (.*)/) + if (bulletMatch) { + const items: TipTapNode[] = [] + let j = i + while (j < lines.length) { + const bm = lines[j].match(/^(\s*)[-*+] (.*)/) + if (!bm) break + items.push({ + type: 'listItem', + content: [paragraph(parseInline(bm[2]))], + }) + j++ + } + nodes.push({ type: 'bulletList', content: items }) + i = j + continue + } + + // Ordered list + const orderedMatch = line.match(/^(\s*)\d+\. (.*)/) + if (orderedMatch) { + const items: TipTapNode[] = [] + let j = i + while (j < lines.length) { + const om = lines[j].match(/^(\s*)\d+\. (.*)/) + if (!om) break + items.push({ + type: 'listItem', + content: [paragraph(parseInline(om[2]))], + }) + j++ + } + nodes.push({ type: 'orderedList', content: items }) + i = j + continue + } + + // Image + const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)/) + if (imgMatch) { + nodes.push({ type: 'image', attrs: { src: imgMatch[2], alt: imgMatch[1] } }) + i++ + continue + } + + // Empty line + if (line.trim() === '') { + i++ + continue + } + + // Paragraph + nodes.push(paragraph(parseInline(line))) + i++ + } + + if (!title) title = '导入的笔记' + return { title, content: makeDoc(nodes) } +} + +// ── Plain text import ───────────────────────────────────────────────────────── + +export function importTxt(text: string): ImportResult { + const lines = text.split('\n') + const title = lines[0]?.trim() || '导入的笔记' + const nodes: TipTapNode[] = lines.map(line => + line.trim() === '' ? { type: 'paragraph' } : paragraph(parseInline(line)), + ) + return { title, content: makeDoc(nodes) } +} + +// ── HTML → TipTap JSON (used by DOCX import) ────────────────────────────────── + +function htmlToTipTap(html: string): TipTapNode[] { + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + return Array.from(doc.body.childNodes).flatMap(n => domNodeToTipTap(n as Element)) +} + +function getInlineMarks(el: Element): TipTapNode[] { + const marks: TipTapNode[] = [] + const tag = el.tagName?.toLowerCase() + if (tag === 'strong' || tag === 'b') marks.push({ type: 'bold' }) + if (tag === 'em' || tag === 'i') marks.push({ type: 'italic' }) + if (tag === 'u') marks.push({ type: 'underline' }) + if (tag === 's' || tag === 'del') marks.push({ type: 'strike' }) + if (tag === 'code') marks.push({ type: 'code' }) + if (tag === 'a') marks.push({ type: 'link', attrs: { href: (el as HTMLAnchorElement).href } }) + return marks +} + +function domInlineToRuns(node: Node, inheritedMarks: TipTapNode[] = []): TipTapNode[] { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent ?? '' + if (!text) return [] + return [textNode(text, inheritedMarks)] + } + if (node.nodeType !== Node.ELEMENT_NODE) return [] + const el = node as Element + const marks = [...inheritedMarks, ...getInlineMarks(el)] + return Array.from(el.childNodes).flatMap(c => domInlineToRuns(c, marks)) +} + +function domNodeToTipTap(el: Element): TipTapNode[] { + if (el.nodeType === Node.TEXT_NODE) { + const text = el.textContent?.trim() ?? '' + return text ? [paragraph([textNode(text)])] : [] + } + if (el.nodeType !== Node.ELEMENT_NODE) return [] + const tag = el.tagName?.toLowerCase() + + const hMatch = tag?.match(/^h([1-4])$/) + if (hMatch) { + return [{ type: 'heading', attrs: { level: parseInt(hMatch[1]) }, content: domInlineToRuns(el) }] + } + if (tag === 'p') { + const runs = domInlineToRuns(el) + return [paragraph(runs)] + } + if (tag === 'ul') { + const items = Array.from(el.querySelectorAll(':scope > li')).map(li => ({ + type: 'listItem', + content: [paragraph(domInlineToRuns(li))], + })) + return items.length ? [{ type: 'bulletList', content: items }] : [] + } + if (tag === 'ol') { + const items = Array.from(el.querySelectorAll(':scope > li')).map(li => ({ + type: 'listItem', + content: [paragraph(domInlineToRuns(li))], + })) + return items.length ? [{ type: 'orderedList', content: items }] : [] + } + if (tag === 'blockquote') { + return [{ type: 'blockquote', content: Array.from(el.childNodes).flatMap(c => domNodeToTipTap(c as Element)) }] + } + if (tag === 'pre' || tag === 'code') { + return [{ type: 'codeBlock', attrs: { language: null }, content: [{ type: 'text', text: el.textContent ?? '' }] }] + } + if (tag === 'hr') return [{ type: 'horizontalRule' }] + if (tag === 'img') { + return [{ type: 'image', attrs: { src: (el as HTMLImageElement).src, alt: (el as HTMLImageElement).alt } }] + } + if (tag === 'br') return [] + // Fallback: recurse into children + return Array.from(el.childNodes).flatMap(c => domNodeToTipTap(c as Element)) +} + +// ── DOCX import ─────────────────────────────────────────────────────────────── + +export async function importDocx(arrayBuffer: ArrayBuffer): Promise { + const result = await mammoth.convertToHtml( + { arrayBuffer }, + { + convertImage: mammoth.images.imgElement(async img => { + const data = await img.read('base64') + return { src: `data:${img.contentType};base64,${data}` } + }), + }, + ) + const nodes = htmlToTipTap(result.value) + const title = nodes.find(n => n.type === 'heading')?.content?.[0]?.text ?? '导入的文档' + return { title, content: makeDoc(nodes) } +} + +// ── PDF import ──────────────────────────────────────────────────────────────── + +export async function importPDF(arrayBuffer: ArrayBuffer): Promise { + const pdf = await getDocument({ data: arrayBuffer }).promise + const nodes: TipTapNode[] = [] + let title = '' + + for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { + const page = await pdf.getPage(pageNum) + const textContent = await page.getTextContent() + + // Group items into lines by y-coordinate (rounded to nearest 2px) + const lineMap = new Map() + for (const item of textContent.items) { + if (!('str' in item)) continue + const y = Math.round((item as { transform: number[] }).transform[5] / 2) * 2 + if (!lineMap.has(y)) lineMap.set(y, []) + lineMap.get(y)!.push((item as { str: string }).str) + } + + // Sort lines top-to-bottom (descending y in PDF coords) + const sortedYs = Array.from(lineMap.keys()).sort((a, b) => b - a) + for (const y of sortedYs) { + const lineText = lineMap.get(y)!.join('').trim() + if (!lineText) continue + if (!title && pageNum === 1) title = lineText + nodes.push(paragraph([textNode(lineText)])) + } + + // Page break between pages (except last) + if (pageNum < pdf.numPages) { + nodes.push({ type: 'horizontalRule' }) + } + } + + if (!title) title = '导入的PDF' + return { title, content: makeDoc(nodes) } +} diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 3aaae72..152a087 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -14,10 +14,12 @@ interface AppState { activeTag: string | null sortBy: 'updatedAt' | 'createdAt' | 'title' sortOrder: 'asc' | 'desc' + _notesVersion: number + _filteredCache: { key: string; result: Note[] } | null // actions loadAll: () => Promise - createNote: (folderId?: string | null) => Promise + createNote: (folderId?: string | null, init?: { title?: string; content?: string }) => Promise updateNote: (id: string, patch: Partial, opts?: { silent?: boolean }) => Promise deleteNote: (id: string) => Promise toggleStar: (id: string) => Promise @@ -51,13 +53,15 @@ export const useAppStore = create((set, get) => ({ activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc', + _notesVersion: 0, + _filteredCache: null, loadAll: async () => { const [notes, folders] = await Promise.all([ db.notes.orderBy('updatedAt').reverse().toArray(), db.folders.orderBy('order').toArray(), ]) - set({ notes, folders }) + set({ notes, folders, _notesVersion: get()._notesVersion + 1, _filteredCache: null }) // Keep welcome screen as default; only auto-select if already on a real note const cur = get().activeNoteId if (cur && cur !== '__welcome__' && !notes.find(n => n.id === cur)) { @@ -65,13 +69,13 @@ export const useAppStore = create((set, get) => ({ } }, - createNote: async (folderId = null) => { + createNote: async (folderId = null, init?: { title?: string; content?: string }) => { const id = generateId() const now = Date.now() const note: Note = { id, - title: '无标题笔记', - content: JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }), + title: init?.title ?? '无标题笔记', + content: init?.content ?? JSON.stringify({ type: 'doc', content: [{ type: 'paragraph' }] }), folderId: folderId ?? get().activeFolderId as string | null, tags: [], starred: false, @@ -80,7 +84,7 @@ export const useAppStore = create((set, get) => ({ wordCount: 0, } await db.notes.add(note) - set(s => ({ notes: [note, ...s.notes], activeNoteId: id })) + set(s => ({ notes: [note, ...s.notes], activeNoteId: id, _notesVersion: s._notesVersion + 1, _filteredCache: null })) return id }, @@ -93,6 +97,8 @@ export const useAppStore = create((set, get) => ({ notes: s.notes.map(n => n.id === id ? { ...n, ...dbPatch } : n ).sort((a, b) => b.updatedAt - a.updatedAt), + _notesVersion: s._notesVersion + 1, + _filteredCache: null, })) }, @@ -101,7 +107,7 @@ export const useAppStore = create((set, get) => ({ set(s => { const notes = s.notes.filter(n => n.id !== id) const activeNoteId = s.activeNoteId === id ? '__welcome__' : s.activeNoteId - return { notes, activeNoteId } + return { notes, activeNoteId, _notesVersion: s._notesVersion + 1, _filteredCache: null } }) }, @@ -152,7 +158,10 @@ export const useAppStore = create((set, get) => ({ setSortOrder: (order) => set({ sortOrder: order }), filteredNotes: () => { - const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder } = get() + const { notes, activeFolderId, searchQuery, activeTag, sortBy, sortOrder, _notesVersion, _filteredCache } = get() + const cacheKey = `${_notesVersion}|${activeFolderId}|${searchQuery}|${activeTag}|${sortBy}|${sortOrder}` + if (_filteredCache?.key === cacheKey) return _filteredCache.result + let result = notes if (activeFolderId === 'starred') { @@ -184,6 +193,7 @@ export const useAppStore = create((set, get) => ({ return sortOrder === 'asc' ? cmp : -cmp }) + set({ _filteredCache: { key: cacheKey, result } }) return result }, })) diff --git a/src/test/store.test.ts b/src/test/store.test.ts new file mode 100644 index 0000000..d56ac08 --- /dev/null +++ b/src/test/store.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest' +import { extractTextFromJSON } from '../lib/utils' + +// Mirror the filteredNotes logic as a pure function for testing +type Note = { + id: string + title: string + content: string + folderId: string | null + tags: string[] + starred: boolean + updatedAt: number + createdAt: number + wordCount: number +} + +function filterNotes( + notes: Note[], + opts: { + activeFolderId: string | null | 'all' | 'starred' + searchQuery: string + activeTag: string | null + sortBy: 'updatedAt' | 'createdAt' | 'title' + sortOrder: 'asc' | 'desc' + }, +): Note[] { + const { activeFolderId, searchQuery, activeTag, sortBy, sortOrder } = opts + let result = notes + + if (activeFolderId === 'starred') { + result = result.filter(n => n.starred) + } else if (activeFolderId !== 'all' && activeFolderId !== null) { + result = result.filter(n => n.folderId === activeFolderId) + } + + if (activeTag) { + result = result.filter(n => n.tags.includes(activeTag)) + } + + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase() + result = result.filter(n => + n.title.toLowerCase().includes(q) || + n.tags.some(t => t.toLowerCase().includes(q)) || + extractTextFromJSON(n.content).toLowerCase().includes(q), + ) + } + + result = [...result].sort((a, b) => { + let cmp = 0 + if (sortBy === 'title') { + cmp = a.title.localeCompare(b.title, 'zh-CN') + } else { + cmp = a[sortBy] - b[sortBy] + } + return sortOrder === 'asc' ? cmp : -cmp + }) + + return result +} + +const makeNote = (overrides: Partial & { id: string }): Note => ({ + title: '无标题', + content: JSON.stringify({ type: 'doc', content: [] }), + folderId: null, + tags: [], + starred: false, + updatedAt: 1000, + createdAt: 1000, + wordCount: 0, + ...overrides, +}) + +const NOTES: Note[] = [ + makeNote({ id: '1', title: '工作计划', folderId: 'folder-a', tags: ['工作'], updatedAt: 3000, createdAt: 1000 }), + makeNote({ id: '2', title: '读书笔记', folderId: 'folder-b', tags: ['学习', '读书'], starred: true, updatedAt: 2000, createdAt: 2000 }), + makeNote({ id: '3', title: 'TypeScript 入门', folderId: null, tags: ['技术', '学习'], updatedAt: 1000, createdAt: 3000 }), + makeNote({ id: '4', title: '收藏的想法', starred: true, updatedAt: 4000, createdAt: 500 }), +] + +describe('filterNotes — folder filter', () => { + it('returns all notes when activeFolderId is "all"', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(4) + }) + + it('filters by specific folderId', () => { + const result = filterNotes(NOTES, { activeFolderId: 'folder-a', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('1') + }) + + it('returns only starred notes', () => { + const result = filterNotes(NOTES, { activeFolderId: 'starred', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result.every(n => n.starred)).toBe(true) + expect(result).toHaveLength(2) + }) +}) + +describe('filterNotes — tag filter', () => { + it('filters by activeTag', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: '学习', sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(2) + expect(result.every(n => n.tags.includes('学习'))).toBe(true) + }) + + it('returns empty array when no notes match tag', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: '不存在', sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(0) + }) +}) + +describe('filterNotes — search', () => { + it('filters by title', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: 'TypeScript', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('3') + }) + + it('filters by tag keyword', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '读书', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('2') + }) + + it('search is case-insensitive', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: 'typescript', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(1) + }) + + it('returns all notes when search is empty', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: ' ', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result).toHaveLength(4) + }) +}) + +describe('filterNotes — sorting', () => { + it('sorts by updatedAt descending', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'desc' }) + expect(result[0].updatedAt).toBeGreaterThanOrEqual(result[1].updatedAt) + }) + + it('sorts by updatedAt ascending', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'updatedAt', sortOrder: 'asc' }) + expect(result[0].updatedAt).toBeLessThanOrEqual(result[1].updatedAt) + }) + + it('sorts by title ascending', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'title', sortOrder: 'asc' }) + for (let i = 1; i < result.length; i++) { + expect(result[i - 1].title.localeCompare(result[i].title, 'zh-CN')).toBeLessThanOrEqual(0) + } + }) + + it('sorts by createdAt descending', () => { + const result = filterNotes(NOTES, { activeFolderId: 'all', searchQuery: '', activeTag: null, sortBy: 'createdAt', sortOrder: 'desc' }) + expect(result[0].createdAt).toBeGreaterThanOrEqual(result[1].createdAt) + }) +}) diff --git a/src/test/utils.test.ts b/src/test/utils.test.ts new file mode 100644 index 0000000..7c5bd03 --- /dev/null +++ b/src/test/utils.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest' +import { extractTextFromJSON, countWords } from '../lib/utils' + +describe('extractTextFromJSON', () => { + it('extracts text from a simple paragraph node', () => { + const json = JSON.stringify({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Hello world' }] }, + ], + }) + expect(extractTextFromJSON(json)).toBe('Hello world') + }) + + it('extracts and joins text from multiple nodes', () => { + const json = JSON.stringify({ + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '第一段' }] }, + { type: 'paragraph', content: [{ type: 'text', text: '第二段' }] }, + ], + }) + const result = extractTextFromJSON(json) + expect(result).toContain('第一段') + expect(result).toContain('第二段') + }) + + it('handles deeply nested nodes', () => { + const json = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: '列表项' }] }, + ], + }, + ], + }, + ], + }) + expect(extractTextFromJSON(json)).toContain('列表项') + }) + + it('returns empty string on invalid JSON', () => { + expect(extractTextFromJSON('not valid json')).toBe('') + expect(extractTextFromJSON('')).toBe('') + expect(extractTextFromJSON('{}')).toBe('') + }) + + it('returns empty string for empty doc', () => { + const json = JSON.stringify({ type: 'doc', content: [] }) + expect(extractTextFromJSON(json)).toBe('') + }) +}) + +describe('countWords', () => { + it('counts CJK characters individually', () => { + expect(countWords('你好世界')).toBe(4) + }) + + it('counts English words correctly', () => { + expect(countWords('hello world')).toBe(2) + expect(countWords(' hello world ')).toBe(2) + }) + + it('handles mixed CJK and English', () => { + // 你好(2) + hello(1) + 世界(2) = 5 + expect(countWords('你好 hello 世界')).toBe(5) + }) + + it('returns 0 for empty string', () => { + expect(countWords('')).toBe(0) + expect(countWords(' ')).toBe(0) + }) + + it('counts single word', () => { + expect(countWords('hello')).toBe(1) + expect(countWords('你')).toBe(1) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 29e1a04..ee7de76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,4 +9,21 @@ export default defineConfig({ '/api': 'http://localhost:3001', }, }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) { + return 'vendor-react' + } + if (id.includes('@tiptap') || id.includes('lowlight') || id.includes('prosemirror')) { + return 'vendor-editor' + } + if (id.includes('framer-motion') || id.includes('@dnd-kit') || id.includes('lucide-react')) { + return 'vendor-ui' + } + }, + }, + }, + }, }) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2b1c323 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + }, +})