15 files changed,
554 insertions(+),
540 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-05-23 15:55:44 +0300
Authored at:
2026-05-23 15:12:18 +0300
Change ID:
kqrnswytroxstrsxkstqnrllylpktnso
Parent:
19e8c1c
jump to
D
app/atom.go
··· 1 -package app 2 - 3 -import ( 4 - "bytes" 5 - "crypto/sha1" 6 - "encoding/xml" 7 - "fmt" 8 - "io" 9 - "net/http" 10 - "strings" 11 - "time" 12 -) 13 - 14 -const ( 15 - atomNamespace = "http://www.w3.org/2005/Atom" 16 - xhtmlNamespace = "http://www.w3.org/1999/xhtml" 17 - defaultAuthor = "rss-tools" 18 -) 19 - 20 -type AtomFeed struct { 21 - XMLName xml.Name `xml:"feed"` 22 - XMLNS string `xml:"xmlns,attr"` 23 - Title string `xml:"title"` 24 - ID string `xml:"id"` 25 - Updated string `xml:"updated"` 26 - Authors []AtomPerson `xml:"author,omitempty"` 27 - Subtitle string `xml:"subtitle,omitempty"` 28 - Entries []AtomEntry `xml:"entry"` 29 -} 30 - 31 -type AtomEntry struct { 32 - Title string `xml:"title"` 33 - ID string `xml:"id"` 34 - Updated string `xml:"updated"` 35 - Links []AtomLink `xml:"link,omitempty"` 36 - Content AtomContent `xml:"content"` 37 -} 38 - 39 -type AtomContent struct { 40 - XMLName xml.Name `xml:"content"` 41 - Type string `xml:"type,attr,omitempty"` 42 - Value string `xml:",chardata"` 43 -} 44 - 45 -func (c AtomContent) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 46 - contentType := c.Type 47 - if contentType == "" { 48 - contentType = "text" 49 - } 50 - 51 - start.Name = xml.Name{Local: "content"} 52 - start.Attr = append(start.Attr, xml.Attr{ 53 - Name: xml.Name{Local: "type"}, 54 - Value: contentType, 55 - }) 56 - 57 - if err := e.EncodeToken(start); err != nil { 58 - return err 59 - } 60 - 61 - if contentType == "xhtml" { 62 - if err := validateXHTMLFragment(c.Value); err != nil { 63 - return err 64 - } 65 - 66 - if err := e.Encode(xhtmlDiv{ 67 - XMLNS: xhtmlNamespace, 68 - Inner: c.Value, 69 - }); err != nil { 70 - return err 71 - } 72 - } else { 73 - if err := e.EncodeToken(xml.CharData([]byte(c.Value))); err != nil { 74 - return err 75 - } 76 - } 77 - 78 - if err := e.EncodeToken(start.End()); err != nil { 79 - return err 80 - } 81 - return e.Flush() 82 -} 83 - 84 -type xhtmlDiv struct { 85 - XMLName xml.Name `xml:"div"` 86 - XMLNS string `xml:"xmlns,attr"` 87 - Inner string `xml:",innerxml"` 88 -} 89 - 90 -func validateXHTMLFragment(fragment string) error { 91 - wrapped := fmt.Sprintf(`<div xmlns="%s">%s</div>`, xhtmlNamespace, fragment) 92 - dec := xml.NewDecoder(strings.NewReader(wrapped)) 93 - for { 94 - _, err := dec.Token() 95 - if err == io.EOF { 96 - return nil 97 - } 98 - if err != nil { 99 - return fmt.Errorf("invalid xhtml content: %w", err) 100 - } 101 - } 102 -} 103 - 104 -type AtomPerson struct { 105 - Name string `xml:"name"` 106 -} 107 - 108 -type AtomLink struct { 109 - Rel string `xml:"rel,attr,omitempty"` 110 - Type string `xml:"type,attr,omitempty"` 111 - Length string `xml:"length,attr,omitempty"` 112 - Href string `xml:"href,attr"` 113 -} 114 - 115 -type FeedEntry struct { 116 - Title string 117 - ID string 118 - Links []FeedLink 119 - Content string 120 - ContentType string // "text", "html", or "xhtml"; defaults to "text" 121 - Updated time.Time 122 -} 123 - 124 -type FeedLink struct { 125 - Rel string 126 - Type string 127 - Length string 128 - Href string 129 -} 130 - 131 -type FeedBuilder struct{ f AtomFeed } 132 - 133 -func NewFeed(title, id string) *FeedBuilder { 134 - return &FeedBuilder{f: AtomFeed{ 135 - XMLNS: atomNamespace, 136 - Title: title, 137 - ID: id, 138 - Updated: time.Now().Format(time.RFC3339), 139 - Authors: []AtomPerson{{Name: defaultAuthor}}, 140 - }} 141 -} 142 - 143 -func (f *FeedBuilder) WithSubtitle(subtitle string) *FeedBuilder { 144 - f.f.Subtitle = subtitle 145 - return f 146 -} 147 - 148 -func (f *FeedBuilder) WithAuthor(name string) *FeedBuilder { 149 - name = strings.TrimSpace(name) 150 - if name == "" { 151 - return f 152 - } 153 - f.f.Authors = []AtomPerson{{Name: name}} 154 - return f 155 -} 156 - 157 -func (f *FeedBuilder) WithUpdated(updated time.Time) *FeedBuilder { 158 - if !updated.IsZero() { 159 - f.f.Updated = updated.Format(time.RFC3339) 160 - } 161 - return f 162 -} 163 - 164 -func (f *FeedBuilder) Add(entry FeedEntry) *FeedBuilder { 165 - if entry.Updated.IsZero() { 166 - entry.Updated = time.Now() 167 - } 168 - if entry.ID == "" { 169 - hash := sha1.Sum(fmt.Appendf(nil, "%s|%s|%s", entry.Title, entry.Content, entry.Updated.Format(time.RFC3339Nano))) 170 - entry.ID = fmt.Sprintf("urn:sha1:%x", hash) 171 - } 172 - 173 - contentType := entry.ContentType 174 - if contentType == "" { 175 - contentType = "text" 176 - } 177 - 178 - links := make([]AtomLink, 0, len(entry.Links)) 179 - for _, link := range entry.Links { 180 - if link.Href == "" { 181 - continue 182 - } 183 - links = append(links, AtomLink(link)) 184 - } 185 - 186 - f.f.Entries = append(f.f.Entries, AtomEntry{ 187 - Title: entry.Title, 188 - ID: entry.ID, 189 - Updated: entry.Updated.Format(time.RFC3339), 190 - Links: links, 191 - Content: AtomContent{ 192 - Type: contentType, 193 - Value: entry.Content, 194 - }, 195 - }) 196 - 197 - feedUpdated, err := time.Parse(time.RFC3339, f.f.Updated) 198 - if err != nil || entry.Updated.After(feedUpdated) { 199 - f.f.Updated = entry.Updated.Format(time.RFC3339) 200 - } 201 - return f 202 -} 203 - 204 -func (f *FeedBuilder) SetUpdated(updated time.Time) *FeedBuilder { 205 - if !updated.IsZero() { 206 - f.f.Updated = updated.Format(time.RFC3339) 207 - } 208 - return f 209 -} 210 - 211 -func (f *FeedBuilder) WriteTo(w io.Writer) error { 212 - enc := xml.NewEncoder(w) 213 - enc.Indent("", " ") 214 - return enc.Encode(f.f) 215 -} 216 - 217 -func (f *FeedBuilder) Bytes() ([]byte, error) { 218 - var buf bytes.Buffer 219 - if err := f.WriteTo(&buf); err != nil { 220 - return nil, err 221 - } 222 - return buf.Bytes(), nil 223 -} 224 - 225 -func (f *FeedBuilder) Render(w http.ResponseWriter) error { 226 - w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") 227 - return f.WriteTo(w) 228 -}
A
app/atom/atom.go
··· 1 +// Based on golang.org/x/tools/blog/atom. 2 + 3 +package atom 4 + 5 +import ( 6 + "encoding/xml" 7 + "time" 8 +) 9 + 10 +const xhtmlNamespace = "http://www.w3.org/1999/xhtml" 11 + 12 +// Feed represents an Atom feed. 13 +type Feed struct { 14 + XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"` 15 + Title string `xml:"title"` 16 + ID string `xml:"id"` 17 + Link []Link `xml:"link,omitempty"` 18 + Updated TimeStr `xml:"updated"` 19 + Author []*Person `xml:"author,omitempty"` 20 + Subtitle string `xml:"subtitle,omitempty"` 21 + Entry []*Entry `xml:"entry"` 22 +} 23 + 24 +// Entry represents an Atom entry. 25 +type Entry struct { 26 + Title string `xml:"title"` 27 + ID string `xml:"id"` 28 + Link []Link `xml:"link,omitempty"` 29 + Published TimeStr `xml:"published,omitempty"` 30 + Updated TimeStr `xml:"updated"` 31 + Author []*Person `xml:"author,omitempty"` 32 + Summary *Text `xml:"summary,omitempty"` 33 + Content *Text `xml:"content,omitempty"` 34 +} 35 + 36 +// Link represents an Atom link. 37 +type Link struct { 38 + Rel string `xml:"rel,attr,omitempty"` 39 + Href string `xml:"href,attr"` 40 + Type string `xml:"type,attr,omitempty"` 41 + HrefLang string `xml:"hreflang,attr,omitempty"` 42 + Title string `xml:"title,attr,omitempty"` 43 + Length uint `xml:"length,attr,omitempty"` 44 +} 45 + 46 +// Person represents an Atom person. 47 +type Person struct { 48 + Name string `xml:"name"` 49 + URI string `xml:"uri,omitempty"` 50 + Email string `xml:"email,omitempty"` 51 + InnerXML string `xml:",innerxml"` 52 +} 53 + 54 +// Text represents Atom text content. 55 +type Text struct { 56 + Type string `xml:"type,attr"` 57 + Body string `xml:",chardata"` 58 +} 59 + 60 +type TimeStr string 61 + 62 +func Time(t time.Time) TimeStr { 63 + return TimeStr(t.Format(time.RFC3339)) 64 +}
A
app/atom/atom_test.go
··· 1 +package atom 2 + 3 +import ( 4 + "encoding/xml" 5 + "net/http" 6 + "net/http/httptest" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "olexsmir.xyz/x/is" 12 +) 13 + 14 +func TestNewFeedDefaults(t *testing.T) { 15 + feed := NewFeed("test", "feed-id") 16 + 17 + is.Equal(t, "test", feed.Title) 18 + is.Equal(t, "feed-id", feed.ID) 19 + is.NotEqual(t, "", feed.Updated) 20 + is.Equal(t, 1, len(feed.Author)) 21 + is.Equal(t, "rss-tools", feed.Author[0].Name) 22 +} 23 + 24 +func TestFeedAddAppendsEntry(t *testing.T) { 25 + feed := NewFeed("test", "feed-id") 26 + entry := &Entry{ 27 + Title: "entry", 28 + ID: "entry-id", 29 + Updated: Time(time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)), 30 + Content: NewText("body", ""), 31 + } 32 + feed.Add(entry) 33 + 34 + is.Equal(t, 1, len(feed.Entry)) 35 + is.Equal(t, "entry-id", feed.Entry[0].ID) 36 +} 37 + 38 +func TestFeedBytesAndWriteTo(t *testing.T) { 39 + updated := time.Date(2026, 4, 20, 12, 30, 0, 0, time.UTC) 40 + feed := NewFeed("test", "feed-id"). 41 + Add(&Entry{ 42 + Title: "entry", 43 + ID: "entry-id", 44 + Updated: Time(updated), 45 + Content: NewText("content", ""), 46 + }) 47 + 48 + raw, err := feed.Bytes() 49 + is.Err(t, err, nil) 50 + 51 + var parsed Feed 52 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 53 + is.Equal(t, "test", parsed.Title) 54 +} 55 + 56 +func TestFeedWithAuthor(t *testing.T) { 57 + feed := NewFeed("test", "feed-id").WithAuthor("moviefeed") 58 + raw, err := feed.Bytes() 59 + is.Err(t, err, nil) 60 + var parsed Feed 61 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 62 + is.Equal(t, 1, len(parsed.Author)) 63 + is.Equal(t, "moviefeed", parsed.Author[0].Name) 64 +} 65 + 66 +func TestFeedRender(t *testing.T) { 67 + r := httptest.NewRecorder() 68 + err := NewFeed("test", "feed-id"). 69 + Add(&Entry{ 70 + Title: "entry", 71 + ID: "entry-id", 72 + Content: NewText("content", ""), 73 + Updated: Time(time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)), 74 + }). 75 + Render(r) 76 + is.Err(t, err, nil) 77 + 78 + is.Equal(t, http.StatusOK, r.Code) 79 + if got := r.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { 80 + t.Fatalf("unexpected content type: %q", got) 81 + } 82 +} 83 + 84 +func TestFeedEntryTextContent(t *testing.T) { 85 + feed := NewFeed("test", "feed-id"). 86 + Add(&Entry{ 87 + Title: "text entry", 88 + ID: "entry-id", 89 + Content: NewText("plain text content", "text"), 90 + Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)), 91 + }) 92 + 93 + raw, err := feed.Bytes() 94 + is.Err(t, err, nil) 95 + if !strings.Contains(string(raw), `<content type="text">plain text content</content>`) { 96 + t.Fatalf("expected text content with type attribute in serialized feed") 97 + } 98 + 99 + var parsed Feed 100 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 101 + is.Equal(t, 1, len(parsed.Entry)) 102 + 103 + entry := parsed.Entry[0] 104 + if entry.Content == nil { 105 + t.Fatalf("expected content element in entry") 106 + } 107 + is.Equal(t, "text", entry.Content.Type) 108 + is.Equal(t, "plain text content", entry.Content.Body) 109 +} 110 + 111 +func TestFeedEntryHtmlContent(t *testing.T) { 112 + htmlContent := "<p>Hello <strong>World</strong></p>" 113 + feed := NewFeed("test", "feed-id"). 114 + Add(&Entry{ 115 + Title: "html entry", 116 + ID: "entry-id", 117 + Content: NewText(htmlContent, "html"), 118 + Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)), 119 + }) 120 + 121 + raw, err := feed.Bytes() 122 + is.Err(t, err, nil) 123 + if !strings.Contains(string(raw), `<content type="html">`) { 124 + t.Fatalf("expected HTML content with type='html' attribute in serialized feed") 125 + } 126 + 127 + var parsed Feed 128 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 129 + is.Equal(t, 1, len(parsed.Entry)) 130 + 131 + entry := parsed.Entry[0] 132 + if entry.Content == nil { 133 + t.Fatalf("expected content element in entry") 134 + } 135 + is.Equal(t, "html", entry.Content.Type) 136 + is.Equal(t, htmlContent, entry.Content.Body) 137 +} 138 + 139 +func TestFeedEntryXHTMLContent(t *testing.T) { 140 + xhtmlContent := `<body><p>Hello <strong>World</strong></p></body>` 141 + feed := NewFeed("test", "feed-id"). 142 + Add(&Entry{ 143 + Title: "xhtml entry", 144 + ID: "entry-id", 145 + Content: NewText(xhtmlContent, "xhtml"), 146 + Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)), 147 + }) 148 + 149 + raw, err := feed.Bytes() 150 + is.Err(t, err, nil) 151 + if !strings.Contains(string(raw), `<content type="xhtml">`) { 152 + t.Fatalf("expected XHTML content with type='xhtml' attribute in serialized feed") 153 + } 154 + if !strings.Contains(string(raw), `<div xmlns="http://www.w3.org/1999/xhtml"><body><p>Hello <strong>World</strong></p></body></div>`) { 155 + t.Fatalf("expected XHTML div wrapper for content") 156 + } 157 +} 158 + 159 +func TestFeedEntryLinks(t *testing.T) { 160 + feed := NewFeed("test", "feed-id"). 161 + Add(&Entry{ 162 + Title: "entry", 163 + ID: "entry-id", 164 + Content: NewText("hello", ""), 165 + Link: []Link{ 166 + {Rel: "alternate", Type: "text/html", Href: "https://example.com/item"}, 167 + }, 168 + Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)), 169 + }) 170 + 171 + raw, err := feed.Bytes() 172 + is.Err(t, err, nil) 173 + 174 + var parsed Feed 175 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 176 + is.Equal(t, 1, len(parsed.Entry)) 177 + is.Equal(t, 1, len(parsed.Entry[0].Link)) 178 + is.Equal(t, "https://example.com/item", parsed.Entry[0].Link[0].Href) 179 +} 180 + 181 +func TestFeedEntryLinksWithLength(t *testing.T) { 182 + feed := NewFeed("test", "feed-id"). 183 + Add(&Entry{ 184 + Title: "entry", 185 + ID: "entry-id", 186 + Content: NewText("hello", ""), 187 + Link: []Link{ 188 + {Rel: "enclosure", Type: "image/jpeg", Length: 0, Href: "https://example.com/item.jpg"}, 189 + }, 190 + Updated: Time(time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)), 191 + }) 192 + 193 + raw, err := feed.Bytes() 194 + is.Err(t, err, nil) 195 + var parsed Feed 196 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 197 + if len(parsed.Entry) == 0 || len(parsed.Entry[0].Link) == 0 { 198 + t.Fatalf("expected enclosure link in parsed feed") 199 + } 200 + is.Equal(t, uint(0), parsed.Entry[0].Link[0].Length) 201 +} 202 + 203 +func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) { 204 + updated := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) 205 + feed := NewFeed("test", "feed-id"). 206 + Add(&Entry{ 207 + Title: "text entry", 208 + ID: "entry-text", 209 + Content: NewText("plain text", "text"), 210 + Updated: Time(updated), 211 + }). 212 + Add(&Entry{ 213 + Title: "html entry", 214 + ID: "entry-html", 215 + Content: NewText("<p>html content</p>", "html"), 216 + Updated: Time(updated), 217 + }). 218 + Add(&Entry{ 219 + Title: "default entry", 220 + ID: "entry-default", 221 + Content: NewText("default content", ""), 222 + Updated: Time(updated), 223 + }) 224 + 225 + raw, err := feed.Bytes() 226 + is.Err(t, err, nil) 227 + 228 + var parsed Feed 229 + is.Err(t, xml.Unmarshal(raw, &parsed), nil) 230 + is.Equal(t, 3, len(parsed.Entry)) 231 + 232 + tests := []struct { 233 + name string 234 + expectedType string 235 + expectedText string 236 + }{ 237 + {"text entry", "text", "plain text"}, 238 + {"html entry", "html", "<p>html content</p>"}, 239 + {"default entry", "text", "default content"}, 240 + } 241 + for i, tc := range tests { 242 + entry := parsed.Entry[i] 243 + if entry.Content == nil { 244 + t.Fatalf("expected content element in entry %d", i) 245 + } 246 + is.Equal(t, tc.name, entry.Title) 247 + is.Equal(t, tc.expectedText, entry.Content.Body) 248 + is.Equal(t, tc.expectedType, entry.Content.Type) 249 + } 250 +}
A
app/atom/feed.go
··· 1 +package atom 2 + 3 +import ( 4 + "bytes" 5 + "encoding/xml" 6 + "io" 7 + "net/http" 8 + "strings" 9 + "time" 10 +) 11 + 12 +func NewFeed(title, id string) *Feed { 13 + return &Feed{ 14 + Title: title, 15 + ID: id, 16 + Updated: Time(time.Now()), 17 + Author: []*Person{{Name: "rss-tools"}}, 18 + } 19 +} 20 + 21 +func (f *Feed) WithAuthor(name string) *Feed { 22 + name = strings.TrimSpace(name) 23 + if name == "" { 24 + return f 25 + } 26 + f.Author = []*Person{{Name: name}} 27 + return f 28 +} 29 + 30 +func (f *Feed) WithUpdated(updated time.Time) *Feed { 31 + if !updated.IsZero() { 32 + f.Updated = Time(updated) 33 + } 34 + return f 35 +} 36 + 37 +func (f *Feed) Add(entry *Entry) *Feed { 38 + if entry != nil { 39 + f.Entry = append(f.Entry, entry) 40 + } 41 + return f 42 +} 43 + 44 +func (f *Feed) WriteTo(w io.Writer) error { 45 + enc := xml.NewEncoder(w) 46 + enc.Indent("", " ") 47 + return enc.Encode(f) 48 +} 49 + 50 +func (f *Feed) Bytes() ([]byte, error) { 51 + var buf bytes.Buffer 52 + if err := f.WriteTo(&buf); err != nil { 53 + return nil, err 54 + } 55 + return buf.Bytes(), nil 56 +} 57 + 58 +func (f *Feed) Render(w http.ResponseWriter) error { 59 + w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") 60 + return f.WriteTo(w) 61 +} 62 + 63 +func NewText(body, typ string) *Text { 64 + if body == "" && strings.TrimSpace(typ) == "" { 65 + return nil 66 + } 67 + if strings.TrimSpace(typ) == "" { 68 + typ = "text" 69 + } 70 + return &Text{Type: typ, Body: body} 71 +}
A
app/atom/text.go
··· 1 +package atom 2 + 3 +import ( 4 + "encoding/xml" 5 + "fmt" 6 + "io" 7 + "strings" 8 +) 9 + 10 +func (t Text) MarshalXML(e *xml.Encoder, start xml.StartElement) error { 11 + contentType := strings.TrimSpace(t.Type) 12 + if contentType == "" { 13 + contentType = "text" 14 + } 15 + 16 + start.Attr = append(start.Attr, xml.Attr{ 17 + Name: xml.Name{Local: "type"}, 18 + Value: contentType, 19 + }) 20 + 21 + if err := e.EncodeToken(start); err != nil { 22 + return err 23 + } 24 + 25 + if contentType == "xhtml" { 26 + if err := validateXHTMLFragment(t.Body); err != nil { 27 + return err 28 + } 29 + if err := e.Encode(xhtmlDiv{ 30 + XMLNS: xhtmlNamespace, 31 + Inner: t.Body, 32 + }); err != nil { 33 + return err 34 + } 35 + } else { 36 + if err := e.EncodeToken(xml.CharData([]byte(t.Body))); err != nil { 37 + return err 38 + } 39 + } 40 + 41 + if err := e.EncodeToken(start.End()); err != nil { 42 + return err 43 + } 44 + return e.Flush() 45 +} 46 + 47 +type xhtmlDiv struct { 48 + XMLName xml.Name `xml:"div"` 49 + XMLNS string `xml:"xmlns,attr"` 50 + Inner string `xml:",innerxml"` 51 +} 52 + 53 +func validateXHTMLFragment(fragment string) error { 54 + wrapped := fmt.Sprintf(`<div xmlns="%s">%s</div>`, xhtmlNamespace, fragment) 55 + dec := xml.NewDecoder(strings.NewReader(wrapped)) 56 + for { 57 + _, err := dec.Token() 58 + if err == io.EOF { 59 + return nil 60 + } 61 + if err != nil { 62 + return fmt.Errorf("invalid xhtml content: %w", err) 63 + } 64 + } 65 +}
D
app/atom_test.go
··· 1 -package app 2 - 3 -import ( 4 - "bytes" 5 - "encoding/xml" 6 - "net/http" 7 - "net/http/httptest" 8 - "strings" 9 - "testing" 10 - "time" 11 - 12 - "olexsmir.xyz/x/is" 13 -) 14 - 15 -func TestFeedBuilderAddEntryDefaults(t *testing.T) { 16 - feed := NewFeed("test", "feed-id") 17 - feed.Add(FeedEntry{Title: "entry", Content: "body"}) 18 - 19 - is.Equal(t, 1, len(feed.f.Entries)) 20 - entry := feed.f.Entries[0] 21 - is.NotEqual(t, "", entry.ID) 22 - is.NotEqual(t, "", entry.Updated) 23 - is.Equal(t, 1, len(feed.f.Authors)) 24 - is.Equal(t, "rss-tools", feed.f.Authors[0].Name) 25 -} 26 - 27 -func TestFeedBuilderBytesAndWriteTo(t *testing.T) { 28 - updated := time.Date(2026, 4, 20, 12, 30, 0, 0, time.UTC) 29 - feed := NewFeed("test", "feed-id"). 30 - WithSubtitle("subtitle"). 31 - Add(FeedEntry{Title: "entry", Content: "content", Updated: updated}) 32 - 33 - raw, err := feed.Bytes() 34 - is.Err(t, err, nil) 35 - if !bytes.Contains(raw, []byte("<subtitle>subtitle</subtitle>")) { 36 - t.Fatalf("expected subtitle in serialized feed") 37 - } 38 - 39 - var parsed AtomFeed 40 - is.Err(t, xml.Unmarshal(raw, &parsed), nil) 41 - is.Equal(t, "test", parsed.Title) 42 -} 43 - 44 -func TestFeedBuilderWithAuthor(t *testing.T) { 45 - feed := NewFeed("test", "feed-id").WithAuthor("moviefeed") 46 - raw, err := feed.Bytes() 47 - is.Err(t, err, nil) 48 - var parsed AtomFeed 49 - is.Err(t, xml.Unmarshal(raw, &parsed), nil) 50 - is.Equal(t, 1, len(parsed.Authors)) 51 - is.Equal(t, "moviefeed", parsed.Authors[0].Name) 52 -} 53 - 54 -func TestFeedBuilderRender(t *testing.T) { 55 - r := httptest.NewRecorder() 56 - err := NewFeed("test", "feed-id"). 57 - Add(FeedEntry{ 58 - Title: "entry", 59 - ID: "entry-id", 60 - Content: "content", 61 - Updated: time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC), 62 - }). 63 - Render(r) 64 - is.Err(t, err, nil) 65 - 66 - is.Equal(t, http.StatusOK, r.Code) 67 - if got := r.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") { 68 - t.Fatalf("unexpected content type: %q", got) 69 - } 70 -} 71 - 72 -func TestFeedEntryTextContent(t *testing.T) { 73 - feed := NewFeed("test", "feed-id"). 74 - Add(FeedEntry{ 75 - Title: "text entry", 76 - Content: "plain text content", 77 - ContentType: "text", 78 - Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 79 - }) 80 - 81 - raw, err := feed.Bytes() 82 - is.Err(t, err, nil) 83 - if !strings.Contains(string(raw), `<content type="text">plain text content</content>`) { 84 - t.Fatalf("expected text content with type attribute in serialized feed") 85 - } 86 - 87 - var parsed AtomFeed 88 - is.Err(t, xml.Unmarshal(raw, &parsed), nil) 89 - is.Equal(t, 1, len(parsed.Entries)) 90 - 91 - entry := parsed.Entries[0] 92 - is.Equal(t, "text", entry.Content.Type) 93 - is.Equal(t, "plain text content", entry.Content.Value) 94 -} 95 - 96 -func TestFeedEntryHtmlContent(t *testing.T) { 97 - htmlContent := "<p>Hello <strong>World</strong></p>" 98 - feed := NewFeed("test", "feed-id"). 99 - Add(FeedEntry{ 100 - Title: "html entry", 101 - Content: htmlContent, 102 - ContentType: "html", 103 - Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 104 - }) 105 - 106 - raw, err := feed.Bytes() 107 - is.Err(t, err, nil) 108 - if !strings.Contains(string(raw), `<content type="html">`) { 109 - t.Fatalf("expected HTML content with type='html' attribute in serialized feed") 110 - } 111 - 112 - var parsed AtomFeed 113 - is.Err(t, xml.Unmarshal(raw, &parsed), nil) 114 - is.Equal(t, 1, len(parsed.Entries)) 115 - 116 - entry := parsed.Entries[0] 117 - is.Equal(t, "html", entry.Content.Type) 118 - is.Equal(t, htmlContent, entry.Content.Value) 119 -} 120 - 121 -func TestFeedEntryXHTMLContent(t *testing.T) { 122 - xhtmlContent := `<body><p>Hello <strong>World</strong></p></body>` 123 - feed := NewFeed("test", "feed-id"). 124 - Add(FeedEntry{ 125 - Title: "xhtml entry", 126 - Content: xhtmlContent, 127 - ContentType: "xhtml", 128 - Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 129 - }) 130 - 131 - raw, err := feed.Bytes() 132 - is.Err(t, err, nil) 133 - if !strings.Contains(string(raw), `<content type="xhtml">`) { 134 - t.Fatalf("expected XHTML content with type='xhtml' attribute in serialized feed") 135 - } 136 - if !strings.Contains(string(raw), `<div xmlns="http://www.w3.org/1999/xhtml"><body><p>Hello <strong>World</strong></p></body></div>`) { 137 - t.Fatalf("expected XHTML div wrapper for content") 138 - } 139 -} 140 - 141 -func TestFeedEntryLinks(t *testing.T) { 142 - feed := NewFeed("test", "feed-id"). 143 - Add(FeedEntry{ 144 - Title: "entry", 145 - Content: "hello", 146 - Links: []FeedLink{ 147 - {Rel: "alternate", Type: "text/html", Href: "https://example.com/item"}, 148 - }, 149 - Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 150 - }) 151 - 152 - raw, err := feed.Bytes() 153 - is.Err(t, err, nil) 154 - if !strings.Contains(string(raw), `<link rel="alternate" type="text/html" href="https://example.com/item"></link>`) { 155 - t.Fatalf("expected link element in serialized feed") 156 - } 157 - 158 - var parsed AtomFeed 159 - is.Err(t, xml.Unmarshal(raw, &parsed), nil) 160 - is.Equal(t, 1, len(parsed.Entries)) 161 - is.Equal(t, 1, len(parsed.Entries[0].Links)) 162 - is.Equal(t, "https://example.com/item", parsed.Entries[0].Links[0].Href) 163 -} 164 - 165 -func TestFeedEntryLinksWithLength(t *testing.T) { 166 - feed := NewFeed("test", "feed-id"). 167 - Add(FeedEntry{ 168 - Title: "entry", 169 - Content: "hello", 170 - Links: []FeedLink{ 171 - {Rel: "enclosure", Type: "image/jpeg", Length: "0", Href: "https://example.com/item.jpg"}, 172 - }, 173 - Updated: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), 174 - }) 175 - 176 - raw, err := feed.Bytes() 177 - is.Err(t, err, nil) 178 - if !strings.Contains(string(raw), `rel="enclosure" type="image/jpeg" length="0" href="https://example.com/item.jpg"`) { 179 - t.Fatalf("expected enclosure link with length in serialized feed") 180 - } 181 -} 182 - 183 -func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) { 184 - updated := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) 185 - feed := NewFeed("test", "feed-id"). 186 - Add(FeedEntry{ 187 - Title: "text entry", 188 - Content: "plain text", 189 - ContentType: "text", 190 - Updated: updated, 191 - }). 192 - Add(FeedEntry{ 193 - Title: "html entry", 194 - Content: "<p>html content</p>", 195 - ContentType: "html", 196 - Updated: updated, 197 - }). 198 - Add(FeedEntry{ 199 - Title: "default entry", 200 - Content: "default content", 201 - Updated: updated, 202 - }) 203 - 204 - raw, err := feed.Bytes() 205 - is.Err(t, err, nil) 206 - 207 - var parsed AtomFeed 208 - is.Err(t, xml.Unmarshal(raw, &parsed), nil) 209 - is.Equal(t, 3, len(parsed.Entries)) 210 - 211 - tests := []struct { 212 - name string 213 - expectedType string 214 - expectedText string 215 - }{ 216 - {"text entry", "text", "plain text"}, 217 - {"html entry", "html", "<p>html content</p>"}, 218 - {"default entry", "text", "default content"}, 219 - } 220 - for i, tc := range tests { 221 - is.Equal(t, tc.name, parsed.Entries[i].Title) 222 - is.Equal(t, tc.expectedText, parsed.Entries[i].Content.Value) 223 - is.Equal(t, tc.expectedType, parsed.Entries[i].Content.Type) 224 - } 225 -}
M
sources/moviefeed/moviefeed.go
··· 9 9 "time" 10 10 11 11 "olexsmir.xyz/rss-tools/app" 12 + "olexsmir.xyz/rss-tools/app/atom" 12 13 ) 13 14 14 15 type moviefeed struct { ··· 60 61 return allEpisodes, nil 61 62 } 62 63 63 -func generateFeed(episodes []TMDBEpisode) *app.FeedBuilder { 64 - feed := app.NewFeed("moviefeed", "moviefeed"). 65 - WithSubtitle("Latest episodes from followed shows") 64 +func generateFeed(episodes []TMDBEpisode) *atom.Feed { 65 + feed := atom.NewFeed("moviefeed", "moviefeed") 66 66 67 67 for i := len(episodes) - 1; i >= 0; i-- { 68 68 ep := episodes[i] 69 69 airDate, _ := time.Parse(dateFormat, ep.AirDate) 70 70 content, contentType := episodeContent(ep) 71 - links := []app.FeedLink{ 71 + links := []atom.Link{ 72 72 { 73 73 Rel: "alternate", 74 74 Href: fmt.Sprintf("https://www.themoviedb.org/tv/episode/%d", ep.ID), 75 75 }, 76 76 } 77 77 if ep.StillPath != "" { 78 - links = append(links, app.FeedLink{ 78 + links = append(links, atom.Link{ 79 79 Rel: "enclosure", 80 80 Type: "image/jpeg", 81 - Length: "0", 81 + Length: 0, 82 82 Href: tmdbImageBaseURL + ep.StillPath, 83 83 }) 84 84 } 85 85 86 - feed.Add(app.FeedEntry{ 86 + feed.Add(&atom.Entry{ 87 87 ID: fmt.Sprintf("%s-%d-%d", ep.ShowID, ep.SeasonNumber, ep.EpisodeNumber), 88 88 Title: fmt.Sprintf( 89 89 "%s S%dE%d: %s", ··· 92 92 ep.EpisodeNumber, 93 93 ep.Name, 94 94 ), 95 - Content: content, 96 - ContentType: contentType, 97 - Updated: airDate, 98 - Links: links, 95 + Content: atom.NewText(content, contentType), 96 + Updated: atom.Time(airDate), 97 + Link: links, 99 98 }) 100 99 } 101 100 return feed
M
sources/moviefeed/moviefeed_test.go
··· 10 10 "testing" 11 11 "time" 12 12 13 - "olexsmir.xyz/rss-tools/app" 13 + "olexsmir.xyz/rss-tools/app/atom" 14 14 "olexsmir.xyz/x/is" 15 15 ) 16 16 ··· 35 35 t.Fatalf("expected atom response content-type, got %q", got) 36 36 } 37 37 38 - var feed app.AtomFeed 38 + var feed atom.Feed 39 39 is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 40 40 is.Equal(t, feed.Title, "moviefeed") 41 41 is.Equal(t, feed.Subtitle, "Latest episodes from followed shows") 42 - is.Equal(t, len(feed.Entries), 2) 43 - is.Equal(t, strings.Contains(feed.Entries[0].Title, "S1E2"), true) 44 - is.Equal(t, feed.Entries[0].Content.Type, "text") 45 - is.Equal(t, len(feed.Entries[1].Links), 2) 46 - is.Equal(t, feed.Entries[1].Links[1].Rel, "enclosure") 47 - is.Equal(t, feed.Entries[1].Links[1].Type, "image/jpeg") 48 - is.Equal(t, feed.Entries[1].Links[1].Length, "0") 49 - is.Equal(t, feed.Entries[1].Links[1].Href, "https://image.tmdb.org/t/p/w500/e1.jpg") 50 - is.Equal(t, feed.Entries[1].Content.Type, "xhtml") 42 + is.Equal(t, len(feed.Entry), 2) 43 + is.Equal(t, strings.Contains(feed.Entry[0].Title, "S1E2"), true) 44 + is.Equal(t, feed.Entry[0].Content.Type, "text") 45 + is.Equal(t, len(feed.Entry[1].Link), 2) 46 + is.Equal(t, feed.Entry[1].Link[1].Rel, "enclosure") 47 + is.Equal(t, feed.Entry[1].Link[1].Type, "image/jpeg") 48 + is.Equal(t, feed.Entry[1].Link[1].Length, uint(0)) 49 + is.Equal(t, feed.Entry[1].Link[1].Href, "https://image.tmdb.org/t/p/w500/e1.jpg") 50 + is.Equal(t, feed.Entry[1].Content.Type, "xhtml") 51 51 } 52 52 53 53 func TestHandleMoviesContinuesWhenOneShowFails(t *testing.T) { ··· 68 68 69 69 is.Equal(t, rr.Code, http.StatusOK) 70 70 71 - var feed app.AtomFeed 71 + var feed atom.Feed 72 72 is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 73 - is.Equal(t, len(feed.Entries), 2) 73 + is.Equal(t, len(feed.Entry), 2) 74 74 } 75 75 76 76 func TestFetchEpisodesForShowFiltersRecentAndMapsFields(t *testing.T) {
M
sources/telegram/links.go
··· 7 7 "regexp" 8 8 "strings" 9 9 10 - "olexsmir.xyz/rss-tools/app" 10 + "olexsmir.xyz/rss-tools/app/atom" 11 11 ) 12 12 13 13 var ( ··· 90 90 return out 91 91 } 92 92 93 -func feedLinks(urls []string) []app.FeedLink { 94 - links := make([]app.FeedLink, 0, len(urls)) 93 +func feedLinks(urls []string) []atom.Link { 94 + links := make([]atom.Link, 0, len(urls)) 95 95 for _, u := range urls { 96 - links = append(links, app.FeedLink{ 96 + links = append(links, atom.Link{ 97 97 Rel: "alternate", 98 98 Type: "text/html", 99 99 Href: u,
M
sources/telegram/telegram.go
··· 13 13 "time" 14 14 15 15 "olexsmir.xyz/rss-tools/app" 16 + "olexsmir.xyz/rss-tools/app/atom" 16 17 ) 17 18 18 19 type telegram struct { ··· 62 63 return 63 64 } 64 65 65 - feed := app.NewFeed("Telegram feed", "telegram-feed") 66 + feed := atom.NewFeed("Telegram feed", "telegram-feed") 66 67 for _, m := range messages { 67 68 if changed := t.enrichMessageWithLinkTitles(r.Context(), m); changed { 68 69 if err := t.saveMessage(m); err != nil { ··· 261 262 return changed 262 263 } 263 264 264 -func feedEntryFromMessage(m *Message) app.FeedEntry { 265 +func feedEntryFromMessage(m *Message) *atom.Entry { 265 266 updated := time.Unix(m.Date, 0) 266 267 text := normalizeMessageText(messageText(m)) 267 268 normalizedLinks := normalizeLinks(messageLinks(text)) ··· 296 297 contentType = "html" 297 298 } 298 299 299 - return app.FeedEntry{ 300 - Title: title, 301 - ID: entryID, 302 - Links: feedLinks(normalizedLinks), 303 - Content: content, 304 - Updated: updated, 305 - ContentType: contentType, 300 + return &atom.Entry{ 301 + Title: title, 302 + ID: entryID, 303 + Link: feedLinks(normalizedLinks), 304 + Content: atom.NewText(content, contentType), 305 + Updated: atom.Time(updated), 306 306 } 307 307 } 308 308 ··· 323 323 parts = append(parts, fmt.Sprintf(`<p><img src="data:%s;base64,%s" alt="telegram image"/></p>`, mimeType, photo.Base64)) 324 324 } 325 325 326 - return app.FeedEntry{ 327 - Title: fmt.Sprintf("🖼️ [%s]", updated.Format("2006-01-02")), 328 - ID: entryID, 329 - Links: feedLinks(normalizedLinks), 330 - Content: strings.Join(parts, ""), 331 - ContentType: "html", 332 - Updated: updated, 326 + return &atom.Entry{ 327 + Title: fmt.Sprintf("🖼️ [%s]", updated.Format("2006-01-02")), 328 + ID: entryID, 329 + Link: feedLinks(normalizedLinks), 330 + Content: atom.NewText(strings.Join(parts, ""), "html"), 331 + Updated: atom.Time(updated), 333 332 } 334 333 } 335 334
M
sources/telegram/telegram_test.go
··· 19 19 20 20 entry := feedEntryFromMessage(msg) 21 21 is.Equal(t, "🖼️ [2026-04-22]", entry.Title) 22 - is.Equal(t, "html", entry.ContentType) 23 - if !strings.Contains(entry.Content, "<p>hello <world></p>") { 24 - t.Fatalf("expected escaped text in image entry: %s", entry.Content) 22 + if entry.Content == nil { 23 + t.Fatalf("expected content in image entry") 24 + } 25 + is.Equal(t, "html", entry.Content.Type) 26 + if !strings.Contains(entry.Content.Body, "<p>hello <world></p>") { 27 + t.Fatalf("expected escaped text in image entry: %s", entry.Content.Body) 25 28 } 26 - if !strings.Contains(entry.Content, `src="data:image/png;base64,YWJj"`) { 27 - t.Fatalf("expected image data URI in image entry: %s", entry.Content) 29 + if !strings.Contains(entry.Content.Body, `src="data:image/png;base64,YWJj"`) { 30 + t.Fatalf("expected image data URI in image entry: %s", entry.Content.Body) 28 31 } 29 32 } 30 33 ··· 40 43 } 41 44 42 45 entry := feedEntryFromMessage(msg) 43 - if !strings.Contains(entry.Content, `src="data:image/png;base64,YWJj"`) { 44 - t.Fatalf("expected first image data URI in image entry: %s", entry.Content) 46 + if entry.Content == nil { 47 + t.Fatalf("expected content in image entry") 45 48 } 46 - if !strings.Contains(entry.Content, `src="data:image/jpeg;base64,ZGVm"`) { 47 - t.Fatalf("expected second image data URI in image entry: %s", entry.Content) 49 + if !strings.Contains(entry.Content.Body, `src="data:image/png;base64,YWJj"`) { 50 + t.Fatalf("expected first image data URI in image entry: %s", entry.Content.Body) 51 + } 52 + if !strings.Contains(entry.Content.Body, `src="data:image/jpeg;base64,ZGVm"`) { 53 + t.Fatalf("expected second image data URI in image entry: %s", entry.Content.Body) 48 54 } 49 55 } 50 56 ··· 57 63 58 64 entry := feedEntryFromMessage(msg) 59 65 is.Equal(t, "plain text", entry.Title) 60 - is.Equal(t, "", entry.ContentType) 61 - is.Equal(t, "plain text", entry.Content) 66 + if entry.Content == nil { 67 + t.Fatalf("expected content in text entry") 68 + } 69 + is.Equal(t, "text", entry.Content.Type) 70 + is.Equal(t, "plain text", entry.Content.Body) 62 71 } 63 72 64 73 func TestFeedEntryFromMessagePreservesNewlines(t *testing.T) { ··· 69 78 } 70 79 71 80 entry := feedEntryFromMessage(msg) 72 - is.Equal(t, "html", entry.ContentType) 73 - if !strings.Contains(entry.Content, "line 1<br/>line 2") { 74 - t.Fatalf("expected line breaks preserved in content: %s", entry.Content) 81 + if entry.Content == nil { 82 + t.Fatalf("expected content in text entry") 83 + } 84 + is.Equal(t, "html", entry.Content.Type) 85 + if !strings.Contains(entry.Content.Body, "line 1<br/>line 2") { 86 + t.Fatalf("expected line breaks preserved in content: %s", entry.Content.Body) 75 87 } 76 88 } 77 89 ··· 83 95 } 84 96 85 97 entry := feedEntryFromMessage(msg) 86 - is.Equal(t, "html", entry.ContentType) 87 - if !strings.Contains(entry.Content, `<a href="https://example.com">https://example.com</a>`) { 88 - t.Fatalf("expected generic link in content: %s", entry.Content) 98 + if entry.Content == nil { 99 + t.Fatalf("expected content in link entry") 100 + } 101 + is.Equal(t, "html", entry.Content.Type) 102 + if !strings.Contains(entry.Content.Body, `<a href="https://example.com">https://example.com</a>`) { 103 + t.Fatalf("expected generic link in content: %s", entry.Content.Body) 89 104 } 90 - if !strings.Contains(entry.Content, `<a href="https://youtu.be/dQw4w9WgXcQ">https://youtu.be/dQw4w9WgXcQ</a>`) { 91 - t.Fatalf("expected youtube link in content: %s", entry.Content) 105 + if !strings.Contains(entry.Content.Body, `<a href="https://youtu.be/dQw4w9WgXcQ">https://youtu.be/dQw4w9WgXcQ</a>`) { 106 + t.Fatalf("expected youtube link in content: %s", entry.Content.Body) 92 107 } 93 108 94 - is.Equal(t, 2, len(entry.Links)) 95 - is.Equal(t, "https://example.com", entry.Links[0].Href) 96 - is.Equal(t, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", entry.Links[1].Href) 109 + is.Equal(t, 2, len(entry.Link)) 110 + is.Equal(t, "https://example.com", entry.Link[0].Href) 111 + is.Equal(t, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", entry.Link[1].Href) 97 112 is.Equal(t, "yt:video:dQw4w9WgXcQ", entry.ID) 98 113 } 99 114
M
sources/weather/weather.go
··· 14 14 "time" 15 15 16 16 "olexsmir.xyz/rss-tools/app" 17 + "olexsmir.xyz/rss-tools/app/atom" 17 18 ) 18 19 19 20 const ( ··· 77 78 } 78 79 79 80 feedID := weatherFeedID(lat, lon) 80 - feed := app.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID). 81 + feed := atom.NewFeed(fmt.Sprintf("Weather forecast for %s", place), feedID). 81 82 WithUpdated(updated) 82 83 83 - feed.Add(app.FeedEntry{ 84 - Title: "Weather briefing", 85 - ID: fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")), 86 - Content: formatBriefingXHTML(content), 87 - ContentType: "xhtml", 88 - Updated: updated, 84 + feed.Add(&atom.Entry{ 85 + Title: "Weather briefing", 86 + ID: fmt.Sprintf("%s-%s", feedID, updated.Format("20060102")), 87 + Content: atom.NewText(formatBriefingXHTML(content), "xhtml"), 88 + Updated: atom.Time(updated), 89 89 }) 90 90 91 91 if err := feed.Render(rw); err != nil {
M
sources/weather/weather_test.go
··· 8 8 "testing" 9 9 "time" 10 10 11 - "olexsmir.xyz/rss-tools/app" 11 + "olexsmir.xyz/rss-tools/app/atom" 12 12 "olexsmir.xyz/x/is" 13 13 ) 14 14 ··· 106 106 } 107 107 108 108 raw := rr.Body.String() 109 - var feed app.AtomFeed 109 + var feed atom.Feed 110 110 is.Err(t, xml.NewDecoder(strings.NewReader(raw)).Decode(&feed), nil) 111 - is.Equal(t, len(feed.Entries), 1) 111 + is.Equal(t, len(feed.Entry), 1) 112 112 is.Equal(t, feed.Title, "Weather forecast for Kyiv") 113 - is.Equal(t, feed.Entries[0].Title, "Weather briefing") 114 - is.Equal(t, feed.Entries[0].Content.Type, "xhtml") 113 + is.Equal(t, feed.Entry[0].Title, "Weather briefing") 114 + if feed.Entry[0].Content == nil { 115 + t.Fatalf("expected xhtml content") 116 + } 117 + is.Equal(t, feed.Entry[0].Content.Type, "xhtml") 115 118 116 119 content := raw 117 120 if !strings.Contains(content, `<content type="xhtml">`) {
M
sources/ztoe/ztoe.go
··· 15 15 "golang.org/x/net/html/charset" 16 16 17 17 "olexsmir.xyz/rss-tools/app" 18 + "olexsmir.xyz/rss-tools/app/atom" 18 19 ) 19 20 20 21 type ztoe struct { ··· 52 53 slots = append(slots, slot{Range: t, Outage: i < len(row) && row[i]}) 53 54 } 54 55 55 - feed := app.NewFeed( 56 + feed := atom.NewFeed( 56 57 fmt.Sprintf("ZTOE power outages for %s.%s", group, subgroup), 57 58 fmt.Sprintf("ztoe-%s-%s", group, subgroup)) 58 59 59 60 for _, interval := range buildOutageIntervals(slots) { 60 - feed.Add(app.FeedEntry{ 61 + feed.Add(&atom.Entry{ 61 62 Title: fmt.Sprintf("Power outage %s-%s", interval.Start, interval.End), 62 63 ID: fmt.Sprintf("ztoe-%s-%s-%s-%s-%s", group, subgroup, schedule.Date, strings.ReplaceAll(interval.Start, ":", ""), strings.ReplaceAll(interval.End, ":", "")), 63 - Content: fmt.Sprintf("Date: %s\nGroup: %s.%s\nTime: %s-%s", schedule.Date, group, subgroup, interval.Start, interval.End), 64 - Updated: intervalTime(schedule.Date, interval.Start), 64 + Content: atom.NewText(fmt.Sprintf("Date: %s\nGroup: %s.%s\nTime: %s-%s", schedule.Date, group, subgroup, interval.Start, interval.End), ""), 65 + Updated: atom.Time(intervalTime(schedule.Date, interval.Start)), 65 66 }) 66 67 } 67 68 if err := feed.Render(w); err != nil {
M
sources/ztoe/ztoe_test.go
··· 11 11 "strings" 12 12 "testing" 13 13 14 - "olexsmir.xyz/rss-tools/app" 14 + "olexsmir.xyz/rss-tools/app/atom" 15 15 "olexsmir.xyz/x/is" 16 16 ) 17 17 ··· 70 70 t.Fatalf("expected atom response content-type, got %q", got) 71 71 } 72 72 73 - var feed app.AtomFeed 73 + var feed atom.Feed 74 74 is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 75 - is.NotEqual(t, 0, len(feed.Entries)) 75 + is.NotEqual(t, 0, len(feed.Entry)) 76 76 } 77 77 78 78 func TestHandlerRendersEmptyAtomFeedForNoOutages(t *testing.T) { ··· 100 100 t.Fatalf("expected status 200, got %d", rr.Code) 101 101 } 102 102 103 - var feed app.AtomFeed 103 + var feed atom.Feed 104 104 is.Err(t, xml.NewDecoder(rr.Body).Decode(&feed), nil) 105 - is.Equal(t, 0, len(feed.Entries)) 105 + is.Equal(t, 0, len(feed.Entry)) 106 106 } 107 107 108 108 func mustReadFixture(t *testing.T, name string) []byte {