all repos — flounder @ b4c7e88d90f608c2127995eeffdc3d3820b2b276

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