瀏覽代碼

feat(opml): include feed settings in export and import

Extend OPML export to include Miniflux-specific feed settings as custom
attributes on each outline element (scraper rules, rewrite rules, URL
rewrite rules, blocklist/keeplist rules, user agent, cookie, proxy URL,
and various boolean flags).

On import, these attributes are applied when creating new feeds, allowing
a Miniflux OPML export to serve as a full backup and restore mechanism
for feed configuration. Existing feeds are not modified to preserve the
original import semantics.
Mateusz Jabłoński 1 月之前
父節點
當前提交
bad9411d63

+ 117 - 35
internal/reader/opml/handler.go

@@ -9,6 +9,7 @@ import (
 
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
+	"miniflux.app/v2/internal/validator"
 )
 
 // Handler handles the logic for OPML import/export.
@@ -31,6 +32,24 @@ func (h *Handler) Export(userID int64) (string, error) {
 			SiteURL:      feed.SiteURL,
 			Description:  feed.Description,
 			CategoryName: feed.Category.Title,
+
+			ScraperRules:                feed.ScraperRules,
+			RewriteRules:                feed.RewriteRules,
+			UrlRewriteRules:             feed.UrlRewriteRules,
+			BlocklistRules:              feed.BlocklistRules,
+			KeeplistRules:               feed.KeeplistRules,
+			BlockFilterEntryRules:       feed.BlockFilterEntryRules,
+			KeepFilterEntryRules:        feed.KeepFilterEntryRules,
+			UserAgent:                   feed.UserAgent,
+			Crawler:                     feed.Crawler,
+			IgnoreHTTPCache:             feed.IgnoreHTTPCache,
+			FetchViaProxy:               feed.FetchViaProxy,
+			Disabled:                    feed.Disabled,
+			NoMediaPlayer:               feed.NoMediaPlayer,
+			HideGlobally:                feed.HideGlobally,
+			AllowSelfSignedCertificates: feed.AllowSelfSignedCertificates,
+			DisableHTTP2:                feed.DisableHTTP2,
+			IgnoreEntryUpdates:          feed.IgnoreEntryUpdates,
 		})
 	}
 
