gonotes/internal/notes/notes.go

299 lines
6.8 KiB
Go

// Package notes implements a data structure for reasoning about and rendering
// markdown notes
package notes
import (
"bufio"
"bytes"
"encoding/base64"
"errors"
"fmt"
"html/template"
"log"
"os"
"path/filepath"
"strings"
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
markdown "github.com/teekennedy/goldmark-markdown"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
"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"
"gopkg.in/yaml.v2"
)
// 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
Owner string
Viewers []string
}
type NoteStore struct {
notes map[string][]*Note
}
var (
Notes NoteStore
md goldmark.Markdown
)
func Init() error {
Notes = NoteStore{
notes: make(map[string][]*Note),
}
md = goldmark.New(
goldmark.WithExtensions(
extension.TaskList, meta.Meta,
),
)
notesDir := conf.Conf.NotesDir
files, err := os.ReadDir(notesDir)
if err != nil {
if os.IsNotExist(err) {
os.MkdirAll(notesDir, os.FileMode(0750))
} else {
log.Print(err.Error())
return err
}
}
log.Printf("Looking in %s", notesDir)
for _, f := range files {
if !f.IsDir() {
encodedTitle := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name()))
note, err := LoadNote(encodedTitle)
if err != nil {
return err
}
title := note.Title
log.Printf("Found note %s (title '%s', owner %s)", encodedTitle, title, note.Owner)
Notes.notes[note.Owner] = append(Notes.notes[note.Owner], note)
for _, viewer := range note.Viewers {
Notes.notes[viewer] = append(Notes.notes[viewer], note)
}
}
}
return nil
}
func (ns *NoteStore) Get(owner string) []*Note {
return ns.notes[owner]
}
func (ns *NoteStore) GetOne(owner string, encodedTitle string) (*Note, bool) {
notes := ns.Get(owner)
for _, note := range notes {
if note.EncodedTitle() == encodedTitle {
log.Printf("Found single note during GetOne %+v", note)
return note, true
}
}
return nil, false
}
func (ns *NoteStore) Add(note *Note) {
ns.notes[note.Owner] = append(ns.notes[note.Owner], note)
}
func fmtPath(path string) string {
return fmt.Sprintf("%s.%s", path, conf.Conf.Extension)
}
func (n *Note) marshalFrontmatter() ([]byte, error) {
frontmatter := make(map[string]interface{})
frontmatter["owner"] = n.Owner
frontmatter["viewers"] = n.Viewers
marshaled, err := yaml.Marshal(&frontmatter)
return marshaled, err
}
// Save a note to a path derived from the title
func (n *Note) Save() error {
frontmatter, err := n.marshalFrontmatter()
if err != nil {
return err
}
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.EncodedTitle()))
return os.WriteFile(
filename,
[]byte(fmt.Sprintf("---\n%s---\n%s", frontmatter, n.Body)),
0600,
)
}
// Render the markdown content of the note to HTML
func (n *Note) Render() {
var buf bytes.Buffer
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))
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
bodyScanner := bufio.NewScanner(f)
body := make([]byte, 0, 10)
fullBody := make([]byte, 0, 10)
inFrontmatter := false
for bodyScanner.Scan() {
fullBody = append(fullBody, bodyScanner.Bytes()...)
fullBody = append(fullBody, '\n')
text := bodyScanner.Text()
if text == "---" {
if !inFrontmatter {
inFrontmatter = true
} else {
inFrontmatter = false
break
}
}
}
for bodyScanner.Scan() {
body = append(body, bodyScanner.Bytes()...)
body = append(body, '\n')
}
title := DecodeTitle(encodedTitle)
var buf bytes.Buffer
context := parser.NewContext()
if err := md.Convert([]byte(fullBody), &buf, parser.WithContext(context)); err != nil {
return nil, err
}
metaData := meta.Get(context)
log.Printf("note has frontmatter %+v", metaData)
owner, ok := metaData["owner"].(string)
if !ok {
return nil, errors.New("invalid note, missing 'owner' in frontmatter")
}
note := &Note{Title: title, Body: body, Owner: owner}
viewers := metaData["viewers"].([]interface{})
for _, viewer := range viewers {
v, ok := viewer.(string)
if !ok {
return nil, errors.New("invalid note, non string type in 'viewers' in frontmatter")
}
note.Viewers = append(note.Viewers, v)
}
log.Printf("Note %s shared with %v", note.Title, note.Viewers)
return note, 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 renderTaskCheckBox(writer util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
var err error
var _ int
n, ok := node.(*astext.TaskCheckBox)
if !ok {
return ast.WalkContinue, errors.New("not a TaskCheckBox")
}
if entering {
// Render a box if necessary
if n.IsChecked {
_, err = writer.Write([]byte("[x] "))
} else {
_, err = writer.Write([]byte("[ ] "))
}
}
return ast.WalkContinue, err
}
func (n *Note) ToggleBox(nthBox int) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox}
transformer := util.Prioritized(&checkboxTransformer, 0)
renderer := markdown.NewRenderer()
renderer.Register(astext.KindTaskCheckBox, renderTaskCheckBox)
var buf bytes.Buffer
md := goldmark.New(goldmark.WithRenderer(renderer))
md.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(extension.NewTaskCheckBoxParser(), 0),
), 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)
}