package gemtext import ( "bytes" "context" "html/template" "io" "mime" "net/url" "regexp" "strconv" "strings" "time" "tildegit.org/tjp/sliderule/gemini" "tildegit.org/tjp/sliderule/internal/types" ) // GmisubToAtom converts a gemini document to Atom format. // // It identifies feed fields and entries according to the specification at // gemini://gemini.circumlunar.space/docs/companion/subscription.gmi func GmisubToAtom(doc Document, location url.URL, out io.Writer) error { if _, err := out.Write([]byte(``)); err != nil { return err } if _, err := out.Write([]byte{'\n'}); err != nil { return err } if err := atomTmpl.Execute(out, parseGemSub(doc, &location)); err != nil { return err } return nil } // AutoAtom is a middleware which builds atom feeds for any gemtext pages. // // It looks for requests ending with the '.atom' extension, passes through the request // with the extension clipped off, then if the response is in gemtext it converts it to // an Atom feed according to the gmisub spec at // gemini://gemini.circumlunar.space/docs/companion/subscription.gmi var AutoAtom = types.Middleware(func(h types.Handler) types.Handler { return types.HandlerFunc(func(ctx context.Context, request *types.Request) *types.Response { if request.Scheme != "gemini" || !strings.HasSuffix(request.Path, ".atom") { return h.Handle(ctx, request) } req := *request u := *request.URL u.Path = strings.TrimSuffix(u.Path, ".atom") req.URL = &u response := h.Handle(ctx, &req) if response.Status != gemini.StatusSuccess { return response } mtype, _, err := mime.ParseMediaType(response.Meta.(string)) if err != nil || mtype != "text/gemini" { return response } defer func() { _ = response.Close() }() doc, err := Parse(response.Body) if err != nil { return gemini.Failure(err) } buf := &bytes.Buffer{} if err := GmisubToAtom(doc, *request.URL, buf); err != nil { return gemini.Failure(err) } return gemini.Success("application/atom+xml; charset=utf-8", buf) }) }) type gmiSub struct { ID template.URL Title string Subtitle string Updated string Entries []gmiSubEntry } type gmiSubEntry struct { ID template.URL Updated string Title string } var linkElemRE = regexp.MustCompile(`(\d{4})-([0-1]\d)-([0-3]\d)`) func parseGemSub(doc Document, location *url.URL) *gmiSub { sub := &gmiSub{ID: template.URL(location.String())} updated := time.Time{} for i, line := range doc { switch line.Type() { case LineTypeHeading1: if sub.Title != "" { continue } sub.Title = line.(HeadingLine).Body() for { // skip any empty lines i += 1 if i >= len(doc) || strings.TrimPrefix(doc[i].String(), "\r") != "\n" { break } } if i < len(doc) && doc[i].Type() == LineTypeHeading2 { sub.Subtitle = doc[i].(HeadingLine).Body() } case LineTypeLink: label := line.(LinkLine).Label() if len(label) < 10 { continue } match := linkElemRE.FindStringSubmatch(label[:10]) if match == nil { continue } year, err := strconv.Atoi(match[1]) if err != nil { continue } month, err := strconv.Atoi(match[2]) if err != nil || month > 12 { continue } day, err := strconv.Atoi(match[3]) if err != nil || day > 31 { continue } entryUpdated := time.Date(year, time.Month(month), day, 12, 0, 0, 0, time.UTC) entryTitle := strings.TrimLeft(strings.TrimPrefix(strings.TrimLeft(label[10:], " \t"), "-"), " \t") sub.Entries = append(sub.Entries, gmiSubEntry{ ID: template.URL(line.(LinkLine).URL()), Updated: entryUpdated.Format(time.RFC3339), Title: entryTitle, }) if entryUpdated.After(updated) { updated = entryUpdated sub.Updated = updated.Format(time.RFC3339) } } } return sub } var atomTmpl = template.Must(template.New("atom").Parse(` {{.ID}} {{.Title}} {{- if .Subtitle }} {{.Subtitle}} {{- end }} {{.Updated}} {{- range .Entries }} {{.ID}} {{.Title}} {{.Updated}} {{- end }} `[1:]))