@@ -45,42 +64,105 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
 	}
 
 	for _, subscription := range subscriptions {
-		if !h.store.FeedURLExists(userID, subscription.FeedURL) {
-			var category *model.Category
-			var err error
-
-			if subscription.CategoryName == "" {
-				category, err = h.store.FirstCategory(userID)
-				if err != nil {
-					return fmt.Errorf("opml: unable to find first category: %w", err)
-				}
-			} else {
-				category, err = h.store.CategoryByTitle(userID, subscription.CategoryName)
-				if err != nil {
-					return fmt.Errorf("opml: unable to search category by title: %w", err)
-				}
-
-				if category == nil {
-					category, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: subscription.CategoryName})
-					if err != nil {
-						return fmt.Errorf(`opml: unable to create this category: %q`, subscription.CategoryName)
-					}
-				}
-			}
-
-			feed := &model.Feed{
-				UserID:      userID,
-				Title:       subscription.Title,
-				FeedURL:     subscription.FeedURL,
-				SiteURL:     subscription.SiteURL,
-				Description: subscription.Description,
-				Category:    category,
-			}
-
-			if err := h.store.CreateFeed(feed); err != nil {
-				return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
-			}
+		if h.store.FeedURLExists(userID, subscription.FeedURL) {
+			continue
+		}
+
+		category, err := h.resolveCategory(userID, subscription.CategoryName)
+		if err != nil {
+			return err
+		}
+
+		if validationErr := validateSubscription(userID, category.ID, h.store, subscription); validationErr != nil {
+			return fmt.Errorf(`opml: invalid feed settings for %q: %w`, subscription.FeedURL, validationErr)
+		}
+
+		feed := &model.Feed{
+			UserID:      userID,
+			Title:       subscription.Title,
+			FeedURL:     subscription.FeedURL,
+			SiteURL:     subscription.SiteURL,
+			Description: subscription.Description,
+			Category:    category,
+		}
+		applySubscriptionSettings(feed, subscription)
+		if err := h.store.CreateFeed(feed); err != nil {
+			return fmt.Errorf(`opml: unable to create this feed: %q`, subscription.FeedURL)
+		}
+	}
+
+	return nil
+}
+
+func (h *Handler) resolveCategory(userID int64, categoryName string) (*model.Category, error) {
+	if categoryName == "" {
+		category, err := h.store.FirstCategory(userID)
+		if err != nil {
+			return nil, fmt.Errorf("opml: unable to find first category: %w", err)
 		}
+		return category, nil
+	}
+
+	category, err := h.store.CategoryByTitle(userID, categoryName)
+	if err != nil {
+		return nil, fmt.Errorf("opml: unable to search category by title: %w", err)
+	}
+
+	if category == nil {
+		category, err = h.store.CreateCategory(userID, &model.CategoryCreationRequest{Title: categoryName})
+		if err != nil {
+			return nil, fmt.Errorf(`opml: unable to create this category: %q`, categoryName)
+		}
+	}
+
+	return category, nil
+}
+
+func applySubscriptionSettings(feed *model.Feed, s subcription) {
+	feed.ScraperRules = s.ScraperRules
+	feed.RewriteRules = s.RewriteRules
+	feed.UrlRewriteRules = s.UrlRewriteRules
+	feed.BlocklistRules = s.BlocklistRules
+	feed.KeeplistRules = s.KeeplistRules
+	feed.BlockFilterEntryRules = s.BlockFilterEntryRules
+	feed.KeepFilterEntryRules = s.KeepFilterEntryRules
+	feed.UserAgent = s.UserAgent
+	feed.Crawler = s.Crawler
+	feed.IgnoreHTTPCache = s.IgnoreHTTPCache
+	feed.FetchViaProxy = s.FetchViaProxy
+	feed.Disabled = s.Disabled
+	feed.NoMediaPlayer = s.NoMediaPlayer
+	feed.HideGlobally = s.HideGlobally
+	feed.AllowSelfSignedCertificates = s.AllowSelfSignedCertificates
+	feed.DisableHTTP2 = s.DisableHTTP2
+	feed.IgnoreEntryUpdates = s.IgnoreEntryUpdates
+}
+
+func validateSubscription(userID, categoryID int64, store *storage.Storage, s subcription) error {
+	feedCreationRequest := &model.FeedCreationRequest{
+		FeedURL:                     s.FeedURL,
+		CategoryID:                  categoryID,
+		UserAgent:                   s.UserAgent,
+		Crawler:                     s.Crawler,
+		IgnoreEntryUpdates:          s.IgnoreEntryUpdates,
+		Disabled:                    s.Disabled,
+		NoMediaPlayer:               s.NoMediaPlayer,
+		IgnoreHTTPCache:             s.IgnoreHTTPCache,
+		AllowSelfSignedCertificates: s.AllowSelfSignedCertificates,
+		FetchViaProxy:               s.FetchViaProxy,
+		HideGlobally:                s.HideGlobally,
+		DisableHTTP2:                s.DisableHTTP2,
+		ScraperRules:                s.ScraperRules,
+		RewriteRules:                s.RewriteRules,
+		BlocklistRules:              s.BlocklistRules,
+		KeeplistRules:               s.KeeplistRules,
+		BlockFilterEntryRules:       s.BlockFilterEntryRules,
+		KeepFilterEntryRules:        s.KeepFilterEntryRules,
+		UrlRewriteRules:             s.UrlRewriteRules,
+	}
+
+	if validationErr := validator.ValidateFeedCreation(store, userID, feedCreationRequest); validationErr != nil {
+		return validationErr.Error()
 	}
 
 	return nil

+ 98 - 4
internal/reader/opml/opml.go

@@ -5,15 +5,20 @@ package opml // import "miniflux.app/v2/internal/reader/opml"
 
 import (
 	"encoding/xml"
+	"fmt"
+	"strconv"
 	"strings"
 )
 
+const minifluxOPMLNamespace = "https://miniflux.app/opml"
+
 // Specs: http://opml.org/spec2.opml
 type opmlDocument struct {
-	XMLName  xml.Name              `xml:"opml"`
-	Version  string                `xml:"version,attr"`
-	Header   opmlHeader            `xml:"head"`
-	Outlines opmlOutlineCollection `xml:"body>outline"`
+	XMLName           xml.Name              `xml:"opml"`
+	Version           string                `xml:"version,attr"`
+	MinifluxNamespace string                `xml:"xmlns:miniflux,attr,omitempty"`
+	Header            opmlHeader            `xml:"head"`
+	Outlines          opmlOutlineCollection `xml:"body>outline"`
 }
 
 type opmlHeader struct {
@@ -29,6 +34,25 @@ type opmlOutline struct {
 	SiteURL     string                `xml:"htmlUrl,attr,omitempty"`
 	Description string                `xml:"description,attr,omitempty"`
 	Outlines    opmlOutlineCollection `xml:"outline,omitempty"`
+
+	// Miniflux-specific feed settings
+	ScraperRules                string `xml:"miniflux:scraperRules,attr,omitempty"`
+	RewriteRules                string `xml:"miniflux:rewriteRules,attr,omitempty"`
+	UrlRewriteRules             string `xml:"miniflux:urlRewriteRules,attr,omitempty"`
+	BlocklistRules              string `xml:"miniflux:blocklistRules,attr,omitempty"`
+	KeeplistRules               string `xml:"miniflux:keeplistRules,attr,omitempty"`
+	BlockFilterEntryRules       string `xml:"miniflux:blockFilterEntryRules,attr,omitempty"`
+	KeepFilterEntryRules        string `xml:"miniflux:keepFilterEntryRules,attr,omitempty"`
+	UserAgent                   string `xml:"miniflux:userAgent,attr,omitempty"`
+	Crawler                     bool   `xml:"miniflux:crawler,attr,omitempty"`
+	IgnoreHTTPCache             bool   `xml:"miniflux:ignoreHTTPCache,attr,omitempty"`
+	FetchViaProxy               bool   `xml:"miniflux:fetchViaProxy,attr,omitempty"`
+	Disabled                    bool   `xml:"miniflux:disabled,attr,omitempty"`
+	NoMediaPlayer               bool   `xml:"miniflux:noMediaPlayer,attr,omitempty"`
+	HideGlobally                bool   `xml:"miniflux:hideGlobally,attr,omitempty"`
+	AllowSelfSignedCertificates bool   `xml:"miniflux:allowSelfSignedCertificates,attr,omitempty"`
+	DisableHTTP2                bool   `xml:"miniflux:disableHTTP2,attr,omitempty"`
+	IgnoreEntryUpdates          bool   `xml:"miniflux:ignoreEntryUpdates,attr,omitempty"`
 }
 
 func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
@@ -48,6 +72,25 @@ func (o opmlOutline) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
 	}, start)
 }
 
