8000 [pull] main from miniflux:main by pull[bot] · Pull Request #260 · abcdlsj/miniflux · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,32 @@ func (c *Client) UpdateFeedContext(ctx context.Context, feedID int64, feedChange
return f, nil
}

// ImportFeedEntry imports a single entry into a feed.
func (c *Client) ImportFeedEntry(feedID int64, payload any) (int64, error) {
ctx, cancel := withDefaultTimeout()
defer cancel()

body, err := c.request.Post(
ctx,
fmt.Sprintf("/v1/feeds/%d/entries/import", feedID),
payload,
)
if err != nil {
return 0, err
}
defer body.Close()

var 10BC8 response struct {
ID int64 `json:"id"`
}

if err := json.NewDecoder(body).Decode(&response); err != nil {
return 0, fmt.Errorf("miniflux: json error (%v)", err)
}

return response.ID, nil
}

// MarkFeedAsRead marks all unread entries of the feed as read.
func (c *Client) MarkFeedAsRead(feedID int64) error {
ctx, cancel := withDefaultTimeout()
Expand Down
1 change: 1 addition & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/export", handler.exportFeeds).Methods(http.MethodGet)
sr.HandleFunc("/import", handler.importFeeds).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries", handler.getFeedEntries).Methods(http.MethodGet)
sr.HandleFunc("/feeds/{feedID}/entries/import", handler.importFeedEntry).Methods(http.MethodPost)
sr.HandleFunc("/feeds/{feedID}/entries/{entryID}", handler.getFeedEntry).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet)
sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut)
Expand Down
53 changes: 53 additions & 0 deletions internal/api/api_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"testing"

miniflux "miniflux.app/v2/client"
"miniflux.app/v2/internal/model"
)

const skipIntegrationTestsMessage = `Set TEST_MINIFLUX_* environment variables to run the API integration tests`
Expand Down Expand Up @@ -2932,3 +2933,55 @@ func TestFlushHistoryEndpoint(t *testing.T) {
t.Fatalf(`Invalid total, got %d`, readEntries.Total)
}
}

func TestImportFeedEntryEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}

client := miniflux.NewClient(
testConfig.testBaseURL,
testConfig.testAdminUsername,
testConfig.testAdminPassword,
)

// Create a feed
feedID, err := client.CreateFeed(&miniflux.FeedCreationRequest{
FeedURL: testConfig.testFeedURL,
})
if err != nil {
t.Fatal(err)
}
defer client.DeleteFeed(feedID)

payload := map[string]any{
"title": "Imported Entry",
"url": "https://example.org/imported-entry",
"content": "Hello world",
"external_id": "integration-test-entry-1",
"status": model.EntryStatusUnread,
"starred": false,
"published_at": 0,
}

// First import
firstID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}

if firstID == 0 {
t.Fatal("expected non-zero entry ID on first import")
}

// Second import (same payload)
secondID, err := client.ImportFeedEntry(feedID, payload)
if err != nil {
t.Fatal(err)
}

if secondID != firstID {
t.Fatalf("expected same entry ID on re-import, got %d and %d", firstID, secondID)
}
}
111 changes: 111 additions & 0 deletions internal/api/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import (
"time"

"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/crypto"
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response/json"
"miniflux.app/v2/internal/integration"
"miniflux.app/v2/internal/mediaproxy"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/processor"
"miniflux.app/v2/internal/reader/readingtime"
"miniflux.app/v2/internal/reader/sanitizer"
"miniflux.app/v2/internal/storage"
"miniflux.app/v2/internal/validator"
)
Expand Down Expand Up @@ -274,6 +276,11 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
return
}

if entryUpdateRequest.Content != nil {
sanitizedContent := sanitizer.SanitizeHTML(entry.URL, *entryUpdateRequest.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
entryUpdateRequest.Content = &sanitizedContent
}

entryUpdateRequest.Patch(entry)
if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
Expand All @@ -287,6 +294,110 @@ func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) {
json.Created(w, r, entry)
}

