all repos — flounder @ 3897a98f21983a472d5c3935f8c71a223dada400

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