瀏覽代碼

feat(api): add entry ID listing endpoint

Add GET /v1/entries/ids to return paginated entry IDs for the current user.

The endpoint supports status and starred filters, returns the total matching 
count, and exposes matching client methods.
John Brayton 6 小時之前
父節點
當前提交
7d8ffd2eb0

+ 49 - 0
client/client.go

@@ -888,6 +888,55 @@ func (c *Client) EntryContext(ctx context.Context, entryID int64) (*Entry, error
 	return entry, nil
 	return entry, nil
 }
 }
 
 
+// EntryIDs returns entry IDs for the current user, optionally filtered by starred status and/or read status.
+func (c *Client) EntryIDs(filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
+	ctx, cancel := withDefaultTimeout()
+	defer cancel()
+	return c.EntryIDsContext(ctx, filter)
+}
+
+// EntryIDsContext returns entry IDs for the current user, optionally filtered by starred status and/or read status.
+func (c *Client) EntryIDsContext(ctx context.Context, filter *EntryIDsFilter) (*EntryIDsResultSet, error) {
+	body, err := c.request.Get(ctx, buildEntryIDsFilterQueryString("/v1/entries/ids", filter))
+	if err != nil {
+		return nil, err
+	}
+	defer body.Close()
+
+	var result EntryIDsResultSet
+	if err := json.NewDecoder(body).Decode(&result); err != nil {
+		return nil, fmt.Errorf("miniflux: response error (%v)", err)
+	}
+
+	return &result, nil
+}
+
+func buildEntryIDsFilterQueryString(path string, filter *EntryIDsFilter) string {
+	if filter == nil {
+		return path
+	}
+
+	params := url.Values{}
+	if filter.Limit > 0 {
+		params.Set("limit", strconv.Itoa(filter.Limit))
+	}
+	if filter.Offset > 0 {
+		params.Set("offset", strconv.Itoa(filter.Offset))
+	}
+	if filter.Starred != nil {
+		params.Set("starred", strconv.FormatBool(*filter.Starred))
+	}
+	if filter.Status != "" {
+		params.Set("status", filter.Status)
+	}
+
+	if len(params) == 0 {
+		return path
+	}
+
+	return path + "?" + params.Encode()
+}
+
 // Entries fetches entries using the given filter.
 // Entries fetches entries using the given filter.
 func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
 func (c *Client) Entries(filter *Filter) (*EntryResultSet, error) {
 	ctx, cancel := withDefaultTimeout()
 	ctx, cancel := withDefaultTimeout()

+ 107 - 0
client/client_test.go

@@ -1286,3 +1286,110 @@ func TestUpdateEnclosure(t *testing.T) {
 		t.Fatalf("Expected no error, got %v", err)
 		t.Fatalf("Expected no error, got %v", err)
 	}
 	}
 }
 }
