gemfeed.go (view raw)
1// Parses Gemfeed according to the companion spec: gemini://gemini.circumlunar.space/docs/companion/subscription.gmi
2package main
3
4import (
5 "bufio"
6 "fmt"
7 "io"
8 "net/url"
9 "os"
10 "path"
11 "path/filepath"
12 "regexp"
13 "sort"
14 "strings"
15 "time"
16)
17
18type Gemfeed struct {
19 Title string
20 Creator string
21 Url *url.URL
22 Entries []*FeedEntry
23}
24
25type FeedEntry struct {
26 Title string
27 Url *url.URL
28 Date time.Time
29 DateString string
30 Feed *Gemfeed
31 File string // TODO refactor
32}
33
34// Non-standard extension
35// Requires yyyy-mm-dd formatted files
36func generateFeedFromUser(user string) []FeedEntry {
37 gemlogFolder := "gemlog" // TODO make configurable
38 gemlogFolderPath := path.Join(c.FilesDirectory, user, gemlogFolder)
39 // NOTE: assumes sanitized input
40 feed := Gemfeed{
41 Title: user + "'s Gemfeed",
42 Creator: user,
43 // URL? etc?
44 }
45 var feedEntries []FeedEntry
46 err := filepath.Walk(gemlogFolderPath, func(thepath string, info os.FileInfo, err error) error {
47 base := path.Base(thepath)
48 if len(base) >= 10 {
49 entry := FeedEntry{}
50 date, err := time.Parse("2006-01-02", base[:10])
51 if err != nil {
52 return nil
53 }
54 entry.Date = date
55 entry.DateString = base[:10]
56 entry.Feed = &feed
57 f, err := os.Open(thepath)
58 if err != nil {
59 return nil
60 }
61 defer f.Close()
62 scanner := bufio.NewScanner(f)
63 for scanner.Scan() {
64 // skip blank lines
65 if scanner.Text() == "" {
66 continue
67 }
68 line := scanner.Text()
69 if strings.HasPrefix(line, "#") {
70 entry.Title = strings.Trim(line, "# \t")
71 break
72 } else {
73 var title string
74 if len(line) > 50 {
75 title = line[:50]
76 } else {
77 title = line
78 }
79 entry.Title = "[" + title + "...]"
80 }
81 }
82 entry.File = base
83 feedEntries = append(feedEntries, entry)
84 }
85 return nil
86 })
87 if err != nil {
88 return nil
89 }
90 sort.Slice(feedEntries, func(i, j int) bool {
91 return feedEntries[i].Date.After(feedEntries[j].Date)
92 })
93 return feedEntries
94}
95
96// TODO definitely cache this function
97// TODO include generateFeedFromFolder for "gemfeed" folders
98func getAllGemfeedEntries() ([]*FeedEntry, []*Gemfeed, error) {
99 maxUserItems := 25
100 maxItems := 50
101 var feedEntries []*FeedEntry
102 var feeds []*Gemfeed
103 err := filepath.Walk(c.FilesDirectory, func(thepath string, info os.FileInfo, err error) error {
104 if isGemini(info.Name()) {
105 f, err := os.Open(thepath)
106 // TODO verify no path bugs here
107 creator := getCreator(thepath)
108 baseUrl := url.URL{}
109 baseUrl.Host = creator + "." + c.Host
110 baseUrl.Path = getLocalPath(thepath)
111 feed, err := ParseGemfeed(f, baseUrl, maxUserItems) // TODO make configurable
112 f.Close()
113 if err == nil {
114 feed.Creator = creator
115 if feed.Title == "" {
116 feed.Title = "(Untitled Feed)"
117 }
118 feed.Url = &baseUrl
119 feedEntries = append(feedEntries, feed.Entries...)
120 feeds = append(feeds, feed)
121 }
122 }
123 return nil
124 })
125 if err != nil {
126 return nil, nil, err
127 } else {
128 sort.Slice(feedEntries, func(i, j int) bool {
129 return feedEntries[i].Date.After(feedEntries[j].Date)
130 })
131 if len(feedEntries) > maxItems {
132 return feedEntries[:maxItems], feeds, nil
133 }
134 return feedEntries, feeds, nil
135 }
136}
137
138var GemfeedRegex = regexp.MustCompile(`=>\s*(\S+)\s([0-9]{4}-[0-9]{2}-[0-9]{2})\s?-?\s?(.*)`)
139
140// Parsed Gemfeed text Returns error if not a gemfeed
141// Doesn't sort output
142// Doesn't get posts dated in the future
143// if limit > -1 -- limit how many we are getting
144func ParseGemfeed(text io.Reader, baseUrl url.URL, limit int) (*Gemfeed, error) {
145 scanner := bufio.NewScanner(text)
146 gf := Gemfeed{}
147 for scanner.Scan() {
148 if limit > -1 && len(gf.Entries) >= limit {
149 break
150 }
151 line := scanner.Text()
152 if gf.Title == "" && strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "##") {
153 gf.Title = strings.Trim(line[1:], " \t")
154 } else if strings.HasPrefix(line, "=>") {
155 matches := GemfeedRegex.FindStringSubmatch(line)
156 if len(matches) == 4 {
157 parsedUrl, err := url.Parse(matches[1])
158 if err != nil {
159 continue
160 }
161 date, err := time.Parse("2006-01-02", matches[2])
162 if err != nil {
163 continue
164 }
165 title := matches[3]
166 if parsedUrl.Host == "" {
167 // Is relative link
168 parsedUrl.Host = baseUrl.Host
169 parsedUrl.Path = path.Join(path.Dir(baseUrl.Path), parsedUrl.Path)
170 }
171 parsedUrl.Scheme = ""
172 if time.Now().After(date) {
173 fe := FeedEntry{title, parsedUrl, date, matches[2], &gf, ""}
174 if fe.Title == "" {
175 fe.Title = "(Untitled)"
176 }
177 gf.Entries = append(gf.Entries, &fe)
178 }
179 }
180 }
181 }
182 if len(gf.Entries) == 0 {
183 return nil, fmt.Errorf("No Gemfeed entries found")
184 }
185 return &gf, nil
186}