all repos — flounder @ 0caa75e34952ee16af3d2d65ffc01a120c751621

A small site builder for the Gemini protocol

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