// Notes implements a data structure for reasoning about and rendering // markdown notes package notes import ( "bytes" "encoding/base64" "fmt" "html/template" "log" "os" "path/filepath" "forgejo.gwairfelin.com/max/gonotes/internal/conf" markdown "github.com/teekennedy/goldmark-markdown" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" astext "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" ) // Note is the central data structure. It can be Saved, Rendered and Loaded // using the Save, Render and LoadNote functions. type Note struct { Title string BodyRendered template.HTML Body []byte } func fmtPath(path string) string { return fmt.Sprintf("%s.%s", path, conf.Conf.Extension) } // Save a note to a path derived from the title func (n *Note) Save() error { filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.EncodedTitle())) return os.WriteFile(filename, n.Body, 0600) } // Render the markdown content of the note to HTML func (n *Note) Render() { var buf bytes.Buffer md := goldmark.New( goldmark.WithExtensions( extension.TaskList, ), ) err := md.Convert(n.Body, &buf) if err != nil { log.Fatal(err) } n.BodyRendered = template.HTML(buf.String()) } // LoadNote from the disk. The path is derived from the title func LoadNote(encodedTitle string) (*Note, error) { filename := filepath.Join(conf.Conf.NotesDir, fmtPath(encodedTitle)) body, err := os.ReadFile(filename) if err != nil { return nil, err } title := DecodeTitle(encodedTitle) return &Note{Title: title, Body: body}, nil } func DeleteNote(title string) error { filename := filepath.Join(conf.Conf.NotesDir, fmtPath(title)) return os.Remove(filename) } func (n *Note) EncodedTitle() string { return base64.StdEncoding.EncodeToString([]byte(n.Title)) } type toggleCheckboxTransformer struct{ nthBox int } func (t *toggleCheckboxTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { boxesFound := 0 // Walk to find checkbox, toggle checked status ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } box, ok := n.(*astext.TaskCheckBox) if !ok { return ast.WalkContinue, nil } if boxesFound < t.nthBox { boxesFound += 1 return ast.WalkContinue, nil } box.IsChecked = !box.IsChecked return ast.WalkStop, nil }) } func (r *markdown.Renderer) renderTaskCheckBox(node ast.Node, entering bool) ast.WalkStatus { if entering { var itemPrefix []byte l := r.rc.lists[len(r.rc.lists)-1] if l.list.IsOrdered() { itemPrefix = append(itemPrefix, []byte(fmt.Sprint(l.num))...) r.rc.lists[len(r.rc.lists)-1].num += 1 } itemPrefix = append(itemPrefix, l.list.Marker, ' ') // Prefix the current line with the item prefix r.rc.writer.PushPrefix(itemPrefix, 0, 0) // Prefix subsequent lines with padding the same length as the item prefix indentLen := int(max(r.config.NestedListLength, NestedListLengthMinimum)) indent := bytes.Repeat([]byte{' '}, indentLen) r.rc.writer.PushPrefix(bytes.Repeat(indent, len(itemPrefix)), 1) } else { r.rc.writer.PopPrefix() r.rc.writer.PopPrefix() } return ast.WalkContinue } func (n *Note) ToggleBox(nthBox int) { log.Printf("Toggling %dth box", nthBox) checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox} transformer := util.Prioritized(&checkboxTransformer, 0) renderer := markdown.NewRenderer() renderer.Register(astext.KindTaskCheckBox, func(writer util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { }) var buf bytes.Buffer md := goldmark.New( goldmark.WithExtensions( extension.TaskList, ), goldmark.WithRenderer(markdown.NewRenderer()), goldmark.WithParserOptions(parser.WithASTTransformers(transformer)), ) md.Convert(n.Body, &buf) n.Body = buf.Bytes() n.Save() } func DecodeTitle(encodedTitle string) string { title, err := base64.StdEncoding.DecodeString(encodedTitle) if err != nil { log.Printf("Couldn't decode base64 string '%s': %s", encodedTitle, err) } return string(title) }