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