Subject: [PATCH links] Allow removal of all tags from a bookmark, note, listing or short url.

From: Peter Sanchez <[email-masked]>
Date: Thu, 27 Nov 2025 11:26:17 -0600

Fixes: https://todo.code.netlandish.com/~netlandish/links/117
Changelog-fixed: Issue blocking the removal of ALL tags from an object.
---
 api/api_test.go               |  81 +++++++++++++-
 api/graph/schema.resolvers.go | 204 +++++++++++++++++++++-------------
 2 files changed, 204 insertions(+), 81 deletions(-)

diff --git a/api/api_test.go b/api/api_test.go
index d098661..b0125bd 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -3346,7 +3346,7 @@ func TestAPI(t *testing.T) {
 		opA.Var("title", "Link A isolation")
 		opA.Var("url", "https://example.com/link-a-isolation")
 		opA.Var("description", "")
-		opA.Var("visibility", models.OrgLinkVisibilityPublic)
+		opA.Var("visibility", models.OrgLinkVisibilityPrivate)
 		opA.Var("tags", "isolation-common, isolation-unique-a")
 		opA.Var("slug", "personal-org")
 		opA.Var("unread", false)
@@ -3366,7 +3366,7 @@ func TestAPI(t *testing.T) {
 		opB.Var("title", "Link B isolation")
 		opB.Var("url", "https://example.com/link-b-isolation")
 		opB.Var("description", "")
-		opB.Var("visibility", models.OrgLinkVisibilityPublic)
+		opB.Var("visibility", models.OrgLinkVisibilityPrivate)
 		opB.Var("tags", "isolation-common, isolation-unique-b")
 		opB.Var("slug", "personal-org")
 		opB.Var("unread", false)
@@ -3398,7 +3398,7 @@ func TestAPI(t *testing.T) {
 		opUpdate.Var("hash", linkA.Link.Hash)
 		opUpdate.Var("title", "Link A isolation")
 		opUpdate.Var("url", "https://example.com/link-a-isolation")
-		opUpdate.Var("visibility", models.OrgLinkVisibilityPublic)
+		opUpdate.Var("visibility", models.OrgLinkVisibilityPrivate)
 		opUpdate.Var("tags", "isolation-unique-a")
 		err = links.Execute(ctx, opUpdate, &updatedA)
 		c.NoError(err)
@@ -3426,4 +3426,79 @@ func TestAPI(t *testing.T) {
 		c.True(tagSlugs["isolation-common"], "Link B should still have 'isolation-common' tag")
 		c.True(tagSlugs["isolation-unique-b"], "Link B should still have 'isolation-unique-b' tag")
 	})
+
+	t.Run("remove all tags from link", func(t *testing.T) {
+		c := require.New(t)
+		type AddLinkResponse struct {
+			Link models.OrgLink `json:"addLink"`
+		}
+		type UpdateLinkResponse struct {
+			Link models.OrgLink `json:"updateLink"`
+		}
+
+		addLinkQuery := `mutation AddLink($title: String!, $url: String!, $description: String,
+						  $visibility: LinkVisibility!, $unread: Boolean!, $starred: Boolean!,
+						  $archive: Boolean! $slug: String!, $tags: String) {
+					addLink(input: {
+								title: $title,
+								url: $url,
+								description: $description,
+								visibility: $visibility,
+								unread: $unread,
+								starred: $starred,
+								archive: $archive,
+								orgSlug: $slug,
+								tags: $tags}) {
+						id
+						hash
+					}
+				}`
+
+		var linkA AddLinkResponse
+		opA := gqlclient.NewOperation(addLinkQuery)
+		opA.Var("title", "Link with tags to remove")
+		opA.Var("url", "https://test-tags.example.org/remove-all-tags")
+		opA.Var("description", "")
+		opA.Var("visibility", models.OrgLinkVisibilityPrivate)
+		opA.Var("tags", "tag-to-remove-1, tag-to-remove-2")
+		opA.Var("slug", "personal-org")
+		opA.Var("unread", false)
+		opA.Var("starred", false)
+		opA.Var("archive", false)
+		err := links.Execute(ctx, opA, &linkA)
+		c.NoError(err)
+		c.NotZero(linkA.Link.ID)
+
+		tagLinksA, err := models.GetTagLinks(dbCtx,
+			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", linkA.Link.ID)})
+		c.NoError(err)
+		c.Equal(2, len(tagLinksA), "Link should have 2 tags after creation")
+
+		updateLinkQuery := `mutation UpdateLink($hash: String!, $title: String!,
+								 $url: String!, $visibility: LinkVisibility!,
+								 $tags: String) {
+				updateLink(input: {
+					title: $title,
+					hash: $hash,
+					url: $url,
+					visibility: $visibility,
+					tags: $tags,
+				}) {id, hash}
+			}`
+
+		var updatedA UpdateLinkResponse
+		opUpdate := gqlclient.NewOperation(updateLinkQuery)
+		opUpdate.Var("hash", linkA.Link.Hash)
+		opUpdate.Var("title", "Link with tags to remove")
+		opUpdate.Var("url", "https://test-tags.example.org/remove-all-tags")
+		opUpdate.Var("visibility", models.OrgLinkVisibilityPrivate)
+		opUpdate.Var("tags", "")
+		err = links.Execute(ctx, opUpdate, &updatedA)
+		c.NoError(err)
+
+		tagLinksAAfter, err := models.GetTagLinks(dbCtx,
+			&database.FilterOptions{Filter: sq.Expr("org_link_id = ?", linkA.Link.ID)})
+		c.NoError(err)
+		c.Equal(0, len(tagLinksAAfter), "Link should have 0 tags after update with empty tags")
+	})
 }
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 74e3a84..b04fe6e 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -655,7 +655,8 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 			WithCode(valid.ErrValidationCode)
 	}
 	tags := make([]string, 0)
-	if input.Tags != nil && *input.Tags != "" {
+	tagsProvided := input.Tags != nil
+	if tagsProvided && *input.Tags != "" {
 		tags = strings.Split(strings.TrimSpace(*input.Tags), ",")
 		validator.Expect(len(tags) <= 10, "%s", lt.Translate("Tags may not exceed 10")).
 			WithField("tags").
@@ -761,35 +762,50 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 		return nil, err
 	}
 
-	if len(tags) > 0 {
-		// Calculate the difference between original tags and new ones
-		tagIDs, err := links.ProcessTags(ctx, tags)
-		if err != nil {
-			return nil, err
-		}
-		originalTags := orgLink.Tags
-		tagIDsToRemove := make([]int, 0)
-		m := make(map[int]bool)
-		for _, id := range tagIDs {
-			m[id] = true
-		}
-
-		for _, originalTag := range originalTags {
-			if !m[originalTag.ID] {
-				tagIDsToRemove = append(tagIDsToRemove, originalTag.ID)
-			}
-		}
-
-		if len(tagIDsToRemove) > 0 {
-			err = models.RemoveTagsFromLink(ctx, orgLink.ID, tagIDsToRemove)
+	if tagsProvided {
+		if len(tags) > 0 {
+			// Calculate the difference between original tags and new ones
+			tagIDs, err := links.ProcessTags(ctx, tags)
 			if err != nil {
 				return nil, err
 			}
-		}
+			originalTags := orgLink.Tags
+			tagIDsToRemove := make([]int, 0)
+			m := make(map[int]bool)
+			for _, id := range tagIDs {
+				m[id] = true
+			}
 
-		err = models.CreateBatchTagLinks(ctx, orgLink.ID, tagIDs)
-		if err != nil {
-			return nil, err
+			for _, originalTag := range originalTags {
+				if !m[originalTag.ID] {
+					tagIDsToRemove = append(tagIDsToRemove, originalTag.ID)
+				}
+			}
+
+			if len(tagIDsToRemove) > 0 {
+				err = models.RemoveTagsFromLink(ctx, orgLink.ID, tagIDsToRemove)
+				if err != nil {
+					return nil, err
+				}
+			}
+
+			err = models.CreateBatchTagLinks(ctx, orgLink.ID, tagIDs)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			// Tags field provided but empty - remove all existing tags
+			originalTags := orgLink.Tags
+			if len(originalTags) > 0 {
+				tagIDsToRemove := make([]int, len(originalTags))
+				for i, t := range originalTags {
+					tagIDsToRemove[i] = t.ID
+				}
+				err = models.RemoveTagsFromLink(ctx, orgLink.ID, tagIDsToRemove)
+				if err != nil {
+					return nil, err
+				}
+			}
 		}
 	}
 
@@ -2457,7 +2473,8 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 	}
 
 	tags := make([]string, 0)
-	if input.Tags != nil && *input.Tags != "" {
+	tagsProvided := input.Tags != nil
+	if tagsProvided && *input.Tags != "" {
 		tags = strings.Split(strings.TrimSpace(*input.Tags), ",")
 		validator.Expect(len(tags) <= 10, "%s", lt.Translate("Tags may not exceed 10")).
 			WithField("tags").
@@ -2558,35 +2575,50 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 		return nil, err
 	}
 
-	if len(tags) > 0 {
-		tagIDs, err := links.ProcessTags(ctx, tags)
-		if err != nil {
-			return nil, err
-		}
-		// Calculate the difference between original tags and new ones
-		originalTags := linkShort.Tags
-		tagIDsToRemove := make([]int, 0)
-		m := make(map[int]bool)
-		for _, id := range tagIDs {
-			m[id] = true
-		}
-
-		for _, originalTag := range originalTags {
-			if !m[originalTag.ID] {
-				tagIDsToRemove = append(tagIDsToRemove, originalTag.ID)
-			}
-		}
-
-		if len(tagIDsToRemove) > 0 {
-			err = models.RemoveTagsFromLinkShort(ctx, linkShort.ID, tagIDsToRemove)
+	if tagsProvided {
+		if len(tags) > 0 {
+			tagIDs, err := links.ProcessTags(ctx, tags)
 			if err != nil {
 				return nil, err
 			}
-		}
+			// Calculate the difference between original tags and new ones
+			originalTags := linkShort.Tags
+			tagIDsToRemove := make([]int, 0)
+			m := make(map[int]bool)
+			for _, id := range tagIDs {
+				m[id] = true
+			}
 
-		err = models.CreateBatchTagLinkShorts(ctx, linkShort.ID, tagIDs)
-		if err != nil {
-			return nil, err
+			for _, originalTag := range originalTags {
+				if !m[originalTag.ID] {
+					tagIDsToRemove = append(tagIDsToRemove, originalTag.ID)
+				}
+			}
+
+			if len(tagIDsToRemove) > 0 {
+				err = models.RemoveTagsFromLinkShort(ctx, linkShort.ID, tagIDsToRemove)
+				if err != nil {
+					return nil, err
+				}
+			}
+
+			err = models.CreateBatchTagLinkShorts(ctx, linkShort.ID, tagIDs)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			// Tags field provided but empty - remove all existing tags
+			originalTags := linkShort.Tags
+			if len(originalTags) > 0 {
+				tagIDsToRemove := make([]int, len(originalTags))
+				for i, t := range originalTags {
+					tagIDsToRemove[i] = t.ID
+				}
+				err = models.RemoveTagsFromLinkShort(ctx, linkShort.ID, tagIDsToRemove)
+				if err != nil {
+					return nil, err
+				}
+			}
 		}
 	}
 
@@ -3177,7 +3209,8 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 	}
 
 	tags := make([]string, 0)
-	if input.Tags != nil && *input.Tags != "" {
+	tagsProvided := input.Tags != nil
+	if tagsProvided && *input.Tags != "" {
 		tags = strings.Split(strings.TrimSpace(*input.Tags), ",")
 		validator.Expect(len(tags) <= 10, "%s", lt.Translate("Tags may not exceed 10")).
 			WithField("tags").
@@ -3393,35 +3426,50 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 		return nil, err
 	}
 
-	if len(tags) > 0 {
-		tagIDs, err := links.ProcessTags(ctx, tags)
-		if err != nil {
-			return nil, err
-		}
-		// Calculate the difference between original tags and new ones
-		originalTags := listing.Tags
-		tagIDsToRemove := make([]int, 0)
-		m := make(map[int]bool)
-		for _, id := range tagIDs {
-			m[id] = true
-		}
-
-		for _, originalTag := range originalTags {
-			if !m[originalTag.ID] {
-				tagIDsToRemove = append(tagIDsToRemove, originalTag.ID)
-			}
-		}
-
-		if len(tagIDsToRemove) > 0 {
-			err = models.RemoveTagsFromListing(ctx, listing.ID, tagIDsToRemove)
+	if tagsProvided {
+		if len(tags) > 0 {
+			tagIDs, err := links.ProcessTags(ctx, tags)
 			if err != nil {
 				return nil, err
 			}
-		}
+			// Calculate the difference between original tags and new ones
+			originalTags := listing.Tags
+			tagIDsToRemove := make([]int, 0)
+			m := make(map[int]bool)
+			for _, id := range tagIDs {
+				m[id] = true
+			}
 
-		err = models.CreateBatchTagListings(ctx, listing.ID, tagIDs)
-		if err != nil {
-			return nil, err
+			for _, originalTag := range originalTags {
+				if !m[originalTag.ID] {
+					tagIDsToRemove = append(tagIDsToRemove, originalTag.ID)
+				}
+			}
+
+			if len(tagIDsToRemove) > 0 {
+				err = models.RemoveTagsFromListing(ctx, listing.ID, tagIDsToRemove)
+				if err != nil {
+					return nil, err
+				}
+			}
+
+			err = models.CreateBatchTagListings(ctx, listing.ID, tagIDs)
+			if err != nil {
+				return nil, err
+			}
+		} else {
+			// Tags field provided but empty - remove all existing tags
+			originalTags := listing.Tags
+			if len(originalTags) > 0 {
+				tagIDsToRemove := make([]int, len(originalTags))
+				for i, t := range originalTags {
+					tagIDsToRemove[i] = t.ID
+				}
+				err = models.RemoveTagsFromListing(ctx, listing.ID, tagIDsToRemove)
+				if err != nil {
+					return nil, err
+				}
+			}
 		}
 	}
 
-- 
2.49.1