Eric Bower
·
2025-08-08
listparser.go
1package shared
2
3import (
4 "fmt"
5 "html/template"
6 "log"
7 "regexp"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/araddon/dateparse"
13)
14
15var reIndent = regexp.MustCompile(`^[[:blank:]]+`)
16
17type ListParsedText struct {
18 Items []*ListItem
19 *ListMetaData
20}
21
22type ListItem struct {
23 Value string
24 URL template.URL
25 Variable string
26 IsURL bool
27 IsBlock bool
28 IsText bool
29 IsHeaderOne bool
30 IsHeaderTwo bool
31 IsImg bool
32 IsPre bool
33 Indent int
34}
35
36type ListMetaData struct {
37 PublishAt *time.Time
38 Title string
39 Description string
40 Layout string
41 Tags []string
42 ListType string // https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type
43 DigestInterval string
44 Cron string
45 Email string
46 InlineContent bool // allows content inlining to be disabled in feeds.pico.sh emails
47}
48
49var urlToken = "=>"
50var blockToken = ">"
51var varToken = "=:"
52var imgToken = "=<"
53var headerOneToken = "#"
54var headerTwoToken = "##"
55var preToken = "```"
56
57type SplitToken struct {
58 Key string
59 Value string
60}
61
62func TextToSplitToken(text string) *SplitToken {
63 txt := strings.Trim(text, " ")
64 token := &SplitToken{}
65 word := ""
66 for i, c := range txt {
67 if c == ' ' {
68 token.Key = strings.Trim(word, " ")
69 token.Value = strings.Trim(txt[i:], " ")
70 break
71 } else {
72 word += string(c)
73 }
74 }
75
76 if token.Key == "" {
77 token.Key = strings.Trim(text, " ")
78 token.Value = strings.Trim(text, " ")
79 }
80
81 return token
82}
83
84func SplitByNewline(text string) []string {
85 return strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
86}
87
88func PublishAtDate(date string) (*time.Time, error) {
89 t, err := dateparse.ParseStrict(date)
90 return &t, err
91}
92
93func TokenToMetaField(meta *ListMetaData, token *SplitToken) error {
94 switch token.Key {
95 case "publish_at":
96 publishAt, err := PublishAtDate(token.Value)
97 if err == nil {
98 meta.PublishAt = publishAt
99 }
100 case "title":
101 meta.Title = token.Value
102 case "description":
103 meta.Description = token.Value
104 case "list_type":
105 meta.ListType = token.Value
106 case "tags":
107 tags := strings.Split(token.Value, ",")
108 meta.Tags = make([]string, 0)
109 for _, tag := range tags {
110 meta.Tags = append(meta.Tags, strings.TrimSpace(tag))
111 }
112 case "layout":
113 meta.Layout = token.Value
114 case "digest_interval":
115 meta.DigestInterval = token.Value
116 case "cron":
117 meta.Cron = token.Value
118 case "email":
119 meta.Email = token.Value
120 case "inline_content":
121 v, err := strconv.ParseBool(token.Value)
122 if err != nil {
123 // its empty or its improperly configured, just send the content
124 v = true
125 }
126 meta.InlineContent = v
127 }
128
129 return nil
130}
131
132func KeyAsValue(token *SplitToken) string {
133 if token.Value == "" {
134 return token.Key
135 }
136 return token.Value
137}
138
139func parseItem(meta *ListMetaData, li *ListItem, prevItem *ListItem, pre bool, mod int) (bool, bool, int) {
140 skip := false
141
142 if strings.HasPrefix(li.Value, preToken) {
143 pre = !pre
144 if pre {
145 nextValue := strings.Replace(li.Value, preToken, "", 1)
146 li.IsPre = true
147 li.Value = nextValue
148 } else {
149 skip = true
150 }
151 } else if pre {
152 nextValue := strings.Replace(li.Value, preToken, "", 1)
153 prevItem.Value = fmt.Sprintf("%s\n%s", prevItem.Value, nextValue)
154 skip = true
155 } else if strings.HasPrefix(li.Value, urlToken) {
156 li.IsURL = true
157 split := TextToSplitToken(strings.Replace(li.Value, urlToken, "", 1))
158 li.URL = template.URL(split.Key)
159 li.Value = KeyAsValue(split)
160 } else if strings.HasPrefix(li.Value, blockToken) {
161 li.IsBlock = true
162 li.Value = strings.Replace(li.Value, blockToken, "", 1)
163 } else if strings.HasPrefix(li.Value, imgToken) {
164 li.IsImg = true
165 split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
166 key := split.Key
167 li.URL = template.URL(key)
168 li.Value = KeyAsValue(split)
169 } else if strings.HasPrefix(li.Value, varToken) {
170 split := TextToSplitToken(strings.Replace(li.Value, varToken, "", 1))
171 err := TokenToMetaField(meta, split)
172 if err != nil {
173 log.Println(err)
174 }
175 } else if strings.HasPrefix(li.Value, headerTwoToken) {
176 li.IsHeaderTwo = true
177 li.Value = strings.Replace(li.Value, headerTwoToken, "", 1)
178 } else if strings.HasPrefix(li.Value, headerOneToken) {
179 li.IsHeaderOne = true
180 li.Value = strings.Replace(li.Value, headerOneToken, "", 1)
181 } else if reIndent.MatchString(li.Value) {
182 trim := reIndent.ReplaceAllString(li.Value, "")
183 old := len(li.Value)
184 li.Value = trim
185
186 pre, skip, _ = parseItem(meta, li, prevItem, pre, mod)
187 if prevItem != nil && prevItem.Indent == 0 {
188 mod = old - len(trim)
189 li.Indent = 1
190 } else {
191 numerator := old - len(trim)
192 if mod == 0 {
193 li.Indent = 1
194 } else {
195 li.Indent = numerator / mod
196 }
197 }
198 } else {
199 li.IsText = true
200 }
201
202 return pre, skip, mod
203}
204
205func ListParseText(text string) *ListParsedText {
206 textItems := SplitByNewline(text)
207 items := []*ListItem{}
208 meta := ListMetaData{
209 ListType: "disc",
210 Tags: []string{},
211 Layout: "default",
212 InlineContent: true,
213 }
214 pre := false
215 skip := false
216 mod := 0
217 var prevItem *ListItem
218
219 for _, t := range textItems {
220 if len(items) > 0 {
221 prevItem = items[len(items)-1]
222 }
223
224 li := ListItem{
225 Value: t,
226 }
227
228 pre, skip, mod = parseItem(&meta, &li, prevItem, pre, mod)
229
230 if li.IsText && li.Value == "" {
231 skip = true
232 }
233
234 if !skip {
235 items = append(items, &li)
236 }
237 }
238
239 return &ListParsedText{
240 Items: items,
241 ListMetaData: &meta,
242 }
243}