Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ paperpile label create <label_name>

Creates a new label in your Paperpile library.

### `label assign` - Assign a label to a library item

```bash
paperpile label assign <item_id> <label_name>
```

Assigns an existing label to a library item.

### `label delete` - Delete a label

```bash
Expand Down
5 changes: 5 additions & 0 deletions cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions cmd/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func init() {
labelCmd.AddCommand(labelListCmd)
labelCmd.AddCommand(labelGetCmd)
labelCmd.AddCommand(labelCreateCmd)
labelCmd.AddCommand(labelAssignCmd)
labelCmd.AddCommand(labelDeleteCmd)
rootCmd.AddCommand(labelCmd)
}
Expand Down Expand Up @@ -86,6 +87,26 @@ func execLabelCreate(creator LabelCreator, out io.Writer, labelName string) erro
return nil
}

var labelAssignCmd = &cobra.Command{
Use: "assign <item_id> <label_name>",
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 <label_name>",
Short: "Delete a label",
Expand Down
44 changes: 44 additions & 0 deletions cmd/label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions internal/api/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
115 changes: 115 additions & 0 deletions internal/api/label_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down