+
+func boolPtr(b bool) *bool { return &b }
+
+func TestEntryIDsNoFilter(t *testing.T) {
+	expected := &EntryIDsResultSet{
+		Total:    2,
+		EntryIDs: []int64{1, 2},
+	}
+	client := NewClientWithOptions(
+		"http://mf",
+		WithHTTPClient(
+			newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
+				expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids", nil, req)
+				return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
+			})))
+	res, err := client.EntryIDsContext(t.Context(), nil)
+	if err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+	if !reflect.DeepEqual(res, expected) {
+		t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
+	}
+}
+
+func TestEntryIDsWithPaginationFilter(t *testing.T) {
+	expected := &EntryIDsResultSet{
+		Total:    5,
+		EntryIDs: []int64{3},
+	}
+	client := NewClientWithOptions(
+		"http://mf",
+		WithHTTPClient(
+			newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
+				expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=1&offset=2", nil, req)
+				return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
+			})))
+	res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 1, Offset: 2})
+	if err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+	if !reflect.DeepEqual(res, expected) {
+		t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
+	}
+}
+
+func TestEntryIDsWithStarredFilter(t *testing.T) {
+	expected := &EntryIDsResultSet{
+		Total:    1,
+		EntryIDs: []int64{42},
+	}
+	client := NewClientWithOptions(
+		"http://mf",
+		WithHTTPClient(
+			newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
+				expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?starred=true", nil, req)
+				return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
+			})))
+	res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Starred: boolPtr(true)})
+	if err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+	if !reflect.DeepEqual(res, expected) {
+		t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
+	}
+}
+
+func TestEntryIDsWithStatusFilter(t *testing.T) {
+	expected := &EntryIDsResultSet{
+		Total:    10,
+		EntryIDs: []int64{7, 8},
+	}
+	client := NewClientWithOptions(
+		"http://mf",
+		WithHTTPClient(
+			newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
+				expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?status=unread", nil, req)
+				return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
+			})))
+	res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Status: EntryStatusUnread})
+	if err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+	if !reflect.DeepEqual(res, expected) {
+		t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
+	}
+}
+
+func TestEntryIDsWithCombinedFilter(t *testing.T) {
+	expected := &EntryIDsResultSet{
+		Total:    3,
+		EntryIDs: []int64{5},
+	}
+	client := NewClientWithOptions(
+		"http://mf",
+		WithHTTPClient(
+			newFakeHTTPClient(t, func(t *testing.T, req *http.Request) *http.Response {
+				expectRequest(t, http.MethodGet, "http://mf/v1/entries/ids?limit=2&offset=5&starred=false&status=read", nil, req)
+				return jsonResponseFrom(t, http.StatusOK, http.Header{}, expected)
+			})))
+	res, err := client.EntryIDsContext(t.Context(), &EntryIDsFilter{Limit: 2, Offset: 5, Starred: boolPtr(false), Status: EntryStatusRead})
+	if err != nil {
+		t.Fatalf("Expected no error, got %v", err)
+	}
+	if !reflect.DeepEqual(res, expected) {
+		t.Fatalf("Expected %s, got %s", asJSON(expected), asJSON(res))
+	}
+}

+ 14 - 0
client/model.go

@@ -327,6 +327,20 @@ type EntryResultSet struct {
 	Entries Entries `json:"entries"`
 	Entries Entries `json:"entries"`
 }
 }
 
 
