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