Changelog-added: Added ability to rename and delete tags via the
Pinboard API bridge.
---
pinboard/input.go | 11 ++++
pinboard/routes.go | 97 ++++++++++++++++++++++++++++++-
pinboard/routes_test.go | 124 ++++++++++++++++++++++++++++++++++++++--
3 files changed, 226 insertions(+), 6 deletions(-)
diff --git a/pinboard/input.go b/pinboard/input.go
index 1a72e47..baac2a6 100644
--- a/pinboard/input.go
+++ b/pinboard/input.go
@@ -44,4 +44,15 @@ type AllPostInput struct {
Fromdt string `query:"fromdt"`
Todt string `query:"todt"`
Meta string `query:"meta"`
+}
+
+// DeleteTagInput represents the input for /v1/tags/delete
+type DeleteTagInput struct {
+ Tag string `query:"tag" validate:"required"`
+}
+
+// RenameTagInput represents the input for /v1/tags/rename
+type RenameTagInput struct {
+ Old string `query:"old" validate:"required"`
+ New string `query:"new" validate:"required"`
}
\ No newline at end of file
diff --git a/pinboard/routes.go b/pinboard/routes.go
index 5393056..19c838a 100644
--- a/pinboard/routes.go
+++ b/pinboard/routes.go
@@ -847,10 +847,103 @@ func (s *Service) TagsGet(c echo.Context) error {
// TagsRename handles /v1/tags/rename
func (s *Service) TagsRename(c echo.Context) error {
- return formatError(c, "Tag renaming is unsupported")
+ var input RenameTagInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ if err := c.Validate(input); err != nil {
+ return formatError(c, err.Error())
+ }
+
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ type GraphQLResponse struct {
+ RenameTag struct {
+ Success bool `json:"success"`
+ Message string `json:"message"`
+ } `json:"renameTag"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `mutation RenameTag($orgSlug: String!, $service: DomainService!, $tag: String!, $newTag: String!) {
+ renameTag(input: {orgSlug: $orgSlug, service: $service, tag: $tag, newTag: $newTag}) {
+ success
+ message
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+ op.Var("service", "LINKS")
+ op.Var("tag", input.Old)
+ op.Var("newTag", input.New)
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ if graphError, ok := err.(*gqlclient.Error); ok {
+ return formatError(c, graphError.Error())
+ }
+ return formatError(c, err.Error())
+ }
+
+ if !result.RenameTag.Success {
+ return formatError(c, "Failed to rename tag")
+ }
+
+ return formatSuccess(c)
}
// TagsDelete handles /v1/tags/delete
func (s *Service) TagsDelete(c echo.Context) error {
- return formatError(c, "Tag deletion is unsupported")
+ var input DeleteTagInput
+ if err := c.Bind(&input); err != nil {
+ return formatError(c, "Invalid input parameters")
+ }
+
+ if err := c.Validate(input); err != nil {
+ return formatError(c, err.Error())
+ }
+
+ org, err := s.getUserOrg(c)
+ if err != nil {
+ return formatError(c, "Failed to get organization")
+ }
+
+ type GraphQLResponse struct {
+ DeleteTag struct {
+ Success bool `json:"success"`
+ ObjectID string `json:"objectId"`
+ } `json:"deleteTag"`
+ }
+
+ var result GraphQLResponse
+ op := gqlclient.NewOperation(
+ `mutation DeleteTag($orgSlug: String!, $service: DomainService!, $tag: String!) {
+ deleteTag(input: {orgSlug: $orgSlug, service: $service, tag: $tag}) {
+ success
+ objectId
+ }
+ }`)
+
+ op.Var("orgSlug", org.Slug)
+ op.Var("service", "LINKS")
+ op.Var("tag", input.Tag)
+
+ err = links.Execute(c.Request().Context(), op, &result)
+ if err != nil {
+ if graphError, ok := err.(*gqlclient.Error); ok {
+ return formatError(c, graphError.Error())
+ }
+ return formatError(c, err.Error())
+ }
+
+ if !result.DeleteTag.Success {
+ return formatError(c, "Failed to delete tag")
+ }
+
+ return formatSuccess(c)
}
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
index 07bea5b..44831df 100644
--- a/pinboard/routes_test.go
+++ b/pinboard/routes_test.go
@@ -1453,7 +1453,20 @@ func TestTagEndpoints(t *testing.T) {
c.False(strings.Contains(body, "<tag "))
})
- t.Run("tags/rename unsupported", func(t *testing.T) {
+ t.Run("tags/rename success", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+ "data": map[string]any{
+ "renameTag": map[string]any{
+ "success": true,
+ "message": "Tag renamed successfully",
+ },
+ },
+ }))
+
request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag&new=newtag", nil)
recorder := httptest.NewRecorder()
@@ -1466,10 +1479,23 @@ func TestTagEndpoints(t *testing.T) {
body := recorder.Body.String()
checkXMLResponse(body, "result")
- c.True(strings.Contains(body, "Tag renaming is unsupported"))
+ c.True(strings.Contains(body, `code="done"`))
})
- t.Run("tags/delete unsupported", func(t *testing.T) {
+ t.Run("tags/delete success", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+ "data": map[string]any{
+ "deleteTag": map[string]any{
+ "success": true,
+ "objectId": "tag123",
+ },
+ },
+ }))
+
request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=unwanted", nil)
recorder := httptest.NewRecorder()
@@ -1482,7 +1508,97 @@ func TestTagEndpoints(t *testing.T) {
body := recorder.Body.String()
checkXMLResponse(body, "result")
- c.True(strings.Contains(body, "Tag deletion is unsupported"))
+ c.True(strings.Contains(body, `code="done"`))
+ })
+
+ t.Run("tags/rename missing required fields", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=oldtag", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/rename")
+
+ err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "something went wrong"))
+ })
+
+ t.Run("tags/rename failure", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+ "data": map[string]any{
+ "renameTag": map[string]any{
+ "success": false,
+ "message": "Tag not found",
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/rename?old=nonexistent&new=newtag", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/rename")
+
+ err := test.MakeRequest(srv, pinboardService.TagsRename, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "Failed to rename tag"))
+ })
+
+ t.Run("tags/delete missing required field", func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/delete")
+
+ err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "something went wrong"))
+ })
+
+ t.Run("tags/delete failure", func(t *testing.T) {
+ httpmock.Activate()
+ defer httpmock.DeactivateAndReset()
+
+ httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
+ httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]any{
+ "data": map[string]any{
+ "deleteTag": map[string]any{
+ "success": false,
+ "objectId": "",
+ },
+ },
+ }))
+
+ request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/tags/delete?tag=nonexistent", nil)
+ recorder := httptest.NewRecorder()
+
+ ctx := createAuthContext(request, recorder)
+ ctx.SetPath("/pinboard/v1/tags/delete")
+
+ err := test.MakeRequest(srv, pinboardService.TagsDelete, ctx)
+ c.NoError(err)
+ c.Equal(http.StatusOK, recorder.Code)
+
+ body := recorder.Body.String()
+ checkXMLResponse(body, "result")
+ c.True(strings.Contains(body, "Failed to delete tag"))
})
t.Run("tags/get with GraphQL error", func(t *testing.T) {
--
2.49.1