refactor
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
this pakckage basically handles notes which are the individual files for spaced
|
||||
repetition in mend
|
||||
*/
|
||||
|
||||
package note
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
glStyles "github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
type Section struct {
|
||||
Title string
|
||||
Content string
|
||||
Hints []string
|
||||
}
|
||||
|
||||
type ViewState int
|
||||
|
||||
const (
|
||||
StateTitleOnly ViewState = iota
|
||||
StateContent
|
||||
StateHints
|
||||
)
|
||||
|
||||
type NoteView struct {
|
||||
// the actual content
|
||||
Path string
|
||||
rawContent string // full content for editing
|
||||
sections []Section
|
||||
currentSectionIndex int
|
||||
// display layer
|
||||
err error
|
||||
loading bool
|
||||
vp viewport.Model
|
||||
mdRenderer *glamour.TermRenderer
|
||||
viewState ViewState
|
||||
// editing
|
||||
textarea textarea.Model
|
||||
isEditing bool
|
||||
}
|
||||
|
||||
// ================== messages ===================
|
||||
type LoadNoteMsg struct {
|
||||
Path string
|
||||
Force bool
|
||||
}
|
||||
|
||||
type LoadedNote struct {
|
||||
RawContent string
|
||||
Sections []Section
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewNoteView() *NoteView {
|
||||
mdRenderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath(glStyles.TokyoNightStyle),
|
||||
glamour.WithWordWrap(80),
|
||||
)
|
||||
ta := textarea.New()
|
||||
ta.Focus()
|
||||
ta.Prompt = ""
|
||||
ta.ShowLineNumbers = false
|
||||
|
||||
return &NoteView{
|
||||
loading: false,
|
||||
mdRenderer: mdRenderer,
|
||||
vp: viewport.New(0, 0),
|
||||
viewState: StateTitleOnly,
|
||||
textarea: ta,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *NoteView) Init() tea.Cmd {
|
||||
return textarea.Blink
|
||||
}
|
||||
|
||||
func (m *NoteView) IsEditing() bool {
|
||||
return m.isEditing
|
||||
}
|
||||
|
||||
func (m *NoteView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.vp.Width = msg.Width
|
||||
m.vp.Height = msg.Height - 1
|
||||
m.textarea.SetWidth(msg.Width)
|
||||
m.textarea.SetHeight(msg.Height)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.isEditing {
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+q":
|
||||
m.isEditing = false
|
||||
return m, saveContent(m.Path, m.textarea.Value())
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.textarea, cmd = m.textarea.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
if m.Path != "" && !m.loading {
|
||||
m.isEditing = true
|
||||
m.textarea.SetValue(m.rawContent)
|
||||
m.textarea.Focus()
|
||||
return m, textarea.Blink
|
||||
}
|
||||
case " ":
|
||||
switch m.viewState {
|
||||
case StateTitleOnly:
|
||||
m.viewState = StateHints
|
||||
case StateHints:
|
||||
m.viewState = StateContent
|
||||
case StateContent:
|
||||
m.viewState = StateTitleOnly
|
||||
}
|
||||
m.vp.SetContent(m.renderNote())
|
||||
case "pgup":
|
||||
m.vp.PageUp()
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
m.vp.PageDown()
|
||||
return m, nil
|
||||
case "left", "a":
|
||||
if m.currentSectionIndex > 0 {
|
||||
m.currentSectionIndex--
|
||||
m.vp.SetContent(m.renderNote())
|
||||
m.vp.GotoTop()
|
||||
}
|
||||
return m, nil
|
||||
case "right", "d":
|
||||
if m.currentSectionIndex < len(m.sections)-1 {
|
||||
m.currentSectionIndex++
|
||||
m.vp.SetContent(m.renderNote())
|
||||
m.vp.GotoTop()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.vp, cmd = m.vp.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case LoadNoteMsg:
|
||||
if m.Path == msg.Path && !msg.Force {
|
||||
return m, nil //noop
|
||||
}
|
||||
m.Path = msg.Path
|
||||
m.isEditing = false
|
||||
m.loading = true
|
||||
m.currentSectionIndex = 0
|
||||
return m, fetchContent(msg.Path)
|
||||
|
||||
case LoadedNote:
|
||||
m.loading = false
|
||||
m.rawContent = msg.RawContent
|
||||
m.sections = msg.Sections
|
||||
m.err = msg.Err
|
||||
m.currentSectionIndex = 0
|
||||
m.viewState = StateTitleOnly
|
||||
m.vp.SetContent(m.renderNote())
|
||||
|
||||
return m, nil
|
||||
|
||||
case tea.MouseMsg:
|
||||
if m.isEditing {
|
||||
return m, nil // or handle mouse in textarea if supported
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.vp, cmd = m.vp.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m NoteView) View() string {
|
||||
if m.loading {
|
||||
return "loading..."
|
||||
}
|
||||
if m.Path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
return "Error: " + m.err.Error()
|
||||
}
|
||||
|
||||
if m.isEditing {
|
||||
return m.textarea.View()
|
||||
}
|
||||
|
||||
page := m.currentSectionIndex + 1
|
||||
total := len(m.sections)
|
||||
var footer string
|
||||
if total == 0 {
|
||||
footer = "No sections"
|
||||
} else {
|
||||
footer = fmt.Sprintf("%d/%d", page, total)
|
||||
}
|
||||
footer = lipgloss.NewStyle().Width(m.vp.Width).Align(lipgloss.Right).Render(footer)
|
||||
|
||||
return m.vp.View() + "\n" + footer
|
||||
}
|
||||
|
||||
func fetchContent(path string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return LoadedNote{Err: err}
|
||||
}
|
||||
|
||||
rawContent := string(data)
|
||||
sections := ParseSections(data)
|
||||
|
||||
return LoadedNote{
|
||||
RawContent: rawContent,
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ParseSections(source []byte) []Section {
|
||||
md := goldmark.New()
|
||||
reader := text.NewReader(source)
|
||||
doc := md.Parser().Parse(reader)
|
||||
|
||||
title := "no title"
|
||||
sections := make([]Section, 0)
|
||||
lastPos := 0
|
||||
|
||||
for child := doc.FirstChild(); child != nil; child = child.NextSibling() {
|
||||
if child.Kind() == ast.KindHeading {
|
||||
if child.Lines().Len() <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
level := child.(*ast.Heading).Level
|
||||
headingStart := child.Lines().At(0).Start - level - 1
|
||||
headingEnd := child.Lines().At(child.Lines().Len() - 1).Stop
|
||||
|
||||
contentEnd := headingStart
|
||||
if lastPos < contentEnd {
|
||||
// you have a heading and a content to accumulte over
|
||||
contentsRaw := source[lastPos:contentEnd]
|
||||
contents := strings.TrimSpace(string(contentsRaw))
|
||||
hints := ExtractHints(contents)
|
||||
sections = append(sections, Section{
|
||||
Title: title,
|
||||
Content: contents,
|
||||
Hints: hints,
|
||||
})
|
||||
lastPos = headingEnd
|
||||
}
|
||||
// for the next heading
|
||||
title = strings.TrimSpace(string(source[headingStart:headingEnd]))
|
||||
lastPos = headingEnd
|
||||
}
|
||||
}
|
||||
// last section
|
||||
if lastPos < len(source) {
|
||||
contentsRaw := source[lastPos:]
|
||||
contents := strings.TrimSpace(string(contentsRaw))
|
||||
hints := ExtractHints(contents)
|
||||
sections = append(sections, Section{
|
||||
Title: title,
|
||||
Content: contents,
|
||||
Hints: hints,
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
func saveContent(path, content string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := os.WriteFile(path, []byte(content), 0644)
|
||||
if err != nil {
|
||||
return LoadedNote{Err: err}
|
||||
}
|
||||
return fetchContent(path)()
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractHints(content string) []string {
|
||||
re := regexp.MustCompile(`(?s)\*\*(.*?)\*\*|__(.*?)__`)
|
||||
matches := re.FindAllStringSubmatch(content, -1)
|
||||
hints := make([]string, 0)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 && match[1] != "" {
|
||||
hints = append(hints, match[1])
|
||||
} else if len(match) > 2 && match[2] != "" {
|
||||
hints = append(hints, match[2])
|
||||
}
|
||||
}
|
||||
return hints
|
||||
}
|
||||
|
||||
func (m NoteView) renderNote() string {
|
||||
if m.Path == "" {
|
||||
return "" // no note is loaded, don't need to bother with anything
|
||||
}
|
||||
var titleText, contentText string
|
||||
if len(m.sections) > 0 {
|
||||
titleText = m.sections[m.currentSectionIndex].Title
|
||||
contentText = m.sections[m.currentSectionIndex].Content
|
||||
} else {
|
||||
contentText = m.rawContent
|
||||
}
|
||||
|
||||
title, err1 := m.mdRenderer.Render(titleText)
|
||||
if err1 != nil {
|
||||
title = titleText + "\n\n"
|
||||
}
|
||||
|
||||
var body string
|
||||
var err2 error
|
||||
|
||||
switch m.viewState {
|
||||
case StateContent:
|
||||
body, err2 = m.mdRenderer.Render(contentText)
|
||||
case StateHints:
|
||||
currentHints := []string{}
|
||||
if m.currentSectionIndex < len(m.sections) {
|
||||
currentHints = m.sections[m.currentSectionIndex].Hints
|
||||
}
|
||||
|
||||
if len(currentHints) == 0 {
|
||||
body = "No hints available."
|
||||
} else {
|
||||
// Format hints as a list
|
||||
hintsList := ""
|
||||
for _, h := range currentHints {
|
||||
hintsList += "- " + h + "\n"
|
||||
}
|
||||
body, err2 = m.mdRenderer.Render(hintsList)
|
||||
}
|
||||
case StateTitleOnly:
|
||||
body = "" // TOOD: there was a message here but I removed it, maybe remove enum entry as well
|
||||
// or improve this part ui
|
||||
}
|
||||
|
||||
if err2 != nil {
|
||||
return title + "\nError rendering content."
|
||||
}
|
||||
|
||||
return title + body
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package note
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tests hint extraction from text
|
||||
func TestExtractHints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "single bold hint",
|
||||
content: "This is a **hint**.",
|
||||
expected: []string{"hint"},
|
||||
},
|
||||
{
|
||||
name: "multiple bold hints",
|
||||
content: "**one** and **two**",
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
name: "underscore hints",
|
||||
content: "__one__ and __two__",
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
name: "mixed hints",
|
||||
content: "**one** and __two__",
|
||||
expected: []string{"one", "two"},
|
||||
},
|
||||
{
|
||||
name: "no hints",
|
||||
content: "just plain text",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractHints(tt.content)
|
||||
if !reflect.DeepEqual(got, tt.expected) {
|
||||
t.Errorf("ExtractHints() = %v, want %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tests section parsing from markdown
|
||||
func TestParseSections(t *testing.T) {
|
||||
content := []byte(`# Title 1
|
||||
Content 1 with **hint1**.
|
||||
|
||||
# Title 2
|
||||
Content 2 with __hint2__.
|
||||
`)
|
||||
|
||||
sections := ParseSections(content)
|
||||
|
||||
if len(sections) != 2 {
|
||||
t.Fatalf("expected 2 sections, got %d", len(sections))
|
||||
}
|
||||
|
||||
// verify first section
|
||||
if sections[0].Title != "# Title 1" {
|
||||
t.Errorf("expected title '# Title 1', got '%s'", sections[0].Title)
|
||||
}
|
||||
if sections[0].Content != "Content 1 with **hint1**." {
|
||||
t.Errorf("expected content match for section 1, got '%s'", sections[0].Content)
|
||||
}
|
||||
if len(sections[0].Hints) != 1 || sections[0].Hints[0] != "hint1" {
|
||||
t.Error("failed to extract hint from section 1")
|
||||
}
|
||||
|
||||
// verify second section
|
||||
if sections[1].Title != "# Title 2" {
|
||||
t.Errorf("expected title '# Title 2', got '%s'", sections[1].Title)
|
||||
}
|
||||
if sections[1].Content != "Content 2 with __hint2__." {
|
||||
t.Errorf("expected content match for section 2, got '%s'", sections[1].Content)
|
||||
}
|
||||
if len(sections[1].Hints) != 1 || sections[1].Hints[0] != "hint2" {
|
||||
t.Error("failed to extract hint from section 2")
|
||||
}
|
||||
}
|
||||
|
||||
// tests parsing with no headers
|
||||
func TestParseSectionsNoHeaders(t *testing.T) {
|
||||
content := []byte(`Just some content without any headers.
|
||||
It has **one hint**.`)
|
||||
|
||||
sections := ParseSections(content)
|
||||
|
||||
if len(sections) != 1 {
|
||||
t.Fatalf("expected 1 section, got %d", len(sections))
|
||||
}
|
||||
|
||||
if sections[0].Title != "no title" {
|
||||
t.Errorf("expected 'no title', got '%s'", sections[0].Title)
|
||||
}
|
||||
if len(sections[0].Hints) != 1 || sections[0].Hints[0] != "one hint" {
|
||||
t.Error("failed to extract hint")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user