repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / shared
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}