diff --git a/README.md b/README.md index 4cd1026..2801cee 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. + ### `label delete` - Delete a label ```bash diff --git a/cmd/interfaces.go b/cmd/interfaces.go index ef2bf78..c9f5d0b 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -52,6 +52,11 @@ type LabelCreator interface { CreateLabel(name string) (string, error) } +// LabelAssigner assigns a label to a library item. +type LabelAssigner interface { + AssignLabel(itemID, labelName string) error +} + // LabelDeleter deletes a label. type LabelDeleter interface { DeleteLabel(labelName string) error diff --git a/cmd/label.go b/cmd/label.go index b527e86..e86c9d0 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) labelCmd.AddCommand(labelDeleteCmd) rootCmd.AddCommand(labelCmd) } @@ -86,6 +87,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 +} + var labelDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete a label", diff --git a/cmd/label_test.go b/cmd/label_test.go index 4bdd1e7..f6f1d0f 100644 --- a/cmd/label_test.go +++ b/cmd/label_test.go @@ -161,6 +161,50 @@ func TestExecLabelCreate_error(t *testing.T) { } } +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") + } +} + type mockLabelDeleter struct { calledName string err error diff --git a/internal/api/label.go b/internal/api/label.go index b8ea330..6f92f0a 100644 --- a/internal/api/label.go +++ b/internal/api/label.go @@ -127,3 +127,44 @@ 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": "Library", + "action": "update", + "id": itemID, + "timestamp": now, + "fields": []string{"labelIds", "updated"}, + "data": map[string]any{"labelIds": newLabelIDs, "updated": now}, + }, + } + + _, 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 f00b3f4..021f49c 100644 --- a/internal/api/label_test.go +++ b/internal/api/label_test.go @@ -197,6 +197,121 @@ func TestCreateLabel_serverError(t *testing.T) { } } +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"] != "Library" { + t.Errorf("mcollection = %v, want %q", change["mcollection"], "Library") + } + 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") + } + + 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 { + t.Errorf("labelIds is not an array") + } 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) + 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") + } +} + func TestDeleteLabel_success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json")