gonotes/internal/notes/notes.go

422 lines
8.9 KiB
Go
Raw Normal View History

2025-07-29 12:44:01 +01:00
// Package notes implements a data structure for reasoning about and rendering
// markdown notes
package notes
import (
2025-08-08 21:10:10 +01:00
"bufio"
"bytes"
2026-01-05 20:46:32 +00:00
"cmp"
"crypto/rand"
2025-07-29 12:44:01 +01:00
"errors"
"fmt"
2025-01-27 22:28:18 +00:00
"html/template"
"log"
2026-01-05 20:46:32 +00:00
"maps"
"os"
"path/filepath"
2026-01-05 20:46:32 +00:00
"slices"
"strings"
2025-09-30 11:14:29 +01:00
"time"
2025-06-01 21:27:08 +01:00
"forgejo.gwairfelin.com/max/gonotes/internal/conf"
2025-07-25 13:44:21 +01:00
markdown "github.com/teekennedy/goldmark-markdown"
"github.com/yuin/goldmark"
meta "github.com/yuin/goldmark-meta"
2025-07-25 13:44:21 +01:00
"github.com/yuin/goldmark/ast"
2025-01-27 22:28:18 +00:00
"github.com/yuin/goldmark/extension"
2025-07-25 13:44:21 +01:00
astext "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
2025-08-08 21:10:10 +01:00
"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
2025-01-27 22:28:18 +00:00
BodyRendered template.HTML
Body []byte
2025-07-30 13:41:03 +01:00
Owner string
Viewers map[string]struct{}
Uid string
2025-09-30 11:14:29 +01:00
LastModified time.Time
2025-11-04 17:05:49 +00:00
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) {
2025-09-30 11:14:29 +01:00
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
}
2026-01-05 20:46:32 +00:00
func (ns *noteSet) Sort() []*Note {
orderByTitle := func(a, b *Note) int {
return cmp.Compare(a.Title, b.Title)
}
return slices.SortedFunc(maps.Keys(*ns), orderByTitle)
}
func (ns *NoteStore) Get(owner string) noteSet {
return ns.notes[owner]
}
2026-01-05 20:46:32 +00:00
func (ns *NoteStore) GetOrdered(owner string) []*Note {
notes := ns.Get(owner)
return notes.Sort()
}
func (ns *NoteStore) GetOne(owner string, uid string) (*Note, bool) {
2025-08-08 21:10:10 +01:00
notes := ns.Get(owner)
2026-01-05 20:46:32 +00:00
for note := range notes {
if note.Uid == uid {
log.Printf("Found single note during GetOne %s", note.Title)
2025-08-08 21:10:10 +01:00
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)
2025-08-08 21:10:10 +01:00
}
2025-11-04 17:05:49 +00:00
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)
2026-01-05 20:53:41 +00:00
return slices.Sorted(maps.Keys(tagSet))
2025-11-04 17:05:49 +00:00
}
func fmtPath(path string) string {
return fmt.Sprintf("%s.%s", path, conf.Conf.Extension)
}
2025-08-08 21:10:10 +01:00
func (n *Note) marshalFrontmatter() ([]byte, error) {
viewers := make([]string, 0, len(n.Viewers))
for viewer := range n.Viewers {
viewers = append(viewers, viewer)
}
2025-11-04 17:05:49 +00:00
frontmatter := make(map[string]any)
2025-08-08 21:10:10 +01:00
frontmatter["owner"] = n.Owner
frontmatter["viewers"] = viewers
frontmatter["title"] = n.Title
2025-11-04 17:05:49 +00:00
frontmatter["tags"] = n.Tags
2025-08-08 21:10:10 +01:00
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{})
2025-11-04 17:05:49 +00:00
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 {
2025-08-08 21:10:10 +01:00
frontmatter, err := n.marshalFrontmatter()
if err != nil {
return err
}
filename := filepath.Join(conf.Conf.NotesDir, fmtPath(n.Uid))
2025-08-08 21:10:10 +01:00
return os.WriteFile(
filename,
[]byte(fmt.Sprintf("---\n%s---\n%s", frontmatter, n.Body)),
2025-09-30 11:14:29 +01:00
0o600,
2025-08-08 21:10:10 +01:00
)
}
// Render the markdown content of the note to HTML
func (n *Note) Render() {
var buf bytes.Buffer
2025-01-27 22:28:18 +00:00
err := md.Convert(n.Body, &buf)
if err != nil {
log.Fatal(err)
}
2025-01-27 22:28:18 +00:00
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))
2025-08-08 21:10:10 +01:00
f, err := os.Open(filename)
if err != nil {
return nil, err
}
2025-08-08 21:10:10 +01:00
defer f.Close()
2025-09-30 11:14:29 +01:00
stat, err := os.Stat(filename)
if err != nil {
return nil, err
}
2025-08-08 21:10:10 +01:00
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()
2025-08-08 21:10:10 +01:00
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
2025-09-30 11:14:29 +01:00
note.LastModified = stat.ModTime()
2025-11-04 17:05:49 +00:00
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)
2025-11-04 17:05:49 +00:00
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
}
2025-06-18 21:40:42 +01:00
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))
2025-06-18 21:40:42 +01:00
return os.Remove(filename)
}
2025-06-27 22:56:50 +01:00
type toggleCheckboxTransformer struct {
nthBox int
boxToggled bool
boxChecked bool
}
2025-07-25 13:44:21 +01:00
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
t.boxToggled = true
t.boxChecked = box.IsChecked
2025-07-25 13:44:21 +01:00
return ast.WalkStop, nil
})
}
2025-07-29 12:44:01 +01:00
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")
}
2025-07-25 13:44:21 +01:00
if entering {
2025-07-29 12:44:01 +01:00
// Render a box if necessary
if n.IsChecked {
_, err = writer.Write([]byte("[x] "))
} else {
_, err = writer.Write([]byte("[ ] "))
2025-07-25 13:44:21 +01:00
}
}
2025-07-29 12:44:01 +01:00
return ast.WalkContinue, err
2025-07-25 13:44:21 +01:00
}
func (n *Note) ToggleBox(nthBox int) (bool, bool) {
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox, boxToggled: false}
2025-07-25 13:44:21 +01:00
transformer := util.Prioritized(&checkboxTransformer, 0)
renderer := markdown.NewRenderer()
2025-07-29 12:44:01 +01:00
renderer.Register(astext.KindTaskCheckBox, renderTaskCheckBox)
2025-07-25 13:44:21 +01:00
var buf bytes.Buffer
2025-07-29 12:44:01 +01:00
md := goldmark.New(goldmark.WithRenderer(renderer))
md.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(extension.NewTaskCheckBoxParser(), 0),
), parser.WithASTTransformers(transformer))
2025-07-25 13:44:21 +01:00
md.Convert(n.Body, &buf)
n.Body = buf.Bytes()
n.Save()
return checkboxTransformer.boxToggled, checkboxTransformer.boxChecked
2025-07-25 13:44:21 +01:00
}
2025-11-05 08:46:22 +00:00
func (n *Note) HasTag(tag string) bool {
2026-01-05 20:46:32 +00:00
return slices.Contains(n.Tags, tag)
2025-11-05 08:46:22 +00:00
}