Просмотр исходного кода

feat(reader): parse and persist feed and entry language from RSS, Atom, and JSON Feed

Reads the language declared by each feed and entry at parse time, persists
it on new `feeds.language` and `entries.language` columns, and exposes both
via the existing Feed/Entry JSON marshalling.

Sources:
- RSS feed: <language>
- RSS item: <dc:language>
- Atom 1.0 feed/entry: xml:lang
- Atom 0.3 feed/entry: xml:lang
- JSON Feed feed/item: "language"

Values are normalized at parse time (trim + lower-case + _ -> -) so they
are directly usable as an HTML lang attribute. No strict BCP-47 validation
is performed: many real feeds use loose values, and silently dropping them
yields worse downstream behaviour than passing them through.

The refresh path treats language as feed/entry-declared metadata and always
trusts the latest fetched value.
Bram Duvigneau 1 месяц назад
Родитель
Сommit
d456718c05

+ 7 - 0
internal/database/migrations.go

@@ -1545,4 +1545,11 @@ var migrations = [...]func(tx *sql.Tx) error{
 		`)
 		return err
 	},
+	func(tx *sql.Tx) (err error) {
+		_, err = tx.Exec(`
+			ALTER TABLE feeds ADD COLUMN language text not null default '';
+			ALTER TABLE entries ADD COLUMN language text not null default '';
+		`)
+		return err
+	},
 }

+ 1 - 0
internal/model/entry.go

@@ -33,6 +33,7 @@ type Entry struct {
 	Title       string        `json:"title"`
 	URL         string        `json:"url"`
 	CommentsURL string        `json:"comments_url"`
+	Language    string        `json:"language"`
 	Date        time.Time     `json:"published_at"`
 	CreatedAt   time.Time     `json:"created_at"`
 	ChangedAt   time.Time     `json:"changed_at"`

+ 1 - 0
internal/model/feed.go

@@ -28,6 +28,7 @@ type Feed struct {
 	SiteURL                     string    `json:"site_url"`
 	Title                       string    `json:"title"`
 	Description                 string    `json:"description"`
+	Language                    string    `json:"language"`
 	CheckedAt                   time.Time `json:"checked_at"`
 	NextCheckAt                 time.Time `json:"next_check_at"`
 	EtagHeader                  string    `json:"etag_header"`

+ 17 - 0
internal/model/language.go

@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package model // import "miniflux.app/v2/internal/model"
+
+import "strings"
+
+// NormalizeLanguage cleans up a language tag declared by a feed so it is
+// suitable for use as an HTML lang attribute. It trims surrounding
+// whitespace, lower-cases the value, and replaces underscores with hyphens
+// (e.g. "en_US" -> "en-us"). No strict BCP-47 validation is performed:
+// many real feeds use loose values and silently dropping them yields worse
+// downstream behaviour than passing them through.
+func NormalizeLanguage(s string) string {
+	s = strings.ToLower(strings.TrimSpace(s))
+	return strings.ReplaceAll(s, "_", "-")
+}

+ 26 - 0
internal/model/language_test.go

@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package model // import "miniflux.app/v2/internal/model"
+
+import "testing"
+
+func TestNormalizeLanguage(t *testing.T) {
+	cases := []struct {
+		in, want string
+	}{
+		{"", ""},
+		{"   ", ""},
+		{"en", "en"},
+		{"EN", "en"},
+		{"en_US", "en-us"},
+		{"EN-us", "en-us"},
+		{"pt-BR", "pt-br"},
+		{"  fr-FR  ", "fr-fr"},
+	}
+	for _, c := range cases {
+		if got := NormalizeLanguage(c.in); got != c.want {
+			t.Errorf("NormalizeLanguage(%q) = %q, want %q", c.in, got, c.want)
+		}
+	}
+}

+ 4 - 0
internal/reader/atom/atom_03.go

@@ -13,6 +13,8 @@ import (
 type atom03Feed struct {
 	Version string `xml:"version,attr"`
 
+	Language string `xml:"lang,attr"`
+
 	// The "atom:id" element's content conveys a permanent, globally unique identifier for the feed.
 	// It MUST NOT change over time, even if the feed is relocated. atom:feed elements MAY contain an atom:id element,
 	// but MUST NOT contain more than one. The content of this element, when present, MUST be a URI.
@@ -47,6 +49,8 @@ type atom03Entry struct {
 	// If the same entry is syndicated in two atom:feeds published by the same entity, the entry's atom:id MUST be the same in both feeds.
 	ID string `xml:"id"`
 
+	Language string `xml:"lang,attr"`
+
 	// The "atom:title" element is a Content construct that conveys a human-readable title for the entry.
 	// atom:entry elements MUST have exactly one "atom:title" element.
 	// If an entry describes a Web resource, its content SHOULD be the same as that resource's title.

+ 4 - 0
internal/reader/atom/atom_03_adapter.go

@@ -47,9 +47,13 @@ func (a *atom03Adapter) buildFeed(baseURL string) *model.Feed {
 		feed.Title = feed.SiteURL
 	}
 
+	feed.Language = model.NormalizeLanguage(a.atomFeed.Language)
+
 	for _, atomEntry := range a.atomFeed.Entries {
 		entry := model.NewEntry()
 
+		entry.Language = model.NormalizeLanguage(atomEntry.Language)
+
 		// Populate the entry URL.
 		entry.URL = atomEntry.Links.originalLink()
 		if entry.URL != "" {

+ 7 - 0
internal/reader/atom/atom_10.go

@@ -22,6 +22,11 @@ import (
 type atom10Feed struct {
 	XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
 
+	// xml:lang declares the natural language of the feed and, by
+	// inheritance, of its entries. Persisted on the feed row and emitted
+	// as an HTML lang attribute by the web reader.
+	Language string `xml:"lang,attr"`
+
 	// The "atom:id" element conveys a permanent, universally unique
 	// identifier for an entry or feed.
 	//
@@ -96,6 +101,8 @@ type atom10Entry struct {
 	// atom:entry elements MUST contain exactly one atom:id element.
 	ID string `xml:"http://www.w3.org/2005/Atom id"`
 
+	Language string `xml:"lang,attr"`
+
 	// The "atom:title" element is a Text construct that conveys a human-
 	// readable title for an entry or feed.
 	//

+ 4 - 0
internal/reader/atom/atom_10_adapter.go

@@ -51,6 +51,8 @@ func (a *atom10Adapter) buildFeed(baseURL string) *model.Feed {
 	// Populate the feed description.
 	feed.Description = a.atomFeed.Subtitle.body()
 
+	feed.Language = model.NormalizeLanguage(a.atomFeed.Language)
+
 	// Populate the feed icon.
 	for _, value := range []string{a.atomFeed.Icon, a.atomFeed.Logo} {
 		if value = strings.TrimSpace(value); value == "" {
@@ -109,6 +111,8 @@ func (a *atom10Adapter) populateEntries(siteURL string) model.Entries {
 			}
 		}
 
+		entry.Language = model.NormalizeLanguage(atomEntry.Language)
+
 		// Populate the entry author.
 		authors := atomEntry.Authors.personNames()
 		if len(authors) == 0 {

+ 96 - 0
internal/reader/atom/atom_10_test.go

@@ -1865,3 +1865,99 @@ func TestParseEntryWithIDAsURL(t *testing.T) {
 		t.Errorf("Incorrect entry URL, got: %s", feed.Entries[1].URL)
 	}
 }
+
+func TestParseFeedWithLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="fr-CA">
+		<title>Example Feed</title>
+		<link href="http://example.org/"/>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+	</feed>`
+
+	feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if feed.Language != "fr-ca" {
+		t.Errorf("Incorrect language, got: %q", feed.Language)
+	}
+}
+
+func TestParseFeedWithoutLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom">
+		<title>Example Feed</title>
+		<link href="http://example.org/"/>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+	</feed>`
+
+	feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if feed.Language != "" {
+		t.Errorf("Expected empty language, got: %q", feed.Language)
+	}
+}
+
+func TestParseEntryWithLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
+		<title>Example Feed</title>
+		<link href="http://example.org/"/>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+		<entry xml:lang="fr-CA">
+			<title>Bonjour</title>
+			<link href="http://example.org/2003/12/13/bonjour"/>
+			<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+			<updated>2003-12-13T18:30:02Z</updated>
+		</entry>
+	</feed>`
+
+	feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Language != "fr-ca" {
+		t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
+	}
+}
+
+func TestParseEntryWithoutLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+	<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
+		<title>Example Feed</title>
+		<link href="http://example.org/"/>
+		<updated>2003-12-13T18:30:02Z</updated>
+		<id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id>
+		<entry>
+			<title>Hello</title>
+			<link href="http://example.org/2003/12/13/hello"/>
+			<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
+			<updated>2003-12-13T18:30:02Z</updated>
+		</entry>
+	</feed>`
+
+	feed, err := Parse("http://example.org/feed.xml", bytes.NewReader([]byte(data)), "10")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Language != "" {
+		t.Errorf("Expected empty entry language, got: %q", feed.Entries[0].Language)
+	}
+}

+ 5 - 4
internal/reader/dublincore/dublincore.go

@@ -8,8 +8,9 @@ type DublinCoreChannelElement struct {
 }
 
 type DublinCoreItemElement struct {
-	DublinCoreTitle   string `xml:"http://purl.org/dc/elements/1.1/ title"`
-	DublinCoreDate    string `xml:"http://purl.org/dc/elements/1.1/ date"`
-	DublinCoreCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
-	DublinCoreContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
+	DublinCoreTitle    string `xml:"http://purl.org/dc/elements/1.1/ title"`
+	DublinCoreDate     string `xml:"http://purl.org/dc/elements/1.1/ date"`
+	DublinCoreCreator  string `xml:"http://purl.org/dc/elements/1.1/ creator"`
+	DublinCoreContent  string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
+	DublinCoreLanguage string `xml:"http://purl.org/dc/elements/1.1/ language"`
 }

+ 1 - 1
internal/reader/handler/handler.go

@@ -340,7 +340,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
 
 		originalFeed.EtagHeader = responseHandler.ETag()
 		originalFeed.LastModifiedHeader = responseHandler.LastModified()
-
+		originalFeed.Language = updatedFeed.Language
 		originalFeed.IconURL = updatedFeed.IconURL
 		iconChecker := icon.NewIconChecker(store, originalFeed)
 		if forceRefresh {

+ 2 - 0
internal/reader/json/adapter.go

@@ -31,6 +31,7 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
 		FeedURL:     strings.TrimSpace(j.jsonFeed.FeedURL),
 		SiteURL:     strings.TrimSpace(j.jsonFeed.HomePageURL),
 		Description: strings.TrimSpace(j.jsonFeed.Description),
+		Language:    model.NormalizeLanguage(j.jsonFeed.Language),
 	}
 
 	if feed.FeedURL == "" {
@@ -69,6 +70,7 @@ func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
 
 	for _, item := range j.jsonFeed.Items {
 		entry := model.NewEntry()
+		entry.Language = model.NormalizeLanguage(item.Language)
 
 		for _, itemURL := range []string{item.URL, item.ExternalURL} {
 			if itemURL = strings.TrimSpace(itemURL); itemURL == "" {

+ 100 - 0
internal/reader/json/parser_test.go

@@ -1103,3 +1103,103 @@ func TestParseNullJSONFeed(t *testing.T) {
 		t.Fatalf("Feed should not be nil")
 	}
 }
+
+func TestParseFeedWithLanguage(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1.1",
+		"title": "Example",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"language": "en-US",
+		"items": []
+	}`
+
+	feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if feed.Language != "en-us" {
+		t.Errorf("Incorrect language, got: %q", feed.Language)
+	}
+}
+
+func TestParseFeedWithoutLanguage(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1.1",
+		"title": "Example",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"items": []
+	}`
+
+	feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if feed.Language != "" {
+		t.Errorf("Expected empty language, got: %q", feed.Language)
+	}
+}
+
+func TestParseItemWithLanguage(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1.1",
+		"title": "Example",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"language": "en-US",
+		"items": [
+			{
+				"id": "1",
+				"url": "https://example.org/item",
+				"content_text": "Bonjour",
+				"language": "fr-FR"
+			}
+		]
+	}`
+
+	feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Language != "fr-fr" {
+		t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
+	}
+}
+
+func TestParseItemWithoutLanguage(t *testing.T) {
+	data := `{
+		"version": "https://jsonfeed.org/version/1.1",
+		"title": "Example",
+		"home_page_url": "https://example.org/",
+		"feed_url": "https://example.org/feed.json",
+		"language": "en-US",
+		"items": [
+			{
+				"id": "1",
+				"url": "https://example.org/item",
+				"content_text": "Hello"
+			}
+		]
+	}`
+
+	feed, err := Parse("https://example.org/feed.json", bytes.NewBufferString(data))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Language != "" {
+		t.Errorf("Expected empty entry language, got: %q", feed.Entries[0].Language)
+	}
+}

