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