427 lines
8.9 KiB
Go
427 lines
8.9 KiB
Go
// Package notes implements a data structure for reasoning about and rendering
|
|
// markdown notes
|
|
package notes
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"cmp"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"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 *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]
|
|
}
|
|
|
|
func (ns *NoteStore) GetOrdered(owner string) []*Note {
|
|
notes := ns.Get(owner)
|
|
return notes.Sort()
|
|
}
|
|
|
|
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
|
|
boxToggled bool
|
|
boxChecked bool
|
|
}
|
|
|
|
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
|
|
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) (bool, bool) {
|
|
checkboxTransformer := toggleCheckboxTransformer{nthBox: nthBox, boxToggled: false}
|
|
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()
|
|
|
|
return checkboxTransformer.boxToggled, checkboxTransformer.boxChecked
|
|
}
|
|
|
|
func (n *Note) HasTag(tag string) bool {
|
|
return slices.Contains(n.Tags, tag)
|
|
}
|