+func (o *opmlOutline) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
+	*o = opmlOutline{}
+
+	type opmlOutlineAlias opmlOutline
+	if err := d.DecodeElement((*opmlOutlineAlias)(o), &start); err != nil {
+		return err
+	}
+
+	for _, attr := range start.Attr {
+		if attr.Name.Space == minifluxOPMLNamespace {
+			if err := o.setMinifluxAttribute(attr.Name.Local, attr.Value); err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
 func (o opmlOutline) IsSubscription() bool {
 	return strings.TrimSpace(o.FeedURL) != ""
 }
@@ -85,3 +128,54 @@ type opmlOutlineCollection []opmlOutline
 func (o opmlOutlineCollection) HasChildren() bool {
 	return len(o) > 0
 }
+
+func (o *opmlOutline) setMinifluxAttribute(name, value string) error {
+	switch name {
+	case "scraperRules":
+		o.ScraperRules = value
+	case "rewriteRules":
+		o.RewriteRules = value
+	case "urlRewriteRules":
+		o.UrlRewriteRules = value
+	case "blocklistRules":
+		o.BlocklistRules = value
+	case "keeplistRules":
+		o.KeeplistRules = value
+	case "blockFilterEntryRules":
+		o.BlockFilterEntryRules = value
+	case "keepFilterEntryRules":
+		o.KeepFilterEntryRules = value
+	case "userAgent":
+		o.UserAgent = value
+	case "crawler":
+		return setMinifluxBoolAttribute(name, value, &o.Crawler)
+	case "ignoreHTTPCache":
+		return setMinifluxBoolAttribute(name, value, &o.IgnoreHTTPCache)
+	case "fetchViaProxy":
+		return setMinifluxBoolAttribute(name, value, &o.FetchViaProxy)
+	case "disabled":
+		return setMinifluxBoolAttribute(name, value, &o.Disabled)
+	case "noMediaPlayer":
+		return setMinifluxBoolAttribute(name, value, &o.NoMediaPlayer)
+	case "hideGlobally":
+		return setMinifluxBoolAttribute(name, value, &o.HideGlobally)
+	case "allowSelfSignedCertificates":
+		return setMinifluxBoolAttribute(name, value, &o.AllowSelfSignedCertificates)
+	case "disableHTTP2":
+		return setMinifluxBoolAttribute(name, value, &o.DisableHTTP2)
+	case "ignoreEntryUpdates":
+		return setMinifluxBoolAttribute(name, value, &o.IgnoreEntryUpdates)
+	}
+
+	return nil
+}
+
+func setMinifluxBoolAttribute(name, value string, target *bool) error {
+	parsedValue, err := strconv.ParseBool(value)
+	if err != nil {
+		return fmt.Errorf("opml: invalid miniflux attribute %q: %w", name, err)
+	}
+
+	*target = parsedValue
+	return nil
+}

+ 18 - 0
internal/reader/opml/parser.go

@@ -38,6 +38,24 @@ func getSubscriptionsFromOutlines(outlines opmlOutlineCollection, category strin
 				SiteURL:      outline.GetSiteURL(),
 				Description:  outline.Description,
 				CategoryName: category,
+
+				ScraperRules:                outline.ScraperRules,
+				RewriteRules:                outline.RewriteRules,
+				UrlRewriteRules:             outline.UrlRewriteRules,
+				BlocklistRules:              outline.BlocklistRules,
+				KeeplistRules:               outline.KeeplistRules,
+				BlockFilterEntryRules:       outline.BlockFilterEntryRules,
+				KeepFilterEntryRules:        outline.KeepFilterEntryRules,
+				UserAgent:                   outline.UserAgent,
+				Crawler:                     outline.Crawler,
+				IgnoreHTTPCache:             outline.IgnoreHTTPCache,
+				FetchViaProxy:               outline.FetchViaProxy,
+				Disabled:                    outline.Disabled,
+				NoMediaPlayer:               outline.NoMediaPlayer,
+				HideGlobally:                outline.HideGlobally,
+				AllowSelfSignedCertificates: outline.AllowSelfSignedCertificates,
+				DisableHTTP2:                outline.DisableHTTP2,
+				IgnoreEntryUpdates:          outline.IgnoreEntryUpdates,
 			})
 		} else if outline.Outlines.HasChildren() {
 			subscriptions = append(subscriptions, getSubscriptionsFromOutlines(outline.Outlines, outline.GetTitle())...)

+ 80 - 14
internal/reader/opml/parser_test.go

@@ -8,13 +8,6 @@ import (
 	"testing"
 )
 
-// equals compare two subscriptions.
-func (s subcription) equals(subscription subcription) bool {
-	return s.Title == subscription.Title && s.SiteURL == subscription.SiteURL &&
-		s.FeedURL == subscription.FeedURL && s.CategoryName == subscription.CategoryName &&
-		s.Description == subscription.Description
-}
-
 func TestParseOpmlWithoutCategories(t *testing.T) {
 	data := `<?xml version="1.0" encoding="ISO-8859-1"?>
 	<opml version="2.0">
@@ -51,7 +44,7 @@ func TestParseOpmlWithoutCategories(t *testing.T) {
 		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 13)
 	}
 
-	if !subscriptions[0].equals(expected[0]) {
+	if subscriptions[0] != expected[0] {
 		t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[0], expected[0])
 	}
 }
@@ -89,7 +82,7 @@ func TestParseOpmlWithCategories(t *testing.T) {
 	}
 
 	for i := range len(subscriptions) {
-		if !subscriptions[i].equals(expected[i]) {
+		if subscriptions[i] != expected[i] {
 			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
@@ -122,7 +115,7 @@ func TestParseOpmlWithEmptyTitleAndEmptySiteURL(t *testing.T) {
 	}
 
 	for i := range len(subscriptions) {
-		if !subscriptions[i].equals(expected[i]) {
+		if subscriptions[i] != expected[i] {
 			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
@@ -160,7 +153,7 @@ func TestParseOpmlVersion1(t *testing.T) {
 	}
 
 	for i := range len(subscriptions) {
-		if !subscriptions[i].equals(expected[i]) {
+		if subscriptions[i] != expected[i] {
 			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
@@ -194,7 +187,7 @@ func TestParseOpmlVersion1WithoutOuterOutline(t *testing.T) {
 	}
 
 	for i := range len(subscriptions) {
-		if !subscriptions[i].equals(expected[i]) {
+		if subscriptions[i] != expected[i] {
 			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
@@ -236,7 +229,7 @@ func TestParseOpmlVersion1WithSeveralNestedOutlines(t *testing.T) {
 	}
 
 	for i := range len(subscriptions) {
-		if !subscriptions[i].equals(expected[i]) {
+		if subscriptions[i] != expected[i] {
 			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
@@ -269,7 +262,7 @@ func TestParseOpmlWithInvalidCharacterEntity(t *testing.T) {
 	}
 
 	for i := range len(subscriptions) {
-		if !subscriptions[i].equals(expected[i]) {
+		if subscriptions[i] != expected[i] {
 			t.Errorf(`Subscription is different: "%v" vs "%v"`, subscriptions[i], expected[i])
 		}
 	}
@@ -282,3 +275,76 @@ func TestParseInvalidXML(t *testing.T) {
 		t.Error("Parse should generate an error")
 	}
 }
+
+func TestParseOpmlWithMinifluxSettings(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<opml version="2.0" xmlns:miniflux="https://miniflux.app/opml">
+		<head><title>Miniflux</title></head>
+		<body>
+			<outline text="My Category">
+				<outline type="rss"
+					text="Feed 1"
+					title="Feed 1"
+					xmlUrl="http://example.org/feed1/"
+					htmlUrl="http://example.org/1"
+					miniflux:scraperRules="article.content"
+					miniflux:rewriteRules="replace(&quot;foo&quot;|&quot;bar&quot;)"
+					miniflux:urlRewriteRules="rewrite(&quot;^https://old&quot;|&quot;https://new&quot;)"
+					miniflux:blocklistRules="sponsored"
+					miniflux:keeplistRules="important"
+					miniflux:blockFilterEntryRules="EntryTitle=~&quot;ad&quot;"
+					miniflux:keepFilterEntryRules="EntryTitle=~&quot;news&quot;"
+					miniflux:userAgent="CustomAgent/1.0"
+					miniflux:proxyUrl="http://proxy.example.org"
+					miniflux:crawler="true"
+					miniflux:ignoreHTTPCache="true"
+					miniflux:fetchViaProxy="true"
+					miniflux:disabled="true"
+					miniflux:noMediaPlayer="true"
+					miniflux:hideGlobally="true"
+					miniflux:allowSelfSignedCertificates="true"
+					miniflux:disableHTTP2="true"
+					miniflux:ignoreEntryUpdates="true"
+				/>
+			</outline>
+		</body>
+	</opml>
+	`
+
+	expected := subcription{
+		Title:                       "Feed 1",
+		FeedURL:                     "http://example.org/feed1/",
+		SiteURL:                     "http://example.org/1",
+		CategoryName:                "My Category",
+		ScraperRules:                "article.content",
+		RewriteRules:                `replace("foo"|"bar")`,
+		UrlRewriteRules:             `rewrite("^https://old"|"https://new")`,
+		BlocklistRules:              "sponsored",
+		KeeplistRules:               "important",
+		BlockFilterEntryRules:       `EntryTitle=~"ad"`,
+		KeepFilterEntryRules:        `EntryTitle=~"news"`,
+		UserAgent:                   "CustomAgent/1.0",
+		Crawler:                     true,
+		IgnoreHTTPCache:             true,
+		FetchViaProxy:               true,
+		Disabled:                    true,
+		NoMediaPlayer:               true,
+		HideGlobally:                true,
+		AllowSelfSignedCertificates: true,
+		DisableHTTP2:                true,
+		IgnoreEntryUpdates:          true,
+	}
+
+	subscriptions, err := parse(bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(subscriptions) != 1 {
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(subscriptions), 1)
+	}
+
+	if subscriptions[0] != expected {
+		t.Errorf("Subscription is different:\ngot:  %+v\nwant: %+v", subscriptions[0], expected)
+	}
+}

+ 19 - 0
internal/reader/opml/serializer.go

@@ -34,6 +34,7 @@ func serialize(subscriptions []subcription) string {
 func convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {
 	opmlDocument := &opmlDocument{}
 	opmlDocument.Version = "2.0"
+	opmlDocument.MinifluxNamespace = minifluxOPMLNamespace
 	opmlDocument.Header.Title = "Miniflux"
 	opmlDocument.Header.DateCreated = time.Now().Format("Mon, 02 Jan 2006 15:04:05 MST")
 
@@ -53,6 +54,24 @@ func convertSubscriptionsToOPML(subscriptions []subcription) *opmlDocument {
 				FeedURL:     subscription.FeedURL,
 				SiteURL:     subscription.SiteURL,
 				Description: subscription.Description,
+
+				ScraperRules:                subscription.ScraperRules,
+				RewriteRules:                subscription.RewriteRules,
+				UrlRewriteRules:             subscription.UrlRewriteRules,
+				BlocklistRules:              subscription.BlocklistRules,
+				KeeplistRules:               subscription.KeeplistRules,
+				BlockFilterEntryRules:       subscription.BlockFilterEntryRules,
+				KeepFilterEntryRules:        subscription.KeepFilterEntryRules,
+				UserAgent:                   subscription.UserAgent,
+				Crawler:                     subscription.Crawler,
+				IgnoreHTTPCache:             subscription.IgnoreHTTPCache,
+				FetchViaProxy:               subscription.FetchViaProxy,
+				Disabled:                    subscription.Disabled,
+				NoMediaPlayer:               subscription.NoMediaPlayer,
+				HideGlobally:                subscription.HideGlobally,
+				AllowSelfSignedCertificates: subscription.AllowSelfSignedCertificates,
+				DisableHTTP2:                subscription.DisableHTTP2,
+				IgnoreEntryUpdates:          subscription.IgnoreEntryUpdates,
 			})
 		}
 

+ 87 - 0
internal/reader/opml/serializer_test.go

@@ -5,6 +5,7 @@ package opml // import "miniflux.app/v2/internal/reader/opml"
 
 import (
 	"bytes"
+	"strings"
 	"testing"
 )
 
@@ -38,6 +39,92 @@ func TestSerialize(t *testing.T) {
 	}
 }
 
