// Package notes implements a data structure for reasoning about and rendering // markdown notes package notes import ( "bufio" "bytes" "crypto/rand" "errors" "fmt" "html/template" "log" "os" "path/filepath" "strings" "time" "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 map[string]struct{} Uid string LastModified time.Time Tags []string } type noteSet map[*Note]bool type NoteStore struct { notes map[string]noteSet } var ( Notes NoteStore md goldmark.Markdown ) func Init() error { Notes = NoteStore{ notes: make(map[string]noteSet), } 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(0o750)) } else { log.Print(err.Error()) return err } } log.Printf("Looking in %s", notesDir) for _, f := range files { if !f.IsDir() { uid := strings.TrimSuffix(f.Name(), filepath.Ext(f.Name())) note, err := loadNote(uid) if err != nil { return err } title := note.Title log.Printf("Found note %s (title '%s', owner %s)", uid, title, note.Owner) Notes.Add(note, note.Owner) for viewer := range note.Viewers { Notes.Add(note, viewer) } } } return nil } func (ns *NoteStore) Get(owner string) noteSet { return ns.notes[owner] } func (ns *NoteStore) GetOne(owner string, uid string) (*Note, bool) { notes := ns.Get(owner) for note, _ := range notes { if note.Uid == uid { log.Printf("Found single note during GetOne %s", note.Title) return note, true } } return nil, false } func (ns *NoteStore) Add(note *Note, user string) { if ns.notes[user] == nil { ns.notes[user] = make(noteSet) } ns.notes[user][note] = true } func (ns *NoteStore) Del(note *Note, user string) { delete(ns.notes[user], note) } func (ns *NoteStore) UserTags(user string) []string { tagSet := make(map[string]bool) notes, ok := ns.notes[user] if !ok { return make([]string, 0) } log.Printf("Got notes for user %v", notes) for note := range notes { log.Printf("considering note %s (%s)", note.Title, note.Tags) for _, tag := range note.Tags { tagSet[tag] = true } } log.Printf("Tagset is %v", tagSet) tags := make([]string, len(tagSet)) i := 0 for tag := range tagSet { tags[i] = tag i++ } return tags } func fmtPath(path string) string { return fmt.Sprintf("%s.%s", path, conf.Conf.Extension) } func (n *Note) marshalFrontmatter() ([]byte, error) { viewers := make([]string, 0, len(n.Viewers)) for viewer := range n.Viewers { viewers = append(viewers, viewer) } frontmatter := make(map[string]any) frontmatter["owner"] = n.Owner frontmatter["viewers"] = viewers frontmatter["title"] = n.Title frontmatter["tags"] = n.Tags marshaled, err := yaml.Marshal(&frontmatter) return marshaled, err } func (n *Note) ViewersAsList() []string { keys := make([]string, 0, len(n.Viewers)) for key := range n.Viewers { keys = append(keys, key) } return keys } func NewNoteNoSave(title string, owner string) *Note { note := &Note{Title: title, Owner: owner} note.Viewers = make(map[string]struct{}) note.Tags = make([]string, 0, 5) return note } func NewNote(title string, owner string) *Note { note := NewNoteNoSave(title, owner) note.Uid = rand.Text() note.Save() Notes.Add(note, note.Owner) return note } // 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.Uid)) return os.WriteFile( filename, []byte(fmt.Sprintf("---\n%s---\n%s", frontmatter, n.Body)), 0o600, ) } // 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 uid func loadNote(uid string) (*Note, error) { filename := filepath.Join(conf.Conf.NotesDir, fmtPath(uid)) f, err := os.Open(filename) if err != nil { return nil, err } defer f.Close() stat, err := os.Stat(filename) if err != nil { return nil, err } 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') } 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") } title, ok := metaData["title"].(string) if !ok { return nil, errors.New("invalid note, missing 'title' in frontmatter") } note := NewNoteNoSave(title, owner) note.Uid = uid note.Body = body note.LastModified = stat.ModTime() viewers := metaData["viewers"].([]any) for _, viewer := range viewers { v, ok := viewer.(string) if !ok { return nil, errors.New("invalid note, non string type in 'viewers' in frontmatter") } if v == "" { continue } note.AddViewer(v) } log.Printf("Note %s shared with %v", note.Title, note.Viewers) tags, ok := metaData["tags"] if ok { tags := tags.([]any) for _, tag := range tags { t, ok := tag.(string) if !ok { return nil, errors.New("invalid note, non string type in 'tags' in frontmatter") } if t == "" { continue } note.Tags = append(note.Tags, t) } } return note, nil } func (n *Note) AddViewer(viewer string) { n.Viewers[viewer] = struct{}{} } func (n *Note) DelViewer(viewer string) { delete(n.Viewers, viewer) } func DeleteNote(uid string) error { filename := filepath.Join(conf.Conf.NotesDir, fmtPath(uid)) return os.Remove(filename) } 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 (n *Note) HasTag(tag string) bool { for _, tag_ := range n.Tags { if tag_ == tag { return true } } return false }