Browse Source

feat: add API endpoint to import entries into existing feed

Gerald Cox 3 months ago
parent
commit
90a389ac25

+ 26 - 0
client/client.go

@@ -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 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()

+ 1 - 0
internal/api/api.go

@@ -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)

+ 53 - 0
internal/api/api_integration_test.go

@@ -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`
@@ -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)
+	}
+}

+ 102 - 0
internal/api/entry.go

@@ -11,6 +11,7 @@ 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"
@@ -287,6 +288,107 @@ 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.Content = req.Content
+	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 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")

+ 14 - 0
internal/api/payload.go

@@ -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"`
 }

+ 52 - 0
internal/storage/entry.go

@@ -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)