func (h *handler) importFeedEntry(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
feedID := request.RouteInt64Param(r, "feedID")

if feedID <= 0 {
json.BadRequest(w, r, errors.New("invalid feed ID"))
return
}

if !h.store.FeedExists(userID, feedID) {
json.BadRequest(w, r, errors.New("feed does not exist"))
return
}

var req EntryImportRequest
if err := json_parser.NewDecoder(r.Body).Decode(&req); err != nil {
json.BadRequest(w, r, err)
return
}

if req.URL == "" {
json.BadRequest(w, r, errors.New("url is required"))
return
}

if req.Status == "" {
req.Status = model.EntryStatusRead
}

if err := validator.ValidateEntryStatus(req.Status); err != nil {
json.BadRequest(w, r, err)
return
}

entry := model.NewEntry()
entry.URL = req.URL
entry.CommentsURL = req.CommentsURL
entry.Author = req.Author
entry.Tags = req.Tags

if req.PublishedAt > 0 {
entry.Date = time.Unix(req.PublishedAt, 0).UTC()
} else {
entry.Date = time.Now().UTC()
}

if req.Title == "" {
entry.Title = entry.URL
} else {
entry.Title = req.Title
}

hashInput := req.ExternalID
if hashInput == "" {
hashInput = req.URL
}
entry.Hash = crypto.HashFromBytes([]byte(hashInput))

user, err := h.store.UserByID(userID)
if err != nil {
json.ServerError(w, r, err)
return
}

if user == nil {
json.NotFound(w, r)
return
}

if req.Content != "" {
entry.Content = sanitizer.SanitizeHTML(entry.URL, req.Content, &sanitizer.SanitizerOptions{OpenLinksInNewTab: user.OpenExternalLinksInNewTab})
}

if user.ShowReadingTime {
entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed)
}

created, err := h.store.InsertEntryForFeed(userID, feedID, entry)
if err != nil {
json.ServerError(w, r, err)
return
}

if err := h.store.SetEntriesStatus(userID, []int64{entry.ID}, req.Status); err != nil {
json.ServerError(w, r, err)
return
}
entry.Status = req.Status

if req.Starred {
if err := h.store.SetEntriesStarredState(userID, []int64{entry.ID}, true); err != nil {
json.ServerError(w, r, err)
return
}
entry.Starred = true
}

if created {
json.Created(w, r, map[string]int64{"id": entry.ID})
} else {
json.OK(w, r, map[string]int64{"id": entry.ID})
}
}

func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) {
loggedUserID := request.UserID(r)
entryID := request.RouteInt64Param(r, "entryID")
Expand Down
14 changes: 14 additions & 0 deletions internal/api/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}

// EntryImportRequest represents a manually imported entry for a feed.
type EntryImportRequest struct {
URL string `json:"url"`
Title string `json:"title"`
Content string `json:"content"`
Author string `json:"author"`
CommentsURL string `json:"comments_url"`
PublishedAt int64 `json:"published_at"`
Status string `json:"status"`
Starred bool `json:"starred"`
Tags []string `json:"tags"`
ExternalID string `json:"external_id"`
}

type feedCreationResponse struct {
FeedID int64 `json:"feed_id"`
}
Expand Down
4 changes: 2 additions & 2 deletions internal/config/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ func validateChoices(rawValue string, choices []string) error {

func validateListChoices(inputValues, choices []string) error {
for _, value := range inputValues {
if !slices.Contains(choices, value) {
return fmt.Errorf("value must be one of: %v", strings.Join(choices, ", "))
if err := validateChoices(value, choices); err != nil {
return err
}
}
return nil
Expand Down
52 changes: 52 additions & 0 deletions internal/storage/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,58 @@ func (s *Storage) entryExists(tx *sql.Tx, entry *model.Entry) (bool, error) {
return result, nil
}

func (s *Storage) getEntryIDByHash(tx *sql.Tx, feedID int64, entryHash string) (int64, error) {
var entryID int64

err := tx.QueryRow(
`SELECT id FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`,
feedID,
entryHash,
).Scan(&entryID)

if err != nil {
return 0, fmt.Errorf(`store: unable to fetch entry ID: %v`, err)
}

return entryID, nil
}

// InsertEntryForFeed inserts a single entry into a feed, optionally updating if it already exists.
// Returns true if a new entry was created, false if an existing one was reused.
func (s *Storage) InsertEntryForFeed(userID, feedID int64, entry *model.Entry) (bool, error) {
entry.UserID = userID
entry.FeedID = feedID

tx, err := s.db.Begin()
if err != nil {
return false, fmt.Errorf("store: unable to start transaction: %v", err)
}
defer tx.Rollback()

exists, err := s.entryExists(tx, entry)
if err != nil {
return false, err
}

if exists {
entryID, err := s.getEntryIDByHash(tx, entry.FeedID, entry.Hash)
if err != nil {
return false, err
}
entry.ID = entryID
} else {
if err := s.createEntry(tx, entry); err != nil {
return false, err
}
}

if err := tx.Commit(); err != nil {
return false, err
}

return !exists, nil
}

func (s *Storage) IsNewEntry(feedID int64, entryHash string) bool {
var result bool
s.db.QueryRow(`SELECT true FROM entries WHERE feed_id=$1 AND hash=$2 LIMIT 1`, feedID, entryHash).Scan(&result)
Expand Down
12 changes: 12 additions & 0 deletions internal/ui/static/css/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ a:hover {
translate: -50% 0;
}

/* Smoother pages transition */
@media not all and (prefers-reduced-motion: reduce) {
@view-transition {
navigation: auto;
}
}
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
}

/* Header and main menu */
.header {
margin-top: 10px;
Expand Down
Loading
0