aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-08-23 12:44:53 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-08-23 12:44:53 -0700
commit68f615cc6e6ac177109db5b3be4f9767c9cfd1a8 (patch)
tree37380267c1356e67783e9273495bfa099048486c /internal
parent4df6b97311e321c1ef178efce905307b10ce6fa7 (diff)
Add feed-backed threaded news imports
Diffstat (limited to 'internal')
-rw-r--r--internal/mobius/config.go37
-rw-r--r--internal/mobius/config_test.go91
-rw-r--r--internal/mobius/feed_news.go217
-rw-r--r--internal/mobius/feed_news_fetch.go478
-rw-r--r--internal/mobius/feed_news_import.go238
-rw-r--r--internal/mobius/feed_news_test.go462
-rw-r--r--internal/mobius/handlers_news.go2
-rw-r--r--internal/mobius/testdata/afterglow-appcast.xml22
-rw-r--r--internal/mobius/testdata/mobius-releases.atom16
9 files changed, 1562 insertions, 1 deletions
diff --git a/internal/mobius/config.go b/internal/mobius/config.go
index 171f6c7..a0924df 100644
--- a/internal/mobius/config.go
+++ b/internal/mobius/config.go
@@ -2,9 +2,11 @@ package mobius
import (
"fmt"
+ "net/url"
"os"
"path/filepath"
"strings"
+ "unicode/utf8"
"github.com/go-playground/validator/v10"
"github.com/jhalter/mobius/hotline"
@@ -29,6 +31,10 @@ func LoadConfig(path string) (*hotline.Config, error) {
return nil, fmt.Errorf("unmarshal YAML: %v", err)
}
+ if err := normalizeNewsFeedConfig(&config); err != nil {
+ return nil, err
+ }
+
validate := validator.New()
if err = validate.RegisterValidation("bannerext", func(fl validator.FieldLevel) bool {
filename := fl.Field().String()
@@ -56,3 +62,34 @@ func LoadConfig(path string) (*hotline.Config, error) {
// only the caller knows how to resolve it (e.g. against the config dir for the OS backend).
return &config, nil
}
+
+func normalizeNewsFeedConfig(config *hotline.Config) error {
+ seenPaths := make(map[string]struct{}, len(config.NewsFeeds))
+ for i := range config.NewsFeeds {
+ feed := &config.NewsFeeds[i]
+ if len(feed.CategoryPath) == 0 {
+ return fmt.Errorf("NewsFeeds[%d].CategoryPath is required", i)
+ }
+ for j, segment := range feed.CategoryPath {
+ if segment == "" || !utf8.ValidString(segment) || strings.ContainsRune(segment, '\x00') || len(segment) > 255 {
+ return fmt.Errorf("NewsFeeds[%d].CategoryPath[%d] must be non-empty valid UTF-8 without NUL bytes and at most 255 bytes", i, j)
+ }
+ }
+
+ pathKey := strings.Join(feed.CategoryPath, "\x00")
+ if _, exists := seenPaths[pathKey]; exists {
+ return fmt.Errorf("NewsFeeds[%d].CategoryPath duplicates another news feed", i)
+ }
+ seenPaths[pathKey] = struct{}{}
+
+ parsedURL, err := url.Parse(feed.URL)
+ if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
+ return fmt.Errorf("NewsFeeds[%d].URL must be an absolute HTTP or HTTPS URL", i)
+ }
+ if parsedURL.User != nil {
+ return fmt.Errorf("NewsFeeds[%d].URL must not contain credentials", i)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/mobius/config_test.go b/internal/mobius/config_test.go
index f090b5c..9985745 100644
--- a/internal/mobius/config_test.go
+++ b/internal/mobius/config_test.go
@@ -5,6 +5,9 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestLoadConfig_InvalidBannerFileExtension(t *testing.T) {
@@ -124,3 +127,91 @@ FileRoot: "files"
})
}
}
+
+func TestLoadConfig_NewsFeeds(t *testing.T) {
+ t.Run("accepts an auto-detected RSS or Atom feed", func(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configContent := `
+Name: Test Server
+Description: Test Description
+FileRoot: Files
+NewsFeeds:
+ - CategoryPath: [Afterglow Releases]
+ URL: https://morphing.cloud/afterglow/appcast.xml
+`
+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
+
+ config, err := LoadConfig(configPath)
+ require.NoError(t, err)
+ require.Len(t, config.NewsFeeds, 1)
+ assert.Equal(t, []string{"Afterglow Releases"}, config.NewsFeeds[0].CategoryPath)
+ assert.Equal(t, "https://morphing.cloud/afterglow/appcast.xml", config.NewsFeeds[0].URL)
+ })
+
+ tests := []struct {
+ name string
+ feedYAML string
+ wantErr string
+ }{
+ {
+ name: "rejects non HTTP URL",
+ feedYAML: `
+ - CategoryPath: [News]
+ URL: file:///tmp/feed.xml`,
+ wantErr: "absolute HTTP or HTTPS URL",
+ },
+ {
+ name: "rejects credentials",
+ feedYAML: `
+ - CategoryPath: [News]
+ URL: https://user:password@example.com/feed.xml`,
+ wantErr: "must not contain credentials",
+ },
+ {
+ name: "rejects duplicate category",
+ feedYAML: `
+ - CategoryPath: [News]
+ URL: https://example.com/one.xml
+ - CategoryPath: [News]
+ URL: https://example.com/two.xml`,
+ wantErr: "duplicates another news feed",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configContent := `
+Name: Test Server
+Description: Test Description
+FileRoot: Files
+NewsFeeds:` + tt.feedYAML + "\n"
+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
+
+ _, err := LoadConfig(configPath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ })
+ }
+
+ for _, removed := range []string{"Format: rss", "RefreshInterval: 1h", "MaxItems: 25"} {
+ t.Run("rejects removed setting "+removed, func(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.yaml")
+ configContent := `
+Name: Test Server
+Description: Test Description
+FileRoot: Files
+NewsFeeds:
+ - CategoryPath: [Releases]
+ URL: http://example.com/releases.xml
+ ` + removed + "\n"
+ require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0644))
+
+ _, err := LoadConfig(configPath)
+ require.ErrorContains(t, err, "unknown NewsFeeds setting")
+ })
+ }
+}
diff --git a/internal/mobius/feed_news.go b/internal/mobius/feed_news.go
new file mode 100644
index 0000000..fcc0664
--- /dev/null
+++ b/internal/mobius/feed_news.go
@@ -0,0 +1,217 @@
+package mobius
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+
+ "github.com/jhalter/mobius/hotline"
+ "golang.org/x/sync/singleflight"
+ textencoding "golang.org/x/text/encoding"
+ "golang.org/x/text/encoding/charmap"
+)
+
+// FeedNewsManager refreshes configured feeds when their existing category is
+// opened. Everything except ListArticles remains ordinary threaded-news
+// behavior because imported entries are ordinary ThreadedNews.yaml articles.
+type FeedNewsManager struct {
+ base *ThreadedNewsYAML
+ feeds map[string]configuredNewsFeed
+ logger *slog.Logger
+ fetcher *feedFetcher
+ encoding string
+ refresh singleflight.Group
+}
+
+type configuredNewsFeed struct {
+ config hotline.NewsFeedConfig
+ wirePath []string
+}
+
+var _ hotline.ThreadedNewsMgr = (*FeedNewsManager)(nil)
+
+func NewFeedNewsManager(
+ base *ThreadedNewsYAML,
+ feeds []hotline.NewsFeedConfig,
+ logger *slog.Logger,
+ userAgent string,
+ clientEncoding string,
+) (*FeedNewsManager, error) {
+ if base == nil {
+ return nil, errors.New("feed news manager requires a threaded news YAML manager")
+ }
+ if logger == nil {
+ logger = slog.Default()
+ }
+
+ normalized := &hotline.Config{NewsFeeds: slices.Clone(feeds)}
+ if err := normalizeNewsFeedConfig(normalized); err != nil {
+ return nil, err
+ }
+
+ manager := &FeedNewsManager{
+ base: base,
+ feeds: make(map[string]configuredNewsFeed, len(feeds)),
+ logger: logger,
+ fetcher: newFeedFetcher(userAgent),
+ encoding: normalizedFeedEncodingName(clientEncoding),
+ }
+ for _, config := range normalized.NewsFeeds {
+ wirePath := encodeFeedPath(config.CategoryPath, manager.encoding)
+ key := newsPathKey(wirePath)
+ if _, exists := manager.feeds[key]; exists {
+ return nil, fmt.Errorf("NewsFeeds categories collide after %s encoding", manager.encoding)
+ }
+ if err := base.validateFeedCategory(wirePath); err != nil {
+ return nil, fmt.Errorf("NewsFeeds category %q: %w", strings.Join(config.CategoryPath, "/"), err)
+ }
+ manager.feeds[key] = configuredNewsFeed{config: config, wirePath: wirePath}
+ }
+ return manager, nil
+}
+
+func (m *FeedNewsManager) ListArticles(newsPath []string) (hotline.NewsArtListData, error) {
+ feed, mapped := m.feeds[newsPathKey(newsPath)]
+ if !mapped {
+ return m.base.ListArticles(newsPath)
+ }
+
+ _, err, _ := m.refresh.Do(newsPathKey(newsPath), func() (any, error) {
+ return nil, m.refreshFeed(feed)
+ })
+ if err != nil {
+ m.logger.Warn("Unable to refresh feed-backed news; serving local articles",
+ "category", strings.Join(feed.config.CategoryPath, "/"),
+ "err", err,
+ )
+ }
+ return m.base.ListArticles(newsPath)
+}
+
+func (m *FeedNewsManager) refreshFeed(feed configuredNewsFeed) error {
+ validators, err := m.base.feedValidators(feed.wirePath, feed.config.URL)
+ if err != nil {
+ return err
+ }
+ result, err := m.fetcher.fetch(context.Background(), feed.config.URL, validators)
+ if err != nil {
+ return err
+ }
+ if result.notModified {
+ return nil
+ }
+ if result.skippedNoIdentity > 0 {
+ m.logger.Warn("Skipped feed entries without a stable identity",
+ "category", strings.Join(feed.config.CategoryPath, "/"),
+ "count", result.skippedNoIdentity,
+ )
+ }
+
+ items := make([]normalizedFeedItem, len(result.items))
+ for i, item := range result.items {
+ items[i] = item
+ items[i].article = encodeFeedArticle(item.article, m.encoding)
+ }
+ imported, err := m.base.importFeedArticles(
+ feed.wirePath,
+ feed.config.URL,
+ result.etag,
+ result.lastModified,
+ items,
+ )
+ if err != nil {
+ return err
+ }
+ if imported.limitReached {
+ m.logger.Warn("Stopped importing feed entries at the Hotline article-list size limit",
+ "category", strings.Join(feed.config.CategoryPath, "/"),
+ "imported", imported.count,
+ )
+ }
+ return nil
+}
+
+func (m *FeedNewsManager) GetArticle(newsPath []string, articleID uint32) *hotline.NewsArtData {
+ return m.base.GetArticle(newsPath, articleID)
+}
+
+func (m *FeedNewsManager) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
+ return m.base.DeleteArticle(newsPath, articleID, recursive)
+}
+
+func (m *FeedNewsManager) PostArticle(newsPath []string, parentArticleID uint32, article hotline.NewsArtData) error {
+ return m.base.PostArticle(newsPath, parentArticleID, article)
+}
+
+func (m *FeedNewsManager) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
+ return m.base.CreateGrouping(newsPath, name, itemType)
+}
+
+func (m *FeedNewsManager) GetCategories(newsPath []string) []hotline.NewsCategoryListData15 {
+ return m.base.GetCategories(newsPath)
+}
+
+func (m *FeedNewsManager) NewsItem(newsPath []string) hotline.NewsCategoryListData15 {
+ return m.base.NewsItem(newsPath)
+}
+
+func (m *FeedNewsManager) DeleteNewsItem(newsPath []string) error {
+ return m.base.DeleteNewsItem(newsPath)
+}
+
+func newsPathKey(path []string) string {
+ return strings.Join(path, "\x00")
+}
+
+func normalizedFeedEncodingName(value string) string {
+ if value == "utf8" {
+ return "utf8"
+ }
+ return "macintosh"
+}
+
+func encodeFeedPath(path []string, clientEncoding string) []string {
+ encoded := slices.Clone(path)
+ for i := range encoded {
+ encoded[i] = encodeFeedText(encoded[i], clientEncoding)
+ }
+ return encoded
+}
+
+func encodeFeedArticle(article hotline.NewsArtData, clientEncoding string) hotline.NewsArtData {
+ article.Title = truncateEncodedFeedText(article.Title, maxNewsStringBytes, clientEncoding)
+ article.Poster = truncateEncodedFeedText(article.Poster, maxNewsStringBytes, clientEncoding)
+ article.Data = truncateEncodedFeedText(article.Data, maxNewsBodyBytes, clientEncoding)
+ article.DataFlav = slices.Clone(hotline.NewsFlavor)
+ return article
+}
+
+func truncateEncodedFeedText(value string, maxBytes int, clientEncoding string) string {
+ encoded := encodeFeedText(value, clientEncoding)
+ if len(encoded) <= maxBytes {
+ return encoded
+ }
+ if normalizedFeedEncodingName(clientEncoding) == "utf8" {
+ return truncateUTF8(encoded, maxBytes)
+ }
+ return encoded[:maxBytes]
+}
+
+func encodeFeedText(value, clientEncoding string) string {
+ if normalizedFeedEncodingName(clientEncoding) == "utf8" {
+ return value
+ }
+ encoded, err := textencoding.ReplaceUnsupported(charmap.Macintosh.NewEncoder()).String(value)
+ if err == nil {
+ return encoded
+ }
+ return strings.Map(func(r rune) rune {
+ if r <= 0x7f {
+ return r
+ }
+ return '?'
+ }, value)
+}
diff --git a/internal/mobius/feed_news_fetch.go b/internal/mobius/feed_news_fetch.go
new file mode 100644
index 0000000..18d25ee
--- /dev/null
+++ b/internal/mobius/feed_news_fetch.go
@@ -0,0 +1,478 @@
+package mobius
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ stdhtml "html"
+ "io"
+ "net/http"
+ "net/url"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/mmcdole/gofeed"
+ xhtml "golang.org/x/net/html"
+)
+
+const (
+ feedRequestTimeout = 10 * time.Second
+ maxFeedBodyBytes = 2 << 20
+ maxNewsStringBytes = 255
+ maxNewsBodyBytes = 65535
+)
+
+var errFeedResponseTooLarge = errors.New("feed response exceeds 2 MiB")
+
+type normalizedFeedItem struct {
+ identity string
+ article hotline.NewsArtData
+ publishedAt time.Time
+ sourceIndex int
+}
+
+type feedHTTPValidators struct {
+ etag string
+ lastModified string
+}
+
+type feedFetchResult struct {
+ notModified bool
+ etag string
+ lastModified string
+ items []normalizedFeedItem
+ skippedNoIdentity int
+}
+
+type feedFetcher struct {
+ client *http.Client
+ parser *gofeed.Parser
+ now func() time.Time
+ userAgent string
+}
+
+func newFeedFetcher(userAgent string) *feedFetcher {
+ if userAgent == "" {
+ userAgent = "mobius-hotline-server"
+ }
+ return &feedFetcher{
+ client: &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ if len(via) >= 10 {
+ return errors.New("stopped after 10 redirects")
+ }
+ if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
+ return fmt.Errorf("redirected to unsupported URL scheme %q", req.URL.Scheme)
+ }
+ if req.URL.User != nil {
+ return errors.New("redirected to a URL containing credentials")
+ }
+ return nil
+ },
+ },
+ parser: gofeed.NewParser(),
+ now: time.Now,
+ userAgent: userAgent,
+ }
+}
+
+func (f *feedFetcher) fetch(ctx context.Context, sourceURL string, validators feedHTTPValidators) (feedFetchResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, feedRequestTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
+ if err != nil {
+ return feedFetchResult{}, errors.New("create request from configured feed URL")
+ }
+ req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8")
+ req.Header.Set("User-Agent", f.userAgent)
+ if validators.etag != "" {
+ req.Header.Set("If-None-Match", validators.etag)
+ }
+ if validators.lastModified != "" {
+ req.Header.Set("If-Modified-Since", validators.lastModified)
+ }
+
+ response, err := f.client.Do(req)
+ if err != nil {
+ return feedFetchResult{}, fmt.Errorf("fetch feed: %w", redactHTTPError(err))
+ }
+ defer func() { _ = response.Body.Close() }()
+
+ if response.StatusCode == http.StatusNotModified {
+ if validators.etag == "" && validators.lastModified == "" {
+ return feedFetchResult{}, errors.New("feed returned 304 without a conditional request")
+ }
+ return feedFetchResult{notModified: true}, nil
+ }
+ if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
+ return feedFetchResult{}, fmt.Errorf("feed returned %s", response.Status)
+ }
+
+ body, err := io.ReadAll(io.LimitReader(response.Body, maxFeedBodyBytes+1))
+ if err != nil {
+ return feedFetchResult{}, fmt.Errorf("read feed response: %w", err)
+ }
+ if len(body) > maxFeedBodyBytes {
+ return feedFetchResult{}, errFeedResponseTooLarge
+ }
+ items, skipped, err := f.parseFeedResponse(body, f.now())
+ if err != nil {
+ return feedFetchResult{}, err
+ }
+
+ return feedFetchResult{
+ etag: response.Header.Get("ETag"),
+ lastModified: response.Header.Get("Last-Modified"),
+ items: items,
+ skippedNoIdentity: skipped,
+ }, nil
+}
+
+func (f *feedFetcher) parseFeedResponse(body []byte, fetchedAt time.Time) ([]normalizedFeedItem, int, error) {
+ detected := gofeed.DetectFeedType(bytes.NewReader(body))
+ if detected != gofeed.FeedTypeRSS && detected != gofeed.FeedTypeAtom {
+ return nil, 0, fmt.Errorf("unsupported feed format %q; expected RSS or Atom", detected)
+ }
+ feed, err := f.parser.Parse(bytes.NewReader(body))
+ if err != nil {
+ return nil, 0, fmt.Errorf("parse feed type %v: %w", detected, err)
+ }
+ items, skipped := normalizeSyndicationFeed(feed, fetchedAt)
+ return items, skipped, nil
+}
+
+func redactHTTPError(err error) error {
+ for {
+ var urlError *url.Error
+ if !errors.As(err, &urlError) || urlError.Err == nil || urlError.Err == err {
+ return err
+ }
+ err = urlError.Err
+ }
+}
+
+func normalizeSyndicationFeed(feed *gofeed.Feed, fetchedAt time.Time) ([]normalizedFeedItem, int) {
+ items := make([]normalizedFeedItem, 0, len(feed.Items))
+ seen := make(map[string]struct{}, len(feed.Items))
+ skipped := 0
+ for sourceIndex, item := range feed.Items {
+ if item == nil {
+ continue
+ }
+ identity, ok := feedItemIdentity(item)
+ if !ok {
+ skipped++
+ continue
+ }
+ if _, exists := seen[identity]; exists {
+ continue
+ }
+ seen[identity] = struct{}{}
+
+ publishedAt := fetchedAt
+ if item.PublishedParsed != nil {
+ publishedAt = *item.PublishedParsed
+ } else if item.UpdatedParsed != nil {
+ publishedAt = *item.UpdatedParsed
+ }
+ rawBody := firstNonEmpty(strings.TrimSpace(item.Content), strings.TrimSpace(item.Description))
+ title := strings.TrimSpace(item.Title)
+ if title == "" {
+ title = "(untitled)"
+ }
+ poster := feedItemPoster(item, feed, "News Feed")
+ items = append(items, normalizedFeedItem{
+ identity: identity,
+ article: hotline.NewsArtData{
+ Title: title,
+ Poster: poster,
+ Date: hotline.NewNewsTime(publishedAt),
+ DataFlav: slices.Clone(hotline.NewsFlavor),
+ Data: feedArticleBody(item, rawBody),
+ },
+ publishedAt: publishedAt,
+ sourceIndex: sourceIndex,
+ })
+ }
+
+ // Most feeds are newest-first. Dates establish the real order; reversing
+ // source order resolves equal or missing dates so newer items receive newer
+ // ordinary Hotline article IDs.
+ sort.SliceStable(items, func(i, j int) bool {
+ if items[i].publishedAt.Equal(items[j].publishedAt) {
+ return items[i].sourceIndex > items[j].sourceIndex
+ }
+ return items[i].publishedAt.Before(items[j].publishedAt)
+ })
+ return items, skipped
+}
+
+func feedItemIdentity(item *gofeed.Item) (string, bool) {
+ if value := strings.TrimSpace(item.GUID); value != "" {
+ return "id:" + value, true
+ }
+ if value := firstHTTPURL(append([]string{item.Link}, item.Links...)...); value != "" {
+ return "link:" + value, true
+ }
+ for _, enclosure := range item.Enclosures {
+ if enclosure != nil {
+ if value := safeHTTPURL(enclosure.URL); value != "" {
+ return "enclosure:" + value, true
+ }
+ }
+ }
+ return "", false
+}
+
+func feedItemPoster(item *gofeed.Item, feed *gofeed.Feed, fallback string) string {
+ for _, author := range item.Authors {
+ if author != nil && strings.TrimSpace(author.Name) != "" {
+ return strings.TrimSpace(author.Name)
+ }
+ }
+ if item.Author != nil && strings.TrimSpace(item.Author.Name) != "" {
+ return strings.TrimSpace(item.Author.Name)
+ }
+ for _, author := range feed.Authors {
+ if author != nil && strings.TrimSpace(author.Name) != "" {
+ return strings.TrimSpace(author.Name)
+ }
+ }
+ if feed.Author != nil && strings.TrimSpace(feed.Author.Name) != "" {
+ return strings.TrimSpace(feed.Author.Name)
+ }
+ if strings.TrimSpace(feed.Title) != "" {
+ return strings.TrimSpace(feed.Title)
+ }
+ return fallback
+}
+
+func feedArticleBody(item *gofeed.Item, rawBody string) string {
+ body := markupToPlainText(rawBody)
+ links := make([]string, 0, len(item.Enclosures)+2)
+ if source := firstHTTPURL(append([]string{item.Link}, item.Links...)...); source != "" {
+ links = append(links, "Source: "+source)
+ }
+ if releaseNotes := extensionValue(item, "sparkle", "releaseNotesLink"); releaseNotes != "" {
+ if releaseNotes = safeHTTPURL(releaseNotes); releaseNotes != "" {
+ links = appendUnique(links, "Release notes: "+releaseNotes)
+ }
+ }
+ for _, enclosure := range item.Enclosures {
+ if enclosure == nil {
+ continue
+ }
+ if download := safeHTTPURL(enclosure.URL); download != "" {
+ links = appendUnique(links, "Download: "+download)
+ }
+ }
+ if len(links) > 0 {
+ if body != "" {
+ body += "\r\r"
+ }
+ body += strings.Join(links, "\r")
+ }
+ return body
+}
+
+func extensionValue(item *gofeed.Item, namespace, name string) string {
+ if item.Extensions == nil || item.Extensions[namespace] == nil {
+ return ""
+ }
+ values := item.Extensions[namespace][name]
+ if len(values) == 0 {
+ return ""
+ }
+ return strings.TrimSpace(values[0].Value)
+}
+
+func markupToPlainText(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ if !strings.Contains(value, "<") {
+ return normalizePlainText(value)
+ }
+
+ tokenizer := xhtml.NewTokenizer(strings.NewReader(value))
+ var out strings.Builder
+ var anchors []string
+ skipDepth := 0
+ for {
+ tokenType := tokenizer.Next()
+ switch tokenType {
+ case xhtml.ErrorToken:
+ return normalizePlainText(out.String())
+ case xhtml.StartTagToken, xhtml.SelfClosingTagToken:
+ token := tokenizer.Token()
+ tag := strings.ToLower(token.Data)
+ if tag == "script" || tag == "style" {
+ if tokenType == xhtml.StartTagToken {
+ skipDepth++
+ }
+ continue
+ }
+ if skipDepth > 0 {
+ continue
+ }
+ switch tag {
+ case "br":
+ out.WriteByte('\n')
+ case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre":
+ writeLineBreak(&out, 2)
+ case "li":
+ writeLineBreak(&out, 1)
+ out.WriteString("- ")
+ case "a":
+ anchors = append(anchors, safeHTTPURL(attributeValue(token.Attr, "href")))
+ }
+ case xhtml.EndTagToken:
+ token := tokenizer.Token()
+ tag := strings.ToLower(token.Data)
+ if tag == "script" || tag == "style" {
+ if skipDepth > 0 {
+ skipDepth--
+ }
+ continue
+ }
+ if skipDepth > 0 {
+ continue
+ }
+ if tag == "a" && len(anchors) > 0 {
+ href := anchors[len(anchors)-1]
+ anchors = anchors[:len(anchors)-1]
+ if href != "" {
+ out.WriteString(" (")
+ out.WriteString(href)
+ out.WriteByte(')')
+ }
+ }
+ switch tag {
+ case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre":
+ writeLineBreak(&out, 2)
+ case "li":
+ writeLineBreak(&out, 1)
+ }
+ case xhtml.TextToken:
+ if skipDepth > 0 {
+ continue
+ }
+ text := strings.Join(strings.Fields(stdhtml.UnescapeString(string(tokenizer.Text()))), " ")
+ if text == "" {
+ continue
+ }
+ if needsSpace(out.String()) {
+ out.WriteByte(' ')
+ }
+ out.WriteString(text)
+ }
+ }
+}
+
+func normalizePlainText(value string) string {
+ value = strings.ReplaceAll(value, "\r\n", "\n")
+ value = strings.ReplaceAll(value, "\r", "\n")
+ lines := strings.Split(value, "\n")
+ out := make([]string, 0, len(lines))
+ blank := false
+ for _, line := range lines {
+ line = strings.Join(strings.Fields(line), " ")
+ if line == "" {
+ if len(out) > 0 && !blank {
+ out = append(out, "")
+ blank = true
+ }
+ continue
+ }
+ out = append(out, line)
+ blank = false
+ }
+ for len(out) > 0 && out[len(out)-1] == "" {
+ out = out[:len(out)-1]
+ }
+ return strings.Join(out, "\r")
+}
+
+func writeLineBreak(out *strings.Builder, count int) {
+ value := out.String()
+ newlines := 0
+ for i := len(value) - 1; i >= 0 && value[i] == '\n'; i-- {
+ newlines++
+ }
+ for newlines < count {
+ out.WriteByte('\n')
+ newlines++
+ }
+}
+
+func needsSpace(value string) bool {
+ if value == "" {
+ return false
+ }
+ last := value[len(value)-1]
+ return last != ' ' && last != '\n' && last != '\t' && last != '-'
+}
+
+func attributeValue(attrs []xhtml.Attribute, name string) string {
+ for _, attr := range attrs {
+ if strings.EqualFold(attr.Key, name) {
+ return strings.TrimSpace(attr.Val)
+ }
+ }
+ return ""
+}
+
+func safeHTTPURL(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ parsed, err := url.Parse(value)
+ if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+ return ""
+ }
+ return parsed.String()
+}
+
+func firstHTTPURL(values ...string) string {
+ for _, value := range values {
+ if parsed := safeHTTPURL(value); parsed != "" {
+ return parsed
+ }
+ }
+ return ""
+}
+
+func appendUnique(values []string, value string) []string {
+ if !slices.Contains(values, value) {
+ return append(values, value)
+ }
+ return values
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func truncateUTF8(value string, maxBytes int) string {
+ if len(value) <= maxBytes {
+ return value
+ }
+ value = value[:maxBytes]
+ for !utf8.ValidString(value) {
+ value = value[:len(value)-1]
+ }
+ return value
+}
diff --git a/internal/mobius/feed_news_import.go b/internal/mobius/feed_news_import.go
new file mode 100644
index 0000000..4baacd9
--- /dev/null
+++ b/internal/mobius/feed_news_import.go
@@ -0,0 +1,238 @@
+package mobius
+
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "slices"
+
+ "github.com/jhalter/mobius/hotline"
+)
+
+var errNewsArticleListTooLarge = errors.New("Hotline news article list exceeds 65,535 bytes")
+
+type feedImportResult struct {
+ count int
+ limitReached bool
+}
+
+func (n *ThreadedNewsYAML) validateFeedCategory(newsPath []string) error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ _, _, category, ok := n.feedCategoryLocked(newsPath)
+ if !ok {
+ return errors.New("target category does not exist")
+ }
+ if category.Type != hotline.NewsCategory {
+ return errors.New("target must be an ordinary news category")
+ }
+ return nil
+}
+
+func (n *ThreadedNewsYAML) feedValidators(newsPath []string, sourceURL string) (feedHTTPValidators, error) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+ _, _, category, ok := n.feedCategoryLocked(newsPath)
+ if !ok {
+ return feedHTTPValidators{}, errors.New("feed target category no longer exists")
+ }
+ if category.Type != hotline.NewsCategory {
+ return feedHTTPValidators{}, errors.New("feed target is no longer an ordinary news category")
+ }
+ if category.FeedState == nil || category.FeedState.SourceURL != sourceURL {
+ return feedHTTPValidators{}, nil
+ }
+ return feedHTTPValidators{
+ etag: category.FeedState.ETag,
+ lastModified: category.FeedState.LastModified,
+ }, nil
+}
+
+func (n *ThreadedNewsYAML) importFeedArticles(
+ newsPath []string,
+ sourceURL string,
+ etag string,
+ lastModified string,
+ items []normalizedFeedItem,
+) (feedImportResult, error) {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ parent, name, original, ok := n.feedCategoryLocked(newsPath)
+ if !ok {
+ return feedImportResult{}, errors.New("feed target category no longer exists")
+ }
+ if original.Type != hotline.NewsCategory {
+ return feedImportResult{}, errors.New("feed target is no longer an ordinary news category")
+ }
+
+ working := cloneNewsCategory(original)
+ if working.Articles == nil {
+ working.Articles = make(map[uint32]*hotline.NewsArtData)
+ }
+ if working.FeedState == nil {
+ working.FeedState = &hotline.NewsFeedCategoryState{Imported: make(map[string]uint32)}
+ }
+ if working.FeedState.Imported == nil {
+ working.FeedState.Imported = make(map[string]uint32)
+ }
+
+ result := feedImportResult{}
+ for _, item := range items {
+ identityHash := feedIdentityHash(sourceURL, item.identity)
+ if _, seen := working.FeedState.Imported[identityHash]; seen {
+ continue
+ }
+
+ articleID, previousID, err := nextNewsArticleID(working.Articles)
+ if err != nil {
+ return feedImportResult{}, err
+ }
+ article := item.article
+ article.PrevArt = [4]byte{}
+ article.NextArt = [4]byte{}
+ article.ParentArt = [4]byte{}
+ article.FirstChildArt = [4]byte{}
+ article.DataFlav = slices.Clone(hotline.NewsFlavor)
+
+ var oldPreviousNext [4]byte
+ if previousID != 0 {
+ oldPreviousNext = working.Articles[previousID].NextArt
+ binary.BigEndian.PutUint32(article.PrevArt[:], previousID)
+ binary.BigEndian.PutUint32(working.Articles[previousID].NextArt[:], articleID)
+ }
+ working.Articles[articleID] = &article
+
+ if err := validateNewsArticleListSize(working.Articles); err != nil {
+ delete(working.Articles, articleID)
+ if previousID != 0 {
+ working.Articles[previousID].NextArt = oldPreviousNext
+ }
+ if errors.Is(err, errNewsArticleListTooLarge) {
+ result.limitReached = true
+ break
+ }
+ return feedImportResult{}, err
+ }
+
+ working.FeedState.Imported[identityHash] = articleID
+ result.count++
+ }
+
+ if !result.limitReached {
+ working.FeedState.SourceURL = sourceURL
+ working.FeedState.ETag = etag
+ working.FeedState.LastModified = lastModified
+ }
+
+ if result.count == 0 && feedStateEqual(original.FeedState, working.FeedState) {
+ return result, nil
+ }
+ parent[name] = working
+ if err := n.writeFile(); err != nil {
+ parent[name] = original
+ return feedImportResult{}, fmt.Errorf("persist imported feed articles: %w", err)
+ }
+ return result, nil
+}
+
+func (n *ThreadedNewsYAML) feedCategoryLocked(newsPath []string) (map[string]hotline.NewsCategoryListData15, string, hotline.NewsCategoryListData15, bool) {
+ if len(newsPath) == 0 {
+ return nil, "", hotline.NewsCategoryListData15{}, false
+ }
+ categories := n.ThreadedNews.Categories
+ for i, segment := range newsPath {
+ category, ok := categories[segment]
+ if !ok {
+ return nil, "", hotline.NewsCategoryListData15{}, false
+ }
+ if i == len(newsPath)-1 {
+ return categories, segment, category, true
+ }
+ categories = category.SubCats
+ if categories == nil {
+ return nil, "", hotline.NewsCategoryListData15{}, false
+ }
+ }
+ return nil, "", hotline.NewsCategoryListData15{}, false
+}
+
+func cloneNewsCategory(category hotline.NewsCategoryListData15) hotline.NewsCategoryListData15 {
+ clone := category
+ clone.Articles = make(map[uint32]*hotline.NewsArtData, len(category.Articles))
+ for id, article := range category.Articles {
+ if article == nil {
+ clone.Articles[id] = nil
+ continue
+ }
+ articleClone := *article
+ articleClone.DataFlav = slices.Clone(article.DataFlav)
+ clone.Articles[id] = &articleClone
+ }
+ clone.SubCats = make(map[string]hotline.NewsCategoryListData15, len(category.SubCats))
+ for name, subcategory := range category.SubCats {
+ clone.SubCats[name] = cloneNewsCategory(subcategory)
+ }
+ if category.FeedState != nil {
+ state := *category.FeedState
+ state.Imported = make(map[string]uint32, len(category.FeedState.Imported))
+ for identity, id := range category.FeedState.Imported {
+ state.Imported[identity] = id
+ }
+ clone.FeedState = &state
+ }
+ return clone
+}
+
+func nextNewsArticleID(articles map[uint32]*hotline.NewsArtData) (uint32, uint32, error) {
+ var previousID uint32
+ for id := range articles {
+ if id > previousID {
+ previousID = id
+ }
+ }
+ if previousID == math.MaxUint32 {
+ return 0, 0, errors.New("news category exhausted article IDs")
+ }
+ return previousID + 1, previousID, nil
+}
+
+func validateNewsArticleListSize(articles map[uint32]*hotline.NewsArtData) error {
+ category := hotline.NewsCategoryListData15{Articles: articles}
+ list, err := category.GetNewsArtListData()
+ if err != nil {
+ return err
+ }
+ encoded, err := io.ReadAll(&list)
+ if err != nil {
+ return err
+ }
+ if len(encoded) > math.MaxUint16 {
+ return errNewsArticleListTooLarge
+ }
+ return nil
+}
+
+func feedIdentityHash(sourceURL, identity string) string {
+ hash := sha256.Sum256([]byte(sourceURL + "\x00" + identity))
+ return hex.EncodeToString(hash[:])
+}
+
+func feedStateEqual(a, b *hotline.NewsFeedCategoryState) bool {
+ if a == nil || b == nil {
+ return a == b
+ }
+ if a.SourceURL != b.SourceURL || a.ETag != b.ETag || a.LastModified != b.LastModified || len(a.Imported) != len(b.Imported) {
+ return false
+ }
+ for identity, id := range a.Imported {
+ if b.Imported[identity] != id {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/mobius/feed_news_test.go b/internal/mobius/feed_news_test.go
new file mode 100644
index 0000000..037d1a0
--- /dev/null
+++ b/internal/mobius/feed_news_test.go
@@ -0,0 +1,462 @@
+package mobius
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/jhalter/mobius/hotline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ textencoding "golang.org/x/text/encoding"
+ "golang.org/x/text/encoding/charmap"
+)
+
+const testFeedURL = "https://example.com/releases.xml"
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+ return f(request)
+}
+
+func testFeedResponse(status int, body string, headers map[string]string) *http.Response {
+ response := &http.Response{
+ StatusCode: status,
+ Status: fmt.Sprintf("%d %s", status, http.StatusText(status)),
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+ for key, value := range headers {
+ response.Header.Set(key, value)
+ }
+ return response
+}
+
+func testNewsFeedConfig(url string, path ...string) hotline.NewsFeedConfig {
+ return hotline.NewsFeedConfig{CategoryPath: path, URL: url}
+}
+
+func addTestNewsCategory(t *testing.T, base *ThreadedNewsYAML, path []string, name string) {
+ t.Helper()
+ require.NoError(t, base.CreateGrouping(path, name, hotline.NewsCategory))
+}
+
+func newTestFeedNewsManager(
+ t *testing.T,
+ base *ThreadedNewsYAML,
+ config hotline.NewsFeedConfig,
+ transport http.RoundTripper,
+ encoding string,
+) *FeedNewsManager {
+ t.Helper()
+ manager, err := NewFeedNewsManager(
+ base,
+ []hotline.NewsFeedConfig{config},
+ slog.New(slog.NewTextHandler(io.Discard, nil)),
+ "mobius-test",
+ encoding,
+ )
+ require.NoError(t, err)
+ if transport != nil {
+ manager.fetcher.client = &http.Client{Transport: transport}
+ }
+ return manager
+}
+
+func feedState(t *testing.T, base *ThreadedNewsYAML, path ...string) *hotline.NewsFeedCategoryState {
+ t.Helper()
+ state := base.NewsItem(path).FeedState
+ require.NotNil(t, state)
+ return state
+}
+
+func TestFeedNewsManager_ImportsAfterglowAsOrdinaryNews(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Afterglow Releases")
+ requests := 0
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Afterglow Releases"), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ assert.Equal(t, "application/rss+xml, application/atom+xml, application/xml;q=0.9, text/xml;q=0.8", request.Header.Get("Accept"))
+ assert.Equal(t, "mobius-test", request.Header.Get("User-Agent"))
+ return testFeedResponse(http.StatusOK, feedFixture(t, "afterglow-appcast.xml"), map[string]string{
+ "ETag": `"afterglow-23"`,
+ "Last-Modified": "Sat, 22 Aug 2026 18:09:55 GMT",
+ }), nil
+ }), "utf8")
+
+ list, err := manager.ListArticles([]string{"Afterglow Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 1, list.Count)
+ assert.Equal(t, 1, requests)
+
+ state := feedState(t, base, "Afterglow Releases")
+ assert.Equal(t, testFeedURL, state.SourceURL)
+ assert.Equal(t, `"afterglow-23"`, state.ETag)
+ require.Len(t, state.Imported, 1)
+ var rootID uint32
+ for _, id := range state.Imported {
+ rootID = id
+ }
+ root := manager.GetArticle([]string{"Afterglow Releases"}, rootID)
+ require.NotNil(t, root)
+ assert.Equal(t, "Afterglow 1.0rc4", root.Title)
+ assert.Equal(t, "Afterglow", root.Poster)
+ assert.Contains(t, root.Data, "Fixed main-window resizing.")
+ assert.Contains(t, root.Data, "Release notes: https://morphing.cloud/afterglow/1.0rc4-notes.html")
+ assert.Contains(t, root.Data, "Download: https://morphing.cloud/afterglow/Afterglow-v1.0rc4.zip")
+ assert.NotContains(t, root.Data, "<h3>")
+ expectedTime := time.Date(2026, time.August, 22, 18, 9, 55, 0, time.FixedZone("appcast", -7*60*60))
+ assert.True(t, hotline.Time(root.Date).NewsTime().Equal(expectedTime))
+
+ // Imported roots are completely ordinary: local roots, replies, and deletion
+ // all use the conventional manager.
+ require.NoError(t, manager.PostArticle([]string{"Afterglow Releases"}, 0, hotline.NewsArtData{Title: "Local root"}))
+ require.NoError(t, manager.PostArticle([]string{"Afterglow Releases"}, rootID, hotline.NewsArtData{Title: "Reply"}))
+ require.NoError(t, manager.DeleteArticle([]string{"Afterglow Releases"}, rootID, true))
+ assert.Nil(t, manager.GetArticle([]string{"Afterglow Releases"}, rootID))
+
+ // The durable seen marker survives deletion, so the same feed entry stays
+ // deleted even though the source still returns it.
+ list, err = manager.ListArticles([]string{"Afterglow Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 2, list.Count)
+ assert.Nil(t, manager.GetArticle([]string{"Afterglow Releases"}, rootID))
+ assert.Equal(t, 2, requests)
+
+ yamlData, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ assert.Contains(t, string(yamlData), "FeedState:")
+ assert.Contains(t, string(yamlData), "Imported:")
+ assert.NoFileExists(t, filepath.Join(filepath.Dir(base.filePath), "FeedNewsState.yaml"))
+ assert.NoFileExists(t, filepath.Join(filepath.Dir(base.filePath), "FeedNewsCache.json"))
+}
+
+func TestFeedNewsManager_AutoDetectsAtomAndIgnoresUpdates(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Mobius Releases")
+ var requests int
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Mobius Releases"), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests++
+ if requests == 1 {
+ return testFeedResponse(http.StatusOK, feedFixture(t, "mobius-releases.atom"), map[string]string{"ETag": `"v1"`}), nil
+ }
+ assert.Equal(t, `"v1"`, request.Header.Get("If-None-Match"))
+ return testFeedResponse(http.StatusOK, atomBatchFixture(
+ atomEntry("v0.22.0 renamed", "tag:github.com,2008:Repository/272052223/v0.22.0", "2026-06-12T16:03:33Z"),
+ atomEntry("v0.23.0", "tag:github.com,2008:Repository/272052223/v0.23.0", "2026-07-01T12:00:00Z"),
+ ), map[string]string{"ETag": `"v2"`}), nil
+ }), "utf8")
+
+ _, err := manager.ListArticles([]string{"Mobius Releases"})
+ require.NoError(t, err)
+ first := manager.GetArticle([]string{"Mobius Releases"}, 1)
+ require.NotNil(t, first)
+ assert.Equal(t, "v0.22.0", first.Title)
+
+ list, err := manager.ListArticles([]string{"Mobius Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 2, list.Count)
+ assert.Equal(t, "v0.22.0", manager.GetArticle([]string{"Mobius Releases"}, 1).Title, "same-identity changes must be ignored")
+ assert.Equal(t, "v0.23.0", manager.GetArticle([]string{"Mobius Releases"}, 2).Title)
+ assert.Equal(t, `"v2"`, feedState(t, base, "Mobius Releases").ETag)
+}
+
+func TestFeedNewsManager_RestartUsesConditionalGETWithoutDuplicates(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ config := testNewsFeedConfig(testFeedURL, "Releases")
+ manager := newTestFeedNewsManager(t, base, config, roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, rssFixture("One", "one", "Sat, 22 Aug 2026 18:09:55 -0700"), map[string]string{
+ "ETag": `"one"`,
+ "Last-Modified": "Sat, 22 Aug 2026 18:09:55 GMT",
+ }), nil
+ }), "utf8")
+ _, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+
+ reloaded, err := NewThreadedNewsYAML(base.filePath)
+ require.NoError(t, err)
+ restarted := newTestFeedNewsManager(t, reloaded, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ assert.Equal(t, `"one"`, request.Header.Get("If-None-Match"))
+ assert.Equal(t, "Sat, 22 Aug 2026 18:09:55 GMT", request.Header.Get("If-Modified-Since"))
+ return testFeedResponse(http.StatusNotModified, "", nil), nil
+ }), "utf8")
+ before, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ list, err := restarted.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ after, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ assert.Equal(t, 1, list.Count)
+ assert.Equal(t, before, after, "304 responses must not rewrite ThreadedNews.yaml")
+}
+
+func TestFeedNewsManager_SourceURLChangesKeepSeenHistory(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ path := []string{"Releases"}
+ urlA := "https://example.com/a.xml"
+ urlB := "https://example.com/b.xml"
+ fixture := rssFixture("Release", "shared-id", "Sat, 22 Aug 2026 18:09:55 -0700")
+
+ for _, sourceURL := range []string{urlA, urlB, urlA} {
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(sourceURL, path...), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ assert.Empty(t, request.Header.Get("If-None-Match"), "a URL switch must clear validators")
+ return testFeedResponse(http.StatusOK, fixture, map[string]string{"ETag": `"etag"`}), nil
+ }), "utf8")
+ _, err := manager.ListArticles(path)
+ require.NoError(t, err)
+ }
+
+ list, err := base.ListArticles(path)
+ require.NoError(t, err)
+ assert.Equal(t, 2, list.Count, "the same ID is distinct across URLs but not reimported after switching back")
+ state := feedState(t, base, path...)
+ assert.Equal(t, urlA, state.SourceURL)
+ assert.Len(t, state.Imported, 2)
+}
+
+func TestFeedNewsManager_FailuresServeLocalAndEachLoadRetries(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ var requests atomic.Int32
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ if requests.Add(1) == 1 {
+ return nil, errors.New("offline")
+ }
+ return testFeedResponse(http.StatusOK, rssFixture("Recovered", "recovered", "Sat, 22 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Zero(t, list.Count)
+ list, err = manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, 1, list.Count)
+ assert.Equal(t, int32(2), requests.Load())
+}
+
+func TestFeedNewsManager_CoalescesConcurrentLoads(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ var requests atomic.Int32
+ requestStarted := make(chan struct{})
+ releaseRequest := make(chan struct{})
+ var startedOnce sync.Once
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ requests.Add(1)
+ startedOnce.Do(func() { close(requestStarted) })
+ <-releaseRequest
+ return testFeedResponse(http.StatusOK, rssFixture("One", "one", "Sat, 22 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+
+ const callers = 12
+ start := make(chan struct{})
+ var ready sync.WaitGroup
+ var done sync.WaitGroup
+ ready.Add(callers)
+ done.Add(callers)
+ for range callers {
+ go func() {
+ defer done.Done()
+ ready.Done()
+ <-start
+ _, err := manager.ListArticles([]string{"Releases"})
+ assert.NoError(t, err)
+ }()
+ }
+ ready.Wait()
+ close(start)
+ <-requestStarted
+ time.Sleep(25 * time.Millisecond)
+ close(releaseRequest)
+ done.Wait()
+ assert.Equal(t, int32(1), requests.Load())
+}
+
+func TestFeedNewsManager_SkipsEntriesWithoutIdentityAndRejectsJSON(t *testing.T) {
+ t.Run("missing identity", func(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ fixture := `<?xml version="1.0"?><rss version="2.0"><channel><title>Releases</title><item><title>No identity</title><description>Notes</description></item></channel></rss>`
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, fixture, map[string]string{"ETag": `"empty"`}), nil
+ }), "utf8")
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Zero(t, list.Count)
+ assert.Empty(t, feedState(t, base, "Releases").Imported)
+ })
+
+ t.Run("JSON", func(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, `{"version":"https://jsonfeed.org/version/1.1","items":[]}`, nil), nil
+ }), "utf8")
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Zero(t, list.Count)
+ assert.Nil(t, base.NewsItem([]string{"Releases"}).FeedState)
+ })
+}
+
+func TestFeedNewsManager_EncodingIsAppliedOnceAtImport(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ title := "Café — Snowman ☃"
+ fixture := rssFixture(title, "unicode", "Sat, 22 Aug 2026 18:09:55 -0700")
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, fixture, nil), nil
+ }), "macintosh")
+ _, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+
+ wantTitle, err := textencoding.ReplaceUnsupported(charmap.Macintosh.NewEncoder()).String(title)
+ require.NoError(t, err)
+ article := base.GetArticle([]string{"Releases"}, 1)
+ require.NotNil(t, article)
+ assert.Equal(t, wantTitle, article.Title)
+ yamlData, err := os.ReadFile(base.filePath)
+ require.NoError(t, err)
+ assert.Contains(t, string(yamlData), "!!binary", "MacRoman bytes that are invalid UTF-8 must round-trip through YAML")
+
+ reloaded, err := NewThreadedNewsYAML(base.filePath)
+ require.NoError(t, err)
+ assert.Equal(t, wantTitle, reloaded.GetArticle([]string{"Releases"}, 1).Title)
+
+ // Changing server encoding later affects future imports only.
+ utf8Manager := newTestFeedNewsManager(t, reloaded, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, rssFixture("UTF-8 ☃", "unicode-2", "Sun, 23 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+ _, err = utf8Manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, wantTitle, reloaded.GetArticle([]string{"Releases"}, 1).Title)
+ assert.Equal(t, "UTF-8 ☃", reloaded.GetArticle([]string{"Releases"}, 2).Title)
+}
+
+func TestFeedNewsManager_ArticleListLimitPersistsFittingPrefixAndRetries(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ fixture := rssBatchFixture("large", 300, 255)
+ var requests atomic.Int32
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(request *http.Request) (*http.Response, error) {
+ requests.Add(1)
+ assert.Empty(t, request.Header.Get("If-None-Match"), "validators must not advance while unseen entries remain")
+ return testFeedResponse(http.StatusOK, fixture, map[string]string{"ETag": `"too-large"`}), nil
+ }), "utf8")
+
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Greater(t, list.Count, 0)
+ assert.Less(t, list.Count, 300)
+ encoded, err := io.ReadAll(&list)
+ require.NoError(t, err)
+ assert.LessOrEqual(t, len(encoded), 65535)
+ state := feedState(t, base, "Releases")
+ assert.Empty(t, state.ETag)
+ assert.Len(t, state.Imported, list.Count)
+
+ second, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err)
+ assert.Equal(t, list.Count, second.Count)
+ assert.Equal(t, int32(2), requests.Load(), "the first unseen item must be retried on the next load")
+}
+
+func TestFeedNewsManager_ValidatesExistingOrdinaryTarget(t *testing.T) {
+ base := newTestThreadedNews(t)
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+
+ _, err := NewFeedNewsManager(base, []hotline.NewsFeedConfig{testNewsFeedConfig(testFeedURL, "Missing")}, logger, "test", "utf8")
+ require.ErrorContains(t, err, "does not exist")
+ _, err = NewFeedNewsManager(base, []hotline.NewsFeedConfig{testNewsFeedConfig(testFeedURL, "Archive")}, logger, "test", "utf8")
+ require.ErrorContains(t, err, "ordinary news category")
+ _, err = NewFeedNewsManager(base, []hotline.NewsFeedConfig{
+ testNewsFeedConfig("https://example.com/one.xml", "General"),
+ testNewsFeedConfig("https://example.com/two.xml", "General"),
+ }, logger, "test", "utf8")
+ require.ErrorContains(t, err, "duplicates")
+}
+
+func TestFeedNewsManager_WriteFailureRollsBackImport(t *testing.T) {
+ base := newTestThreadedNews(t)
+ addTestNewsCategory(t, base, nil, "Releases")
+ manager := newTestFeedNewsManager(t, base, testNewsFeedConfig(testFeedURL, "Releases"), roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, rssFixture("One", "one", "Sat, 22 Aug 2026 18:09:55 -0700"), nil), nil
+ }), "utf8")
+ base.filePath = filepath.Join(t.TempDir(), "missing", "ThreadedNews.yaml")
+
+ list, err := manager.ListArticles([]string{"Releases"})
+ require.NoError(t, err, "import failures serve local news")
+ assert.Zero(t, list.Count)
+ item := base.NewsItem([]string{"Releases"})
+ assert.Empty(t, item.Articles)
+ assert.Nil(t, item.FeedState)
+}
+
+func TestFeedFetcherLimitsAndConditional304(t *testing.T) {
+ t.Run("oversized response", func(t *testing.T) {
+ fetcher := newFeedFetcher("test")
+ fetcher.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusOK, strings.Repeat("x", maxFeedBodyBytes+1), nil), nil
+ })}
+ _, err := fetcher.fetch(t.Context(), testFeedURL, feedHTTPValidators{})
+ assert.ErrorIs(t, err, errFeedResponseTooLarge)
+ })
+
+ t.Run("unprompted 304", func(t *testing.T) {
+ fetcher := newFeedFetcher("test")
+ fetcher.client = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
+ return testFeedResponse(http.StatusNotModified, "", nil), nil
+ })}
+ _, err := fetcher.fetch(t.Context(), testFeedURL, feedHTTPValidators{})
+ require.ErrorContains(t, err, "without a conditional request")
+ })
+}
+
+func feedFixture(t *testing.T, name string) string {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join("testdata", name))
+ require.NoError(t, err)
+ return string(data)
+}
+
+func rssFixture(title, guid, published string) string {
+ return fmt.Sprintf(`<?xml version="1.0"?><rss version="2.0"><channel><title>Releases</title><item><title>%s</title><guid>%s</guid><pubDate>%s</pubDate><description><![CDATA[<p>Notes for %s.</p>]]></description></item></channel></rss>`, title, guid, published, title)
+}
+
+func atomEntry(title, id, updated string) string {
+ return fmt.Sprintf(`<entry><id>%s</id><updated>%s</updated><title>%s</title><content type="html">Notes</content><author><name>Mobius</name></author></entry>`, id, updated, title)
+}
+
+func atomBatchFixture(entries ...string) string {
+ return `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Releases</title>` + strings.Join(entries, "") + `</feed>`
+}
+
+func rssBatchFixture(prefix string, count, titleSize int) string {
+ var builder strings.Builder
+ builder.WriteString(`<?xml version="1.0"?><rss version="2.0"><channel><title>Releases</title>`)
+ for i := 0; i < count; i++ {
+ title := strings.Repeat("x", titleSize)
+ fmt.Fprintf(&builder, `<item><title>%s</title><guid>%s-%d</guid><description>Notes</description></item>`, title, prefix, i)
+ }
+ builder.WriteString(`</channel></rss>`)
+ return builder.String()
+}
+
+func TestEncodeFeedTextDoesNotSplitUTF8(t *testing.T) {
+ value := strings.Repeat("é", 200)
+ encoded := truncateEncodedFeedText(value, 255, "utf8")
+ assert.True(t, bytes.Equal([]byte(encoded), []byte(strings.Repeat("é", 127))))
+}
diff --git a/internal/mobius/handlers_news.go b/internal/mobius/handlers_news.go
index 29b0ae2..368375e 100644
--- a/internal/mobius/handlers_news.go
+++ b/internal/mobius/handlers_news.go
@@ -342,7 +342,7 @@ func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []ho
hotline.NewsArtData{
Title: string(t.GetField(hotline.FieldNewsArtTitle).Data),
Poster: string(cc.GetUserName()),
- Date: hotline.NewTime(time.Now()),
+ Date: hotline.NewNewsTime(time.Now()),
DataFlav: hotline.NewsFlavor,
Data: string(t.GetField(hotline.FieldNewsArtData).Data),
},
diff --git a/internal/mobius/testdata/afterglow-appcast.xml b/internal/mobius/testdata/afterglow-appcast.xml
new file mode 100644
index 0000000..b31831e
--- /dev/null
+++ b/internal/mobius/testdata/afterglow-appcast.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
+ <channel>
+ <title>Afterglow</title>
+ <link>https://morphing.cloud/afterglow/appcast.xml</link>
+ <description>Most recent updates to Afterglow</description>
+ <item>
+ <title>Afterglow 1.0rc4</title>
+ <pubDate>Sat, 22 Aug 2026 18:09:55 -0700</pubDate>
+ <sparkle:version>23</sparkle:version>
+ <sparkle:releaseNotesLink>https://morphing.cloud/afterglow/1.0rc4-notes.html</sparkle:releaseNotesLink>
+ <description><![CDATA[
+ <h3>Fixes</h3>
+ <ul>
+ <li>Fixed main-window resizing.</li>
+ <li>Restored the missing <strong>Quit</strong> command.</li>
+ </ul>
+ ]]></description>
+ <enclosure url="https://morphing.cloud/afterglow/Afterglow-v1.0rc4.zip" length="33560698" type="application/octet-stream"/>
+ </item>
+ </channel>
+</rss>
diff --git a/internal/mobius/testdata/mobius-releases.atom b/internal/mobius/testdata/mobius-releases.atom
new file mode 100644
index 0000000..f928f60
--- /dev/null
+++ b/internal/mobius/testdata/mobius-releases.atom
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
+ <id>tag:github.com,2008:https://github.com/jhalter/mobius/releases</id>
+ <link type="text/html" rel="alternate" href="https://github.com/jhalter/mobius/releases"/>
+ <link type="application/atom+xml" rel="self" href="https://github.com/jhalter/mobius/releases.atom"/>
+ <title>Release notes from mobius</title>
+ <updated>2026-06-12T15:55:05Z</updated>
+ <entry>
+ <id>tag:github.com,2008:Repository/272052223/v0.22.0</id>
+ <updated>2026-06-12T16:03:33Z</updated>
+ <link rel="alternate" type="text/html" href="https://github.com/jhalter/mobius/releases/tag/v0.22.0"/>
+ <title>v0.22.0</title>
+ <content type="html">&lt;h2&gt;Changelog&lt;/h2&gt;&lt;ul&gt;&lt;li&gt;Fix data races&lt;/li&gt;&lt;li&gt;Improve graceful shutdown&lt;/li&gt;&lt;/ul&gt;</content>
+ <author><name>jhalter</name></author>
+ </entry>
+</feed>