feat: 初始化 kenaito-notebook 桌面记事本项目

This commit is contained in:
2026-06-13 21:08:00 +08:00
commit 5fafcd28e3
55 changed files with 9722 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
build/bin
node_modules
frontend/dist
server/config.properties
# 编译产物
kenaito-notebook
+450
View File
@@ -0,0 +1,450 @@
# Kenaito Notebook
一个基于 Go + Wails + Vue 2 + Gin Server 的多层级嵌套 Markdown 笔记应用。
## 📖 项目简介
Kenaito Notebook 是一款轻量级、高性能的 Markdown 笔记应用,支持无限层级嵌套的文件夹结构,帮助你高效组织和管理笔记内容。采用前后端分离架构,Wails 桌面客户端通过 REST API 与独立的 Gin 服务端通信。支持用户注册登录,笔记数据按用户隔离。
## 🏗️ 架构概览
```
┌─────────────────────────────────┐
│ Wails 桌面客户端 │
│ app.go (HTTP 客户端 + JWT) │
│ + Wails 原生功能 (文件对话框等) │
│ Vue 2 + Element UI + mavon │
└──────────┬──────────────────────┘
│ HTTP REST API (Bearer Token)
▼
┌─────────────────────────────────┐
│ Server (Gin) :8080 │
│ /api/auth/* (公开) │
│ /api/notes/* (需登录) │
│ /api/files/* (需登录) │
│ /api/export/* (需登录) │
├─────────────────────────────────┤
│ AuthService (bcrypt + JWT) │
│ Database (SQLite) │
│ FileService │
│ ExportService │
└─────────────────────────────────┘
```
## 🛠️ 技术栈
| 层级 | 技术 | 版本 |
|--------------|-----------------------------|---------|
| 桌面框架 | Wails | v2.12.0 |
| 客户端后端 | Go | 1.25+ |
| 服务端框架 | Gin | v1.10.0 |
| 数据库 | SQLite (modernc.org/sqlite) | v1.53.0 |
| 密码哈希 | bcrypt (golang.org/x/crypto)| v0.x |
| 前端 | Vue | 2.7.x |
| UI 框架 | Element UI | 2.15.x |
| Markdown 编辑 | mavon-editor | 2.10.x |
| Markdown 解析 | goldmark | v1.8.x |
## ✨ 功能特性
### 用户系统
- 🔐 **用户注册/登录** - 用户名 + 密码 + 邮箱注册,JWT Token 认证
- 🔑 **安全存储** - 密码 bcrypt 加密,Token 7 天有效期
- 🚪 **登录/注销** - 优雅的登录界面,一键注销
### 核心功能
- 📁 **多层级嵌套** - 支持无限层级的文件夹和笔记组织
- ✏️ **Markdown 编辑** - 实时预览、语法高亮、工具栏快捷操作
- 🔍 **全文搜索** - 支持标题和内容的模糊搜索
- 🏷️ **标签管理** - 为笔记添加自定义标签分类
- 💾 **自动保存** - 编辑内容 500ms 防抖自动保存
- 🗑️ **回收站** - 软删除 + 恢复 + 永久删除
### 导出功能
- 📤 **HTML 导出** - 生成格式完整的 HTML 页面
- 📝 **Markdown 导出** - 导出原始 Markdown 文件
- 📦 **ZIP 导出** - 文件夹/全部笔记打包导出
### Markdown 增强
- 🎨 **代码高亮** - 支持多种编程语言的语法高亮
- 📊 **表格支持** - Markdown 表格渲染
- 🖼️ **图片上传** - 支持粘贴和上传图片,本地文件服务存储
- 🔗 **链接处理** - 自动识别和处理链接
- 📐 **数学公式** - KaTeX 数学公式支持
## 📦 安装与使用
### 环境要求
- **Go** >= 1.25
- **Node.js** >= 18
- **Wails CLI** v2.x
### 安装 Wails CLI
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
```
### 克隆项目
```bash
git clone <repository-url>
cd kenaito-notebook
```
### 安装依赖
```bash
# 安装客户端前端依赖
cd frontend && npm install && cd ..
# 安装 Server 依赖(会自动下载)
cd server && go mod tidy && cd ..
```
### 启动
> ⚠️ 必须先启动 Server,再启动 Wails 客户端。
```bash
# 1. 启动 Server(默认监听 :8080)
cd server
go run .
# 2. 新开终端,启动 Wails 客户端
cd kenaito-notebook
wails dev
```
### 构建生产版本
```bash
# 构建 Server
cd server && go build -o kenaito-notebook-server . && cd ..
# 构建 Wails 客户端
wails build
```
构建产物:
```
server/kenaito-notebook-server # Server 可执行文件
build/bin/kenaito-notebook.app # macOS 客户端
build/bin/kenaito-notebook.exe # Windows 客户端
build/bin/kenaito-notebook # Linux 客户端
```
## 📁 项目结构
```
kenaito-notebook/
├── app.go # Wails 客户端 (HTTP 客户端 + 原生功能)
├── main.go # Wails 入口
├── go.mod # 客户端模块 (仅依赖 wails/v2)
├── wails.json # Wails 项目配置
│
├── models/ # 共享数据模型
│ └── note.go # Note / NoteTree 定义
│
├── server/ # 独立服务端 (Gin)
│ ├── main.go # Gin 启动入口 (路由注册 + 认证中间件)
│ ├── config.go # 配置管理
│ ├── go.mod # 服务端模块
│ ├── models/ # 数据模型
│ │ ├── note.go # Note / NoteTree
│ │ └── user.go # User 用户模型
│ ├── services/ # 业务服务层
│ │ ├── database.go # SQLite 数据库服务 (笔记 + 用户表)
│ │ ├── fileservice.go # 文件/图片上传服务
│ │ ├── export.go # 导出服务 (HTML/Markdown/ZIP)
│ │ └── auth.go # 认证服务 (bcrypt + JWT)
│ └── handlers/ # REST API 处理器
│ ├── note.go # 笔记 CRUD API
│ ├── file.go # 文件上传 API
│ ├── export.go # 导出 API
│ └── auth.go # 认证 API (注册/登录/中间件)
│
├── frontend/ # 前端项目
│ ├── index.html
│ ├── package.json
│ ├── vite.config.js
│ └── src/
│ ├── main.js
│ ├── App.vue # 主组件 (登录态判断 + 布局)
│ ├── style.css
│ └── components/
│ ├── LoginView.vue # 登录/注册页面
│ ├── NoteTree.vue # 树形导航组件
│ └── TrashView.vue # 回收站视图
│
├── build/ # 构建输出
│ └── bin/
│
└── README.md
```
## 🔧 数据存储
### 数据库位置
SQLite 数据库和资源文件存储在用户主目录下:
```
~/.kenaito-notebook/
├── notes.db # SQLite 数据库
└── assets/ # 图片资源
└── 2026/06/ # 按日期分组
├── a1b2c3d4.png
└── e5f6g7h8.jpg
```
### 数据库表结构
```sql
-- 用户表
CREATE TABLE users
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
hashed_password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_users_username ON users (username);
-- 笔记表
CREATE TABLE notes
(
id INTEGER PRIMARY KEY AUTOINCREMENT,
parent_id INTEGER DEFAULT 0, -- 父级 ID,0 为根目录
title TEXT NOT NULL DEFAULT '',
content TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
is_folder BOOLEAN DEFAULT FALSE,
tag TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL -- 软删除标记
);
CREATE INDEX idx_notes_parent_id ON notes (parent_id);
CREATE INDEX idx_notes_sort_order ON notes (sort_order);
CREATE INDEX idx_notes_deleted_at ON notes (deleted_at);
```
## 📡 API 文档
### Server REST API
Server 默认运行在 `http://localhost:8080`,所有端点前缀为 `/api`。
#### 认证
| 方法 | 路径 | 请求体 | 返回 | 说明 |
|------|---------------------|------------------------------------------|----------------|----------|
| POST | `/api/auth/register`| `{username, password, email}` | `{token}` | 用户注册 |
| POST | `/api/auth/login` | `{username, password}` | `{token}` | 用户登录 |
| GET | `/api/auth/me` | - (需 Bearer Token) | `{id, username, email, created_at}` | 获取当前用户 |
> 🔒 除 `/api/auth/register` 和 `/api/auth/login` 外,所有 API 需在请求头中携带 `Authorization: Bearer <token>`。
#### 笔记管理
| 方法 | 路径 | 请求体 | 返回 | 说明 |
|--------|--------------------------|-----------------------------------------------|---------------|----------|
| GET | `/api/notes/tree` | - | `[]NoteTree` | 获取笔记树 |
| GET | `/api/notes/:id` | - | `Note` | 获取单个笔记 |
| POST | `/api/notes` | `{parentId, title, isFolder}` | `Note` | 创建笔记/文件夹 |
| PUT | `/api/notes/:id` | `{title, content, tag}` | `{message}` | 更新笔记 |
| DELETE | `/api/notes/:id` | - | `{message}` | 软删除(移入回收站)|
| GET | `/api/notes/search?q=xxx`| - | `[]Note` | 搜索笔记 |
#### 回收站
| 方法 | 路径 | 返回 | 说明 |
|--------|-----------------------|-------------|----------|
| GET | `/api/trash` | `[]Note` | 获取回收站 |
| POST | `/api/trash/:id/restore`| `{message}` | 恢复笔记 |
| DELETE | `/api/trash/:id` | `{message}` | 永久删除 |
| DELETE | `/api/trash` | `{message}` | 清空回收站 |
#### 文件管理
| 方法 | 路径 | 请求体 | 返回 | 说明 |
|------|----------------------|--------------------------|-----------|---------|
| POST | `/api/files/image` | `{data: base64}` | `{url}` | 上传图片 |
| POST | `/api/files/upload` | `{data: base64, filename}`| `{url}` | 上传文件 |
| GET | `/api/files/server-url`| - | `{url}` | 文件服务器地址 |
| GET | `/api/files/assets-path`| - | `{path}` | 资源目录路径 |
#### 导出
| 方法 | 路径 | 请求体 | 返回 | 说明 |
|------|---------------------------------|--------------------------|-------------|-----------|
| POST | `/api/export/html` | `{title, content}` | `{filePath}`| 导出 HTML |
| POST | `/api/export/markdown` | `{title, content}` | `{filePath}`| 导出 Markdown|
| POST | `/api/export/folder-zip/:folderId`| - | `{filePath}`| 文件夹导出 ZIP |
| POST | `/api/export/all-zip` | - | `{filePath}`| 全部导出 ZIP |
### Wails 客户端 API
Wails 客户端通过 `window.go.main.App.*` 调用,方法名与 Server API 对应:
```javascript
// 用户认证
const token = await window.go.main.App.Register(username, password, email)
const token = await window.go.main.App.Login(username, password)
window.go.main.App.Logout()
const user = await window.go.main.App.GetUser()
const loggedIn = await window.go.main.App.IsLoggedIn()
// 获取笔记树
const tree = await window.go.main.App.GetNoteTree()
// 获取单个笔记
const note = await window.go.main.App.GetNote(noteId)
// 创建笔记
await window.go.main.App.CreateNote(parentId, title, isFolder)
// 更新笔记
await window.go.main.App.UpdateNote(id, title, content, tag)
// 删除笔记
await window.go.main.App.DeleteNote(id)
// 搜索笔记
const results = await window.go.main.App.SearchNotes(keyword)
// 保存图片
const url = await window.go.main.App.SaveImage(base64Data)
// 导出
await window.go.main.App.ExportHTML(title, content)
await window.go.main.App.ExportMarkdown(title, content)
```
## 🎨 界面说明
### 登录/注册页面
首次打开应用会显示登录/注册页面,支持:
- 用户名 + 密码登录
- 用户名 + 密码 + 邮箱注册
- 登录/注册模式一键切换
- 表单验证(用户名长度、密码长度、邮箱格式、确认密码)
### 主界面布局
```
┌─────────────────────────────────────────────────────────────┐
│ 📝 Kenaito Notebook 👤 username [退出] │
├─────────────┬───────────────────────────────────────────────┤
│ 🔍 搜索... │ 笔记标题 │
├─────────────┤ [导出 ▼] │
│ [新建笔记] │ [标签] │
│ [新建文件夹] │ ┌─────────────────────────────────────────────┐
├─────────────┤ │ │
│ 📁 文件夹 │ │ Markdown 编辑器 │
│ 📄 笔记1 │ │ (实时预览) │
│ 📁 子文件夹│ │ │
│ 📄 笔记2│ │ │
│ 📄 笔记3 │ │ │
│ │ └─────────────────────────────────────────────┘
└─────────────┴───────────────────────────────────────────────┘
```
### 操作说明
| 操作 | 方式 |
|----------|--------------|
| 登录/注册 | 首页自动显示 |
| 选择笔记 | 单击笔记项 |
| 展开/收起文件夹 | 单击文件夹左侧箭头 |
| 新建笔记 | 点击「新建笔记」按钮 |
| 新建文件夹 | 点击「新建文件夹」按钮 |
| 在文件夹内新建 | 右键文件夹 → 选择操作 |
| 删除笔记 | 右键笔记 → 删除 |
| 搜索 | 在搜索框输入关键词 |
| 导出 | 点击右上角「导出」按钮 |
| 退出登录 | 点击右上角「退出」按钮 |
## 🚀 开发指南
### 开发环境配置
1. **安装 Go**: https://golang.org/dl/
2. **安装 Node.js**: https://nodejs.org/
3. **安装 Wails**:
```bash
go install github.com/wailsapp/wails/v2/cmd/wails@latest
```
### 添加新功能
#### 添加 Server API
1. 在 `server/handlers/` 中添加处理器
2. 在 `server/main.go` 的路由组中注册路由(公开接口放 `auth` 组,需登录接口放 `protected` 组)
3. 在 `app.go` 中添加对应的 HTTP 调用方法
4. 在 `frontend/wailsjs/go/main/App.js` 和 `App.d.ts` 中添加绑定声明
5. 前端通过 `window.go.main.App.MethodName()` 调用
#### 认证机制
- 注册:POST `/api/auth/register`,返回 JWT Token,自动保存到客户端
- 登录:POST `/api/auth/login`,返回 JWT Token,自动保存到客户端
- 客户端 `doRequest()` 自动在请求头中添加 `Authorization: Bearer <token>`
- Server 中间件校验 Token 有效性,无效/过期返回 401
- Token 使用 HMAC-SHA256 签名,有效期 7 天
### 代码规范
- Go 代码遵循 `gofmt` 标准
- Vue 代码使用 ESLint 格式化
- 提交信息使用中文描述
## 🐛 常见问题
### Q: 数据库文件在哪里?
A: 数据库存储在 `~/.kenaito-notebook/notes.db`
### Q: 如何备份笔记?
A: 备份 `~/.kenaito-notebook/` 目录即可
### Q: 图片上传后存储在哪里?
A: 图片存储在 `~/.kenaito-notebook/assets/` 目录,按日期分组
### Q: Server 启动失败怎么办?
A: 确认 8080 端口未被占用。可通过环境变量 `SERVER_PORT=9090` 修改端口。
### Q: Wails 客户端连接不上 Server?
A: 确保 Server 已启动。默认连接 `http://localhost:8080`。
### Q: 忘记密码了怎么办?
A: 目前暂不支持密码重置功能,请重新注册账号。
## 📄 许可证
MIT License
## 🤝 贡献
欢迎提交 Issue 和 Pull Request!
## 📮 联系方式
如有问题或建议,请提交 Issue。
+530
View File
@@ -0,0 +1,530 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
stdruntime "runtime"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"kenaito-notebook/models"
)
// 配置后端地址
const defaultServerURL = "http://localhost:8080"
type App struct {
ctx context.Context
serverURL string
http *http.Client
token string
}
func NewApp() *App {
return &App{
serverURL: defaultServerURL,
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
log.Println("[App] Starting up, server:", a.serverURL)
}
func (a *App) shutdown(ctx context.Context) {
log.Println("[App] Shutting down")
}
// doRequest 发起 HTTP 请求的通用方法
func (a *App) doRequest(method, path string, body interface{}) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, a.serverURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if a.token != "" {
req.Header.Set("Authorization", "Bearer "+a.token)
}
resp, err := a.http.Do(req)
if err != nil {
return nil, fmt.Errorf("server connection failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
var errResp struct {
Error string `json:"error"`
}
if json.Unmarshal(data, &errResp) == nil && errResp.Error != "" {
return nil, fmt.Errorf(errResp.Error)
}
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
}
return data, nil
}
// Register 用户注册
func (a *App) Register(email, password, code string) (string, error) {
body := map[string]string{
"email": email,
"password": password,
"code": code,
}
data, err := a.doRequest("POST", "/api/auth/register", body)
if err != nil {
return "", err
}
var resp struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
a.token = resp.Token
return resp.Token, nil
}
// SendVerifyCode 发送验证码
func (a *App) SendVerifyCode(email, purpose string) error {
body := map[string]string{
"email": email,
"purpose": purpose,
}
_, err := a.doRequest("POST", "/api/auth/send-code", body)
return err
}
// VerifyCode 验证验证码
func (a *App) VerifyCode(email, code, purpose string) error {
body := map[string]string{
"email": email,
"code": code,
"purpose": purpose,
}
_, err := a.doRequest("POST", "/api/auth/verify-code", body)
return err
}
// ResetPassword 重置密码
func (a *App) ResetPassword(email, code, newPassword string) error {
body := map[string]string{
"email": email,
"code": code,
"newPassword": newPassword,
}
_, err := a.doRequest("POST", "/api/auth/reset-password", body)
return err
}
// Login 用户登录
func (a *App) Login(email, password string) (string, error) {
body := map[string]string{
"email": email,
"password": password,
}
data, err := a.doRequest("POST", "/api/auth/login", body)
if err != nil {
return "", err
}
var resp struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
a.token = resp.Token
return resp.Token, nil
}
// Logout 退出登录
func (a *App) Logout() {
a.token = ""
}
// GetUser 获取当前用户信息
func (a *App) GetUser() (map[string]interface{}, error) {
data, err := a.doRequest("GET", "/api/auth/me", nil)
if err != nil {
return nil, err
}
var user map[string]interface{}
if err := json.Unmarshal(data, &user); err != nil {
return nil, err
}
return user, nil
}
// IsLoggedIn 是否已登录
func (a *App) IsLoggedIn() bool {
return a.token != ""
}
// GetNoteTree 获取笔记树
func (a *App) GetNoteTree() ([]*models.NoteTree, error) {
data, err := a.doRequest("GET", "/api/notes/tree", nil)
if err != nil {
return nil, err
}
var result []*models.NoteTree
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result, nil
}
// GetNote 获取单个笔记
func (a *App) GetNote(id int64) (*models.Note, error) {
data, err := a.doRequest("GET", fmt.Sprintf("/api/notes/%d", id), nil)
if err != nil {
return nil, err
}
var note models.Note
if err := json.Unmarshal(data, &note); err != nil {
return nil, err
}
return &note, nil
}
// CreateNote 创建笔记或文件夹
func (a *App) CreateNote(parentID int64, title string, isFolder bool) (*models.Note, error) {
body := map[string]interface{}{
"parentId": parentID,
"title": title,
"isFolder": isFolder,
}
data, err := a.doRequest("POST", "/api/notes", body)
if err != nil {
return nil, err
}
var note models.Note
if err := json.Unmarshal(data, &note); err != nil {
return nil, err
}
return &note, nil
}
// UpdateNote 更新笔记
func (a *App) UpdateNote(id int64, title, content, tag string) error {
body := map[string]string{
"title": title,
"content": content,
"tag": tag,
}
_, err := a.doRequest("PUT", fmt.Sprintf("/api/notes/%d", id), body)
return err
}
// DeleteNote 删除笔记
func (a *App) DeleteNote(id int64) error {
_, err := a.doRequest("DELETE", fmt.Sprintf("/api/notes/%d", id), nil)
return err
}
// SearchNotes 搜索笔记
func (a *App) SearchNotes(keyword string) ([]*models.Note, error) {
data, err := a.doRequest("GET", fmt.Sprintf("/api/notes/search?q=%s", keyword), nil)
if err != nil {
return nil, err
}
var notes []*models.Note
if err := json.Unmarshal(data, &notes); err != nil {
return nil, err
}
return notes, nil
}
// GetTrashNotes 获取回收站中的笔记
func (a *App) GetTrashNotes() ([]*models.Note, error) {
data, err := a.doRequest("GET", "/api/trash", nil)
if err != nil {
return nil, err
}
var notes []*models.Note
if err := json.Unmarshal(data, &notes); err != nil {
return nil, err
}
return notes, nil
}
// RestoreNote 恢复笔记
func (a *App) RestoreNote(id int64) error {
_, err := a.doRequest("POST", fmt.Sprintf("/api/trash/%d/restore", id), nil)
return err
}
// PermanentDeleteNote 永久删除笔记
func (a *App) PermanentDeleteNote(id int64) error {
_, err := a.doRequest("DELETE", fmt.Sprintf("/api/trash/%d", id), nil)
return err
}
// EmptyTrash 清空回收站
func (a *App) EmptyTrash() error {
_, err := a.doRequest("DELETE", "/api/trash", nil)
return err
}
// SaveImage 保存图片
func (a *App) SaveImage(base64Data string) (string, error) {
body := map[string]string{"data": base64Data}
data, err := a.doRequest("POST", "/api/files/image", body)
if err != nil {
return "", err
}
var resp struct {
URL string `json:"url"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
return resp.URL, nil
}
// SaveFile 保存任意文件
func (a *App) SaveFile(base64Data, filename string) (string, error) {
body := map[string]string{
"data": base64Data,
"filename": filename,
}
data, err := a.doRequest("POST", "/api/files/upload", body)
if err != nil {
return "", err
}
var resp struct {
URL string `json:"url"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
return resp.URL, nil
}
// GetFileServerURL 获取文件服务器地址
func (a *App) GetFileServerURL() string {
data, err := a.doRequest("GET", "/api/files/server-url", nil)
if err != nil {
return ""
}
var resp struct {
URL string `json:"url"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return ""
}
return resp.URL
}
// GetAssetsPath 获取资源路径
func (a *App) GetAssetsPath() string {
data, err := a.doRequest("GET", "/api/files/assets-path", nil)
if err != nil {
return ""
}
var resp struct {
Path string `json:"path"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return ""
}
return resp.Path
}
// SelectDirectory 弹出目录选择对话框
func (a *App) SelectDirectory(title string) (string, error) {
result, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
Title: title,
})
if err != nil {
return "", err
}
if result == "" {
return "", fmt.Errorf("用户取消选择")
}
return result, nil
}
// SelectSaveFile 弹出文件保存对话框
func (a *App) SelectSaveFile(title, defaultFilename string) (string, error) {
result, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: title,
DefaultFilename: defaultFilename,
})
if err != nil {
return "", err
}
if result == "" {
return "", fmt.Errorf("用户取消保存")
}
return result, nil
}
// RevealInFinder 在 Finder 中显示文件
func (a *App) RevealInFinder(path string) {
var cmd *exec.Cmd
switch stdruntime.GOOS {
case "darwin":
cmd = exec.Command("open", "-R", path)
case "windows":
cmd = exec.Command("explorer", "/select,", path)
default:
cmd = exec.Command("xdg-open", filepath.Dir(path))
}
cmd.Start()
}
// ExportHTML 导出 HTML
func (a *App) ExportHTML(title, content string) (string, error) {
body := map[string]string{"title": title, "content": content}
data, err := a.doRequest("POST", "/api/export/html", body)
if err != nil {
return "", err
}
var resp struct {
FilePath string `json:"filePath"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
defaultFilename := title + ".html"
savePath, err := a.SelectSaveFile("导出 HTML 文件", defaultFilename)
if err != nil {
return "", err
}
if filepath.Ext(savePath) != ".html" {
savePath += ".html"
}
if err := copyFile(resp.FilePath, savePath); err != nil {
return "", err
}
return savePath, nil
}
// ExportMarkdown 导出 Markdown
func (a *App) ExportMarkdown(title, content string) (string, error) {
body := map[string]string{"title": title, "content": content}
data, err := a.doRequest("POST", "/api/export/markdown", body)
if err != nil {
return "", err
}
var resp struct {
FilePath string `json:"filePath"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
defaultFilename := title + ".md"
savePath, err := a.SelectSaveFile("导出 Markdown 文件", defaultFilename)
if err != nil {
return "", err
}
if filepath.Ext(savePath) != ".md" {
savePath += ".md"
}
if err := copyFile(resp.FilePath, savePath); err != nil {
return "", err
}
return savePath, nil
}
// ExportFolderMarkdown 导出整个文件夹为 ZIP
func (a *App) ExportFolderMarkdown(folderID int64) (string, error) {
data, err := a.doRequest("POST", fmt.Sprintf("/api/export/folder-zip/%d", folderID), nil)
if err != nil {
return "", err
}
var resp struct {
FilePath string `json:"filePath"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
savePath, err := a.SelectSaveFile("导出文件夹为 ZIP", "export.zip")
if err != nil {
return "", err
}
if filepath.Ext(savePath) != ".zip" {
savePath += ".zip"
}
if err := copyFile(resp.FilePath, savePath); err != nil {
return "", err
}
return savePath, nil
}
// ExportAllMarkdown 导出所有笔记为 ZIP
func (a *App) ExportAllMarkdown() (string, error) {
data, err := a.doRequest("POST", "/api/export/all-zip", nil)
if err != nil {
return "", err
}
var resp struct {
FilePath string `json:"filePath"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return "", err
}
savePath, err := a.SelectSaveFile("导出所有笔记为 ZIP", "所有笔记.zip")
if err != nil {
return "", err
}
if filepath.Ext(savePath) != ".zip" {
savePath += ".zip"
}
if err := copyFile(resp.FilePath, savePath); err != nil {
return "", err
}
return savePath, nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+35
View File
@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+15
View File
@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}
+114
View File
@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd
+249
View File
@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
View File
+8
View File
@@ -0,0 +1,8 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs,
check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>kenaito-notebook</title>
</head>
<body>
<div id="app"></div>
<script src="./src/main.js" type="module"></script>
</body>
</html>
+2399
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^2.7.14",
"element-ui": "^2.15.14",
"mavon-editor": "^2.10.4",
"highlight.js": "^11.9.0",
"marked": "^9.1.6"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.3.1",
"vite": "^3.0.7",
"vite-plugin-vue2": "^2.0.3"
}
}
+1
View File
@@ -0,0 +1 @@
2bd2793b3ea7c36dd202ef834837c1bb
+938
View File
@@ -0,0 +1,938 @@
<template>
<div id="app" class="app-container">
<!-- 未登录:显示登录页面 -->
<login-view v-if="!isLoggedIn" @login-success="onLoginSuccess" />
<!-- 已登录:显示主界面 -->
<el-container v-else style="height: 100vh">
<el-aside width="280px" class="sidebar">
<div class="sidebar-header">
<div class="title-row">
<el-dropdown trigger="click" @command="handleUserCommand">
<span class="user-info">
<i class="el-icon-user-solid"></i>
{{ email }}
<i class="el-icon-arrow-down"></i>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="logout">
<i class="el-icon-switch-button"></i> 退出登录
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="搜索笔记..."
prefix-icon="el-icon-search"
clearable
@input="handleSearch"
/>
</div>
</div>
<note-tree
:notes="searchKeyword ? searchResults : noteTree"
:selected-id="selectedNoteId"
@select="openNote"
@delete="deleteNote"
@context-menu="handleContextMenu"
/>
<div class="sidebar-footer">
<el-button
:type="showTrash ? 'primary' : 'text'"
icon="el-icon-delete"
@click="openTrash"
>
回收站
</el-button>
</div>
</el-aside>
<el-main class="main-content">
<!-- 回收站视图 -->
<trash-view
v-if="showTrash"
:trash-notes="trashNotes"
@restore="restoreNote"
@permanent-delete="permanentDeleteNote"
@empty-trash="emptyTrash"
@close="showTrash = false"
/>
<!-- 多标签页 -->
<div v-else-if="tabs.length > 0" class="tabs-container">
<div class="tabs-header">
<div
v-for="tab in tabs"
:key="tab.id"
class="tab-item"
:class="{ 'is-active': activeTabId === tab.id }"
@click="switchTab(tab.id)"
>
<span class="tab-title">{{ tab.title || '未命名' }}</span>
<span class="tab-close" @click.stop="closeTab(tab.id)">
<i class="el-icon-close"></i>
</span>
</div>
</div>
<!-- 编辑器内容 -->
<div v-if="activeTab" class="editor-container">
<div class="editor-header">
<el-input
v-model="activeTab.title"
class="note-title-input"
@blur="saveTab"
placeholder="笔记标题"
/>
<div class="editor-actions">
<el-dropdown trigger="click" @command="handleExport">
<el-button size="small" icon="el-icon-download">
导出 <i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="html">导出 HTML</el-dropdown-item>
<el-dropdown-item command="markdown">导出 Markdown</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<mavon-editor
ref="md"
v-model="activeTab.content"
:toolbars="toolbars"
:imageUpload="imageUpload"
@change="onContentChange"
@imgAdd="handleImgAdd"
@imgDel="handleImgDel"
style="height: calc(100vh - 180px)"
/>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!showTrash" class="empty-state">
<i class="el-icon-document"></i>
<p>选择或创建一个笔记开始编写</p>
</div>
</el-main>
</el-container>
</div>
</template>
<script>
import NoteTree from './components/NoteTree.vue'
import TrashView from './components/TrashView.vue'
import LoginView from './components/LoginView.vue'
export default {
name: 'App',
components: {
NoteTree,
TrashView,
LoginView
},
data() {
return {
isLoggedIn: false,
email: '',
noteTree: [],
tabs: [], // 打开的标签页列表
activeTabId: null, // 当前激活的标签页 ID
searchKeyword: '',
searchResults: [],
saveTimers: {}, // 每个标签页的保存定时器
showTrash: false, // 是否显示回收站
trashNotes: [], // 回收站中的笔记
toolbars: {
bold: true,
italic: true,
header: true,
underline: true,
strikethrough: true,
mark: true,
quote: true,
ol: true,
ul: true,
link: true,
imagelink: true,
code: true,
table: true,
fullscreen: true,
htmlcode: true,
undo: true,
redo: true,
preview: true,
subfield: true,
help: true
},
imageUpload: true
}
},
computed: {
selectedNoteId() {
return this.activeTabId
},
activeTab() {
return this.tabs.find(tab => tab.id === this.activeTabId)
}
},
async mounted() {
// 检查登录状态
try {
const loggedIn = await window.go.main.App.IsLoggedIn()
if (loggedIn) {
this.isLoggedIn = true
await this.loadUserInfo()
await this.loadNoteTree()
}
} catch (err) {
console.error('Failed to check login status:', err)
}
// 监听全局粘贴和拖放事件
document.addEventListener('paste', this.handlePaste)
document.addEventListener('dragover', this.handleDragOver, true)
document.addEventListener('drop', this.handleDrop, true)
},
beforeDestroy() {
document.removeEventListener('paste', this.handlePaste)
document.removeEventListener('dragover', this.handleDragOver, true)
document.removeEventListener('drop', this.handleDrop, true)
},
methods: {
async onLoginSuccess() {
this.isLoggedIn = true
await this.loadUserInfo()
await this.loadNoteTree()
},
async loadUserInfo() {
try {
const user = await window.go.main.App.GetUser()
this.email = user.email || ''
} catch (err) {
console.error('Failed to load user info:', err)
}
},
handleUserCommand(command) {
if (command === 'logout') {
this.$confirm('确定要退出登录吗?', '提示', {
type: 'info',
confirmButtonText: '退出',
cancelButtonText: '取消'
}).then(() => {
window.go.main.App.Logout()
this.isLoggedIn = false
this.email = ''
this.tabs = []
this.activeTabId = null
this.noteTree = []
this.$message.success('已退出登录')
}).catch(() => {})
}
},
async loadNoteTree() {
try {
this.noteTree = await window.go.main.App.GetNoteTree()
} catch (err) {
console.error('Failed to load note tree:', err)
this.$message.error('加载笔记失败')
}
},
// 打开笔记到标签页
async openNote(note) {
if (note.isFolder) return
// 检查是否已经在标签页中
const existingTab = this.tabs.find(tab => tab.id === note.id)
if (existingTab) {
this.activeTabId = note.id
return
}
try {
const fullNote = await window.go.main.App.GetNote(note.id)
this.tabs.push({
id: fullNote.id,
title: fullNote.title,
content: fullNote.content,
parentId: fullNote.parentId,
isDirty: false
})
this.activeTabId = fullNote.id
} catch (err) {
console.error('Failed to load note:', err)
this.$message.error('加载笔记内容失败')
}
},
// 切换标签页
switchTab(tabId) {
this.activeTabId = tabId
},
// 关闭标签页
closeTab(tabId) {
const tab = this.tabs.find(t => t.id === tabId)
if (tab && tab.isDirty) {
this.$confirm('有未保存的更改,确定要关闭吗?', '提示', {
type: 'warning'
}).then(() => {
this.removeTab(tabId)
}).catch(() => {})
} else {
this.removeTab(tabId)
}
},
removeTab(tabId) {
const index = this.tabs.findIndex(t => t.id === tabId)
this.tabs.splice(index, 1)
if (this.activeTabId === tabId) {
// 切换到相邻的标签页
if (this.tabs.length > 0) {
const newIndex = Math.min(index, this.tabs.length - 1)
this.activeTabId = this.tabs[newIndex].id
} else {
this.activeTabId = null
}
}
// 清理定时器
if (this.saveTimers[tabId]) {
clearTimeout(this.saveTimers[tabId])
delete this.saveTimers[tabId]
}
},
async createNoteDirectly(parentId, isFolder) {
try {
const title = isFolder ? '未命名文件夹' : '未命名笔记'
const newNote = await window.go.main.App.CreateNote(parentId, title, isFolder)
await this.loadNoteTree()
// 如果是新建笔记,自动打开
if (!isFolder) {
await this.openNote(newNote)
}
this.$message.success('创建成功')
} catch (err) {
console.error('Failed to create note:', err)
this.$message.error('创建失败')
}
},
async deleteNote(note) {
// 如果标签页中有该笔记,先关闭
const tabIndex = this.tabs.findIndex(t => t.id === note.id)
if (tabIndex > -1) {
this.removeTab(note.id)
}
try {
await window.go.main.App.DeleteNote(note.id)
await this.loadNoteTree()
if (this.showTrash) await this.loadTrashNotes()
this.$message.success('已移到回收站')
} catch (err) {
console.error('Failed to delete note:', err)
this.$message.error('删除失败')
}
},
saveTab() {
if (!this.activeTab) return
if (this.saveTimers[this.activeTabId]) {
clearTimeout(this.saveTimers[this.activeTabId])
}
this.saveTimers[this.activeTabId] = setTimeout(async () => {
try {
await window.go.main.App.UpdateNote(
this.activeTab.id,
this.activeTab.title,
this.activeTab.content,
''
)
this.activeTab.isDirty = false
await this.loadNoteTree()
} catch (err) {
console.error('Failed to save note:', err)
}
}, 500)
},
onContentChange() {
if (this.activeTab) {
this.activeTab.isDirty = true
}
this.saveTab()
},
// 粘贴事件处理
async handlePaste(e) {
if (!this.activeTab) return
const items = e.clipboardData?.items
if (!items) return
// 检查是否有文件数据
const files = []
for (let i = 0; i < items.length; i++) {
if (items[i].kind === 'file') {
const file = items[i].getAsFile()
if (file) files.push(file)
}
}
if (files.length === 0) return
e.preventDefault()
e.stopPropagation()
for (const file of files) {
const maxSize = 6 * 1024 * 1024 // 6MB
if (file.size > maxSize) {
this.$message.warning(`文件 ${file.name} 大小超过 6MB,已跳过`)
continue
}
try {
let base64Data
const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name)
if (isImage) {
base64Data = await this.compressImage(file)
} else {
base64Data = await this.fileToBase64(file)
}
const fileUrl = await window.go.main.App.SaveFile(base64Data, file.name)
this.insertFileLink(file.name, fileUrl)
this.$message.success(`${file.name} 嵌入成功`)
} catch (err) {
console.error('Failed to upload file:', err)
this.$message.error(`${file.name} 上传失败`)
}
}
},
// 拖放事件处理(全局 capture 阶段)
handleDragOver(e) {
if (!this.activeTab) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
},
async handleDrop(e) {
if (!this.activeTab) return
const files = e.dataTransfer?.files
if (!files || files.length === 0) return
e.preventDefault()
e.stopPropagation()
for (const file of files) {
const maxSize = 6 * 1024 * 1024 // 6MB
if (file.size > maxSize) {
this.$message.warning(`文件 ${file.name} 大小超过 6MB,已跳过`)
continue
}
try {
let base64Data
const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name)
if (isImage) {
base64Data = await this.compressImage(file)
} else {
base64Data = await this.fileToBase64(file)
}
const fileUrl = await window.go.main.App.SaveFile(base64Data, file.name)
this.insertFileLink(file.name, fileUrl)
this.$message.success(`${file.name} 嵌入成功`)
} catch (err) {
console.error('Failed to upload file:', err)
this.$message.error(`${file.name} 上传失败`)
}
}
},
fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = error => reject(error)
})
},
insertFileLink(filename, filePath) {
if (!this.activeTab) return
// 根据文件类型决定插入格式
const isImage = /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(filename)
let markdown = ''
if (isImage) {
markdown = `![${filename}](${filePath})`
} else {
markdown = `[${filename}](${filePath})`
}
// 在内容末尾插入
this.activeTab.content += `\n\n${markdown}\n`
this.saveTab()
},
async handleSearch() {
if (!this.searchKeyword.trim()) {
this.searchResults = []
return
}
try {
this.searchResults = await window.go.main.App.SearchNotes(this.searchKeyword)
} catch (err) {
console.error('Failed to search notes:', err)
}
},
async handleExport(command) {
if (!this.activeTab) return
try {
let filePath
if (command === 'html') {
filePath = await window.go.main.App.ExportHTML(
this.activeTab.title,
this.activeTab.content
)
} else {
filePath = await window.go.main.App.ExportMarkdown(
this.activeTab.title,
this.activeTab.content
)
}
if (filePath) {
this.$confirm('导出成功!是否打开文件所在目录?', '导出完成', {
confirmButtonText: '打开目录',
cancelButtonText: '确定',
type: 'success'
}).then(() => {
window.go.main.App.RevealInFinder(filePath)
}).catch(() => {})
}
} catch (err) {
// 用户取消选择不显示错误
if (err && err.includes && err.includes('用户取消')) {
return
}
console.error('Failed to export:', err)
this.$message.error('导出失败')
}
},
async exportFolder(folder) {
try {
const filePath = await window.go.main.App.ExportFolderMarkdown(folder.id)
if (filePath) {
this.$confirm('导出成功!是否打开文件所在目录?', '导出完成', {
confirmButtonText: '打开目录',
cancelButtonText: '确定',
type: 'success'
}).then(() => {
window.go.main.App.RevealInFinder(filePath)
}).catch(() => {})
}
} catch (err) {
if (err && err.includes && err.includes('用户取消')) {
return
}
console.error('Failed to export folder:', err)
this.$message.error('导出失败')
}
},
async exportAll() {
try {
const filePath = await window.go.main.App.ExportAllMarkdown()
if (filePath) {
this.$confirm('导出成功!是否打开文件所在目录?', '导出完成', {
confirmButtonText: '打开目录',
cancelButtonText: '确定',
type: 'success'
}).then(() => {
window.go.main.App.RevealInFinder(filePath)
}).catch(() => {})
}
} catch (err) {
if (err && err.includes && err.includes('用户取消')) {
return
}
console.error('Failed to export all:', err)
this.$message.error('导出失败')
}
},
handleContextMenu(event) {
const { command, note } = event
if (command === 'newChild') {
this.createNoteDirectly(note.id, false)
} else if (command === 'newFolder') {
this.createNoteDirectly(note ? note.id : 0, true)
} else if (command === 'newNote') {
this.createNoteDirectly(0, false)
} else if (command === 'delete') {
this.deleteNote(note)
} else if (command === 'trash') {
this.showTrash = true
} else if (command === 'renamed') {
this.loadNoteTree()
} else if (command === 'exportFolder') {
this.exportFolder(note)
} else if (command === 'exportAll') {
this.exportAll()
}
},
// 回收站相关方法
async openTrash() {
this.showTrash = !this.showTrash
},
async loadTrashNotes() {
try {
const notes = await window.go.main.App.GetTrashNotes()
this.trashNotes = notes || []
} catch (err) {
console.error('Failed to load trash notes:', err)
this.$message.error('加载回收站失败')
}
},
async restoreNote(note) {
try {
await this.$confirm(`确定要恢复 "${note.title || '未命名'}" 吗?`, '提示', {
type: 'info',
confirmButtonText: '恢复',
cancelButtonText: '取消'
})
await window.go.main.App.RestoreNote(note.id)
await this.loadTrashNotes()
await this.loadNoteTree()
this.$message.success('已恢复')
} catch (err) {
if (err !== 'cancel') {
console.error('Failed to restore note:', err)
this.$message.error('恢复失败')
}
}
},
async permanentDeleteNote(note) {
try {
await this.$confirm(`确定要永久删除 "${note.title || '未命名'}" 吗?此操作不可恢复!`, '警告', {
type: 'warning',
confirmButtonText: '永久删除',
cancelButtonText: '取消'
})
await window.go.main.App.PermanentDeleteNote(note.id)
await this.loadTrashNotes()
this.$message.success('已永久删除')
} catch (err) {
if (err !== 'cancel') {
console.error('Failed to permanently delete note:', err)
this.$message.error('删除失败')
}
}
},
async emptyTrash() {
try {
await this.$confirm('确定要清空回收站吗?所有笔记将被永久删除!', '警告', {
type: 'warning',
confirmButtonText: '清空',
cancelButtonText: '取消'
})
await window.go.main.App.EmptyTrash()
await this.loadTrashNotes()
this.$message.success('回收站已清空')
} catch (err) {
if (err !== 'cancel') {
console.error('Failed to empty trash:', err)
this.$message.error('清空失败')
}
}
},
// 图片压缩为 JPEG 格式,质量 50
compressImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
const img = new Image()
img.src = reader.result
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
// 压缩为 JPEG 格式,质量 50
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.5)
resolve(compressedDataUrl)
}
img.onerror = reject
}
reader.onerror = reject
})
},
// mavon-editor 图片上传处理
async handleImgAdd(pos, $file) {
try {
// 压缩图片
const compressedDataUrl = await this.compressImage($file)
// 保存到后端文件服务器
const fileUrl = await window.go.main.App.SaveFile(compressedDataUrl, $file.name)
// 替换编辑器中的图片链接
this.$refs.md.$img2Url(pos, fileUrl)
this.$message.success('图片上传成功')
} catch (err) {
console.error('Failed to upload image:', err)
this.$message.error('图片上传失败')
this.$refs.md.$img2Url(pos, '')
}
},
handleImgDel(pos) {
// 图片删除时可以做清理工作(可选)
console.log('Image deleted at position:', pos)
}
},
watch: {
showTrash(newVal) {
if (newVal) {
this.loadTrashNotes()
}
}
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.app-container {
height: 100vh;
}
.sidebar {
background-color: #f5f7fa;
border-right: 1px solid #e4e7ed;
display: flex;
flex-direction: column;
overflow: hidden !important;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #e4e7ed;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #606266;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.user-info:hover {
background-color: #ebeef5;
}
.search-box {
margin-top: 8px;
}
.sidebar-footer {
margin-top: auto;
padding: 12px 16px;
border-top: 1px solid #e4e7ed;
}
.main-content {
padding: 0;
display: flex;
flex-direction: column;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
}
.empty-state i {
font-size: 64px;
margin-bottom: 16px;
}
.empty-state p {
font-size: 16px;
}
/* 标签页样式 */
.tabs-container {
display: flex;
flex-direction: column;
height: 100%;
}
.tabs-header {
display: flex;
background-color: #f5f7fa;
border-bottom: 1px solid #e4e7ed;
overflow-x: auto;
min-height: 40px;
}
.tab-item {
display: flex;
align-items: center;
padding: 0 16px;
height: 40px;
border-right: 1px solid #e4e7ed;
cursor: pointer;
background-color: #f5f7fa;
min-width: 120px;
max-width: 200px;
transition: all 0.2s;
}
.tab-item:hover {
background-color: #ebeef5;
}
.tab-item.is-active {
background-color: #fff;
border-bottom: 2px solid #409eff;
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
margin-left: 8px;
opacity: 0;
transition: all 0.2s;
}
.tab-item:hover .tab-close {
opacity: 1;
}
.tab-close:hover {
background-color: #dcdfe6;
}
.editor-container {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.editor-header {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e4e7ed;
gap: 12px;
}
.note-title-input {
flex: 1;
}
.note-title-input .el-input__inner {
font-size: 20px;
font-weight: 600;
border: none;
padding: 0;
background: transparent;
}
.note-title-input .el-input__inner:focus {
outline: none;
}
.editor-actions {
display: flex;
gap: 8px;
}
/* 覆盖 mavon-editor 样式 */
.v-note-wrapper {
border: none !important;
border-radius: 0 !important;
z-index: 0 !important;
}
.v-note-op {
border-bottom: 1px solid #e4e7ed !important;
}
.v-show-content {
padding: 20px !important;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1 class="login-title">Kenaito Notebook</h1>
<p class="login-subtitle">{{ isLogin ? '欢迎回来,请登录' : '创建新账号' }}</p>
</div>
<el-form
ref="form"
:model="form"
:rules="rules"
@submit.native.prevent="handleSubmit"
>
<el-form-item prop="email">
<el-input
v-model="form.email"
prefix-icon="el-icon-message"
placeholder="请输入邮箱"
size="medium"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
prefix-icon="el-icon-lock"
type="password"
placeholder="请输入密码"
size="medium"
show-password
/>
</el-form-item>
<el-form-item v-if="!isLogin" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
prefix-icon="el-icon-lock"
type="password"
placeholder="请再次输入密码"
size="medium"
show-password
/>
</el-form-item>
<el-form-item v-if="!isLogin" prop="code">
<div style="display: flex; gap: 10px;">
<el-input
v-model="form.code"
prefix-icon="el-icon-postcard"
placeholder="请输入验证码"
size="medium"
style="flex: 1;"
/>
<el-button
size="medium"
:disabled="codeCooldown > 0 || !form.email"
:loading="sendingCode"
@click="handleSendCode"
>
{{ codeCooldown > 0 ? codeCooldown + 's' : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="loading"
style="width: 100%"
size="medium"
>
{{ loading ? '处理中...' : (isLogin ? '登 录' : '注 册') }}
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<span>{{ isLogin ? '还没有账号?' : '已有账号?' }}</span>
<el-button type="text" @click="toggleMode">
{{ isLogin ? '立即注册' : '去登录' }}
</el-button>
</div>
</div>
</div>
</template>
<script>
import { SendVerifyCode } from '../../wailsjs/go/main/App'
export default {
name: 'LoginView',
data() {
const validateConfirmPassword = (rule, value, callback) => {
if (value !== this.form.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
return {
isLogin: true,
loading: false,
sendingCode: false,
codeCooldown: 0,
cooldownTimer: null,
form: {
email: '',
password: '',
confirmPassword: '',
code: ''
},
rules: {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 个字符', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
],
code: [
{ required: true, message: '请输入验证码', trigger: 'blur' },
{ len: 6, message: '验证码为 6 位数字', trigger: 'blur' }
]
}
}
},
beforeDestroy() {
if (this.cooldownTimer) {
clearInterval(this.cooldownTimer)
}
},
methods: {
toggleMode() {
this.isLogin = !this.isLogin
if (this.cooldownTimer) {
clearInterval(this.cooldownTimer)
this.codeCooldown = 0
}
this.form.code = ''
this.$nextTick(() => {
if (this.$refs.form) {
this.$refs.form.clearValidate()
}
})
},
async handleSendCode() {
try {
await this.$refs.form.validateField('email')
} catch {
return
}
this.sendingCode = true
try {
const purpose = 'register'
await SendVerifyCode(this.form.email, purpose)
this.$message.success('验证码已发送到您的邮箱')
this.startCooldown()
} catch (err) {
this.$message.error(err.toString() || '发送验证码失败')
} finally {
this.sendingCode = false
}
},
startCooldown() {
this.codeCooldown = 60
this.cooldownTimer = setInterval(() => {
this.codeCooldown--
if (this.codeCooldown <= 0) {
clearInterval(this.cooldownTimer)
this.cooldownTimer = null
}
}, 1000)
},
async handleSubmit() {
if (!this.$refs.form) return
try {
await this.$refs.form.validate()
} catch {
return
}
this.loading = true
try {
if (this.isLogin) {
await window.go.main.App.Login(this.form.email, this.form.password)
this.$message.success('登录成功')
} else {
await window.go.main.App.Register(this.form.email, this.form.password, this.form.code)
this.$message.success('注册成功')
}
this.$emit('login-success')
} catch (err) {
this.$message.error(err.toString() || '操作失败')
} finally {
this.loading = false
}
}
}
}
</script>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: #f0f2f5;
}
.login-card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 8px;
}
.login-subtitle {
font-size: 14px;
color: #909399;
}
.login-footer {
text-align: center;
margin-top: 16px;
font-size: 14px;
color: #909399;
}
.el-form-item.is-required >>> .el-form-item__label::before {
content: none;
}
</style>
+385
View File
@@ -0,0 +1,385 @@
<template>
<div class="note-tree" @contextmenu.prevent="showContextMenu($event, null)">
<div v-if="!notes || notes.length === 0" class="empty-tree">
<p>暂无笔记</p>
</div>
<div v-else class="tree-content">
<div
v-for="note in notes"
:key="note.id"
class="tree-item"
>
<div
class="tree-node"
:class="{
'is-selected': selectedId === note.id,
'is-folder': note.isFolder
}"
@click="$emit('select', note)"
@contextmenu.prevent="showContextMenu($event, note)"
>
<span
v-if="note.isFolder"
class="expand-icon"
:class="{ 'is-expanded': expandedIds.includes(note.id) }"
@click.stop="toggleExpand(note.id)"
>
<i class="el-icon-arrow-right"></i>
</span>
<span v-else class="expand-icon-placeholder"></span>
<span class="node-icon">
<i :class="getNodeIcon(note)"></i>
</span>
<input
v-if="renamingId === note.id"
ref="renameInput"
v-model="renameTitle"
class="rename-input"
@blur="confirmRename(note)"
@keyup.enter="confirmRename(note)"
@keyup.escape="cancelRename"
@click.stop
/>
<span v-else class="node-label-wrapper">
<span class="node-label" :title="note.title">
{{ note.title || '未命名' }}
</span>
<span v-if="note.createdAt" class="node-date">
{{ formatDate(note.createdAt) }}
</span>
</span>
</div>
<div
v-if="note.isFolder && note.children && expandedIds.includes(note.id)"
class="tree-children"
>
<note-tree
:notes="note.children"
:selected-id="selectedId"
@select="$emit('select', $event)"
@delete="$emit('delete', $event)"
@context-menu="$emit('context-menu', $event)"
/>
</div>
</div>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
@click.stop
>
<template v-if="contextMenu.note && contextMenu.note.isFolder">
<div class="context-menu-item" @click.stop="handleContextAction('newChild')">
<i class="el-icon-document-add"></i> 新建笔记
</div>
<div class="context-menu-item" @click.stop="handleContextAction('newFolder')">
<i class="el-icon-folder-add"></i> 新建子文件夹
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click.stop="handleContextAction('rename')">
<i class="el-icon-edit"></i> 重命名
</div>
<div class="context-menu-item" @click.stop="handleContextAction('exportFolder')">
<i class="el-icon-download"></i> 导出为 Markdown
</div>
<div class="context-menu-item delete" @click.stop="handleContextAction('delete')">
<i class="el-icon-delete"></i> 删除
</div>
</template>
<template v-else-if="contextMenu.note">
<div class="context-menu-item delete" @click.stop="handleContextAction('delete')">
<i class="el-icon-delete"></i> 删除
</div>
</template>
<template v-else>
<div class="context-menu-item" @click.stop="handleContextAction('newNote')">
<i class="el-icon-document-add"></i> 新建笔记
</div>
<div class="context-menu-item" @click.stop="handleContextAction('newFolder')">
<i class="el-icon-folder-add"></i> 新建文件夹
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item" @click.stop="handleContextAction('exportAll')">
<i class="el-icon-download"></i> 导出所有笔记
</div>
</template>
</div>
</div>
</template>
<script>
export default {
name: 'NoteTree',
props: {
notes: {
type: Array,
default: () => []
},
selectedId: {
type: Number,
default: null
}
},
data() {
return {
expandedIds: [],
renamingId: null,
renameTitle: '',
contextMenu: {
visible: false,
x: 0,
y: 0,
note: null
}
}
},
mounted() {
document.addEventListener('click', this.hideContextMenu)
document.addEventListener('contextmenu', this.hideContextMenu)
},
beforeDestroy() {
document.removeEventListener('click', this.hideContextMenu)
document.removeEventListener('contextmenu', this.hideContextMenu)
},
methods: {
toggleExpand(id) {
const index = this.expandedIds.indexOf(id)
if (index > -1) {
this.expandedIds.splice(index, 1)
} else {
this.expandedIds.push(id)
}
},
getNodeIcon(note) {
if (note.isFolder) {
return this.expandedIds.includes(note.id)
? 'el-icon-folder-opened'
: 'el-icon-folder'
}
return 'el-icon-document'
},
showContextMenu(event, note) {
event.stopPropagation()
this.contextMenu = {
visible: true,
x: event.clientX,
y: event.clientY,
note: note
}
},
hideContextMenu() {
this.contextMenu.visible = false
},
handleContextAction(command) {
if (command === 'rename') {
this.startRename(this.contextMenu.note)
} else {
this.$emit('context-menu', {
command: command,
note: this.contextMenu.note
})
}
this.hideContextMenu()
},
startRename(note) {
this.renamingId = note.id
this.renameTitle = note.title
this.$nextTick(() => {
if (this.$refs.renameInput) {
this.$refs.renameInput.focus()
this.$refs.renameInput.select()
}
})
},
async confirmRename(note) {
if (!this.renamingId) return
const newTitle = this.renameTitle.trim()
if (newTitle && newTitle !== note.title) {
await window.go.main.App.UpdateNote(note.id, newTitle, note.content, note.tag)
this.$emit('context-menu', { command: 'renamed', note })
}
this.renamingId = null
this.renameTitle = ''
},
cancelRename() {
this.renamingId = null
this.renameTitle = ''
},
formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
},
watch: {
notes: {
handler(newNotes) {
// 自动展开第一级文件夹
if (newNotes) {
newNotes.forEach(note => {
if (note.isFolder && note.children && note.children.length > 0) {
if (!this.expandedIds.includes(note.id)) {
this.expandedIds.push(note.id)
}
}
})
}
},
immediate: true
}
}
}
</script>
<style scoped>
.note-tree {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.empty-tree {
padding: 20px;
text-align: center;
color: #909399;
}
.tree-content {
padding: 0 8px;
}
.tree-item {
margin: 2px 0;
}
.tree-node {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
user-select: none;
}
.tree-node:hover {
background-color: #ebeef5;
}
.tree-node.is-selected {
background-color: #ecf5ff;
color: #409eff;
}
.tree-node.is-folder {
font-weight: 500;
}
.expand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
transition: transform 0.2s;
}
.expand-icon.is-expanded {
transform: rotate(90deg);
}
.expand-icon-placeholder {
width: 20px;
}
.node-icon {
margin-right: 8px;
font-size: 16px;
}
.node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-label-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.node-date {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.rename-input {
flex: 1;
border: 1px solid #409eff;
border-radius: 4px;
padding: 2px 8px;
font-size: 14px;
outline: none;
background: white;
}
.tree-children {
padding-left: 20px;
}
/* 右键菜单 */
.context-menu {
position: fixed;
background: white;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 160px;
padding: 4px 0;
}
.context-menu-item {
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.context-menu-item:hover {
background-color: #f5f7fa;
}
.context-menu-item.delete {
color: #f56c6c;
}
.context-menu-divider {
height: 1px;
background-color: #e4e7ed;
margin: 4px 0;
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div class="trash-view">
<div class="trash-header">
<h3>
<i class="el-icon-delete"></i> 回收站
<span v-if="trashNotes.length > 0" class="trash-count">({{ trashNotes.length }})</span>
</h3>
<div class="trash-header-actions">
<el-button
v-if="trashNotes.length > 0"
type="danger"
size="small"
icon="el-icon-delete-solid"
@click="emptyTrash"
>
清空回收站
</el-button>
</div>
</div>
<div v-if="trashNotes.length === 0" class="empty-trash">
<i class="el-icon-folder-delete"></i>
<p>回收站为空</p>
</div>
<div v-else class="trash-list">
<div
v-for="note in trashNotes"
:key="note.id"
class="trash-item"
>
<div class="trash-item-icon">
<i :class="note.isFolder ? 'el-icon-folder' : 'el-icon-document'"></i>
</div>
<div class="trash-item-info">
<div class="trash-item-title">{{ note.title || '未命名' }}</div>
<div class="trash-item-meta">
<span v-if="note.deletedAt" class="delete-time">
删除于: {{ formatDate(note.deletedAt) }}
</span>
</div>
</div>
<div class="trash-item-actions">
<el-button
type="primary"
size="mini"
icon="el-icon-refresh"
@click="restoreNote(note)"
>
恢复
</el-button>
<el-button
type="danger"
size="mini"
icon="el-icon-delete"
@click="permanentDelete(note)"
>
永久删除
</el-button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'TrashView',
props: {
trashNotes: {
type: Array,
default: () => []
}
},
methods: {
formatDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
},
restoreNote(note) {
this.$emit('restore', note)
},
permanentDelete(note) {
this.$emit('permanent-delete', note)
},
emptyTrash() {
this.$emit('empty-trash')
}
}
}
</script>
<style scoped>
.trash-view {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
}
.trash-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
}
.trash-header-actions {
display: flex;
gap: 8px;
}
.trash-header h3 {
margin: 0;
font-size: 18px;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
}
.trash-count {
color: #909399;
font-weight: normal;
}
.empty-trash {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
}
.empty-trash i {
font-size: 64px;
margin-bottom: 16px;
}
.trash-list {
flex: 1;
overflow-y: auto;
}
.trash-item {
display: flex;
align-items: center;
padding: 12px 16px;
border: 1px solid #ebeef5;
border-radius: 8px;
margin-bottom: 8px;
transition: all 0.2s;
}
.trash-item:hover {
border-color: #dcdfe6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.trash-item-icon {
font-size: 24px;
color: #909399;
margin-right: 12px;
}
.trash-item-info {
flex: 1;
min-width: 0;
}
.trash-item-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trash-item-meta {
font-size: 12px;
color: #909399;
}
.trash-item-actions {
display: flex;
gap: 8px;
margin-left: 12px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import Vue from 'vue'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'
import App from './App.vue'
import './style.css'
Vue.use(ElementUI, { size: 'small' })
Vue.use(mavonEditor)
new Vue({
render: h => h(App)
}).$mount('#app')
+160
View File
@@ -0,0 +1,160 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #ffffff;
color: #303133;
}
#app {
height: 100vh;
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: #c0c4cc;
border-radius: 3px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
/* Element UI 样式覆盖 */
.el-menu {
border-right: none !important;
}
.el-tree {
background-color: transparent !important;
}
/* Markdown 预览样式 */
.markdown-body {
font-size: 16px;
line-height: 1.6;
word-wrap: break-word;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 {
padding-bottom: 0.3em;
font-size: 2em;
border-bottom: 1px solid #eaecef;
}
.markdown-body h2 {
padding-bottom: 0.3em;
font-size: 1.5em;
border-bottom: 1px solid #eaecef;
}
.markdown-body code {
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
font-size: 85%;
margin: 0;
padding: 0.2em 0.4em;
}
.markdown-body pre {
background-color: #f6f8fa;
border-radius: 3px;
font-size: 85%;
line-height: 1.45;
overflow: auto;
padding: 16px;
}
.markdown-body pre code {
background-color: transparent;
border: 0;
display: inline;
line-height: inherit;
margin: 0;
max-width: auto;
overflow: visible;
padding: 0;
white-space: pre;
word-break: normal;
}
.markdown-body blockquote {
border-left: 0.25em solid #dfe2e5;
color: #6a737d;
margin: 0;
padding: 0 1em;
}
.markdown-body table {
border-collapse: collapse;
border-spacing: 0;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
}
.markdown-body table th,
.markdown-body table td {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
.markdown-body table th {
font-weight: 600;
background-color: #f6f8fa;
}
.markdown-body table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.markdown-body img {
border-style: none;
max-width: 100%;
box-sizing: border-box;
}
.markdown-body a {
color: #0366d6;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
+21
View File
@@ -0,0 +1,21 @@
import {defineConfig} from 'vite'
import { createVuePlugin } from 'vite-plugin-vue2'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [createVuePlugin()],
server: {
// Wails dev 模式下的配置
host: '0.0.0.0',
port: 5173,
strictPort: true,
// 不启用 HMR 的 WebSocket 代理,让 Wails 处理
},
// 确保资源路径正确
base: './',
build: {
// 确保输出目录正确
outDir: 'dist',
assetsDir: 'assets'
}
})
+61
View File
@@ -0,0 +1,61 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {models} from '../models';
export function CreateNote(arg1:number,arg2:string,arg3:boolean):Promise<models.Note>;
export function DeleteNote(arg1:number):Promise<void>;
export function EmptyTrash():Promise<void>;
export function ExportAllMarkdown():Promise<string>;
export function ExportFolderMarkdown(arg1:number):Promise<string>;
export function ExportHTML(arg1:string,arg2:string):Promise<string>;
export function ExportMarkdown(arg1:string,arg2:string):Promise<string>;
export function GetAssetsPath():Promise<string>;
export function GetFileServerURL():Promise<string>;
export function GetNote(arg1:number):Promise<models.Note>;
export function GetNoteTree():Promise<Array<models.NoteTree>>;
export function GetTrashNotes():Promise<Array<models.Note>>;
export function GetUser():Promise<Record<string, any>>;
export function IsLoggedIn():Promise<boolean>;
export function Login(arg1:string,arg2:string):Promise<string>;
export function Logout():Promise<void>;
export function PermanentDeleteNote(arg1:number):Promise<void>;
export function Register(arg1:string,arg2:string,arg3:string):Promise<string>;
export function ResetPassword(arg1:string,arg2:string,arg3:string):Promise<void>;
export function RestoreNote(arg1:number):Promise<void>;
export function RevealInFinder(arg1:string):Promise<void>;
export function SaveFile(arg1:string,arg2:string):Promise<string>;
export function SaveImage(arg1:string):Promise<string>;
export function SearchNotes(arg1:string):Promise<Array<models.Note>>;
export function SelectDirectory(arg1:string):Promise<string>;
export function SelectSaveFile(arg1:string,arg2:string):Promise<string>;
export function SendVerifyCode(arg1:string,arg2:string):Promise<void>;
export function UpdateNote(arg1:number,arg2:string,arg3:string,arg4:string):Promise<void>;
export function VerifyCode(arg1:string,arg2:string,arg3:string):Promise<void>;
+119
View File
@@ -0,0 +1,119 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CreateNote(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateNote'](arg1, arg2, arg3);
}
export function DeleteNote(arg1) {
return window['go']['main']['App']['DeleteNote'](arg1);
}
export function EmptyTrash() {
return window['go']['main']['App']['EmptyTrash']();
}
export function ExportAllMarkdown() {
return window['go']['main']['App']['ExportAllMarkdown']();
}
export function ExportFolderMarkdown(arg1) {
return window['go']['main']['App']['ExportFolderMarkdown'](arg1);
}
export function ExportHTML(arg1, arg2) {
return window['go']['main']['App']['ExportHTML'](arg1, arg2);
}
export function ExportMarkdown(arg1, arg2) {
return window['go']['main']['App']['ExportMarkdown'](arg1, arg2);
}
export function GetAssetsPath() {
return window['go']['main']['App']['GetAssetsPath']();
}
export function GetFileServerURL() {
return window['go']['main']['App']['GetFileServerURL']();
}
export function GetNote(arg1) {
return window['go']['main']['App']['GetNote'](arg1);
}
export function GetNoteTree() {
return window['go']['main']['App']['GetNoteTree']();
}
export function GetTrashNotes() {
return window['go']['main']['App']['GetTrashNotes']();
}
export function GetUser() {
return window['go']['main']['App']['GetUser']();
}
export function IsLoggedIn() {
return window['go']['main']['App']['IsLoggedIn']();
}
export function Login(arg1, arg2) {
return window['go']['main']['App']['Login'](arg1, arg2);
}
export function Logout() {
return window['go']['main']['App']['Logout']();
}
export function PermanentDeleteNote(arg1) {
return window['go']['main']['App']['PermanentDeleteNote'](arg1);
}
export function Register(arg1, arg2, arg3) {
return window['go']['main']['App']['Register'](arg1, arg2, arg3);
}
export function ResetPassword(arg1, arg2, arg3) {
return window['go']['main']['App']['ResetPassword'](arg1, arg2, arg3);
}
export function RestoreNote(arg1) {
return window['go']['main']['App']['RestoreNote'](arg1);
}
export function RevealInFinder(arg1) {
return window['go']['main']['App']['RevealInFinder'](arg1);
}
export function SaveFile(arg1, arg2) {
return window['go']['main']['App']['SaveFile'](arg1, arg2);
}
export function SaveImage(arg1) {
return window['go']['main']['App']['SaveImage'](arg1);
}
export function SearchNotes(arg1) {
return window['go']['main']['App']['SearchNotes'](arg1);
}
export function SelectDirectory(arg1) {
return window['go']['main']['App']['SelectDirectory'](arg1);
}
export function SelectSaveFile(arg1, arg2) {
return window['go']['main']['App']['SelectSaveFile'](arg1, arg2);
}
export function SendVerifyCode(arg1, arg2) {
return window['go']['main']['App']['SendVerifyCode'](arg1, arg2);
}
export function UpdateNote(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['UpdateNote'](arg1, arg2, arg3, arg4);
}
export function VerifyCode(arg1, arg2, arg3) {
return window['go']['main']['App']['VerifyCode'](arg1, arg2, arg3);
}
+113
View File
@@ -0,0 +1,113 @@
export namespace models {
export class Note {
id: number;
parentId: number;
title: string;
content: string;
sortOrder: number;
isFolder: boolean;
tag: string;
// Go type: time
createdAt: any;
// Go type: time
updatedAt: any;
// Go type: time
deletedAt?: any;
children?: Note[];
static createFrom(source: any = {}) {
return new Note(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.parentId = source["parentId"];
this.title = source["title"];
this.content = source["content"];
this.sortOrder = source["sortOrder"];
this.isFolder = source["isFolder"];
this.tag = source["tag"];
this.createdAt = this.convertValues(source["createdAt"], null);
this.updatedAt = this.convertValues(source["updatedAt"], null);
this.deletedAt = this.convertValues(source["deletedAt"], null);
this.children = this.convertValues(source["children"], Note);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class NoteTree {
id: number;
parentId: number;
title: string;
content: string;
sortOrder: number;
isFolder: boolean;
tag: string;
// Go type: time
createdAt: any;
// Go type: time
updatedAt: any;
// Go type: time
deletedAt?: any;
children?: Note[];
children: NoteTree[];
static createFrom(source: any = {}) {
return new NoteTree(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.parentId = source["parentId"];
this.title = source["title"];
this.content = source["content"];
this.sortOrder = source["sortOrder"];
this.isFolder = source["isFolder"];
this.tag = source["tag"];
this.createdAt = this.convertValues(source["createdAt"], null);
this.updatedAt = this.convertValues(source["updatedAt"], null);
this.deletedAt = this.convertValues(source["deletedAt"], null);
this.children = this.convertValues(source["children"], Note);
this.children = this.convertValues(source["children"], NoteTree);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}
+330
View File
@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+298
View File
@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+38
View File
@@ -0,0 +1,38 @@
module kenaito-notebook
go 1.25.0
require github.com/wailsapp/wails/v2 v2.12.0
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.12.0 => /Users/mac/go/pkg/mod
+82
View File
@@ -0,0 +1,82 @@
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
Title: "kenaito-notebook",
Width: 1024,
Height: 768,
MinWidth: 800,
MinHeight: 600,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
Mac: &mac.Options{
DisableZoom: false,
},
})
if err != nil {
println("Error:", err.Error())
}
}
+22
View File
@@ -0,0 +1,22 @@
package models
import "time"
type Note struct {
ID int64 `json:"id"`
ParentID int64 `json:"parentId"` // 0 = root level
Title string `json:"title"`
Content string `json:"content"`
SortOrder int `json:"sortOrder"`
IsFolder bool `json:"isFolder"`
Tag string `json:"tag"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt"` // nil = not deleted
Children []*Note `json:"children,omitempty"` // 子项(用于树形结构)
}
type NoteTree struct {
Note
Children []*NoteTree `json:"children"`
}
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"kenaito-notebook-server/services"
)
type Config struct {
Port int
BaseURL string
SMTP services.EmailConfig
}
func loadProperties(filename string) map[string]string {
props := make(map[string]string)
// Try executable directory first
exe, err := os.Executable()
if err == nil {
exeDir := filepath.Dir(exe)
props = loadFromFile(filepath.Join(exeDir, filename))
if len(props) > 0 {
return props
}
}
// Try working directory
props = loadFromFile(filename)
return props
}
func loadFromFile(path string) map[string]string {
props := make(map[string]string)
f, err := os.Open(path)
if err != nil {
return props
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if idx := strings.Index(line, "="); idx > 0 {
key := strings.TrimSpace(line[:idx])
value := strings.TrimSpace(line[idx+1:])
props[key] = value
}
}
return props
}
func DefaultConfig() *Config {
props := loadProperties("config.properties")
port := 8080
if p := props["server.port"]; p != "" {
if v, err := strconv.Atoi(p); err == nil {
port = v
}
}
baseURL := props["server.base_url"]
if baseURL == "" {
baseURL = fmt.Sprintf("http://localhost:%d", port)
}
return &Config{
Port: port,
BaseURL: baseURL,
SMTP: services.EmailConfig{
Host: props["smtp.host"],
Port: props["smtp.port"],
Username: props["smtp.username"],
Password: props["smtp.password"],
From: props["smtp.from"],
},
}
}
+48
View File
@@ -0,0 +1,48 @@
module kenaito-notebook-server
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/yuin/goldmark v1.8.2
golang.org/x/crypto v0.48.0
modernc.org/sqlite v1.53.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+137
View File
@@ -0,0 +1,137 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+338
View File
@@ -0,0 +1,338 @@
package handlers
import (
"log"
"net/http"
"net/mail"
"strings"
"github.com/gin-gonic/gin"
"kenaito-notebook-server/services"
)
func isValidEmail(email string) bool {
_, err := mail.ParseAddress(email)
return err == nil
}
type AuthHandler struct {
db *services.Database
auth *services.AuthService
email *services.EmailService
verify *services.VerificationService
}
func NewAuthHandler(db *services.Database, auth *services.AuthService, email *services.EmailService, verify *services.VerificationService) *AuthHandler {
return &AuthHandler{db: db, auth: auth, email: email, verify: verify}
}
type RegisterRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
Code string `json:"code" binding:"required"`
}
type LoginRequest struct {
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required"`
}
type SendCodeRequest struct {
Email string `json:"email" binding:"required"`
Purpose string `json:"purpose" binding:"required"`
}
type VerifyCodeRequest struct {
Email string `json:"email" binding:"required"`
Code string `json:"code" binding:"required"`
Purpose string `json:"purpose" binding:"required"`
}
type ResetPasswordRequest struct {
Email string `json:"email" binding:"required"`
Code string `json:"code" binding:"required"`
NewPassword string `json:"newPassword" binding:"required"`
}
// SendCode 发送验证码
func (h *AuthHandler) SendCode(c *gin.Context) {
var req SendCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[Auth] SendCode bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
return
}
if !isValidEmail(req.Email) {
log.Printf("[Auth] SendCode invalid email: %s", req.Email)
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
if req.Purpose != "register" && req.Purpose != "reset" {
log.Printf("[Auth] SendCode invalid purpose: %s", req.Purpose)
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求类型"})
return
}
// 注册时检查邮箱是否已注册
if req.Purpose == "register" {
existing, _ := h.db.GetUserByEmail(req.Email)
if existing != nil {
log.Printf("[Auth] SendCode email already registered: %s", req.Email)
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"})
return
}
}
// 重置密码时检查邮箱是否存在
if req.Purpose == "reset" {
existing, _ := h.db.GetUserByEmail(req.Email)
if existing == nil {
log.Printf("[Auth] SendCode email not found for reset: %s", req.Email)
c.JSON(http.StatusNotFound, gin.H{"error": "邮箱未注册"})
return
}
}
// 限频检查
if !h.verify.CheckRateLimit(req.Email, req.Purpose) {
log.Printf("[Auth] SendCode rate limit exceeded: %s, purpose: %s", req.Email, req.Purpose)
c.JSON(http.StatusTooManyRequests, gin.H{"error": "发送过于频繁,请 60 秒后重试"})
return
}
code := h.verify.GenerateCode(req.Email, req.Purpose)
if err := h.email.SendVerificationCode(req.Email, code, req.Purpose); err != nil {
log.Printf("[Auth] SendCode send email failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "发送验证码失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
}
// VerifyCode 验证验证码
func (h *AuthHandler) VerifyCode(c *gin.Context) {
var req VerifyCodeRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[Auth] VerifyCode bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
return
}
if err := h.verify.VerifyCode(req.Email, req.Code, req.Purpose); err != nil {
log.Printf("[Auth] VerifyCode failed: email=%s, purpose=%s, err=%v", req.Email, req.Purpose, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "验证码正确"})
}
// Register 用户注册
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[Auth] Register bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
return
}
if !isValidEmail(req.Email) {
log.Printf("[Auth] Register invalid email: %s", req.Email)
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
// 检查邮箱是否已注册
existing, _ := h.db.GetUserByEmail(req.Email)
if existing != nil {
log.Printf("[Auth] Register email already registered: %s", req.Email)
c.JSON(http.StatusConflict, gin.H{"error": "邮箱已被注册"})
return
}
// 验证并消耗验证码
if err := h.verify.UseCode(req.Email, req.Code, "register"); err != nil {
log.Printf("[Auth] Register use code failed: email=%s, err=%v", req.Email, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 哈希密码
hashedPassword, err := h.auth.HashPassword(req.Password)
if err != nil {
log.Printf("[Auth] Register hash password failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
return
}
// 创建用户
user, err := h.db.CreateUser(req.Email, hashedPassword)
if err != nil {
log.Printf("[Auth] Register create user failed: email=%s, err=%v", req.Email, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
return
}
// 生成 token
token, err := h.auth.GenerateToken(user.ID, user.Email)
if err != nil {
log.Printf("[Auth] Register generate token failed: userID=%d, err=%v", user.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "注册失败"})
return
}
c.JSON(http.StatusCreated, gin.H{
"token": token,
"user": user,
})
}
// Login 用户登录
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[Auth] Login bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写邮箱和密码"})
return
}
if !isValidEmail(req.Email) {
log.Printf("[Auth] Login invalid email: %s", req.Email)
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
// 查找用户
user, err := h.db.GetUserByEmail(req.Email)
if err != nil {
log.Printf("[Auth] Login user not found: %s, err=%v", req.Email, err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
// 验证密码
if !h.auth.CheckPassword(req.Password, user.HashedPassword) {
log.Printf("[Auth] Login password mismatch: email=%s", req.Email)
c.JSON(http.StatusUnauthorized, gin.H{"error": "邮箱或密码错误"})
return
}
// 生成 token
token, err := h.auth.GenerateToken(user.ID, user.Email)
if err != nil {
log.Printf("[Auth] Login generate token failed: userID=%d, err=%v", user.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "登录失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user,
})
}
// ResetPassword 重置密码
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("[Auth] ResetPassword bind error: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写完整信息"})
return
}
if !isValidEmail(req.Email) {
log.Printf("[Auth] ResetPassword invalid email: %s", req.Email)
c.JSON(http.StatusBadRequest, gin.H{"error": "邮箱格式不正确"})
return
}
// 验证并消耗验证码
if err := h.verify.UseCode(req.Email, req.Code, "reset"); err != nil {
log.Printf("[Auth] ResetPassword use code failed: email=%s, err=%v", req.Email, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 查找用户
user, err := h.db.GetUserByEmail(req.Email)
if err != nil {
log.Printf("[Auth] ResetPassword user not found: %s, err=%v", req.Email, err)
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
// 哈希新密码
hashedPassword, err := h.auth.HashPassword(req.NewPassword)
if err != nil {
log.Printf("[Auth] ResetPassword hash password failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置失败"})
return
}
// 更新密码
if err := h.db.UpdatePassword(user.ID, hashedPassword); err != nil {
log.Printf("[Auth] ResetPassword update password failed: userID=%d, err=%v", user.ID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码重置成功"})
}
// GetMe 获取当前用户信息
func (h *AuthHandler) GetMe(c *gin.Context) {
userID, exists := c.Get("userID")
if !exists {
log.Printf("[Auth] GetMe userID not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
user, err := h.db.GetUserByID(userID.(int64))
if err != nil {
log.Printf("[Auth] GetMe user not found: userID=%v, err=%v", userID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
return
}
c.JSON(http.StatusOK, user)
}
// AuthMiddleware JWT 认证中间件
func AuthMiddleware(auth *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
log.Printf("[Auth] Middleware missing Authorization header: ip=%s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
c.Abort()
return
}
// 提取 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
log.Printf("[Auth] Middleware invalid auth format: ip=%s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的认证格式"})
c.Abort()
return
}
payload, err := auth.ValidateToken(parts[1])
if err != nil {
log.Printf("[Auth] Middleware token validation failed: ip=%s, err=%v", c.ClientIP(), err)
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
c.Abort()
return
}
// 将用户信息存入上下文
c.Set("userID", payload.UserID)
c.Set("email", payload.Email)
c.Next()
}
}
+182
View File
@@ -0,0 +1,182 @@
package handlers
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
"kenaito-notebook-server/models"
"kenaito-notebook-server/services"
)
type ExportHandler struct {
db *services.Database
exportSvc *services.ExportService
}
func NewExportHandler(db *services.Database, exportSvc *services.ExportService) *ExportHandler {
return &ExportHandler{db: db, exportSvc: exportSvc}
}
func (h *ExportHandler) ExportHTML(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
outputPath, err := h.getTempExportPath(req.Title, ".html")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.exportSvc.ExportHTML(req.Title, req.Content, outputPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
}
func (h *ExportHandler) ExportMarkdown(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
outputPath, err := h.getTempExportPath(req.Title, ".md")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.exportSvc.ExportMarkdown(req.Title, req.Content, outputPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
}
func (h *ExportHandler) ExportFolderZip(c *gin.Context) {
userID := c.GetInt64("userID")
folderID, err := strconv.ParseInt(c.Param("folderId"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid folder id"})
return
}
folderTree, err := h.db.GetFolderTree(userID, folderID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
noteInfoTree := convertNoteToNoteInfo(folderTree)
outputPath, err := h.getTempExportPath(folderTree.Title, ".zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.exportSvc.ExportFolderZip(folderTree.Title, noteInfoTree.Children, outputPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
}
func (h *ExportHandler) ExportAllZip(c *gin.Context) {
userID := c.GetInt64("userID")
allNotesTree, err := h.db.GetAllNotesTree(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
noteInfos := make([]*services.NoteInfo, len(allNotesTree))
for i, noteTree := range allNotesTree {
noteInfos[i] = convertNoteTreeToNoteInfo(noteTree)
}
outputPath, err := h.getTempExportPath("所有笔记", ".zip")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.exportSvc.ExportAllZip(noteInfos, outputPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"filePath": outputPath})
}
func (h *ExportHandler) getTempExportPath(title, ext string) (string, error) {
tmpDir := filepath.Join(os.TempDir(), "kenaito-notebook-export")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
return "", fmt.Errorf("failed to create temp export dir: %w", err)
}
safeName := h.exportSvc.GetSafeFilename(title)
return filepath.Join(tmpDir, fmt.Sprintf("%s%s", safeName, ext)), nil
}
func convertNoteToNoteInfo(note *models.Note) *services.NoteInfo {
if note == nil {
return nil
}
info := &services.NoteInfo{
Title: note.Title,
Content: note.Content,
IsFolder: note.IsFolder,
}
if note.Children != nil && len(note.Children) > 0 {
info.Children = make([]*services.NoteInfo, len(note.Children))
for i, child := range note.Children {
info.Children[i] = convertNoteToNoteInfo(child)
}
}
return info
}
func convertNoteTreeToNoteInfo(noteTree *models.NoteTree) *services.NoteInfo {
if noteTree == nil {
return nil
}
info := &services.NoteInfo{
Title: noteTree.Title,
Content: noteTree.Content,
IsFolder: noteTree.IsFolder,
}
if noteTree.Children != nil && len(noteTree.Children) > 0 {
info.Children = make([]*services.NoteInfo, len(noteTree.Children))
for i, child := range noteTree.Children {
info.Children[i] = convertNoteTreeToNoteInfo(child)
}
}
return info
}
+60
View File
@@ -0,0 +1,60 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"kenaito-notebook-server/services"
)
type FileHandler struct {
fileSvc *services.FileService
}
func NewFileHandler(fileSvc *services.FileService) *FileHandler {
return &FileHandler{fileSvc: fileSvc}
}
func (h *FileHandler) SaveImage(c *gin.Context) {
var req struct {
Data string `json:"data" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
url, err := h.fileSvc.SaveImage(req.Data)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
func (h *FileHandler) SaveFile(c *gin.Context) {
var req struct {
Data string `json:"data" binding:"required"`
Filename string `json:"filename" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
url, err := h.fileSvc.SaveFile(req.Data, req.Filename)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"url": url})
}
func (h *FileHandler) GetFileServerURL(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": h.fileSvc.GetBaseURL()})
}
func (h *FileHandler) GetAssetsPath(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"path": h.fileSvc.GetAssetsPath()})
}
+170
View File
@@ -0,0 +1,170 @@
package handlers
import (
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"kenaito-notebook-server/services"
)
type NoteHandler struct {
db *services.Database
}
func NewNoteHandler(db *services.Database) *NoteHandler {
return &NoteHandler{db: db}
}
func (h *NoteHandler) GetNoteTree(c *gin.Context) {
userID := c.GetInt64("userID")
result, err := h.db.GetNoteTree(userID)
if err != nil {
log.Printf("[API] GetNoteTree error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
func (h *NoteHandler) GetNote(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
note, err := h.db.GetNote(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, note)
}
func (h *NoteHandler) CreateNote(c *gin.Context) {
userID := c.GetInt64("userID")
var req struct {
ParentID int64 `json:"parentId"`
Title string `json:"title"`
IsFolder bool `json:"isFolder"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
note, err := h.db.CreateNote(userID, req.ParentID, req.Title, req.IsFolder)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, note)
}
func (h *NoteHandler) UpdateNote(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
var req struct {
Title string `json:"title"`
Content string `json:"content"`
Tag string `json:"tag"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateNote(id, req.Title, req.Content, req.Tag); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "updated"})
}
func (h *NoteHandler) DeleteNote(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.db.DeleteNote(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
func (h *NoteHandler) SearchNotes(c *gin.Context) {
userID := c.GetInt64("userID")
keyword := c.Query("q")
if keyword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword is required"})
return
}
notes, err := h.db.SearchNotes(userID, keyword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, notes)
}
func (h *NoteHandler) GetTrashNotes(c *gin.Context) {
userID := c.GetInt64("userID")
notes, err := h.db.GetTrashNotes(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, notes)
}
func (h *NoteHandler) RestoreNote(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.db.RestoreNote(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "restored"})
}
func (h *NoteHandler) PermanentDeleteNote(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.db.PermanentDeleteNote(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "permanently deleted"})
}
func (h *NoteHandler) EmptyTrash(c *gin.Context) {
userID := c.GetInt64("userID")
if err := h.db.EmptyTrash(userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "trash emptied"})
}
+122
View File
@@ -0,0 +1,122 @@
package main
import (
"fmt"
"log"
"github.com/gin-gonic/gin"
"kenaito-notebook-server/handlers"
"kenaito-notebook-server/services"
)
func main() {
cfg := DefaultConfig()
db, err := services.NewDatabase()
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer db.Close()
log.Println("[Server] Database initialized")
fileSvc, err := services.NewFileService(cfg.BaseURL)
if err != nil {
log.Fatalf("Failed to initialize file service: %v", err)
}
log.Println("[Server] File service initialized")
exportSvc := services.NewExportService()
authSvc := services.NewAuthService()
emailSvc := services.NewEmailService(cfg.SMTP)
verifySvc := services.NewVerificationService()
log.Println("[Server] Services initialized")
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
// CORS
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(200)
return
}
c.Next()
})
noteHandler := handlers.NewNoteHandler(db)
fileHandler := handlers.NewFileHandler(fileSvc)
exportHandler := handlers.NewExportHandler(db, exportSvc)
authHandler := handlers.NewAuthHandler(db, authSvc, emailSvc, verifySvc)
// 静态文件服务(图片、附件等)
r.Static("/api/files/assets", fileSvc.GetAssetsPath())
api := r.Group("/api")
{
// 认证路由(无需登录)
auth := api.Group("/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
auth.POST("/send-code", authHandler.SendCode)
auth.POST("/verify-code", authHandler.VerifyCode)
auth.POST("/reset-password", authHandler.ResetPassword)
}
// 需要登录的路由
protected := api.Group("")
protected.Use(handlers.AuthMiddleware(authSvc))
{
// 用户信息
protected.GET("/auth/me", authHandler.GetMe)
// 笔记管理
notes := protected.Group("/notes")
{
notes.GET("/tree", noteHandler.GetNoteTree)
notes.GET("/search", noteHandler.SearchNotes)
notes.GET("/:id", noteHandler.GetNote)
notes.POST("", noteHandler.CreateNote)
notes.PUT("/:id", noteHandler.UpdateNote)
notes.DELETE("/:id", noteHandler.DeleteNote)
}
// 回收站
trash := protected.Group("/trash")
{
trash.GET("", noteHandler.GetTrashNotes)
trash.POST("/:id/restore", noteHandler.RestoreNote)
trash.DELETE("/:id", noteHandler.PermanentDeleteNote)
trash.DELETE("", noteHandler.EmptyTrash)
}
// 文件管理
files := protected.Group("/files")
{
files.POST("/image", fileHandler.SaveImage)
files.POST("/upload", fileHandler.SaveFile)
files.GET("/server-url", fileHandler.GetFileServerURL)
files.GET("/assets-path", fileHandler.GetAssetsPath)
}
// 导出
export := protected.Group("/export")
{
export.POST("/html", exportHandler.ExportHTML)
export.POST("/markdown", exportHandler.ExportMarkdown)
export.POST("/folder-zip/:folderId", exportHandler.ExportFolderZip)
export.POST("/all-zip", exportHandler.ExportAllZip)
}
}
}
addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("[Server] Starting on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}
+22
View File
@@ -0,0 +1,22 @@
package models
import "time"
type Note struct {
ID int64 `json:"id"`
ParentID int64 `json:"parentId"`
Title string `json:"title"`
Content string `json:"content"`
SortOrder int `json:"sortOrder"`
IsFolder bool `json:"isFolder"`
Tag string `json:"tag"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt"`
Children []*Note `json:"children,omitempty"`
}
type NoteTree struct {
Note
Children []*NoteTree `json:"children"`
}
+9
View File
@@ -0,0 +1,9 @@
package models
import "time"
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
}
+142
View File
@@ -0,0 +1,142 @@
package services
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("邮箱或密码错误")
ErrUserExists = errors.New("邮箱已被注册")
ErrInvalidToken = errors.New("无效的 token")
ErrTokenExpired = errors.New("token 已过期")
)
// JWT 密钥,生产环境应从环境变量或配置文件读取
var jwtSecret = []byte("kenaito-notebook-jwt-secret-2026")
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
type jwtPayload struct {
UserID int64 `json:"userId"`
Email string `json:"email"`
Exp int64 `json:"exp"`
Iat int64 `json:"iat"`
}
type AuthService struct{}
func NewAuthService() *AuthService {
return &AuthService{}
}
// HashPassword 使用 bcrypt 哈希密码
func (s *AuthService) HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword 验证密码
func (s *AuthService) CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// GenerateToken 生成 JWT token
func (s *AuthService) GenerateToken(userID int64, email string) (string, error) {
now := time.Now()
payload := jwtPayload{
UserID: userID,
Email: email,
Exp: now.Add(7 * 24 * time.Hour).Unix(), // 7 天过期
Iat: now.Unix(),
}
// Header
header := jwtHeader{Alg: "HS256", Typ: "JWT"}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// Payload
payloadJSON, err := json.Marshal(payload)
if err != nil {
return "", err
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
// 签名
signingInput := headerB64 + "." + payloadB64
mac := hmac.New(sha256.New, jwtSecret)
mac.Write([]byte(signingInput))
signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return signingInput + "." + signature, nil
}
// ValidateToken 验证 JWT token,返回 payload
func (s *AuthService) ValidateToken(tokenStr string) (*jwtPayload, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, ErrInvalidToken
}
signingInput := parts[0] + "." + parts[1]
// 验证签名
mac := hmac.New(sha256.New, jwtSecret)
mac.Write([]byte(signingInput))
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(parts[2]), []byte(expectedSig)) {
return nil, ErrInvalidToken
}
// 解析 payload
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrInvalidToken
}
var payload jwtPayload
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
return nil, ErrInvalidToken
}
// 检查过期
if time.Now().Unix() > payload.Exp {
return nil, ErrTokenExpired
}
return &payload, nil
}
// ExtractTokenID 从 token 字符串中提取用户 ID(不验证签名,仅用于快速检查)
func ExtractTokenID(token string) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return 0, fmt.Errorf("invalid token format")
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return 0, err
}
var payload jwtPayload
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
return 0, err
}
return payload.UserID, nil
}
+581
View File
@@ -0,0 +1,581 @@
package services
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"time"
_ "modernc.org/sqlite"
"kenaito-notebook-server/models"
)
type Database struct {
db *sql.DB
}
func NewDatabase() (*Database, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
dbDir := filepath.Join(homeDir, ".kenaito-notebook")
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
dbPath := filepath.Join(dbDir, "notes.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
}
if err := createTables(db); err != nil {
db.Close()
return nil, err
}
return &Database{db: db}, nil
}
func createTables(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER DEFAULT 0,
title TEXT NOT NULL DEFAULT '',
content TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
is_folder BOOLEAN DEFAULT FALSE,
tag TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_parent_id ON notes(parent_id);
CREATE INDEX IF NOT EXISTS idx_notes_sort_order ON notes(sort_order);
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
hashed_password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
// 迁移:给已有表加 deleted_at 列
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='deleted_at'").Scan(&count)
if err == nil && count == 0 {
_, err = db.Exec("ALTER TABLE notes ADD COLUMN deleted_at DATETIME DEFAULT NULL")
if err != nil {
return err
}
}
// 迁移:给已有表加 user_id 列
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name='user_id'").Scan(&count)
if err == nil && count == 0 {
_, err = db.Exec("ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
if err != nil {
return err
}
}
_, err = db.Exec(query)
return err
}
func (d *Database) Close() error {
return d.db.Close()
}
// --- 笔记相关方法 ---
func (d *Database) CreateNote(userID int64, parentID int64, title string, isFolder bool) (*models.Note, error) {
now := time.Now()
result, err := d.db.Exec(`
INSERT INTO notes (user_id, parent_id, title, is_folder, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, userID, parentID, title, isFolder, now, now)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &models.Note{
ID: id,
ParentID: parentID,
Title: title,
IsFolder: isFolder,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (d *Database) GetNote(id int64) (*models.Note, error) {
note := &models.Note{}
err := d.db.QueryRow(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes WHERE id = ?
`, id).Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
return note, nil
}
func (d *Database) GetNotesByParentID(userID, parentID int64) ([]*models.Note, error) {
rows, err := d.db.Query(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes WHERE user_id = ? AND parent_id = ? AND deleted_at IS NULL
ORDER BY sort_order, created_at
`, userID, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*models.Note
for rows.Next() {
note := &models.Note{}
err := rows.Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
notes = append(notes, note)
}
return notes, nil
}
func (d *Database) GetAllNotes(userID int64) ([]*models.Note, error) {
rows, err := d.db.Query(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes WHERE user_id = ? AND deleted_at IS NULL AND is_folder = FALSE
ORDER BY sort_order, created_at
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*models.Note
for rows.Next() {
note := &models.Note{}
err := rows.Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
notes = append(notes, note)
}
return notes, nil
}
func (d *Database) GetAllNotesWithFolders(userID int64) ([]*models.Note, error) {
rows, err := d.db.Query(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes WHERE user_id = ? AND deleted_at IS NULL
ORDER BY sort_order, created_at
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*models.Note
for rows.Next() {
note := &models.Note{}
err := rows.Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
notes = append(notes, note)
}
return notes, nil
}
func (d *Database) GetFolderTree(userID, folderID int64) (*models.Note, error) {
folder, err := d.GetNote(folderID)
if err != nil {
return nil, err
}
children, err := d.GetNotesByParentID(userID, folderID)
if err != nil {
return nil, err
}
folder.Children = make([]*models.Note, len(children))
for i, child := range children {
if child.IsFolder {
childFolder, err := d.GetFolderTree(userID, child.ID)
if err == nil {
folder.Children[i] = childFolder
} else {
folder.Children[i] = child
}
} else {
folder.Children[i] = child
}
}
return folder, nil
}
func (d *Database) GetAllNotesTree(userID int64) ([]*models.NoteTree, error) {
notes, err := d.GetAllNotesWithFolders(userID)
if err != nil {
return nil, err
}
return buildTree(notes, 0), nil
}
func (d *Database) UpdateNote(id int64, title, content, tag string) error {
now := time.Now()
_, err := d.db.Exec(`
UPDATE notes SET title = ?, content = ?, tag = ?, updated_at = ?
WHERE id = ?
`, title, content, tag, now, id)
return err
}
func (d *Database) DeleteNote(id int64) error {
now := time.Now()
err := d.softDeleteChildren(id, now)
if err != nil {
return err
}
_, err = d.db.Exec("UPDATE notes SET deleted_at = ? WHERE id = ?", now, id)
return err
}
func (d *Database) softDeleteChildren(parentID int64, deletedAt time.Time) error {
rows, err := d.db.Query("SELECT id FROM notes WHERE parent_id = ? AND deleted_at IS NULL", parentID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return err
}
if err := d.softDeleteChildren(id, deletedAt); err != nil {
return err
}
}
_, err = d.db.Exec("UPDATE notes SET deleted_at = ? WHERE parent_id = ? AND deleted_at IS NULL", deletedAt, parentID)
return err
}
func (d *Database) GetNoteTree(userID int64) ([]*models.NoteTree, error) {
notes, err := d.getAllNotes(userID)
if err != nil {
return nil, err
}
return buildTree(notes, 0), nil
}
func (d *Database) getAllNotes(userID int64) ([]*models.Note, error) {
rows, err := d.db.Query(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes
WHERE user_id = ? AND deleted_at IS NULL
ORDER BY sort_order, created_at
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*models.Note
for rows.Next() {
note := &models.Note{}
err := rows.Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
notes = append(notes, note)
}
return notes, nil
}
func buildTree(notes []*models.Note, parentID int64) []*models.NoteTree {
var result []*models.NoteTree
for _, note := range notes {
if note.ParentID == parentID {
node := &models.NoteTree{
Note: *note,
}
node.Children = buildTree(notes, note.ID)
result = append(result, node)
}
}
return result
}
func (d *Database) SearchNotes(userID int64, keyword string) ([]*models.Note, error) {
rows, err := d.db.Query(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes
WHERE user_id = ? AND (title LIKE ? OR content LIKE ?) AND deleted_at IS NULL
ORDER BY updated_at DESC
`, userID, "%"+keyword+"%", "%"+keyword+"%")
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*models.Note
for rows.Next() {
note := &models.Note{}
err := rows.Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
notes = append(notes, note)
}
return notes, nil
}
func (d *Database) UpdateSortOrder(id int64, sortOrder int) error {
_, err := d.db.Exec("UPDATE notes SET sort_order = ? WHERE id = ?", sortOrder, id)
return err
}
func (d *Database) GetTrashNotes(userID int64) ([]*models.Note, error) {
rows, err := d.db.Query(`
SELECT id, parent_id, title, content, sort_order, is_folder, tag, created_at, updated_at, deleted_at
FROM notes
WHERE user_id = ? AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*models.Note
for rows.Next() {
note := &models.Note{}
err := rows.Scan(
&note.ID, &note.ParentID, &note.Title, &note.Content,
&note.SortOrder, &note.IsFolder, &note.Tag,
&note.CreatedAt, &note.UpdatedAt, &note.DeletedAt,
)
if err != nil {
return nil, err
}
notes = append(notes, note)
}
return notes, nil
}
func (d *Database) RestoreNote(id int64) error {
note, err := d.GetNote(id)
if err != nil {
return err
}
if note.IsFolder {
err = d.restoreChildrenRecursive(id)
if err != nil {
return err
}
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL WHERE id = ?", id)
} else {
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL, parent_id = 0 WHERE id = ?", id)
}
return err
}
func (d *Database) restoreChildrenRecursive(parentID int64) error {
var parentDeletedAt *time.Time
err := d.db.QueryRow("SELECT deleted_at FROM notes WHERE id = ?", parentID).Scan(&parentDeletedAt)
if err != nil || parentDeletedAt == nil {
return nil
}
rows, err := d.db.Query(
"SELECT id, is_folder FROM notes WHERE parent_id = ? AND deleted_at = ?",
parentID, *parentDeletedAt,
)
if err != nil {
return err
}
defer rows.Close()
type childInfo struct {
id int64
isFolder bool
}
var children []childInfo
for rows.Next() {
var child childInfo
if err := rows.Scan(&child.id, &child.isFolder); err != nil {
return err
}
children = append(children, child)
}
for _, child := range children {
if child.isFolder {
if err := d.restoreChildrenRecursive(child.id); err != nil {
return err
}
}
}
if len(children) > 0 {
_, err = d.db.Exec("UPDATE notes SET deleted_at = NULL WHERE parent_id = ? AND deleted_at = ?", parentID, *parentDeletedAt)
if err != nil {
return err
}
}
return nil
}
func (d *Database) PermanentDeleteNote(id int64) error {
err := d.permanentDeleteChildren(id)
if err != nil {
return err
}
_, err = d.db.Exec("DELETE FROM notes WHERE id = ?", id)
return err
}
func (d *Database) permanentDeleteChildren(parentID int64) error {
rows, err := d.db.Query("SELECT id FROM notes WHERE parent_id = ?", parentID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return err
}
if err := d.permanentDeleteChildren(id); err != nil {
return err
}
}
_, err = d.db.Exec("DELETE FROM notes WHERE parent_id = ?", parentID)
return err
}
func (d *Database) EmptyTrash(userID int64) error {
_, err := d.db.Exec("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL", userID)
return err
}
// --- 用户相关方法 ---
type UserRecord struct {
ID int64 `json:"id"`
HashedPassword string `json:"-"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
}
func (d *Database) CreateUser(email, hashedPassword string) (*UserRecord, error) {
now := time.Now()
result, err := d.db.Exec(`
INSERT INTO users (email, hashed_password, created_at)
VALUES (?, ?, ?)
`, email, hashedPassword, now)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
return &UserRecord{
ID: id,
HashedPassword: hashedPassword,
Email: email,
CreatedAt: now,
}, nil
}
func (d *Database) GetUserByEmail(email string) (*UserRecord, error) {
user := &UserRecord{}
err := d.db.QueryRow(`
SELECT id, hashed_password, email, created_at
FROM users WHERE email = ?
`, email).Scan(&user.ID, &user.HashedPassword, &user.Email, &user.CreatedAt)
if err != nil {
return nil, err
}
return user, nil
}
func (d *Database) GetUserByID(id int64) (*UserRecord, error) {
user := &UserRecord{}
err := d.db.QueryRow(`
SELECT id, hashed_password, email, created_at
FROM users WHERE id = ?
`, id).Scan(&user.ID, &user.HashedPassword, &user.Email, &user.CreatedAt)
if err != nil {
return nil, err
}
return user, nil
}
func (d *Database) UpdatePassword(id int64, hashedPassword string) error {
_, err := d.db.Exec("UPDATE users SET hashed_password = ? WHERE id = ?", hashedPassword, id)
return err
}
+136
View File
@@ -0,0 +1,136 @@
package services
import (
"crypto/tls"
"fmt"
"log"
"net"
"net/smtp"
"strings"
)
type EmailConfig struct {
Host string
Port string
Username string
Password string
From string
}
type EmailService struct {
cfg EmailConfig
configured bool
}
func NewEmailService(cfg EmailConfig) *EmailService {
configured := cfg.Host != "" && cfg.Username != "" && cfg.Password != ""
if !configured {
log.Println("[Email] SMTP not configured, will print codes to console")
}
return &EmailService{cfg: cfg, configured: configured}
}
// SendVerificationCode 发送验证码邮件
func (s *EmailService) SendVerificationCode(to, code, purpose string) error {
var subject, body string
switch purpose {
case "register":
subject = "Kenaito Notebook - 注册验证码"
body = fmt.Sprintf(`
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #333;">Kenaito Notebook</h2>
<p>你正在注册账号,验证码如下:</p>
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; text-align: center; margin: 20px 0;">
<span style="font-size: 32px; font-weight: bold; color: #409eff; letter-spacing: 8px;">%s</span>
</div>
<p style="color: #909399; font-size: 14px;">验证码 5 分钟内有效,请勿泄露给他人。</p>
</div>`, code)
case "reset":
subject = "Kenaito Notebook - 重置密码验证码"
body = fmt.Sprintf(`
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2 style="color: #333;">Kenaito Notebook</h2>
<p>你正在重置密码,验证码如下:</p>
<div style="background: #f5f7fa; padding: 16px; border-radius: 8px; text-align: center; margin: 20px 0;">
<span style="font-size: 32px; font-weight: bold; color: #e6a23c; letter-spacing: 8px;">%s</span>
</div>
<p style="color: #909399; font-size: 14px;">验证码 5 分钟内有效,请勿泄露给他人。</p>
</div>`, code)
default:
return fmt.Errorf("unknown purpose: %s", purpose)
}
if !s.configured {
log.Printf("[Email] SMTP not configured. Verification code for %s (%s): %s", to, purpose, code)
return nil
}
return s.send(to, subject, body)
}
func (s *EmailService) send(to, subject, htmlBody string) error {
addr := net.JoinHostPort(s.cfg.Host, s.cfg.Port)
headers := make(map[string]string)
headers["From"] = s.cfg.From
headers["To"] = to
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/html; charset=UTF-8"
var msg strings.Builder
for k, v := range headers {
msg.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
msg.WriteString("\r\n")
msg.WriteString(htmlBody)
auth := smtp.PlainAuth("", s.cfg.Username, s.cfg.Password, s.cfg.Host)
// SSL connection (port 465)
if s.cfg.Port == "465" {
return s.sendTLS(addr, auth, to, msg.String())
}
// STARTTLS connection (port 587)
return smtp.SendMail(addr, auth, s.cfg.From, []string{to}, []byte(msg.String()))
}
func (s *EmailService) sendTLS(addr string, auth smtp.Auth, to, msg string) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{
ServerName: s.cfg.Host,
})
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, s.cfg.Host)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP auth failed: %w", err)
}
if err = client.Mail(s.cfg.From); err != nil {
return fmt.Errorf("SMTP MAIL FROM failed: %w", err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO failed: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("SMTP DATA failed: %w", err)
}
if _, err = w.Write([]byte(msg)); err != nil {
return fmt.Errorf("SMTP write failed: %w", err)
}
if err = w.Close(); err != nil {
return fmt.Errorf("SMTP close data failed: %w", err)
}
return client.Quit()
}
+247
View File
@@ -0,0 +1,247 @@
package services
import (
"archive/zip"
"fmt"
"html/template"
"os"
"path/filepath"
"strings"
"time"
"github.com/yuin/goldmark"
)
type ExportService struct{}
func NewExportService() *ExportService {
return &ExportService{}
}
func (es *ExportService) ExportHTML(title, content, outputPath string) error {
var htmlContent strings.Builder
if err := goldmark.Convert([]byte(content), &htmlContent); err != nil {
return err
}
htmlTemplate := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
max-width: 900px;
margin: 0 auto;
padding: 20px;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
}
h1 { border-bottom: 2px solid #eee; padding-bottom: 10px; }
code {
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: "Consolas", "Monaco", monospace;
}
pre {
background-color: #f8f8f8;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
overflow-x: auto;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #ddd;
margin: 0;
padding: 0 20px;
color: #666;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}
th, td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #f5f5f5;
}
img {
max-width: 100%;
height: auto;
}
a {
color: #0066cc;
}
</style>
</head>
<body>
<h1>{{.Title}}</h1>
{{.Content}}
<hr>
<p style="color: #999; font-size: 12px;">
导出于 {{.ExportTime}} | Kenaito Notebook
</p>
</body>
</html>`
tmpl, err := template.New("html").Parse(htmlTemplate)
if err != nil {
return err
}
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
return tmpl.Execute(file, map[string]string{
"Title": title,
"Content": htmlContent.String(),
"ExportTime": fmt.Sprintf("%s", time.Now().Format("2006-01-02 15:04:05")),
})
}
func (es *ExportService) ExportMarkdown(title, content, outputPath string) error {
fullContent := fmt.Sprintf("# %s\n\n%s", title, content)
return os.WriteFile(outputPath, []byte(fullContent), 0644)
}
func (es *ExportService) ExportFolderMarkdown(folderTitle string, notes []NoteInfo, outputPath string) error {
var content strings.Builder
content.WriteString(fmt.Sprintf("# %s\n\n", folderTitle))
for _, note := range notes {
content.WriteString(fmt.Sprintf("## %s\n\n", note.Title))
content.WriteString(note.Content)
content.WriteString("\n\n---\n\n")
}
return os.WriteFile(outputPath, []byte(content.String()), 0644)
}
func (es *ExportService) ExportAllMarkdown(notes []NoteInfo, outputPath string) error {
var content strings.Builder
content.WriteString("# 所有笔记\n\n")
for _, note := range notes {
content.WriteString(fmt.Sprintf("## %s\n\n", note.Title))
content.WriteString(note.Content)
content.WriteString("\n\n---\n\n")
}
return os.WriteFile(outputPath, []byte(content.String()), 0644)
}
func (es *ExportService) ExportFolderZip(folderTitle string, notes []*NoteInfo, outputPath string) error {
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
w := zip.NewWriter(file)
defer w.Close()
rootFolder := es.GetSafeFilename(folderTitle) + "/"
_, err = w.Create(rootFolder)
if err != nil {
return err
}
es.addNotesToZip(w, notes, rootFolder)
return nil
}
func (es *ExportService) ExportAllZip(notes []*NoteInfo, outputPath string) error {
file, err := os.Create(outputPath)
if err != nil {
return err
}
defer file.Close()
w := zip.NewWriter(file)
defer w.Close()
rootFolder := "所有笔记/"
_, err = w.Create(rootFolder)
if err != nil {
return err
}
es.addNotesToZip(w, notes, rootFolder)
return nil
}
func (es *ExportService) addNotesToZip(w *zip.Writer, notes []*NoteInfo, parentPath string) {
for _, note := range notes {
safeName := es.GetSafeFilename(note.Title)
if note.IsFolder {
folderPath := parentPath + safeName + "/"
_, _ = w.Create(folderPath)
if note.Children != nil {
es.addNotesToZip(w, note.Children, folderPath)
}
} else {
filePath := parentPath + safeName + ".md"
fullContent := fmt.Sprintf("# %s\n\n%s", note.Title, note.Content)
f, err := w.Create(filePath)
if err != nil {
continue
}
_, _ = f.Write([]byte(fullContent))
}
}
}
type NoteInfo struct {
Title string
Content string
IsFolder bool
Children []*NoteInfo
}
func (es *ExportService) GetDefaultExportPath(title, ext string) string {
homeDir, _ := os.UserHomeDir()
exportDir := filepath.Join(homeDir, "Desktop")
safeName := es.GetSafeFilename(title)
return filepath.Join(exportDir, fmt.Sprintf("%s%s", safeName, ext))
}
func (es *ExportService) GetSafeFilename(title string) string {
safeName := strings.ReplaceAll(title, "/", "-")
safeName = strings.ReplaceAll(safeName, "\\", "-")
safeName = strings.ReplaceAll(safeName, ":", "-")
safeName = strings.ReplaceAll(safeName, "*", "")
safeName = strings.ReplaceAll(safeName, "?", "")
safeName = strings.ReplaceAll(safeName, "\"", "")
safeName = strings.ReplaceAll(safeName, "<", "")
safeName = strings.ReplaceAll(safeName, ">", "")
safeName = strings.ReplaceAll(safeName, "|", "")
if strings.TrimSpace(safeName) == "" {
safeName = "未命名笔记"
}
return safeName
}
+122
View File
@@ -0,0 +1,122 @@
package services
import (
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
const MaxFileSize = 6 * 1024 * 1024 // 6MB
type FileService struct {
dataDir string
baseURL string
}
func NewFileService(baseURL string) (*FileService, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dataDir := filepath.Join(homeDir, ".kenaito-notebook", "assets")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, err
}
log.Printf("[FileService] Assets directory: %s", dataDir)
log.Printf("[FileService] Base URL: %s", baseURL)
return &FileService{
dataDir: dataDir,
baseURL: strings.TrimRight(baseURL, "/"),
}, nil
}
func (fs *FileService) GetBaseURL() string {
return fs.baseURL
}
func (fs *FileService) SaveImage(base64Data string) (string, error) {
parts := strings.Split(base64Data, ",")
if len(parts) != 2 {
return "", fmt.Errorf("invalid base64 format")
}
header := parts[0]
ext := ".png"
if strings.Contains(header, "jpeg") || strings.Contains(header, "jpg") {
ext = ".jpg"
} else if strings.Contains(header, "gif") {
ext = ".gif"
} else if strings.Contains(header, "webp") {
ext = ".webp"
}
data, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
if int64(len(data)) > MaxFileSize {
return "", fmt.Errorf("文件大小超过 6MB 限制")
}
filename := fmt.Sprintf("%s%s", uuid.New().String()[:8], ext)
dateDir := time.Now().Format("2006/01")
dirPath := filepath.Join(fs.dataDir, dateDir)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return "", err
}
filePath := filepath.Join(dirPath, filename)
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", err
}
return fmt.Sprintf("%s/api/files/assets/%s/%s", fs.baseURL, dateDir, filename), nil
}
func (fs *FileService) SaveFile(base64Data, filename string) (string, error) {
parts := strings.Split(base64Data, ",")
if len(parts) != 2 {
return "", fmt.Errorf("invalid base64 format")
}
data, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
if int64(len(data)) > MaxFileSize {
return "", fmt.Errorf("文件大小超过 6MB 限制")
}
ext := filepath.Ext(filename)
nameWithoutExt := strings.TrimSuffix(filename, ext)
uniqueFilename := fmt.Sprintf("%s_%s%s", nameWithoutExt, uuid.New().String()[:8], ext)
dateDir := time.Now().Format("2006/01")
dirPath := filepath.Join(fs.dataDir, dateDir)
if err := os.MkdirAll(dirPath, 0755); err != nil {
return "", err
}
filePath := filepath.Join(dirPath, uniqueFilename)
if err := os.WriteFile(filePath, data, 0644); err != nil {
return "", err
}
return fmt.Sprintf("%s/api/files/assets/%s/%s", fs.baseURL, dateDir, uniqueFilename), nil
}
func (fs *FileService) GetAssetsPath() string {
return fs.dataDir
}
+116
View File
@@ -0,0 +1,116 @@
package services
import (
"fmt"
"math/rand"
"sync"
"time"
)
type verificationEntry struct {
code string
expiresAt time.Time
}
type VerificationService struct {
mu sync.RWMutex
codes map[string]*verificationEntry // key: "email:purpose"
times map[string]time.Time // key: "email:purpose" — 上次发送时间
}
func NewVerificationService() *VerificationService {
v := &VerificationService{
codes: make(map[string]*verificationEntry),
times: make(map[string]time.Time),
}
// 定期清理过期验证码
go v.cleanup()
return v
}
// GenerateCode 生成 6 位随机验证码
func (v *VerificationService) GenerateCode(email, purpose string) string {
code := fmt.Sprintf("%06d", rand.Intn(1000000))
key := email + ":" + purpose
v.mu.Lock()
v.codes[key] = &verificationEntry{
code: code,
expiresAt: time.Now().Add(5 * time.Minute),
}
v.times[key] = time.Now()
v.mu.Unlock()
return code
}
// CheckRateLimit 检查是否在 60 秒限频内
func (v *VerificationService) CheckRateLimit(email, purpose string) bool {
key := email + ":" + purpose
v.mu.RLock()
last, exists := v.times[key]
v.mu.RUnlock()
if exists && time.Since(last) < 60*time.Second {
return false // 被限频
}
return true
}
// VerifyCode 验证验证码(不消耗,仅校验)
func (v *VerificationService) VerifyCode(email, code, purpose string) error {
key := email + ":" + purpose
v.mu.RLock()
entry, exists := v.codes[key]
v.mu.RUnlock()
if !exists {
return fmt.Errorf("验证码不存在或已过期")
}
if time.Now().After(entry.expiresAt) {
return fmt.Errorf("验证码已过期")
}
if entry.code != code {
return fmt.Errorf("验证码错误")
}
return nil
}
// UseCode 验证并消耗验证码(验证通过后删除)
func (v *VerificationService) UseCode(email, code, purpose string) error {
key := email + ":" + purpose
v.mu.Lock()
defer v.mu.Unlock()
entry, exists := v.codes[key]
if !exists {
return fmt.Errorf("验证码不存在或已过期")
}
if time.Now().After(entry.expiresAt) {
delete(v.codes, key)
return fmt.Errorf("验证码已过期")
}
if entry.code != code {
return fmt.Errorf("验证码错误")
}
delete(v.codes, key)
return nil
}
// cleanup 定期清理过期验证码
func (v *VerificationService) cleanup() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for range ticker.C {
v.mu.Lock()
now := time.Now()
for key, entry := range v.codes {
if now.After(entry.expiresAt) {
delete(v.codes, key)
}
}
v.mu.Unlock()
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "kenaito-notebook",
"outputfilename": "kenaito-notebook",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"author": {
"name": "Odboy",
"email": "tianjun@odboy.cn"
}
}