+ 3 - 0
internal/reader/rss/adapter.go

@@ -31,6 +31,7 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
 		FeedURL:     strings.TrimSpace(baseURL),
 		SiteURL:     strings.TrimSpace(r.rss.Channel.Link),
 		Description: strings.TrimSpace(r.rss.Channel.Description),
+		Language:    model.NormalizeLanguage(r.rss.Channel.Language),
 	}
 
 	// Ensure the Site URL is absolute.
@@ -113,6 +114,8 @@ func (r *rssAdapter) buildFeed(baseURL string) *model.Feed {
 			entry.Author = findFeedAuthor(&r.rss.Channel)
 		}
 
+		entry.Language = model.NormalizeLanguage(item.DublinCoreLanguage)
+
 		// Generate the entry hash.
 		//
 		// The RSS 2.0 spec requires <guid> to uniquely identify the item, but

+ 96 - 0
internal/reader/rss/parser_test.go

@@ -2314,3 +2314,99 @@ func TestParseEntriesWithUniqueGUIDsAreUnchanged(t *testing.T) {
 		t.Errorf("Entry 1: incorrect hash, got: %s", feed.Entries[1].Hash)
 	}
 }
+
+func TestParseFeedWithLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<language>en-US</language>
+		</channel>
+		</rss>`
+
+	feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if feed.Language != "en-us" {
+		t.Errorf("Incorrect language, got: %q", feed.Language)
+	}
+}
+
+func TestParseFeedWithoutLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+		</channel>
+		</rss>`
+
+	feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if feed.Language != "" {
+		t.Errorf("Expected empty language, got: %q", feed.Language)
+	}
+}
+
+func TestParseItemWithDublinCoreLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<language>en-US</language>
+			<item>
+				<title>Item</title>
+				<link>https://example.org/item</link>
+				<dc:language>fr-FR</dc:language>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Language != "fr-fr" {
+		t.Errorf("Incorrect entry language, got: %q", feed.Entries[0].Language)
+	}
+}
+
+func TestParseItemWithoutDublinCoreLanguage(t *testing.T) {
+	data := `<?xml version="1.0" encoding="utf-8"?>
+		<rss version="2.0">
+		<channel>
+			<title>Example</title>
+			<link>https://example.org/</link>
+			<language>en-US</language>
+			<item>
+				<title>Item</title>
+				<link>https://example.org/item</link>
+			</item>
+		</channel>
+		</rss>`
+
+	feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(feed.Entries) != 1 {
+		t.Fatalf("Expected 1 entry, got: %d", len(feed.Entries))
+	}
+
+	if feed.Entries[0].Language != "" {
+		t.Errorf("Expected empty entry language, got: %q", feed.Entries[0].Language)
+	}
+}

