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