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