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) []FeedEntry {
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 var feedEntries []FeedEntry
55 err := filepath.Walk(gemlogFolderPath, func(thepath string, info os.FileInfo, err error) error {
56 base := path.Base(thepath)
57 if len(base) >= 10 {
58 entry := FeedEntry{}
59 date, err := time.Parse("2006-01-02", base[:10])
60 if err != nil {
61 return nil
62 }
63 entry.Date = date
64 entry.DateString = base[:10]
65 entry.Feed = &feed
66 f, err := os.Open(thepath)
67 if err != nil {
68 return nil
69 }
70 defer f.Close()
71 scanner := bufio.NewScanner(f)
72 for scanner.Scan() {
73 // skip blank lines
74 if scanner.Text() == "" {
75 continue
76 }
77 line := scanner.Text()
78 if strings.HasPrefix(line, "#") {
79 entry.Title = strings.Trim(line, "# \t")
80 } else {
81 var title string
82 if len(line) > 50 {
83 title = line[:50]
84 } else {
85 title = line
86 }
87 entry.Title = "[" + title + "...]"
88 }
89 break
90 }
91 entry.File = getLocalPath(thepath)
92 u := urlFromPath(thepath)
93 entry.Url = &u
94 feedEntries = append(feedEntries, entry)
95 }
96 return nil
97 })
98 if err != nil {
99 return nil
100 }
101 sort.Slice(feedEntries, func(i, j int) bool {
102 return feedEntries[i].Date.After(feedEntries[j].Date)
103 })
104 return feedEntries
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) > 0 {
121 feeds = append(feeds, *fe[0].Feed)
122 for _, e := range fe {
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}