+func TestSerializeWithMinifluxSettings(t *testing.T) {
+	input := subcription{
+		Title:                       "Feed 1",
+		FeedURL:                     "http://example.org/feed/1",
+		SiteURL:                     "http://example.org/1",
+		CategoryName:                "Category 1",
+		ScraperRules:                `article [class^="content"]`,
+		RewriteRules:                `replace("foo"|"bar")`,
+		UrlRewriteRules:             `rewrite("^https://old"|"https://new")`,
+		BlocklistRules:              "sponsored",
+		KeeplistRules:               "important",
+		BlockFilterEntryRules:       `EntryTitle=~"ad"`,
+		KeepFilterEntryRules:        `EntryTitle=~"news"`,
+		UserAgent:                   "CustomAgent/1.0",
+		Crawler:                     true,
+		IgnoreHTTPCache:             true,
+		FetchViaProxy:               true,
+		Disabled:                    true,
+		NoMediaPlayer:               true,
+		HideGlobally:                true,
+		AllowSelfSignedCertificates: true,
+		DisableHTTP2:                true,
+		IgnoreEntryUpdates:          true,
+	}
+
+	output := serialize([]subcription{input})
+	if !strings.Contains(output, `xmlns:miniflux="https://miniflux.app/opml"`) {
+		t.Fatal("Miniflux OPML namespace is missing")
+	}
+
+	if !strings.Contains(output, `miniflux:crawler="true"`) {
+		t.Fatal("Miniflux settings are not serialized with the Miniflux namespace")
+	}
+
+	if strings.Contains(output, "cookie") {
+		t.Fatal("Sensitive feed settings should not be serialized")
+	}
+
+	feeds, err := parse(bytes.NewBufferString(output))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feeds) != 1 {
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(feeds), 1)
+	}
+
+	if feeds[0] != input {
+		t.Errorf("Round-trip failed:\ngot:  %+v\nwant: %+v", feeds[0], input)
+	}
+}
+
+func TestSerializePreservesNewlinesInRules(t *testing.T) {
+	input := subcription{
+		Title:                 "Feed 1",
+		FeedURL:               "http://example.org/feed/1",
+		SiteURL:               "http://example.org/1",
+		CategoryName:          "Category 1",
+		RewriteRules:          "replace(\"foo\"|\"bar\")\nadd_youtube_video",
+		ScraperRules:          "article.content\np.body",
+		BlockFilterEntryRules: "EntryTitle=~\"ad\"\nEntryURL=~\"click\"",
+	}
+
+	output := serialize([]subcription{input})
+	feeds, err := parse(bytes.NewBufferString(output))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feeds) != 1 {
+		t.Fatalf("Wrong number of subscriptions: %d instead of %d", len(feeds), 1)
+	}
+
+	if feeds[0].RewriteRules != input.RewriteRules {
+		t.Errorf("RewriteRules newlines not preserved:\ngot:  %q\nwant: %q", feeds[0].RewriteRules, input.RewriteRules)
+	}
+
+	if feeds[0].ScraperRules != input.ScraperRules {
+		t.Errorf("ScraperRules newlines not preserved:\ngot:  %q\nwant: %q", feeds[0].ScraperRules, input.ScraperRules)
+	}
+
+	if feeds[0].BlockFilterEntryRules != input.BlockFilterEntryRules {
+		t.Errorf("BlockFilterEntryRules newlines not preserved:\ngot:  %q\nwant: %q", feeds[0].BlockFilterEntryRules, input.BlockFilterEntryRules)
+	}
+}
+
 func TestNormalizedCategoriesOrder(t *testing.T) {
 	var orderTests = []struct {
 		naturalOrderName string

+ 19 - 0
internal/reader/opml/subscription.go

@@ -10,4 +10,23 @@ type subcription struct {
 	FeedURL      string
 	CategoryName string
 	Description  string
+
+	// Miniflux-specific feed settings
+	ScraperRules                string
+	RewriteRules                string
+	UrlRewriteRules             string
+	BlocklistRules              string
+	KeeplistRules               string
+	BlockFilterEntryRules       string
+	KeepFilterEntryRules        string
+	UserAgent                   string
+	Crawler                     bool
+	IgnoreHTTPCache             bool
+	FetchViaProxy               bool
+	Disabled                    bool
+	NoMediaPlayer               bool
+	HideGlobally                bool
+	AllowSelfSignedCertificates bool
+	DisableHTTP2                bool
+	IgnoreEntryUpdates          bool
 }

+ 24 - 0
internal/validator/feed.go

@@ -36,6 +36,18 @@ func ValidateFeedCreation(store *storage.Storage, userID int64, request *model.F
 		return locale.NewLocalizedError("error.feed_invalid_keeplist_rule")
 	}
 
+	if request.BlockFilterEntryRules != "" {
+		if err := isValidFilterRules(request.BlockFilterEntryRules, "block"); err != nil {
+			return err
+		}
+	}
+
+	if request.KeepFilterEntryRules != "" {
+		if err := isValidFilterRules(request.KeepFilterEntryRules, "keep"); err != nil {
+			return err
+		}
+	}
+
 	if request.ProxyURL != "" && !urllib.IsAbsoluteURL(request.ProxyURL) {
 		return locale.NewLocalizedError("error.invalid_feed_proxy_url")
 	}
@@ -93,6 +105,18 @@ func ValidateFeedModification(store *storage.Storage, userID, feedID int64, requ
 		}
 	}
 
+	if request.BlockFilterEntryRules != nil && *request.BlockFilterEntryRules != "" {
+		if err := isValidFilterRules(*request.BlockFilterEntryRules, "block"); err != nil {
+			return err
+		}
+	}
+
+	if request.KeepFilterEntryRules != nil && *request.KeepFilterEntryRules != "" {
+		if err := isValidFilterRules(*request.KeepFilterEntryRules, "keep"); err != nil {
+			return err
+		}
+	}
+
 	if request.ProxyURL != nil {
 		if *request.ProxyURL == "" {
 			return locale.NewLocalizedError("error.proxy_url_not_empty")