-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnote.go
More file actions
203 lines (172 loc) · 4.86 KB
/
note.go
File metadata and controls
203 lines (172 loc) · 4.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
package gonotes
import (
"bufio"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
var reWikiLink = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
const frontmatterSep = "---"
type Note struct {
Frontmatter *Frontmatter
ID string
Date time.Time // is zero if none
Title string
Slug string
Tags []string
Body string
InternalLinks []string
IgnoreLinks []string // glob patterns from ignore-links frontmatter
}
func NewNote() *Note {
return &Note{
Frontmatter: NewFrontmatter(),
}
}
// ReadNote parses a markdown note with optional YAML frontmatter from r.
// The id is set directly from the parameter and is not parsed from content.
func ReadNote(id string, r io.Reader) (*Note, error) {
fm, body, err := splitFrontmatterBody(r)
if err != nil {
return nil, fmt.Errorf("read note: %w", err)
}
note := &Note{
Frontmatter: NewFrontmatter(),
ID: id,
Body: body,
}
// Parse frontmatter if present.
if fm != "" {
if err := yaml.Unmarshal([]byte(fm), note.Frontmatter); err != nil {
return nil, fmt.Errorf("read note: unmarshal frontmatter: %w", err)
}
}
// Extract recognized fields.
if title, ok := note.Frontmatter.Get("title"); ok {
note.Title = title
note.Slug = slugify(title)
}
if tags, ok := note.Frontmatter.Get("tags"); ok {
note.Tags = parseTags(tags)
}
if dateStr, ok := note.Frontmatter.Get("date"); ok {
t, err := time.Parse(dateLayout, dateStr)
if err != nil {
return note, fmt.Errorf("parse date %q: %w", dateStr, err)
}
note.Date = t
}
if ignoreLinks, ok := note.Frontmatter.Get("ignore-links"); ok {
note.IgnoreLinks = parseTags(ignoreLinks)
}
note.InternalLinks = parseInternalLinks(body)
return note, nil
}
// splitFrontmatterBody reads from r and separates the YAML frontmatter from
// the body. The frontmatter delimiters (---) are not included in either part.
// If there is no opening delimiter on the first line, everything is body.
func splitFrontmatterBody(r io.Reader) (fm string, body string, err error) {
scanner := bufio.NewScanner(r)
var fmLines []string
var bodyLines []string
sepCount := 0
lineNum := 0
for scanner.Scan() {
line := scanner.Text()
lineNum++
if sepCount < 2 && line == frontmatterSep {
sepCount++
continue
}
switch {
case sepCount == 1:
// Between first and second ---, this is frontmatter.
fmLines = append(fmLines, line)
default:
// Before first --- or after second ---, this is body.
bodyLines = append(bodyLines, line)
}
}
if err := scanner.Err(); err != nil {
return "", "", err
}
fm = strings.Join(fmLines, "\n")
body = strings.Join(bodyLines, "\n")
return fm, body, nil
}
// parseTags splits a comma-separated tag string into individual tags.
// Each tag is trimmed of whitespace. Empty tags are dropped.
func parseTags(s string) []string {
parts := strings.Split(s, ",")
var tags []string
for _, p := range parts {
t := strings.TrimSpace(p)
if t != "" {
tags = append(tags, t)
}
}
return tags
}
// parseInternalLinks extracts all [[target]] wiki-link targets from body.
func parseInternalLinks(body string) []string {
matches := reWikiLink.FindAllStringSubmatch(body, -1)
if len(matches) == 0 {
return nil
}
links := make([]string, len(matches))
for i, m := range matches {
links[i] = m[1]
}
return links
}
// Markdown serializes the note back to markdown with YAML frontmatter.
// If there is no frontmatter (zero keys), only the body is returned.
func (n *Note) Markdown() string {
var b strings.Builder
if hasFrontmatterKeys(n.Frontmatter) {
fmBytes, err := yaml.Marshal(n.Frontmatter)
if err == nil {
b.WriteString(frontmatterSep)
b.WriteByte('\n')
b.Write(fmBytes)
b.WriteString(frontmatterSep)
b.WriteByte('\n')
}
}
if n.Body != "" {
b.WriteString(n.Body)
}
return b.String()
}
// hasFrontmatterKeys reports whether the frontmatter has any key-value pairs.
func hasFrontmatterKeys(f *Frontmatter) bool {
mn := f.mappingNode()
return len(mn.Content) > 0
}
// noteJSON is the JSON-serializable representation of a Note.
type noteJSON struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Slug string `json:"slug,omitempty"`
Tags []string `json:"tags,omitempty"`
Body string `json:"body,omitempty"`
InternalLinks []string `json:"internalLinks,omitempty"`
Frontmatter map[string]string `json:"frontmatter,omitempty"`
}
// JSON returns a pretty-printed JSON representation of the note.
func (n *Note) JSON() ([]byte, error) {
v := noteJSON{
ID: n.ID,
Title: n.Title,
Slug: n.Slug,
Tags: n.Tags,
Body: n.Body,
InternalLinks: n.InternalLinks,
Frontmatter: n.Frontmatter.Map(),
}
return json.MarshalIndent(v, "", " ")
}