all repos — flounder @ a507b689d5339bd010f078c24ec8ea0a3e6ec58e

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