+// EntryIDsFilter holds optional filter and pagination parameters for the entry IDs endpoint.
+type EntryIDsFilter struct {
+	Limit   int
+	Offset  int
+	Starred *bool
+	Status  string
+}
+
+// EntryIDsResultSet represents the response when fetching entry ID lists.
+type EntryIDsResultSet struct {
+	Total    int     `json:"total"`
+	EntryIDs []int64 `json:"entry_ids"`
+}
+
 // VersionResponse represents the version and the build information of the Miniflux instance.
 // VersionResponse represents the version and the build information of the Miniflux instance.
 type VersionResponse struct {
 type VersionResponse struct {
 	Version   string `json:"version"`
 	Version   string `json:"version"`

+ 1 - 0
internal/api/api.go

@@ -54,6 +54,7 @@ func NewHandler(store *storage.Storage, pool *worker.Pool) http.Handler {
 	mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
 	mux.HandleFunc("GET /v1/feeds/{feedID}/entries", handler.getFeedEntriesHandler)
 	mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
 	mux.HandleFunc("POST /v1/feeds/{feedID}/entries/import", handler.importFeedEntryHandler)
 	mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
 	mux.HandleFunc("GET /v1/feeds/{feedID}/entries/{entryID}", handler.getFeedEntryHandler)
+	mux.HandleFunc("GET /v1/entries/ids", handler.getEntryIDsHandler)
 	mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
 	mux.HandleFunc("GET /v1/entries", handler.getEntriesHandler)
 	mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
 	mux.HandleFunc("PUT /v1/entries", handler.setEntryStatusHandler)
 	mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)
 	mux.HandleFunc("GET /v1/entries/{entryID}", handler.getEntryHandler)

+ 234 - 0
internal/api/api_integration_test.go

@@ -2631,6 +2631,240 @@ func TestUpdateEntryStatusEndpoint(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestGetEntryIDsEndpoint(t *testing.T) {
+	testConfig := newIntegrationTestConfig()
+	if !testConfig.isConfigured() {
+		t.Skip(skipIntegrationTestsMessage)
+	}
+
+	adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
+
+	regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer adminClient.DeleteUser(regularTestUser.ID)
+
+	regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
+
+	boolPtr := func(b bool) *bool { return &b }
+
+	// A new user should have no entries at all.
+	result, err := regularUserClient.EntryIDs(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if result.EntryIDs == nil {
+		t.Fatal(`Entry IDs should not be nil`)
+	}
+
+	if len(result.EntryIDs) != 0 {
+		t.Fatalf(`Expected no entry IDs for a new user, got %d`, len(result.EntryIDs))
+	}
+
+	if result.Total != 0 {
+		t.Fatalf(`Expected total to be 0 for a new user, got %d`, result.Total)
+	}
+
+	// Subscribe to a feed so there are entries.
+	feedID, err := regularUserClient.CreateFeed(&miniflux.FeedCreationRequest{
+		FeedURL: testConfig.testFeedURL,
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	allEntries, err := regularUserClient.FeedEntries(feedID, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(allEntries.Entries) == 0 {
+		t.Fatal(`Expected feed to have entries`)
+	}
+
+	// Without filters, all entries should be returned.
+	result, err = regularUserClient.EntryIDs(nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(result.EntryIDs) != allEntries.Total {
+		t.Fatalf(`Expected %d entry IDs, got %d`, allEntries.Total, len(result.EntryIDs))
+	}
+
+	if result.Total != allEntries.Total {
+		t.Fatalf(`Expected total %d, got %d`, allEntries.Total, result.Total)
+	}
+
+	// Filter by status=unread: all entries should be unread initially.
+	unreadResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(unreadResult.EntryIDs) != allEntries.Total {
+		t.Fatalf(`Expected %d unread entry IDs, got %d`, allEntries.Total, len(unreadResult.EntryIDs))
+	}
+
+	// Mark one entry as read and verify status filter results update.
+	firstEntryID := allEntries.Entries[0].ID
+	if err := regularUserClient.UpdateEntries([]int64{firstEntryID}, miniflux.EntryStatusRead); err != nil {
+		t.Fatal(err)
+	}
+
+	unreadResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(unreadResult.EntryIDs) != allEntries.Total-1 {
+		t.Fatalf(`Expected %d unread entry IDs after marking one as read, got %d`, allEntries.Total-1, len(unreadResult.EntryIDs))
+	}
+
+	if unreadResult.Total != allEntries.Total-1 {
+		t.Fatalf(`Expected total %d after marking one as read, got %d`, allEntries.Total-1, unreadResult.Total)
+	}
+
+	for _, id := range unreadResult.EntryIDs {
+		if id == firstEntryID {
+			t.Fatalf(`Entry ID %d should not appear in unread IDs after being marked as read`, firstEntryID)
+		}
+	}
+
+	readResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusRead})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(readResult.EntryIDs) != 1 || readResult.EntryIDs[0] != firstEntryID {
+		t.Fatalf(`Expected only entry %d in read results, got %v`, firstEntryID, readResult.EntryIDs)
+	}
+
+	// Pagination: limit=1 should return 1 entry but total reflects the full unread count.
+	if allEntries.Total >= 2 {
+		pagedResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if len(pagedResult.EntryIDs) != 1 {
+			t.Fatalf(`Expected 1 entry ID with limit=1, got %d`, len(pagedResult.EntryIDs))
+		}
+
+		if pagedResult.Total != allEntries.Total-1 {
+			t.Fatalf(`Expected total %d with limit=1, got %d`, allEntries.Total-1, pagedResult.Total)
+		}
+
+		// offset=1 should skip the first entry.
+		offsetResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: miniflux.EntryStatusUnread, Limit: 1, Offset: 1})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if len(offsetResult.EntryIDs) != 1 {
+			t.Fatalf(`Expected 1 entry ID with limit=1 offset=1, got %d`, len(offsetResult.EntryIDs))
+		}
+
+		if offsetResult.EntryIDs[0] == pagedResult.EntryIDs[0] {
+			t.Fatalf(`Entry at offset=1 should differ from offset=0, both returned %d`, offsetResult.EntryIDs[0])
+		}
+	}
+
+	// Filter by starred=true: initially no starred entries.
+	starredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(starredResult.EntryIDs) != 0 {
+		t.Fatalf(`Expected no starred entry IDs for a new user, got %d`, len(starredResult.EntryIDs))
+	}
+
+	// Star the first entry and verify it appears in starred results.
+	if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
+		t.Fatal(err)
+	}
+
+	starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(starredResult.EntryIDs) != 1 {
+		t.Fatalf(`Expected 1 starred entry ID, got %d`, len(starredResult.EntryIDs))
+	}
+
+	if starredResult.Total != 1 {
+		t.Fatalf(`Expected total 1, got %d`, starredResult.Total)
+	}
+
+	if starredResult.EntryIDs[0] != firstEntryID {
+		t.Fatalf(`Expected starred entry ID %d, got %d`, firstEntryID, starredResult.EntryIDs[0])
+	}
+
+	// The read starred entry should appear when filtering by starred=true (read status does not affect it).
+	starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(starredResult.EntryIDs) != 1 {
+		t.Fatalf(`Expected starred entry ID to persist after marking as read, got %d result(s)`, len(starredResult.EntryIDs))
+	}
+
+	// starred=false should exclude the starred entry.
+	notStarredResult, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(false)})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	for _, id := range notStarredResult.EntryIDs {
+		if id == firstEntryID {
+			t.Fatalf(`Starred entry %d should not appear in starred=false results`, firstEntryID)
+		}
+	}
+
+	// Pagination with offset past the single starred result should return 0 entries but total 1.
+	pagedStarred, err := regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true), Limit: 0, Offset: 1})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(pagedStarred.EntryIDs) != 0 {
+		t.Fatalf(`Expected 0 entry IDs with offset=1 past the only result, got %d`, len(pagedStarred.EntryIDs))
+	}
+
+	if pagedStarred.Total != 1 {
+		t.Fatalf(`Expected total 1 with offset past results, got %d`, pagedStarred.Total)
+	}
+
+	// Unstarring the entry should remove it from starred=true results.
+	if err := regularUserClient.ToggleStarred(firstEntryID); err != nil {
+		t.Fatal(err)
+	}
+
+	starredResult, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Starred: boolPtr(true)})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(starredResult.EntryIDs) != 0 {
+		t.Fatalf(`Expected no starred entry IDs after unstarring, got %d`, len(starredResult.EntryIDs))
+	}
+
+	if starredResult.Total != 0 {
+		t.Fatalf(`Expected total 0 after unstarring, got %d`, starredResult.Total)
+	}
+
+	// Invalid starred value should return 400.
+	_, err = regularUserClient.EntryIDs(&miniflux.EntryIDsFilter{Status: "maybe"})
+	if err == nil {
+		t.Fatal(`Expected error for invalid status parameter, got nil`)
+	}
+}
+
 func TestUpdateEntryEndpoint(t *testing.T) {
 func TestUpdateEntryEndpoint(t *testing.T) {
 	testConfig := newIntegrationTestConfig()
 	testConfig := newIntegrationTestConfig()
 	if !testConfig.isConfigured() {
 	if !testConfig.isConfigured() {

+ 85 - 0
internal/api/api_test.go

@@ -91,6 +91,91 @@ func TestVersionHandler(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestGetEntryIDsHandlerRequiresAuthentication(t *testing.T) {
+	handler := NewHandler(nil, nil)
+
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, r)
+
+	if got := w.Code; got != http.StatusUnauthorized {
+		t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
+	}
+}
+
+func TestGetEntryIDsHandlerRejectsInvalidStarredParam(t *testing.T) {
+	handler := NewHandler(nil, nil)
+
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?starred=maybe", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, r)
+
+	// Unauthenticated request should be rejected before param validation.
+	if got := w.Code; got != http.StatusUnauthorized {
+		t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
+	}
+}
+
+func TestGetEntryIDsHandlerRejectsInvalidStatusParam(t *testing.T) {
+	handler := NewHandler(nil, nil)
+
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?status=invalid", nil)
+	w := httptest.NewRecorder()
+
+	handler.ServeHTTP(w, r)
+
+	// Unauthenticated request should be rejected before param validation.
+	if got := w.Code; got != http.StatusUnauthorized {
+		t.Fatalf(`Unexpected status code, got %d instead of %d`, got, http.StatusUnauthorized)
+	}
+}
+
+func TestParseEntryIDsParamsDefaults(t *testing.T) {
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids", nil)
+	limit, offset := parseEntryIDsParams(r)
+
+	if limit != 10000 {
+		t.Fatalf(`Expected default limit 10000, got %d`, limit)
+	}
+
+	if offset != 0 {
+		t.Fatalf(`Expected default offset 0, got %d`, offset)
+	}
+}
+
+func TestParseEntryIDsParamsCustomValues(t *testing.T) {
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=500&offset=100", nil)
+	limit, offset := parseEntryIDsParams(r)
+
+	if limit != 500 {
+		t.Fatalf(`Expected limit 500, got %d`, limit)
+	}
+
+	if offset != 100 {
+		t.Fatalf(`Expected offset 100, got %d`, offset)
+	}
+}
+
+func TestParseEntryIDsParamsLimitCappedAtMaximum(t *testing.T) {
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=99999", nil)
+	limit, _ := parseEntryIDsParams(r)
+
+	if limit != 10000 {
+		t.Fatalf(`Expected limit capped at 10000, got %d`, limit)
+	}
+}
+
+func TestParseEntryIDsParamsZeroLimitUsesDefault(t *testing.T) {
+	r := httptest.NewRequest(http.MethodGet, "/v1/entries/ids?limit=0", nil)
+	limit, _ := parseEntryIDsParams(r)
+
+	if limit != 10000 {
+		t.Fatalf(`Expected zero limit to use default 10000, got %d`, limit)
+	}
+}
+
 func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
 func TestNewHandlerSupportsBasePathStripping(t *testing.T) {
 	scenarios := []struct {
 	scenarios := []struct {
 		name   string
 		name   string

+ 58 - 0
internal/api/entry_handlers.go

@@ -497,6 +497,64 @@ func (h *handler) fetchContentHandler(w http.ResponseWriter, r *http.Request) {
 	response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
 	response.JSON(w, r, entryContentResponse{Content: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(entry.Content), ReadingTime: entry.ReadingTime})
 }
 }
 
 
+type entryIDsResponse struct {
+	Total    int     `json:"total"`
+	EntryIDs []int64 `json:"entry_ids"`
+}
+
+func parseEntryIDsParams(r *http.Request) (limit, offset int) {
+	limit = request.QueryIntParam(r, "limit", model.MaxEntryIDsLimit)
+	if limit <= 0 || limit > model.MaxEntryIDsLimit {
+		limit = model.MaxEntryIDsLimit
+	}
+	offset = request.QueryIntParam(r, "offset", 0)
+	return limit, offset
+}
+
+func (h *handler) getEntryIDsHandler(w http.ResponseWriter, r *http.Request) {
+	if request.HasQueryParam(r, "starred") {
+		starredValue := request.QueryStringParam(r, "starred", "")
+		if starredValue != "true" && starredValue != "false" {
+			response.JSONBadRequest(w, r, errors.New(`invalid starred parameter, must be "true" or "false"`))
+			return
+		}
+	}
+
+	if request.HasQueryParam(r, "status") {
+		statusValue := request.QueryStringParam(r, "status", "")
+		if statusValue != model.EntryStatusRead && statusValue != model.EntryStatusUnread {
+			response.JSONBadRequest(w, r, errors.New(`invalid status parameter, must be "read" or "unread"`))
+			return
+		}
+	}
+
+	limit, offset := parseEntryIDsParams(r)
+	builder := h.store.NewEntryQueryBuilder(request.UserID(r)).
+		WithSorting("id", "DESC").
+		WithLimitAndMaximum(limit, model.MaxEntryIDsLimit).
+		WithOffset(offset)
+
+	if request.HasQueryParam(r, "starred") {
+		builder.WithStarred(request.QueryBoolParam(r, "starred", false))
+	}
+
+	if request.HasQueryParam(r, "status") {
+		builder.WithStatuses(request.QueryStringParam(r, "status", ""))
+	}
+
+	entryIDs, total, err := builder.GetEntryIDsWithCount()
+	if err != nil {
+		response.JSONServerError(w, r, err)
+		return
+	}
+
+	if entryIDs == nil {
+		entryIDs = []int64{}
+	}
+
+	response.JSON(w, r, entryIDsResponse{Total: total, EntryIDs: entryIDs})
+}
+
 func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
 func (h *handler) flushHistoryHandler(w http.ResponseWriter, r *http.Request) {
 	loggedUserID := request.UserID(r)
 	loggedUserID := request.UserID(r)
 	go h.store.FlushHistory(loggedUserID)
 	go h.store.FlushHistory(loggedUserID)

+ 4 - 0
internal/model/entry.go

@@ -19,6 +19,10 @@ const (
 // and for the user "entries_per_page" preference.
 // and for the user "entries_per_page" preference.
 const MaxEntryLimit = 1000
 const MaxEntryLimit = 1000
 
 
+// MaxEntryIDsLimit is the maximum allowed value for the "limit" query parameter
+// for the entry ID list endpoints.
+const MaxEntryIDsLimit = 10000
+
 // Entry represents a feed item in the system.
 // Entry represents a feed item in the system.
 type Entry struct {
 type Entry struct {
 	ID          int64         `json:"id"`
 	ID          int64         `json:"id"`

+ 26 - 4
internal/storage/entry_query_builder.go

@@ -207,6 +207,14 @@ func (e *EntryQueryBuilder) WithLimit(limit int) *EntryQueryBuilder {
 	return e
 	return e
 }
 }
 
 
+// WithLimitAndMaximum sets the limit, capped at the given maximum.
+func (e *EntryQueryBuilder) WithLimitAndMaximum(limit, maximum int) *EntryQueryBuilder {
+	if limit > 0 {
+		e.limit = min(limit, maximum)
+	}
+	return e
+}
+
 // WithOffset set the offset.
 // WithOffset set the offset.
 func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder {
 func (e *EntryQueryBuilder) WithOffset(offset int) *EntryQueryBuilder {
 	if offset > 0 {
 	if offset > 0 {
@@ -462,18 +470,32 @@ func (e *EntryQueryBuilder) GetEntryIDs() ([]int64, error) {
 	var entryIDs []int64
 	var entryIDs []int64
 	for rows.Next() {
 	for rows.Next() {
 		var entryID int64
 		var entryID int64
-
-		err := rows.Scan(&entryID)
-		if err != nil {
+		if err := rows.Scan(&entryID); err != nil {
 			return nil, fmt.Errorf("store: unable to fetch entry row: %v", err)
 			return nil, fmt.Errorf("store: unable to fetch entry row: %v", err)
 		}
 		}
-
 		entryIDs = append(entryIDs, entryID)
 		entryIDs = append(entryIDs, entryID)
 	}
 	}
 
 
 	return entryIDs, nil
 	return entryIDs, nil
 }
 }
 
 
+// GetEntryIDsWithCount returns a list of entry IDs and the total count of
+// matching rows (ignoring limit/offset). It uses two queries: one to count
+// all matching rows and one to fetch the paginated IDs.
+func (e *EntryQueryBuilder) GetEntryIDsWithCount() ([]int64, int, error) {
+	total, err := e.CountEntries()
+	if err != nil {
+		return nil, 0, err
+	}
+
+	entryIDs, err := e.GetEntryIDs()
+	if err != nil {
+		return nil, 0, err
+	}
+
+	return entryIDs, total, nil
+}
+
 func (e *EntryQueryBuilder) contentColumn() string {
 func (e *EntryQueryBuilder) contentColumn() string {
 	if e.excludeContent {
 	if e.excludeContent {
 		return "'' AS content"
 		return "'' AS content"