all repos — flounder @ c119411f01c43e7e18898c5a283757e58e4cfe02

A small site builder for the Gemini protocol

gemfeed.go (view raw)

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