all repos — flounder @ f39fba2d03282d8217ee76b53fe46c100acec304

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) *Gemfeed {
 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	err := filepath.Walk(gemlogFolderPath, func(thepath string, info os.FileInfo, err error) error {
 55		base := path.Base(thepath)
 56		if len(base) >= 10 {
 57			entry := FeedEntry{}
 58			date, err := time.Parse("2006-01-02", base[:10])
 59			if err != nil {
 60				return nil
 61			}
 62			entry.Date = date
 63			entry.DateString = base[:10]
 64			entry.Feed = &feed
 65			f, err := os.Open(thepath)
 66			if err != nil {
 67				return nil
 68			}
 69			defer f.Close()
 70			scanner := bufio.NewScanner(f)
 71			for scanner.Scan() {
 72				// skip blank lines
 73				if scanner.Text() == "" {
 74					continue
 75				}
 76				line := scanner.Text()
 77				if strings.HasPrefix(line, "#") {
 78					entry.Title = strings.Trim(line, "# \t")
 79				} else {
 80					var title string
 81					if len(line) > 50 {
 82						title = line[:50]
 83					} else {
 84						title = line
 85					}
 86					entry.Title = "[" + title + "...]"
 87				}
 88				break
 89			}
 90			entry.File = getLocalPath(thepath)
 91			u := urlFromPath(thepath)
 92			entry.Url = &u
 93			feed.Entries = append(feed.Entries, entry)
 94		}
 95		return nil
 96	})
 97	if err != nil {
 98		return nil
 99	}
100	// Reverse chronological sort
101	sort.Slice(feed.Entries, func(i, j int) bool {
102		return feed.Entries[i].Date.After(feed.Entries[j].Date)
103	})
104	return &feed
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.Entries) > 0 {
121				feeds = append(feeds, *fe.Entries[0].Feed)
122				for _, e := range fe.Entries {
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}