From fc518afa94fb50bf730ed6d982e59e21e4acb569 Mon Sep 17 00:00:00 2001 From: Ryohei Ueda Date: Sun, 5 Apr 2026 11:31:08 -0700 Subject: [PATCH 1/2] Add label assign command Allow assigning an existing label to a library item via `paperpile label assign `. The command resolves the label name to its ID, appends it to the item's label list, and pushes the update through the Sync API. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 8 +++ cmd/interfaces.go | 5 ++ cmd/label.go | 21 ++++++++ cmd/label_test.go | 44 +++++++++++++++ internal/api/label.go | 42 +++++++++++++++ internal/api/label_test.go | 107 +++++++++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+) diff --git a/README.md b/README.md index 8bd024f..f02d2c4 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,14 @@ paperpile label create Creates a new label in your Paperpile library. +### `label assign` - Assign a label to a library item + +```bash +paperpile label assign +``` + +Assigns an existing label to a library item. + ## Configuration Session credentials are stored in `~/.config/paperpile/config.yaml`. diff --git a/cmd/interfaces.go b/cmd/interfaces.go index 98396d0..7d9324c 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -51,3 +51,8 @@ type ItemLabelGetter interface { type LabelCreator interface { CreateLabel(name string) (string, error) } + +// LabelAssigner assigns a label to a library item. +type LabelAssigner interface { + AssignLabel(itemID, labelName string) error +} diff --git a/cmd/label.go b/cmd/label.go index 91cf45a..de27d44 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -15,6 +15,7 @@ func init() { labelCmd.AddCommand(labelListCmd) labelCmd.AddCommand(labelGetCmd) labelCmd.AddCommand(labelCreateCmd) + labelCmd.AddCommand(labelAssignCmd) rootCmd.AddCommand(labelCmd) } @@ -85,6 +86,26 @@ func execLabelCreate(creator LabelCreator, out io.Writer, labelName string) erro return nil } +var labelAssignCmd = &cobra.Command{ + Use: "assign ", + Short: "Assign a label to a library item", + Args: cobra.ExactArgs(2), + RunE: runLabelAssign, +} + +func runLabelAssign(cmd *cobra.Command, args []string) error { + client := api.NewClient(config.GetSession()) + return execLabelAssign(client, os.Stdout, args[0], args[1]) +} + +func execLabelAssign(assigner LabelAssigner, out io.Writer, itemID, labelName string) error { + if err := assigner.AssignLabel(itemID, labelName); err != nil { + return fmt.Errorf("failed to assign label: %w", err) + } + fmt.Fprintf(out, "Label %q assigned to item %s\n", labelName, itemID) + return nil +} + func execLabelList(fetcher LabelFetcher, out io.Writer) error { labels, err := fetcher.FetchLabels() if err != nil { diff --git a/cmd/label_test.go b/cmd/label_test.go index 41b47ef..13a556a 100644 --- a/cmd/label_test.go +++ b/cmd/label_test.go @@ -160,3 +160,47 @@ func TestExecLabelCreate_error(t *testing.T) { t.Fatal("execLabelCreate() expected error") } } + +type mockLabelAssigner struct { + calledItemID string + calledLabelName string + err error +} + +func (m *mockLabelAssigner) AssignLabel(itemID, labelName string) error { + m.calledItemID = itemID + m.calledLabelName = labelName + return m.err +} + +func TestExecLabelAssign_success(t *testing.T) { + assigner := &mockLabelAssigner{} + + var buf bytes.Buffer + err := execLabelAssign(assigner, &buf, "item-1", "ML") + if err != nil { + t.Fatalf("execLabelAssign() error: %v", err) + } + + if assigner.calledItemID != "item-1" { + t.Errorf("calledItemID = %q, want %q", assigner.calledItemID, "item-1") + } + if assigner.calledLabelName != "ML" { + t.Errorf("calledLabelName = %q, want %q", assigner.calledLabelName, "ML") + } + + output := buf.String() + if !strings.Contains(output, "ML") { + t.Errorf("output should mention label name, got: %s", output) + } +} + +func TestExecLabelAssign_error(t *testing.T) { + assigner := &mockLabelAssigner{err: errors.New("assign failed")} + + var buf bytes.Buffer + err := execLabelAssign(assigner, &buf, "item-1", "ML") + if err == nil { + t.Fatal("execLabelAssign() expected error") + } +} diff --git a/internal/api/label.go b/internal/api/label.go index fcca5c2..9f0bd69 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -100,3 +100,45 @@ func (c *Client) CreateLabel(name string) (string, error) { } return labelID, nil } + +// AssignLabel assigns a label to a library item by name. +// It resolves the label name to an ID, fetches the item's current labels, +// appends the new label ID, and pushes the update via the Sync API. +func (c *Client) AssignLabel(itemID, labelName string) error { + labelID, err := c.ResolveLabelName(labelName) + if err != nil { + return err + } + + currentLabelIDs, err := c.GetItemLabels(itemID) + if err != nil { + return err + } + + for _, id := range currentLabelIDs { + if id == labelID { + return fmt.Errorf("label %q is already assigned to item %s", labelName, itemID) + } + } + + newLabelIDs := append(currentLabelIDs, labelID) + now := float64(time.Now().UnixMilli()) / 1000.0 + + changes := []map[string]any{ + { + "mcollection": "LibraryItems", + "action": "update", + "id": itemID, + "timestamp": now, + "data": map[string]any{ + "labelIds": newLabelIDs, + }, + }, + } + + _, err = c.pushSyncChanges(changes) + if err != nil { + return fmt.Errorf("failed to assign label: %w", err) + } + return nil +} diff --git a/internal/api/label_test.go b/internal/api/label_test.go index cae7b75..6deb0a5 100644 --- a/internal/api/label_test.go +++ b/internal/api/label_test.go @@ -196,3 +196,110 @@ func TestCreateLabel_serverError(t *testing.T) { t.Fatal("CreateLabel() expected error for 500 response") } } + +func TestAssignLabel_success(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + if r.URL.Path == "/library" { + items := []LibraryItem{ + {ID: "item-1", LabelIDs: []string{"existing-label"}}, + } + json.NewEncoder(w).Encode(items) + return + } + if r.URL.Path == "/collections" { + collections := []Collection{ + {ID: "label-1", Name: "ML", CollectionType: "label"}, + } + json.NewEncoder(w).Encode(collections) + return + } + if r.URL.Path == "/sync" { + body, _ := io.ReadAll(r.Body) + var reqBody map[string]any + json.Unmarshal(body, &reqBody) + + resp := SyncResponse{SyncStartTime: 1234567890.0} + + changes, ok := reqBody["clientChanges"].([]any) + if ok && len(changes) > 0 { + requestCount++ + change := changes[0].(map[string]any) + + if change["mcollection"] != "LibraryItems" { + t.Errorf("mcollection = %v, want %q", change["mcollection"], "LibraryItems") + } + if change["action"] != "update" { + t.Errorf("action = %v, want %q", change["action"], "update") + } + if change["id"] != "item-1" { + t.Errorf("id = %v, want %q", change["id"], "item-1") + } + + data := change["data"].(map[string]any) + labelIDs, ok := data["labelIds"].([]any) + if !ok { + t.Errorf("labelIds is not an array") + } else if len(labelIDs) != 2 { + t.Errorf("labelIds length = %d, want 2", len(labelIDs)) + } + } + + json.NewEncoder(w).Encode(resp) + return + } + })) + defer server.Close() + + client := newTestClient(server) + err := client.AssignLabel("item-1", "ML") + if err != nil { + t.Fatalf("AssignLabel() error: %v", err) + } + if requestCount != 1 { + t.Errorf("expected 1 sync request with changes, got %d", requestCount) + } +} + +func TestAssignLabel_labelNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/collections" { + json.NewEncoder(w).Encode([]Collection{}) + return + } + })) + defer server.Close() + + client := newTestClient(server) + err := client.AssignLabel("item-1", "Nonexistent") + if err == nil { + t.Fatal("AssignLabel() expected error for nonexistent label") + } +} + +func TestAssignLabel_itemNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path == "/collections" { + collections := []Collection{ + {ID: "label-1", Name: "ML", CollectionType: "label"}, + } + json.NewEncoder(w).Encode(collections) + return + } + if r.URL.Path == "/library" { + json.NewEncoder(w).Encode([]LibraryItem{}) + return + } + })) + defer server.Close() + + client := newTestClient(server) + err := client.AssignLabel("nonexistent", "ML") + if err == nil { + t.Fatal("AssignLabel() expected error for nonexistent item") + } +} From ff60207efeaee5816c11af33a71c45c0d3318a82 Mon Sep 17 00:00:00 2001 From: Ryohei Ueda Date: Mon, 6 Apr 2026 22:13:30 -0700 Subject: [PATCH 2/2] Fix AssignLabel schema validation error The Sync API requires mcollection "Library" (not "LibraryItems") for item updates, plus a "fields" key and "updated" timestamp in the data payload. Without these the server rejects with a 400 schema validation error. Matches the pattern used by TrashItem in delete.go. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/label.go | 7 +++---- internal/api/label_test.go | 12 ++++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/api/label.go b/internal/api/label.go index 9f0bd69..9306cf1 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -126,13 +126,12 @@ func (c *Client) AssignLabel(itemID, labelName string) error { changes := []map[string]any{ { - "mcollection": "LibraryItems", + "mcollection": "Library", "action": "update", "id": itemID, "timestamp": now, - "data": map[string]any{ - "labelIds": newLabelIDs, - }, + "fields": []string{"labelIds", "updated"}, + "data": map[string]any{"labelIds": newLabelIDs, "updated": now}, }, } diff --git a/internal/api/label_test.go b/internal/api/label_test.go index 6deb0a5..1fd47df 100644 --- a/internal/api/label_test.go +++ b/internal/api/label_test.go @@ -228,8 +228,8 @@ func TestAssignLabel_success(t *testing.T) { requestCount++ change := changes[0].(map[string]any) - if change["mcollection"] != "LibraryItems" { - t.Errorf("mcollection = %v, want %q", change["mcollection"], "LibraryItems") + if change["mcollection"] != "Library" { + t.Errorf("mcollection = %v, want %q", change["mcollection"], "Library") } if change["action"] != "update" { t.Errorf("action = %v, want %q", change["action"], "update") @@ -238,6 +238,11 @@ func TestAssignLabel_success(t *testing.T) { t.Errorf("id = %v, want %q", change["id"], "item-1") } + fields, ok := change["fields"].([]any) + if !ok || len(fields) != 2 { + t.Errorf("fields = %v, want [labelIds updated]", change["fields"]) + } + data := change["data"].(map[string]any) labelIDs, ok := data["labelIds"].([]any) if !ok { @@ -245,6 +250,9 @@ func TestAssignLabel_success(t *testing.T) { } else if len(labelIDs) != 2 { t.Errorf("labelIds length = %d, want 2", len(labelIDs)) } + if _, ok := data["updated"].(float64); !ok { + t.Errorf("updated should be a float64, got %T", data["updated"]) + } } json.NewEncoder(w).Encode(resp)