+ 8 - 3
internal/storage/entry.go

@@ -98,7 +98,8 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 				reading_time,
 				changed_at,
 				document_vectors,
-				tags
+				tags,
+				language
 			)
 		SELECT
 			$1,
@@ -113,7 +114,8 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 			$10,
 			now(),
 			setweight(to_tsvector($11), 'A') || setweight(to_tsvector($12), 'B'),
-			$13
+			$13,
+			$14
 		WHERE NOT EXISTS (
 			SELECT 1 FROM entry_tombstones WHERE feed_id=$9 AND hash=$2
 		)
@@ -135,6 +137,7 @@ func (s *Storage) createEntry(tx *sql.Tx, entry *model.Entry) error {
 		truncatedTitle,
 		truncatedContent,
 		pq.Array(entry.Tags),
+		entry.Language,
 	).Scan(
 		&entry.ID,
 		&entry.Status,
@@ -176,7 +179,8 @@ func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
 			author=$5,
 			reading_time=$6,
 			document_vectors = setweight(to_tsvector($7), 'A') || setweight(to_tsvector($8), 'B'),
-			tags=$12
+			tags=$12,
+			language=$13
 		WHERE
 			user_id=$9 AND feed_id=$10 AND hash=$11
 		RETURNING
@@ -196,6 +200,7 @@ func (s *Storage) updateEntry(tx *sql.Tx, entry *model.Entry) error {
 		entry.FeedID,
 		entry.Hash,
 		pq.Array(entry.Tags),
+		entry.Language,
 	).Scan(&entry.ID)
 	if err != nil {
 		return fmt.Errorf(`store: unable to update entry %q: %v`, entry.URL, err)

+ 4 - 0
internal/storage/entry_query_builder.go

@@ -308,10 +308,12 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
 			e.created_at,
 			e.changed_at,
 			e.tags,
+			e.language,
 			f.title as feed_title,
 			f.feed_url,
 			f.site_url,
 			f.description,
+			f.language,
 			f.checked_at,
 			f.category_id,
 			c.title as category_title,
@@ -378,10 +380,12 @@ func (e *EntryQueryBuilder) fetchEntries(withCount bool) (model.Entries, int, er
 			&entry.CreatedAt,
 			&entry.ChangedAt,
 			pq.Array(&entry.Tags),
+			&entry.Language,
 			&entry.Feed.Title,
 			&entry.Feed.FeedURL,
 			&entry.Feed.SiteURL,
 			&entry.Feed.Description,
+			&entry.Feed.Language,
 			&entry.Feed.CheckedAt,
 			&entry.Feed.Category.ID,
 			&entry.Feed.Category.Title,

+ 8 - 4
internal/storage/feed.go

@@ -246,10 +246,11 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
 			disable_http2,
 			description,
 			proxy_url,
-			ignore_entry_updates
+			ignore_entry_updates,
+			language
 		)
 		VALUES
-			($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)
+			($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32)
 		RETURNING
 			id
 	`
@@ -286,6 +287,7 @@ func (s *Storage) CreateFeed(feed *model.Feed) error {
 		feed.Description,
 		feed.ProxyURL,
 		feed.IgnoreEntryUpdates,
+		feed.Language,
 	).Scan(&feed.ID)
 	if err != nil {
 		return fmt.Errorf(`store: unable to create feed %q: %v`, feed.FeedURL, err)
@@ -369,9 +371,10 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
 			pushover_enabled=$36,
 			pushover_priority=$37,
 			proxy_url=$38,
-			ignore_entry_updates=$39
+			ignore_entry_updates=$39,
+			language=$40
 		WHERE
-			id=$40 AND user_id=$41
+			id=$41 AND user_id=$42
 	`
 	_, err = s.db.Exec(query,
 		feed.FeedURL,
@@ -413,6 +416,7 @@ func (s *Storage) UpdateFeed(feed *model.Feed) (err error) {
 		feed.PushoverPriority,
 		feed.ProxyURL,
 		feed.IgnoreEntryUpdates,
+		feed.Language,
 		feed.ID,
 		feed.UserID,
 	)

+ 2 - 0
internal/storage/feed_query_builder.go

@@ -144,6 +144,7 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			f.site_url,
 			f.title,
 			f.description,
+			f.language,
 			f.etag_header,
 			f.last_modified_header,
 			f.user_id,
@@ -226,6 +227,7 @@ func (f *feedQueryBuilder) GetFeeds() (model.Feeds, error) {
 			&feed.SiteURL,
 			&feed.Title,
 			&feed.Description,
+			&feed.Language,
 			&feed.EtagHeader,
 			&feed.LastModifiedHeader,
 			&feed.UserID,