| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
- // SPDX-License-Identifier: Apache-2.0
- package json // import "miniflux.app/v2/internal/reader/json"
- import (
- "cmp"
- "log/slog"
- "slices"
- "strings"
- "time"
- "miniflux.app/v2/internal/crypto"
- "miniflux.app/v2/internal/model"
- "miniflux.app/v2/internal/reader/date"
- "miniflux.app/v2/internal/reader/sanitizer"
- "miniflux.app/v2/internal/urllib"
- )
- type JSONAdapter struct {
- jsonFeed *JSONFeed
- }
- func NewJSONAdapter(jsonFeed *JSONFeed) *JSONAdapter {
- return &JSONAdapter{jsonFeed}
- }
- func (j *JSONAdapter) BuildFeed(baseURL string) *model.Feed {
- feed := &model.Feed{
- Title: strings.TrimSpace(j.jsonFeed.Title),
- FeedURL: strings.TrimSpace(j.jsonFeed.FeedURL),
- SiteURL: strings.TrimSpace(j.jsonFeed.HomePageURL),
- Description: strings.TrimSpace(j.jsonFeed.Description),
- }
- if feed.FeedURL == "" {
- feed.FeedURL = strings.TrimSpace(baseURL)
- }
- // Fallback to the feed URL if the site URL is empty.
- if feed.SiteURL == "" {
- feed.SiteURL = feed.FeedURL
- }
- if feedURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.FeedURL); err == nil {
- feed.FeedURL = feedURL
- }
- if siteURL, err := urllib.ResolveToAbsoluteURL(baseURL, feed.SiteURL); err == nil {
- feed.SiteURL = siteURL
- }
- // Fallback to the feed URL if the title is empty.
- if feed.Title == "" {
- feed.Title = feed.SiteURL
- }
- // Populate the icon URL if present.
- for _, iconURL := range []string{j.jsonFeed.FaviconURL, j.jsonFeed.IconURL} {
- if iconURL = strings.TrimSpace(iconURL); iconURL == "" {
- continue
- }
- if absoluteIconURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, iconURL); err == nil {
- feed.IconURL = absoluteIconURL
- break
- }
- }
- for _, item := range j.jsonFeed.Items {
- entry := model.NewEntry()
- for _, itemURL := range []string{item.URL, item.ExternalURL} {
- if itemURL = strings.TrimSpace(itemURL); itemURL == "" {
- continue
- }
- // Make sure the entry URL is absolute.
- if entryURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, itemURL); err == nil {
- entry.URL = entryURL
- break
- }
- }
- entry.Title = strings.TrimSpace(item.Title)
- if entry.Title == "" {
- // The entry title is optional, so we need to find a fallback.
- for _, value := range []string{item.Summary, item.ContentText, item.ContentHTML} {
- if value = sanitizer.TruncateHTML(value, 100); value == "" {
- continue
- }
- entry.Title = value
- break
- }
- }
- // Fallback to the entry URL if the title is empty.
- if entry.Title == "" {
- entry.Title = entry.URL
- }
- // Populate the entry content.
- for _, value := range []string{item.ContentHTML, item.ContentText, item.Summary} {
- if value = strings.TrimSpace(value); value == "" {
- continue
- }
- entry.Content = value
- break
- }
- // Populate the entry date.
- for _, value := range []string{item.DatePublished, item.DateModified} {
- if value = strings.TrimSpace(value); value == "" {
- continue
- }
- parsedDate, err := date.Parse(value)
- if err != nil {
- slog.Debug("Unable to parse date from JSON feed",
- slog.String("date", value),
- slog.String("url", entry.URL),
- slog.Any("error", err),
- )
- continue
- }
- entry.Date = parsedDate
- break
- }
- if entry.Date.IsZero() {
- entry.Date = time.Now()
- }
- // Populate the entry author.
- authorNames := make([]string, 0, len(j.jsonFeed.Authors)+len(item.Authors)+1+1)
- authorNames = appendSorted(authorNames, JSONAuthor.name, j.jsonFeed.Authors...)
- authorNames = appendSorted(authorNames, JSONAuthor.name, item.Authors...)
- authorNames = appendSorted(authorNames, JSONAuthor.name, item.Author, j.jsonFeed.Author)
- entry.Author = strings.Join(authorNames, ", ")
- // Populate the entry enclosures.
- for _, attachment := range item.Attachments {
- attachmentURL := strings.TrimSpace(attachment.URL)
- if attachmentURL == "" {
- continue
- }
- absoluteAttachmentURL, err := urllib.ResolveToAbsoluteURL(feed.SiteURL, attachmentURL)
- if err != nil {
- slog.Debug("Unable to build absolute URL for attachment",
- slog.String("url", attachmentURL),
- slog.String("site_url", feed.SiteURL),
- slog.Any("error", err),
- )
- continue
- }
- entry.Enclosures = append(entry.Enclosures, &model.Enclosure{
- URL: absoluteAttachmentURL,
- MimeType: attachment.MimeType,
- Size: attachment.Size,
- })
- }
- // Populate the entry tags.
- entry.Tags = make([]string, 0, len(item.Tags))
- entry.Tags = appendSorted(entry.Tags, strings.TrimSpace, item.Tags...)
- // Generate a hash for the entry.
- for _, value := range []string{item.ID, item.URL, item.ExternalURL, item.ContentText + item.ContentHTML + item.Summary} {
- value = strings.TrimSpace(value)
- if value != "" {
- entry.Hash = crypto.SHA256(value)
- break
- }
- }
- feed.Entries = append(feed.Entries, entry)
- }
- return feed
- }
- // appendSortedSeq appends elements from "values" slice into "sorted" slice.
- // - "fn" applied to every element of "values"
- // - elements inserted into "sorted" slice so it stays sorted
- // - duplicate elements are not inserted
- func appendSorted[I any, O cmp.Ordered](sorted []O, fn func(I) O, values ...I) []O {
- var zero O
- sorted = slices.Grow(sorted, len(values))
- for in := range slices.Values(values) {
- out := fn(in)
- if out == zero {
- continue
- }
- where, found := slices.BinarySearch(sorted, out)
- if found {
- continue
- }
- // Insert sorted to avoid duplicates.
- sorted = slices.Insert(sorted, where, out)
- }
- return sorted
- }
|