all repos — flounder @ f73a980efa7867e44024c4427954c4a017db70ca

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) toAtomFeed() string {
 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	res, _ := feed.ToAtom()
 44	return res
 45}
 46
 47type FeedEntry struct {
 48	Title      string
 49	Url        *url.URL
 50	Date       time.Time
 51	DateString string
 52	Feed       *Gemfeed
 53	File       string // TODO refactor
 54}
 55
 56func urlFromPath(fullPath string) url.URL {
 57	creator := getCreator(fullPath)
 58	baseUrl := url.URL{}
 59	baseUrl.Host = creator + "." + c.Host
 60	baseUrl.Path = getLocalPath(fullPath)
 61	return baseUrl
 62}
 63
 64// Non-standard extension
 65// Requires yyyy-mm-dd formatted files
 66func generateFeedFromUser(user string) *Gemfeed {
 67	gemlogFolderPath := path.Join(c.FilesDirectory, user, gemlogFolder)
 68	// NOTE: assumes sanitized input
 69	u := urlFromPath(gemlogFolderPath)
 70	feed := Gemfeed{
 71		Title:   strings.Title(user) + "'s Gemlog",
 72		Creator: user,
 73		Url:     &u,
 74	}
 75	err := filepath.Walk(gemlogFolderPath, func(thepath string, info os.FileInfo, err error) error {
 76		base := path.Base(thepath)
 77		if len(base) >= 10 {
 78			entry := FeedEntry{}
 79			date, err := time.Parse("2006-01-02", base[:10])
 80			if err != nil {
 81				return nil
 82			}
 83			entry.Date = date
 84			entry.DateString = base[:10]
 85			entry.Feed = &feed
 86			f, err := os.Open(thepath)
 87			if err != nil {
 88				return nil
 89			}
 90			defer f.Close()
 91			scanner := bufio.NewScanner(f)
 92			for scanner.Scan() {
 93				// skip blank lines
 94				if scanner.Text() == "" {
 95					continue
 96				}
 97				line := scanner.Text()
 98				if strings.HasPrefix(line, "#") {
 99					entry.Title = strings.Trim(line, "# \t")
100				} else {
101					var title string
102					if len(line) > 50 {
103						title = line[:50]
104					} else {
105						title = line
106					}
107					entry.Title = "[" + title + "...]"
108				}
109				break
110			}
111			entry.File = getLocalPath(thepath)
112			u := urlFromPath(thepath)
113			entry.Url = &u
114			feed.Entries = append(feed.Entries, entry)
115		}
116		return nil
117	})
118	if err != nil {
119		return nil
120	}
121	// Reverse chronological sort
122	sort.Slice(feed.Entries, func(i, j int) bool {
123		return feed.Entries[i].Date.After(feed.Entries[j].Date)
124	})
125	return &feed
126}
127
128// TODO definitely cache this function
129// TODO include generateFeedFromFolder for "gemfeed" folders
130func getAllGemfeedEntries() ([]FeedEntry, []Gemfeed, error) {
131	maxUserItems := 25
132	maxItems := 50
133	var feedEntries []FeedEntry
134	var feeds []Gemfeed
135	users, err := getActiveUserNames()
136	if err != nil {
137		return nil, nil, err
138	} else {
139		for _, user := range users {
140			fe := generateFeedFromUser(user)
141			if len(fe.Entries) > 0 {
142				feeds = append(feeds, *fe.Entries[0].Feed)
143				for _, e := range fe.Entries {
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}