all repos — flounder @ 3fea9763a7096b2cfd756691e68cd017a0c72c12

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	users, err := getActiveUserNames()
104	if err != nil {
105		return nil, nil, err
106	} else {
107		for _, user := range users {
108			fe := generateFeedFromUser(user)
109			if len(fe) > 0 {
110				feeds = append(feeds, fe[0].Feed)
111				for _, e := range fe {
112					feedEntries = append(feedEntries, &e)
113				}
114			}
115		}
116	}
117
118	err = filepath.Walk(c.FilesDirectory, func(thepath string, info os.FileInfo, err error) error {
119		if isGemini(info.Name()) {
120			f, err := os.Open(thepath)
121			// TODO verify no path bugs here
122			creator := getCreator(thepath)
123			baseUrl := url.URL{}
124			baseUrl.Host = creator + "." + c.Host
125			baseUrl.Path = getLocalPath(thepath)
126			feed, err := ParseGemfeed(f, baseUrl, maxUserItems) // TODO make configurable
127			f.Close()
128			if err == nil {
129				feed.Creator = creator
130				if feed.Title == "" {
131					feed.Title = "(Untitled Feed)"
132				}
133				feed.Url = &baseUrl
134				feedEntries = append(feedEntries, feed.Entries...)
135				feeds = append(feeds, feed)
136			}
137		}
138		return nil
139	})
140	if err != nil {
141		return nil, nil, err
142	} else {
143		sort.Slice(feedEntries, func(i, j int) bool {
144			return feedEntries[i].Date.After(feedEntries[j].Date)
145		})
146		if len(feedEntries) > maxItems {
147			return feedEntries[:maxItems], feeds, nil
148		}
149		return feedEntries, feeds, nil
150	}
151}
152
153var GemfeedRegex = regexp.MustCompile(`=>\s*(\S+)\s([0-9]{4}-[0-9]{2}-[0-9]{2})\s?-?\s?(.*)`)
154
155// Parsed Gemfeed text Returns error if not a gemfeed
156// Doesn't sort output
157// Doesn't get posts dated in the future
158// if limit > -1 -- limit how many we are getting
159func ParseGemfeed(text io.Reader, baseUrl url.URL, limit int) (*Gemfeed, error) {
160	scanner := bufio.NewScanner(text)
161	gf := Gemfeed{}
162	for scanner.Scan() {
163		if limit > -1 && len(gf.Entries) >= limit {
164			break
165		}
166		line := scanner.Text()
167		if gf.Title == "" && strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "##") {
168			gf.Title = strings.Trim(line[1:], " \t")
169		} else if strings.HasPrefix(line, "=>") {
170			matches := GemfeedRegex.FindStringSubmatch(line)
171			if len(matches) == 4 {
172				parsedUrl, err := url.Parse(matches[1])
173				if err != nil {
174					continue
175				}
176				date, err := time.Parse("2006-01-02", matches[2])
177				if err != nil {
178					continue
179				}
180				title := matches[3]
181				if parsedUrl.Host == "" {
182					// Is relative link
183					parsedUrl.Host = baseUrl.Host
184					parsedUrl.Path = path.Join(path.Dir(baseUrl.Path), parsedUrl.Path)
185				}
186				parsedUrl.Scheme = ""
187				if time.Now().After(date) {
188					fe := FeedEntry{title, parsedUrl, date, matches[2], &gf, ""}
189					if fe.Title == "" {
190						fe.Title = "(Untitled)"
191					}
192					gf.Entries = append(gf.Entries, &fe)
193				}
194			}
195		}
196	}
197	if len(gf.Entries) == 0 {
198		return nil, fmt.Errorf("No Gemfeed entries found")
199	}
200	return &gf, nil
201}