all repos — flounder @ 98c0e38c17e0935914797a5e6763829d3f48ad1f

A small site builder for the Gemini protocol

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}