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