package atomconv import ( "bytes" "context" "html/template" "io" "mime" "net/url" "regexp" "strconv" "strings" "time" "tildegit.org/tjp/sliderule" "tildegit.org/tjp/sliderule/gemini" "tildegit.org/tjp/sliderule/gemini/gemtext" "tildegit.org/tjp/sliderule/internal/types" "tildegit.org/tjp/sliderule/spartan" ) // Convert turns 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 Convert(wr io.Writer, doc gemtext.Document, location *url.URL) error { if location == nil { panic("atomconv.Convert: provided location was nil") } if _, err := wr.Write([]byte(``)); err != nil { return err } if _, err := wr.Write([]byte{'\n'}); err != nil { return err } return atomTmpl.Execute(wr, parseGemSub(doc, location)) } // Auto 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 Auto = types.Middleware(func(h types.Handler) types.Handler { return types.HandlerFunc(func(ctx context.Context, request *types.Request) *types.Response { if !(request.Scheme == "gemini" || request.Scheme == "spartan") || !strings.HasSuffix(request.Path, ".atom") { return h.Handle(ctx, request) } r := *request u := *request.URL u.Path = u.Path[:len(u.Path)-5] r.URL = &u response := h.Handle(ctx, &r) var ( success sliderule.Status buildSuccess func(string, io.Reader) *sliderule.Response buildFailure func(error) *sliderule.Response ) switch request.Scheme { case "gemini": success = gemini.StatusSuccess buildSuccess = gemini.Success buildFailure = gemini.Failure case "spartan": success = spartan.StatusSuccess buildSuccess = spartan.Success buildFailure = spartan.ServerError } if response.Status != success { return response } mtype, _, err := mime.ParseMediaType(response.Meta.(string)) if err != nil || mtype != "text/gemini" { return response } defer func() { _ = response.Close() }() doc, err := gemtext.Parse(response.Body) if err != nil { return buildFailure(err) } buf := &bytes.Buffer{} if err := Convert(buf, doc, request.URL); err != nil { return buildFailure(err) } return buildSuccess("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 gemtext.Document, location *url.URL) *gmiSub { sub := &gmiSub{ID: template.URL(location.String())} updated := time.Time{} for i, line := range doc { switch line.Type() { case gemtext.LineTypeHeading1: if sub.Title != "" { continue } sub.Title = line.(gemtext.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() == gemtext.LineTypeHeading2 { sub.Subtitle = doc[i].(gemtext.HeadingLine).Body() } case gemtext.LineTypeLink: label := line.(gemtext.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.(gemtext.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:]))