509 lines
11 KiB
Go
509 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/app"
|
|
"fyne.io/fyne/v2/canvas"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/layout"
|
|
"fyne.io/fyne/v2/theme"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// Note 笔记数据结构
|
|
type Note struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Content string `json:"content"`
|
|
Tags []string `json:"tags"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// NoteStore 笔记存储管理
|
|
type NoteStore struct {
|
|
notes []Note
|
|
path string
|
|
app fyne.App
|
|
window fyne.Window
|
|
}
|
|
|
|
func NewNoteStore(app fyne.App, window fyne.Window) *NoteStore {
|
|
home, _ := os.UserHomeDir()
|
|
path := filepath.Join(home, ".goknow", "notes.json")
|
|
os.MkdirAll(filepath.Dir(path), 0755)
|
|
|
|
store := &NoteStore{
|
|
app: app,
|
|
window: window,
|
|
path: path,
|
|
}
|
|
store.Load()
|
|
return store
|
|
}
|
|
|
|
func (s *NoteStore) Load() {
|
|
data, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
s.notes = []Note{}
|
|
return
|
|
}
|
|
json.Unmarshal(data, &s.notes)
|
|
}
|
|
|
|
func (s *NoteStore) Save() {
|
|
data, _ := json.MarshalIndent(s.notes, "", " ")
|
|
os.WriteFile(s.path, data, 0644)
|
|
}
|
|
|
|
func (s *NoteStore) Add(note Note) {
|
|
s.notes = append(s.notes, note)
|
|
s.Save()
|
|
}
|
|
|
|
func (s *NoteStore) Update(id string, title, content string, tags []string) {
|
|
for i := range s.notes {
|
|
if s.notes[i].ID == id {
|
|
s.notes[i].Title = title
|
|
s.notes[i].Content = content
|
|
s.notes[i].Tags = tags
|
|
s.notes[i].UpdatedAt = time.Now()
|
|
s.Save()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *NoteStore) Delete(id string) {
|
|
for i := range s.notes {
|
|
if s.notes[i].ID == id {
|
|
s.notes = append(s.notes[:i], s.notes[i+1:]...)
|
|
s.Save()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *NoteStore) Search(query string) []Note {
|
|
if query == "" {
|
|
// 按更新时间排序
|
|
sort.Slice(s.notes, func(i, j int) bool {
|
|
return s.notes[i].UpdatedAt.After(s.notes[j].UpdatedAt)
|
|
})
|
|
return s.notes
|
|
}
|
|
|
|
var results []Note
|
|
query = strings.ToLower(query)
|
|
for _, note := range s.notes {
|
|
if strings.Contains(strings.ToLower(note.Title), query) ||
|
|
strings.Contains(strings.ToLower(note.Content), query) ||
|
|
containsTag(note.Tags, query) {
|
|
results = append(results, note)
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
func containsTag(tags []string, query string) bool {
|
|
for _, tag := range tags {
|
|
if strings.Contains(strings.ToLower(tag), query) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UI 组件
|
|
type NoteApp struct {
|
|
store *NoteStore
|
|
window fyne.Window
|
|
noteList *widget.List
|
|
searchBox *widget.Entry
|
|
titleEntry *widget.Entry
|
|
content *widget.Entry
|
|
tagsEntry *widget.Entry
|
|
preview *widget.RichText
|
|
currentID string
|
|
notes []Note
|
|
}
|
|
|
|
func NewNoteApp(app fyne.App) *NoteApp {
|
|
window := app.NewWindow("GoKnow - 知识管理")
|
|
window.Resize(fyne.NewSize(1200, 800))
|
|
window.CenterOnScreen()
|
|
|
|
noteApp := &NoteApp{
|
|
window: window,
|
|
}
|
|
noteApp.store = NewNoteStore(app, window)
|
|
noteApp.setupUI()
|
|
return noteApp
|
|
}
|
|
|
|
func (a *NoteApp) setupUI() {
|
|
// 左侧边栏 - 搜索和列表
|
|
a.searchBox = widget.NewEntry()
|
|
a.searchBox.SetPlaceHolder("🔍 搜索笔记、标签...")
|
|
a.searchBox.OnChanged = func(s string) {
|
|
a.refreshList()
|
|
}
|
|
|
|
// 新建按钮
|
|
newBtn := widget.NewButtonWithIcon("新建笔记", theme.ContentAddIcon(), func() {
|
|
a.createNewNote()
|
|
})
|
|
newBtn.Importance = widget.HighImportance
|
|
|
|
// 笔记列表
|
|
a.noteList = widget.NewList(
|
|
func() int { return len(a.notes) },
|
|
func() fyne.CanvasObject {
|
|
title := widget.NewLabel("Title")
|
|
timeLabel := widget.NewLabel("Time")
|
|
return container.NewBorder(
|
|
nil, nil,
|
|
widget.NewIcon(theme.DocumentIcon()),
|
|
timeLabel,
|
|
title,
|
|
)
|
|
},
|
|
func(i widget.ListItemID, o fyne.CanvasObject) {
|
|
note := a.notes[i]
|
|
box := o.(*fyne.Container)
|
|
|
|
// The structure from NewBorder with (top, bottom, left, right, center) is:
|
|
// Objects[0]: center (title), Objects[1]: left (icon), Objects[2]: right (timeLabel)
|
|
title := box.Objects[0].(*widget.Label)
|
|
title.SetText(note.Title)
|
|
if note.Title == "" {
|
|
title.SetText("无标题")
|
|
}
|
|
|
|
timeLabel := box.Objects[2].(*widget.Label)
|
|
timeLabel.SetText(formatTime(note.UpdatedAt))
|
|
timeLabel.TextStyle = fyne.TextStyle{Italic: true}
|
|
},
|
|
)
|
|
|
|
a.noteList.OnSelected = func(id widget.ListItemID) {
|
|
if id < len(a.notes) {
|
|
a.loadNote(a.notes[id])
|
|
}
|
|
}
|
|
|
|
sidebar := container.NewBorder(
|
|
container.NewVBox(a.searchBox, newBtn),
|
|
nil, nil, nil,
|
|
a.noteList,
|
|
)
|
|
|
|
// 右侧编辑器
|
|
a.titleEntry = widget.NewEntry()
|
|
a.titleEntry.SetPlaceHolder("笔记标题")
|
|
a.titleEntry.TextStyle = fyne.TextStyle{Bold: true}
|
|
|
|
a.tagsEntry = widget.NewEntry()
|
|
a.tagsEntry.SetPlaceHolder("标签: 用逗号分隔, 如: go, 编程, 笔记")
|
|
|
|
a.content = widget.NewMultiLineEntry()
|
|
a.content.SetPlaceHolder("在此输入 Markdown 内容...\n\n支持:\n- 标题\n- **粗体**\n- *斜体*\n- `代码`\n- [链接](url)")
|
|
a.content.Wrapping = fyne.TextWrapWord
|
|
|
|
// 预览区域
|
|
a.preview = widget.NewRichText()
|
|
a.preview.Wrapping = fyne.TextWrapWord
|
|
|
|
// 工具栏
|
|
saveBtn := widget.NewButtonWithIcon("保存", theme.DocumentSaveIcon(), func() {
|
|
a.saveCurrentNote()
|
|
})
|
|
saveBtn.Importance = widget.HighImportance
|
|
|
|
deleteBtn := widget.NewButtonWithIcon("删除", theme.DeleteIcon(), func() {
|
|
a.deleteCurrentNote()
|
|
})
|
|
|
|
toolbar := container.NewHBox(saveBtn, deleteBtn, layout.NewSpacer())
|
|
|
|
// 编辑器容器
|
|
editor := container.NewBorder(
|
|
container.NewVBox(
|
|
a.titleEntry,
|
|
widget.NewSeparator(),
|
|
a.tagsEntry,
|
|
widget.NewSeparator(),
|
|
toolbar,
|
|
),
|
|
nil, nil, nil,
|
|
container.NewHSplit(
|
|
a.content,
|
|
container.NewScroll(a.preview),
|
|
),
|
|
)
|
|
|
|
// 主布局
|
|
split := container.NewHSplit(sidebar, editor)
|
|
split.Offset = 0.3
|
|
|
|
a.window.SetContent(split)
|
|
|
|
// 实时预览
|
|
a.content.OnChanged = func(s string) {
|
|
a.updatePreview(s)
|
|
}
|
|
|
|
// 初始加载
|
|
a.refreshList()
|
|
if len(a.notes) > 0 {
|
|
a.noteList.Select(0)
|
|
} else {
|
|
a.createNewNote()
|
|
}
|
|
}
|
|
|
|
func (a *NoteApp) createNewNote() {
|
|
note := Note{
|
|
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
a.store.Add(note)
|
|
a.refreshList()
|
|
a.selectNoteByID(note.ID)
|
|
a.titleEntry.SetText("")
|
|
a.content.SetText("")
|
|
a.tagsEntry.SetText("")
|
|
a.currentID = note.ID
|
|
a.updatePreview("")
|
|
}
|
|
|
|
func (a *NoteApp) loadNote(note Note) {
|
|
a.currentID = note.ID
|
|
a.titleEntry.SetText(note.Title)
|
|
a.content.SetText(note.Content)
|
|
a.tagsEntry.SetText(strings.Join(note.Tags, ", "))
|
|
a.updatePreview(note.Content)
|
|
}
|
|
|
|
func (a *NoteApp) saveCurrentNote() {
|
|
if a.currentID == "" {
|
|
return
|
|
}
|
|
|
|
tags := []string{}
|
|
for _, t := range strings.Split(a.tagsEntry.Text, ",") {
|
|
t = strings.TrimSpace(t)
|
|
if t != "" {
|
|
tags = append(tags, t)
|
|
}
|
|
}
|
|
|
|
a.store.Update(a.currentID, a.titleEntry.Text, a.content.Text, tags)
|
|
a.refreshList()
|
|
|
|
// 显示保存动画提示
|
|
a.showToast("已保存")
|
|
}
|
|
|
|
func (a *NoteApp) deleteCurrentNote() {
|
|
if a.currentID == "" {
|
|
return
|
|
}
|
|
|
|
dialog.ShowConfirm("确认删除", "确定要删除这条笔记吗?", func(confirm bool) {
|
|
if confirm {
|
|
a.store.Delete(a.currentID)
|
|
a.refreshList()
|
|
if len(a.notes) > 0 {
|
|
a.noteList.Select(0)
|
|
} else {
|
|
a.createNewNote()
|
|
}
|
|
}
|
|
}, a.window)
|
|
}
|
|
|
|
func (a *NoteApp) refreshList() {
|
|
a.notes = a.store.Search(a.searchBox.Text)
|
|
a.noteList.Refresh()
|
|
if len(a.notes) == 0 && a.searchBox.Text == "" {
|
|
// 如果没有笔记且不是搜索状态,创建默认笔记
|
|
a.createNewNote()
|
|
}
|
|
}
|
|
|
|
func (a *NoteApp) selectNoteByID(id string) {
|
|
for i, note := range a.notes {
|
|
if note.ID == id {
|
|
a.noteList.Select(i)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *NoteApp) updatePreview(markdown string) {
|
|
// 简单的 Markdown 渲染
|
|
segments := parseMarkdown(markdown)
|
|
a.preview.Segments = segments
|
|
a.preview.Refresh()
|
|
}
|
|
|
|
func (a *NoteApp) showToast(message string) {
|
|
// 创建临时提示
|
|
toast := canvas.NewText(message, theme.PrimaryColor())
|
|
toast.TextSize = 16
|
|
toast.TextStyle = fyne.TextStyle{Bold: true}
|
|
|
|
// 添加到窗口右上角
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
}()
|
|
}
|
|
|
|
func formatTime(t time.Time) string {
|
|
now := time.Now()
|
|
diff := now.Sub(t)
|
|
|
|
if diff < time.Hour {
|
|
return fmt.Sprintf("%d分钟前", int(diff.Minutes()))
|
|
} else if diff < 24*time.Hour {
|
|
return fmt.Sprintf("%d小时前", int(diff.Hours()))
|
|
} else if diff < 7*24*time.Hour {
|
|
return fmt.Sprintf("%d天前", int(diff.Hours()/24))
|
|
}
|
|
return t.Format("01-02")
|
|
}
|
|
|
|
// 简单的 Markdown 解析器
|
|
func parseMarkdown(text string) []widget.RichTextSegment {
|
|
var segments []widget.RichTextSegment
|
|
lines := strings.Split(text, "\n")
|
|
|
|
for i, line := range lines {
|
|
// 标题
|
|
if strings.HasPrefix(line, "# ") {
|
|
seg := &widget.TextSegment{
|
|
Text: strings.TrimPrefix(line, "# "),
|
|
Style: widget.RichTextStyleHeading,
|
|
}
|
|
seg.Style.TextStyle = fyne.TextStyle{Bold: true}
|
|
seg.Style.SizeName = theme.SizeNameHeadingText
|
|
segments = append(segments, seg)
|
|
} else if strings.HasPrefix(line, "## ") {
|
|
seg := &widget.TextSegment{
|
|
Text: strings.TrimPrefix(line, "## "),
|
|
Style: widget.RichTextStyleSubHeading,
|
|
}
|
|
seg.Style.TextStyle = fyne.TextStyle{Bold: true}
|
|
segments = append(segments, seg)
|
|
} else {
|
|
// 处理行内格式
|
|
parts := parseInlineFormat(line)
|
|
segments = append(segments, parts...)
|
|
}
|
|
|
|
// 添加换行(除了最后一行)
|
|
if i < len(lines)-1 {
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: "\n",
|
|
Style: widget.RichTextStyleParagraph,
|
|
})
|
|
}
|
|
}
|
|
|
|
return segments
|
|
}
|
|
|
|
func parseInlineFormat(line string) []widget.RichTextSegment {
|
|
var segments []widget.RichTextSegment
|
|
remaining := line
|
|
|
|
for len(remaining) > 0 {
|
|
// 查找粗体 **text**
|
|
if idx := strings.Index(remaining, "**"); idx != -1 {
|
|
if idx > 0 {
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: remaining[:idx],
|
|
Style: widget.RichTextStyleInline,
|
|
})
|
|
}
|
|
end := strings.Index(remaining[idx+2:], "**")
|
|
if end != -1 {
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: remaining[idx+2 : idx+2+end],
|
|
Style: widget.RichTextStyleStrong,
|
|
})
|
|
remaining = remaining[idx+2+end+2:]
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 查找斜体 *text*
|
|
if idx := strings.Index(remaining, "*"); idx != -1 && !strings.HasPrefix(remaining, "**") {
|
|
if idx > 0 {
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: remaining[:idx],
|
|
Style: widget.RichTextStyleInline,
|
|
})
|
|
}
|
|
end := strings.Index(remaining[idx+1:], "*")
|
|
if end != -1 {
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: remaining[idx+1 : idx+1+end],
|
|
Style: widget.RichTextStyleEmphasis,
|
|
})
|
|
remaining = remaining[idx+1+end+1:]
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 查找代码 `text`
|
|
if idx := strings.Index(remaining, "`"); idx != -1 {
|
|
if idx > 0 {
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: remaining[:idx],
|
|
Style: widget.RichTextStyleInline,
|
|
})
|
|
}
|
|
end := strings.Index(remaining[idx+1:], "`")
|
|
if end != -1 {
|
|
seg := &widget.TextSegment{
|
|
Text: remaining[idx+1 : idx+1+end],
|
|
Style: widget.RichTextStyleCodeBlock,
|
|
}
|
|
seg.Style.ColorName = theme.ColorNamePrimary
|
|
segments = append(segments, seg)
|
|
remaining = remaining[idx+1+end+1:]
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 普通文本
|
|
segments = append(segments, &widget.TextSegment{
|
|
Text: remaining,
|
|
Style: widget.RichTextStyleInline,
|
|
})
|
|
break
|
|
}
|
|
|
|
return segments
|
|
}
|
|
|
|
func main() {
|
|
myApp := app.NewWithID("cn.miw.goknow")
|
|
myApp.Settings().SetTheme(theme.DarkTheme())
|
|
|
|
noteApp := NewNoteApp(myApp)
|
|
noteApp.window.ShowAndRun()
|
|
}
|