diff --git a/client/client.go b/client/client.go index 496eb50f..3464e293 100644 --- a/client/client.go +++ b/client/client.go @@ -484,6 +484,22 @@ func (c *Client) UpdateEntries(entryIDs []int64, status string) error { return err } +// UpdateEntry updates an entry. +func (c *Client) UpdateEntry(entryID int64, entryChanges *EntryModificationRequest) (*Entry, error) { + body, err := c.request.Put(fmt.Sprintf("/v1/entries/%d", entryID), entryChanges) + if err != nil { + return nil, err + } + defer body.Close() + + var entry *Entry + if err := json.NewDecoder(body).Decode(&entry); err != nil { + return nil, fmt.Errorf("miniflux: response error (%v)", err) + } + + return entry, nil +} + // ToggleBookmark toggles entry bookmark value. func (c *Client) ToggleBookmark(entryID int64) error { _, err := c.request.Put(fmt.Sprintf("/v1/entries/%d/bookmark", entryID), nil) diff --git a/client/model.go b/client/model.go index 2d194a10..a193d92c 100644 --- a/client/model.go +++ b/client/model.go @@ -222,6 +222,12 @@ type Entry struct { Tags []string `json:"tags"` } +// EntryModificationRequest represents a request to modify an entry. +type EntryModificationRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` +} + // Entries represents a list of entries. type Entries []*Entry diff --git a/contrib/bruno/miniflux/Update entry.bru b/contrib/bruno/miniflux/Update entry.bru new file mode 100644 index 00000000..a3496539 --- /dev/null +++ b/contrib/bruno/miniflux/Update entry.bru @@ -0,0 +1,27 @@ +meta { + name: Update entry + type: http + seq: 41 +} + +put { + url: {{minifluxBaseURL}}/v1/entries/{{entryID}} + body: json + auth: basic +} + +auth:basic { + username: {{minifluxUsername}} + password: {{minifluxPassword}} +} + +body:json { + { + "title": "New title", + "content": "Some text" + } +} + +vars:pre-request { + entryID: 1789 +} diff --git a/internal/api/api.go b/internal/api/api.go index e00e8272..d6a36376 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -63,6 +63,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) { sr.HandleFunc("/entries", handler.getEntries).Methods(http.MethodGet) sr.HandleFunc("/entries", handler.setEntryStatus).Methods(http.MethodPut) sr.HandleFunc("/entries/{entryID}", handler.getEntry).Methods(http.MethodGet) + sr.HandleFunc("/entries/{entryID}", handler.updateEntry).Methods(http.MethodPut) sr.HandleFunc("/entries/{entryID}/bookmark", handler.toggleBookmark).Methods(http.MethodPut) sr.HandleFunc("/entries/{entryID}/save", handler.saveEntry).Methods(http.MethodPost) sr.HandleFunc("/entries/{entryID}/fetch-content", handler.fetchContent).Methods(http.MethodGet) diff --git a/internal/api/entry.go b/internal/api/entry.go index e6c00e3a..2af2b3c7 100644 --- a/internal/api/entry.go +++ b/internal/api/entry.go @@ -18,6 +18,7 @@ import ( "miniflux.app/v2/internal/model" "miniflux.app/v2/internal/proxy" "miniflux.app/v2/internal/reader/processor" + "miniflux.app/v2/internal/reader/readingtime" "miniflux.app/v2/internal/storage" "miniflux.app/v2/internal/urllib" "miniflux.app/v2/internal/validator" @@ -232,6 +233,58 @@ func (h *handler) saveEntry(w http.ResponseWriter, r *http.Request) { json.Accepted(w, r) } +func (h *handler) updateEntry(w http.ResponseWriter, r *http.Request) { + var entryUpdateRequest model.EntryUpdateRequest + if err := json_parser.NewDecoder(r.Body).Decode(&entryUpdateRequest); err != nil { + json.BadRequest(w, r, err) + return + } + + if err := validator.ValidateEntryModification(&entryUpdateRequest); err != nil { + json.BadRequest(w, r, err) + return + } + + loggedUserID := request.UserID(r) + entryID := request.RouteInt64Param(r, "entryID") + + entryBuilder := h.store.NewEntryQueryBuilder(loggedUserID) + entryBuilder.WithEntryID(entryID) + entryBuilder.WithoutStatus(model.EntryStatusRemoved) + + entry, err := entryBuilder.GetEntry() + if err != nil { + json.ServerError(w, r, err) + return + } + + if entry == nil { + json.NotFound(w, r) + return + } + + user, err := h.store.UserByID(loggedUserID) + if err != nil { + json.ServerError(w, r, err) + return + } + + if user == nil { + json.NotFound(w, r) + return + } + + entryUpdateRequest.Patch(entry) + entry.ReadingTime = readingtime.EstimateReadingTime(entry.Content, user.DefaultReadingSpeed, user.CJKReadingSpeed) + + if err := h.store.UpdateEntryTitleAndContent(entry); err != nil { + json.ServerError(w, r, err) + return + } + + json.Created(w, r, entry) +} + func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) { loggedUserID := request.UserID(r) entryID := request.RouteInt64Param(r, "entryID") @@ -251,7 +304,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) { return } - user, err := h.store.UserByID(entry.UserID) + user, err := h.store.UserByID(loggedUserID) if err != nil { json.ServerError(w, r, err) return diff --git a/internal/model/entry.go b/internal/model/entry.go index 87dcae35..7631119a 100644 --- a/internal/model/entry.go +++ b/internal/model/entry.go @@ -58,3 +58,19 @@ type EntriesStatusUpdateRequest struct { EntryIDs []int64 `json:"entry_ids"` Status string `json:"status"` } + +// EntryUpdateRequest represents a request to update an entry. +type EntryUpdateRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` +} + +func (e *EntryUpdateRequest) Patch(entry *Entry) { + if e.Title != nil && *e.Title != "" { + entry.Title = *e.Title + } + + if e.Content != nil && *e.Content != "" { + entry.Content = *e.Content + } +} diff --git a/internal/storage/entry.go b/internal/storage/entry.go index 0ddbeebf..647db94f 100644 --- a/internal/storage/entry.go +++ b/internal/storage/entry.go @@ -67,42 +67,25 @@ func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder { return NewEntryQueryBuilder(s, userID) } -// UpdateEntryContent updates entry content. -func (s *Storage) UpdateEntryContent(entry *model.Entry) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - +// UpdateEntryTitleAndContent updates entry title and content. +func (s *Storage) UpdateEntryTitleAndContent(entry *model.Entry) error { query := ` UPDATE entries SET - content=$1, reading_time=$2 + title=$1, + content=$2, + reading_time=$3, + document_vectors = setweight(to_tsvector(left(coalesce($1, ''), 500000)), 'A') || setweight(to_tsvector(left(coalesce($2, ''), 500000)), 'B') WHERE - id=$3 AND user_id=$4 + id=$4 AND user_id=$5 ` - _, err = tx.Exec(query, entry.Content, entry.ReadingTime, entry.ID, entry.UserID) - if err != nil { - tx.Rollback() - return fmt.Errorf(`store: unable to update content of entry #%d: %v`, entry.ID, err) + + if _, err := s.db.Exec(query, entry.Title, entry.Content, entry.ReadingTime, entry.ID, entry.UserID); err != nil { + return fmt.Errorf(`store: unable to update entry #%d: %v`, entry.ID, err) } - query = ` - UPDATE - entries - SET - document_vectors = setweight(to_tsvector(left(coalesce(title, ''), 500000)), 'A') || setweight(to_tsvector(left(coalesce(content, ''), 500000)), 'B') - WHERE - id=$1 AND user_id=$2 - ` - _, err = tx.Exec(query, entry.ID, entry.UserID) - if err != nil { - tx.Rollback() - return fmt.Errorf(`store: unable to update content of entry #%d: %v`, entry.ID, err) - } - - return tx.Commit() + return nil } // createEntry add a new entry. diff --git a/internal/tests/entry_test.go b/internal/tests/entry_test.go index dde5582a..b0669797 100644 --- a/internal/tests/entry_test.go +++ b/internal/tests/entry_test.go @@ -397,6 +397,40 @@ func TestUpdateStatus(t *testing.T) { } } +func TestUpdateEntry(t *testing.T) { + client := createClient(t) + createFeed(t, client) + + result, err := client.Entries(&miniflux.Filter{Limit: 1}) + if err != nil { + t.Fatal(err) + } + + title := "New title" + content := "New content" + + _, err = client.UpdateEntry(result.Entries[0].ID, &miniflux.EntryModificationRequest{ + Title: &title, + Content: &content, + }) + if err != nil { + t.Fatal(err) + } + + entry, err := client.Entry(result.Entries[0].ID) + if err != nil { + t.Fatal(err) + } + + if entry.Title != title { + t.Fatal("The entry title should be updated") + } + + if entry.Content != content { + t.Fatal("The entry content should be updated") + } +} + func TestToggleBookmark(t *testing.T) { client := createClient(t) createFeed(t, client) diff --git a/internal/ui/entry_scraper.go b/internal/ui/entry_scraper.go index afbaf061..ad442b16 100644 --- a/internal/ui/entry_scraper.go +++ b/internal/ui/entry_scraper.go @@ -58,7 +58,7 @@ func (h *handler) fetchContent(w http.ResponseWriter, r *http.Request) { return } - if err := h.store.UpdateEntryContent(entry); err != nil { + if err := h.store.UpdateEntryTitleAndContent(entry); err != nil { json.ServerError(w, r, err) return } diff --git a/internal/validator/entry.go b/internal/validator/entry.go index dd7216a0..f4fdb116 100644 --- a/internal/validator/entry.go +++ b/internal/validator/entry.go @@ -12,7 +12,7 @@ import ( // ValidateEntriesStatusUpdateRequest validates a status update for a list of entries. func ValidateEntriesStatusUpdateRequest(request *model.EntriesStatusUpdateRequest) error { if len(request.EntryIDs) == 0 { - return fmt.Errorf(`The list of entries cannot be empty`) + return fmt.Errorf(`the list of entries cannot be empty`) } return ValidateEntryStatus(request.Status) @@ -25,7 +25,7 @@ func ValidateEntryStatus(status string) error { return nil } - return fmt.Errorf(`Invalid entry status, valid status values are: "%s", "%s" and "%s"`, model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved) + return fmt.Errorf(`invalid entry status, valid status values are: "%s", "%s" and "%s"`, model.EntryStatusRead, model.EntryStatusUnread, model.EntryStatusRemoved) } // ValidateEntryOrder makes sure the sorting order is valid. @@ -35,5 +35,18 @@ func ValidateEntryOrder(order string) error { return nil } - return fmt.Errorf(`Invalid entry order, valid order values are: "id", "status", "changed_at", "published_at", "created_at", "category_title", "category_id", "title", "author"`) + return fmt.Errorf(`invalid entry order, valid order values are: "id", "status", "changed_at", "published_at", "created_at", "category_title", "category_id", "title", "author"`) +} + +// ValidateEntryModification makes sure the entry modification is valid. +func ValidateEntryModification(request *model.EntryUpdateRequest) error { + if request.Title != nil && *request.Title == "" { + return fmt.Errorf(`the entry title cannot be empty`) + } + + if request.Content != nil && *request.Content == "" { + return fmt.Errorf(`the entry content cannot be empty`) + } + + return nil }