Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id DC1A03F4
	for <~netlandish/links-dev@lists.code.netlandish.com>; Wed, 14 Jan 2026 22:06:56 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.128.195; helo=mail-yw1-f195.google.com; envelope-from=peter@netlandish.com; receiver=<UNKNOWN> 
Authentication-Results: mail.netlandish.com;
	dkim=pass (1024-bit key; unprotected) header.d=netlandish.com header.i=@netlandish.com header.b=F8K23hgQ
Received: from mail-yw1-f195.google.com (mail-yw1-f195.google.com [209.85.128.195])
	by mail.netlandish.com (Postfix) with ESMTP id B38D81D818D
	for <~netlandish/links-dev@lists.code.netlandish.com>; Wed, 14 Jan 2026 22:08:47 +0000 (UTC)
Received: by mail-yw1-f195.google.com with SMTP id 00721157ae682-7927261a3acso2909967b3.0
        for <~netlandish/links-dev@lists.code.netlandish.com>; Wed, 14 Jan 2026 14:08:47 -0800 (PST)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1768428526; x=1769033326; darn=lists.code.netlandish.com;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:from:to:cc:subject:date:message-id:reply-to;
        bh=mvMKXXATNZLdURs+lm2F7o8dGaM+KUmamIuqWugpilM=;
        b=F8K23hgQTvfmft6qjWp5p5mzvlZmuXPXn+/6hDPQJ24thTmsZTahJBWTekURIgshcV
         OdhGLhvSQQwLqQXhiAlt+yzdF/PDy+uakDwEiHCN5lAemLIdUaXKzOYgoSJokTXNFNa+
         TQ1Xw7FroVLcUnPK3NVF0ydRRYKCQOqN4qUPA=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20230601; t=1768428526; x=1769033326;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:x-gm-gg:x-gm-message-state:from:to:cc:subject:date
         :message-id:reply-to;
        bh=mvMKXXATNZLdURs+lm2F7o8dGaM+KUmamIuqWugpilM=;
        b=jKx9w6twgKYm0mPjRSJIZga89En89YqbzIJ7nUXvmb3ME+FaaCaGOmu0sqeZ+DqYyX
         +zMICE0NybsalgBc1BbIpBu+wW1szTfDO4ay6AxKFn9gBLa7y7YiolCHsayoEpW0yDxI
         V6gGjHfMSL47xcPs9p17kIn8IyvHA34/svy9nOk7JsqbSD3hKU7zEcM3fYKQNuYoKIlE
         7Fy6g9LhnSc3tyG6hilD49yTGG81RvohyBYxEUZIMY8Yx5K7ag8C7rw67S/xfKjBO+wT
         VEZDzvipZKK53h6TmgECNt3bUQq/Yu6WAweQPeFgO6x3HRaSrKz8jeGvlGfS34/Rfa36
         rZDw==
X-Gm-Message-State: AOJu0YwtvfidUGBaWSrl1P7cCpAjpj0LjzjKKIxYS15QlUQIlLK/qidd
	6dlAQ3wuHrxJ3eJGS9QWz2cFrr35qz2aiEWiNKUh0Y9dNSkGBJ/22O9KDzqOuP60gBXvqX6O/e6
	7AZ65Dm5cUQ==
X-Gm-Gg: AY/fxX5ZLrtZitgwGMz/4WmndpKH+p5IVepQCiVDkexThbxzTP+M3TPgho2ed3mjDZ8
	4FJer4iPZiwcfMyAheFNjf876HZB/NZLpV2rCj1Dv5Ol/qJNmcz1atL20U81DGoaVhV8mST1Ewn
	p/1u7oi3htHHUdreIOyakLYBQZndegmS7rWcPfedSRVaco/fE6UhrEt0jKsL90vhg+ZDUbdN1ud
	iQdbHgldIOVDw7KATJaBKUMFWEgzUXMQXG6sybUu6iC5U7dHXIQElsdPrrJl5jXxlILgO356qmm
	s8IJhqNb0LVh6U2Nq/pfi3r+YcXjGA0aVgzDwBLqr8gO3P5PQA1DNSbNWKCeCz2zDQqBpTkr6z2
	T9bPoxC3BMtBgZNanPCvs2hL3bVJRUKlsPF9gR8pjrgx1aKoPiro3fR1ms7zlh3QZMo1azX/y2l
	kusDUl5C3RfVk2
X-Received: by 2002:a05:690c:312:b0:78c:5aa8:6dde with SMTP id 00721157ae682-793a1db1adamr30151307b3.70.1768428526068;
        Wed, 14 Jan 2026 14:08:46 -0800 (PST)
Received: from localhost ([2803:2d60:1107:87f:cf74:b680:acd5:a092])
        by smtp.gmail.com with ESMTPSA id 00721157ae682-790aa55b8b6sm95235007b3.4.2026.01.14.14.08.45
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Wed, 14 Jan 2026 14:08:45 -0800 (PST)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] Because of the way tags are stored we always used the first representation of the tag when displaying it. For instance, a user may save `Test` as a tag and another user may save `test` as a tag but the both users will see `Test` (first version) as their tag display.
Date: Wed, 14 Jan 2026 16:08:34 -0600
Message-ID: <20260114220842.3641-1-peter@netlandish.com>
X-Mailer: git-send-email 2.52.0
MIME-Version: 1.0
Content-Transfer-Encoding: 8bit

This is now fixed and each user/organization can set their own tag
casing. In cases where there are multiple casing of the same tag in a
single organization then the first one to be saved is used.

Fixes: https://todo.code.netlandish.com/~netlandish/links/122
Changelog-updated: Tag casing is now preserved at the organization and
  bookmark level.
---
 api/graph/schema.resolvers.go                 | 46 +++++++++----------
 cmd/migrations.go                             |  7 +++
 core/import.go                                |  6 +--
 helpers.go                                    | 23 ++++++----
 .../0009_add_tag_junction_name.down.sql       |  3 ++
 migrations/0009_add_tag_junction_name.up.sql  | 11 +++++
 models/base_url.go                            |  2 +-
 models/link_short.go                          |  4 +-
 models/listing.go                             |  4 +-
 models/models.go                              |  9 ++++
 models/org_link.go                            |  4 +-
 models/organization.go                        |  2 +-
 models/tag.go                                 | 18 ++++++--
 models/tag_link_shorts.go                     | 30 ++++++------
 models/tag_links.go                           | 26 +++++------
 models/tag_listing.go                         | 30 ++++++------
 16 files changed, 131 insertions(+), 94 deletions(-)
 create mode 100644 migrations/0009_add_tag_junction_name.down.sql
 create mode 100644 migrations/0009_add_tag_junction_name.up.sql

diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index 79104c8..2c11efb 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -598,11 +598,11 @@ func (r *mutationResolver) AddLink(ctx context.Context, input *model.LinkInput)
 	}
 
 	if len(tags) > 0 {
-		tagIDs, err := links.ProcessTags(ctx, tags)
+		newTags, err := links.ProcessTags(ctx, tags)
 		if err != nil {
 			return nil, err
 		}
-		err = models.CreateBatchTagLinks(ctx, OrgLink.ID, tagIDs)
+		err = models.CreateBatchTagLinks(ctx, OrgLink.ID, newTags)
 		if err != nil {
 			return nil, err
 		}
@@ -784,15 +784,15 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 	if tagsProvided {
 		if len(tags) > 0 {
 			// Calculate the difference between original tags and new ones
-			tagIDs, err := links.ProcessTags(ctx, tags)
+			newTags, 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 _, tag := range newTags {
+				m[tag.ID] = true
 			}
 
 			for _, originalTag := range originalTags {
@@ -808,7 +808,7 @@ func (r *mutationResolver) UpdateLink(ctx context.Context, input *model.UpdateLi
 				}
 			}
 
-			err = models.CreateBatchTagLinks(ctx, orgLink.ID, tagIDs)
+			err = models.CreateBatchTagLinks(ctx, orgLink.ID, newTags)
 			if err != nil {
 				return nil, err
 			}
@@ -1095,11 +1095,11 @@ func (r *mutationResolver) AddNote(ctx context.Context, input *model.NoteInput)
 	}
 
 	if len(tags) > 0 {
-		tagIDs, err := links.ProcessTags(ctx, tags)
+		newTags, err := links.ProcessTags(ctx, tags)
 		if err != nil {
 			return nil, err
 		}
-		err = models.CreateBatchTagLinks(ctx, OrgLinkNote.ID, tagIDs)
+		err = models.CreateBatchTagLinks(ctx, OrgLinkNote.ID, newTags)
 		if err != nil {
 			return nil, err
 		}
@@ -2444,11 +2444,11 @@ func (r *mutationResolver) AddLinkShort(ctx context.Context, input *model.LinkSh
 	}
 
 	if len(tags) > 0 {
-		tagIDs, err := links.ProcessTags(ctx, tags)
+		newTags, err := links.ProcessTags(ctx, tags)
 		if err != nil {
 			return nil, err
 		}
-		err = models.CreateBatchTagLinkShorts(ctx, linkShort.ID, tagIDs)
+		err = models.CreateBatchTagLinkShorts(ctx, linkShort.ID, newTags)
 		if err != nil {
 			return nil, err
 		}
@@ -2621,7 +2621,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 
 	if tagsProvided {
 		if len(tags) > 0 {
-			tagIDs, err := links.ProcessTags(ctx, tags)
+			newTags, err := links.ProcessTags(ctx, tags)
 			if err != nil {
 				return nil, err
 			}
@@ -2629,8 +2629,8 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 			originalTags := linkShort.Tags
 			tagIDsToRemove := make([]int, 0)
 			m := make(map[int]bool)
-			for _, id := range tagIDs {
-				m[id] = true
+			for _, tag := range newTags {
+				m[tag.ID] = true
 			}
 
 			for _, originalTag := range originalTags {
@@ -2646,7 +2646,7 @@ func (r *mutationResolver) UpdateLinkShort(ctx context.Context, input *model.Upd
 				}
 			}
 
-			err = models.CreateBatchTagLinkShorts(ctx, linkShort.ID, tagIDs)
+			err = models.CreateBatchTagLinkShorts(ctx, linkShort.ID, newTags)
 			if err != nil {
 				return nil, err
 			}
@@ -3040,11 +3040,11 @@ func (r *mutationResolver) AddListing(ctx context.Context, input *model.AddListi
 	}
 
 	if len(tags) > 0 {
-		tagIDs, err := links.ProcessTags(ctx, tags)
+		newTags, err := links.ProcessTags(ctx, tags)
 		if err != nil {
 			return nil, err
 		}
-		err = models.CreateBatchTagListings(ctx, listing.ID, tagIDs)
+		err = models.CreateBatchTagListings(ctx, listing.ID, newTags)
 		if err != nil {
 			return nil, err
 		}
@@ -3472,7 +3472,7 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 
 	if tagsProvided {
 		if len(tags) > 0 {
-			tagIDs, err := links.ProcessTags(ctx, tags)
+			newTags, err := links.ProcessTags(ctx, tags)
 			if err != nil {
 				return nil, err
 			}
@@ -3480,8 +3480,8 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 			originalTags := listing.Tags
 			tagIDsToRemove := make([]int, 0)
 			m := make(map[int]bool)
-			for _, id := range tagIDs {
-				m[id] = true
+			for _, tag := range newTags {
+				m[tag.ID] = true
 			}
 
 			for _, originalTag := range originalTags {
@@ -3497,7 +3497,7 @@ func (r *mutationResolver) UpdateListing(ctx context.Context, input *model.Updat
 				}
 			}
 
-			err = models.CreateBatchTagListings(ctx, listing.ID, tagIDs)
+			err = models.CreateBatchTagListings(ctx, listing.ID, newTags)
 			if err != nil {
 				return nil, err
 			}
@@ -4432,17 +4432,17 @@ func (r *mutationResolver) RenameTag(ctx context.Context, input model.TagInput)
 	}
 	oTag := tags[0]
 
-	tagIDs, err := links.ProcessTags(ctx, []string{*input.NewTag})
+	newTags, err := links.ProcessTags(ctx, []string{*input.NewTag})
 	if err != nil {
 		return nil, err
 	}
-	if len(tagIDs) == 0 {
+	if len(newTags) == 0 {
 		validator.Error("%s", lt.Translate("newTag value is not valid.")).
 			WithField("newTag").
 			WithCode(valid.ErrValidationCode)
 		return nil, nil
 	}
-	nTag := &models.Tag{ID: tagIDs[0]}
+	nTag := &models.Tag{ID: newTags[0].ID}
 	err = nTag.Load(ctx)
 	if err != nil {
 		return nil, err
diff --git a/cmd/migrations.go b/cmd/migrations.go
index 77ed113..8e7c71a 100644
--- a/cmd/migrations.go
+++ b/cmd/migrations.go
@@ -80,5 +80,12 @@ func GetMigrations() []migrate.Migration {
 			0,
 			links.MigrateFS,
 		),
+		migrate.FSFileMigration(
+			"0009_add_tag_junction_name",
+			"migrations/0009_add_tag_junction_name.up.sql",
+			"migrations/0009_add_tag_junction_name.down.sql",
+			0,
+			links.MigrateFS,
+		),
 	}
 }
diff --git a/core/import.go b/core/import.go
index 11ac49a..648daa6 100644
--- a/core/import.go
+++ b/core/import.go
@@ -400,12 +400,12 @@ func importOrgLinks(ctx context.Context, objAdapter *importAdapter, baseURLMap m
 			tags = []string{}
 		}
 		if len(tags) > 0 {
-			tagIDs, err := links.ProcessTags(ctx, tags)
+			newTags, err := links.ProcessTags(ctx, tags)
 			if err != nil {
 				return err
 			}
-			if len(tagIDs) > 0 {
-				err = models.CreateBatchTagLinks(ctx, link.ID, tagIDs)
+			if len(newTags) > 0 {
+				err = models.CreateBatchTagLinks(ctx, link.ID, newTags)
 				if err != nil {
 					return err
 				}
diff --git a/helpers.go b/helpers.go
index df0d56f..09524fe 100644
--- a/helpers.go
+++ b/helpers.go
@@ -761,28 +761,31 @@ func LangForContext(ctx context.Context) string {
 	return lang
 }
 
-func ProcessTags(ctx context.Context, tags []string) ([]int, error) {
-	tagIDs := make([]int, 0)
+func ProcessTags(ctx context.Context, tags []string) ([]models.TagInput, error) {
+	tagInputs := make([]models.TagInput, 0)
 	for _, tag := range tags {
-		tag := strings.TrimSpace(tag)
-		tag = strings.TrimPrefix(tag, "#")
-		if tag != "" {
-			slug := Slugify(tag)
-			if len(tag) > 50 || len(slug) > 50 {
+		originalName := strings.TrimSpace(tag)
+		originalName = strings.TrimPrefix(originalName, "#")
+		if originalName != "" {
+			slug := Slugify(originalName)
+			if len(originalName) > 50 || len(slug) > 50 {
 				continue
 			}
 			Tag := &models.Tag{
-				Name: tag,
+				Name: originalName,
 				Slug: slug,
 			}
 			err := Tag.Store(ctx)
 			if err != nil {
 				return nil, err
 			}
-			tagIDs = append(tagIDs, Tag.ID)
+			tagInputs = append(tagInputs, models.TagInput{
+				ID:   Tag.ID,
+				Name: originalName,
+			})
 		}
 	}
-	return tagIDs, nil
+	return tagInputs, nil
 }
 
 func CreateNoteURL(c echo.Context) (string, string) {
diff --git a/migrations/0009_add_tag_junction_name.down.sql b/migrations/0009_add_tag_junction_name.down.sql
new file mode 100644
index 0000000..a52d644
--- /dev/null
+++ b/migrations/0009_add_tag_junction_name.down.sql
@@ -0,0 +1,3 @@
+ALTER TABLE tag_links DROP COLUMN name;
+ALTER TABLE tag_link_shorts DROP COLUMN name;
+ALTER TABLE tag_listings DROP COLUMN name;
diff --git a/migrations/0009_add_tag_junction_name.up.sql b/migrations/0009_add_tag_junction_name.up.sql
new file mode 100644
index 0000000..064efb4
--- /dev/null
+++ b/migrations/0009_add_tag_junction_name.up.sql
@@ -0,0 +1,11 @@
+ALTER TABLE tag_links ADD COLUMN name VARCHAR(50) DEFAULT '';
+ALTER TABLE tag_link_shorts ADD COLUMN name VARCHAR(50) DEFAULT '';
+ALTER TABLE tag_listings ADD COLUMN name VARCHAR(50) DEFAULT '';
+
+UPDATE tag_links tl SET name = t.name FROM tags t WHERE tl.tag_id = t.id;
+UPDATE tag_link_shorts tls SET name = t.name FROM tags t WHERE tls.tag_id = t.id;
+UPDATE tag_listings tll SET name = t.name FROM tags t WHERE tll.tag_id = t.id;
+
+ALTER TABLE tag_links ALTER COLUMN name SET NOT NULL;
+ALTER TABLE tag_link_shorts ALTER COLUMN name SET NOT NULL;
+ALTER TABLE tag_listings ALTER COLUMN name SET NOT NULL;
diff --git a/models/base_url.go b/models/base_url.go
index f4d10e8..8a88578 100644
--- a/models/base_url.go
+++ b/models/base_url.go
@@ -65,7 +65,7 @@ func GetBaseURLs(ctx context.Context, opts *database.FilterOptions) ([]*BaseURL,
 		rows, err := q.
 			Columns("b.id", "b.url", "b.title", "b.counter", "b.data", "b.public_ready", "b.hash",
 				"b.parse_attempts", "b.last_parse_attempt", "b.created_on", "b.visibility",
-				fmt.Sprintf("json_agg(t ORDER BY %s)::jsonb", tagOrder)).
+				fmt.Sprintf("json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END ORDER BY %s)::jsonb", tagOrder)).
 			From("base_urls b").
 			LeftJoin("org_links ol ON ol.base_url_id = b.id").
 			LeftJoin("organizations o ON o.id = ol.org_id").
diff --git a/models/link_short.go b/models/link_short.go
index 828b931..a2b03bf 100644
--- a/models/link_short.go
+++ b/models/link_short.go
@@ -22,7 +22,7 @@ func GetLinkShorts(ctx context.Context, opts *database.FilterOptions) ([]*LinkSh
 		q := opts.GetBuilder(nil)
 		rows, err := q.
 			Columns("l.id", "l.title", "l.url", "l.short_code", "l.domain_id", "l.org_id", "l.user_id",
-				"l.created_on", "l.updated_on", "d.lookup_name", "json_agg(t)::jsonb").
+				"l.created_on", "l.updated_on", "d.lookup_name", "json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END)::jsonb").
 			From("link_shorts AS l").
 			InnerJoin("domains d ON l.domain_id = d.id").
 			LeftJoin("tag_link_shorts tl ON tl.link_short_id = l.id").
@@ -236,7 +236,7 @@ func ExportLinkShorts(ctx context.Context, opts *database.FilterOptions) ([]*Exp
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
 		rows, err := q.
-			Columns("l.id", "l.title", "l.url", "l.short_code", "l.created_on", "json_agg(t)::jsonb").
+			Columns("l.id", "l.title", "l.url", "l.short_code", "l.created_on", "json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END)::jsonb").
 			From("link_shorts AS l").
 			LeftJoin("tag_link_shorts tl ON tl.link_short_id = l.id").
 			LeftJoin("tags t ON t.id = tl.tag_id").
diff --git a/models/listing.go b/models/listing.go
index 7e58ce8..b7c9636 100644
--- a/models/listing.go
+++ b/models/listing.go
@@ -22,7 +22,7 @@ func GetListings(ctx context.Context, opts *database.FilterOptions) ([]*Listing,
 		rows, err := q.
 			Columns("l.id", "l.title", "l.slug", "l.image", "l.metadata", "l.domain_id", "l.org_id",
 				"l.user_id", "l.is_default", "l.is_active", "l.created_on", "l.updated_on", "d.lookup_name",
-				"json_agg(t)::jsonb").
+				"json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END)::jsonb").
 			From("listings l").
 			InnerJoin("domains d ON l.domain_id = d.id").
 			InnerJoin("organizations o ON l.org_id = o.id").
@@ -220,7 +220,7 @@ func ExportListings(ctx context.Context, opts *database.FilterOptions) ([]*Expor
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
 		rows, err := q.
-			Columns("l.id", "l.title", "l.slug", "l.created_on", "json_agg(t)::jsonb", "json_agg(ll)::jsonb").
+			Columns("l.id", "l.title", "l.slug", "l.created_on", "json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END)::jsonb", "json_agg(ll)::jsonb").
 			From("listings l").
 			LeftJoin("listing_links ll ON l.id = ll.listing_id").
 			LeftJoin("tag_listings tl ON tl.listing_id = l.id").
diff --git a/models/models.go b/models/models.go
index e47190f..bb61912 100644
--- a/models/models.go
+++ b/models/models.go
@@ -138,11 +138,18 @@ type Tag struct {
 	Count int `db:"-" json:"count"`
 }
 
+// TagInput represents a tag with its ID and user's original name
+type TagInput struct {
+	ID   int
+	Name string
+}
+
 // TagLink ...
 type TagLink struct {
 	ID        int       `db:"id"`
 	TagID     int       `db:"tag_id"`
 	OrgLinkID int       `db:"org_link_id"`
+	Name      string    `db:"name"`
 	CreatedOn time.Time `db:"created_on"`
 }
 
@@ -150,6 +157,7 @@ type TagLinkShort struct {
 	ID          int       `db:"id"`
 	TagID       int       `db:"tag_id"`
 	LinkShortID int       `db:"link_short_id"`
+	Name        string    `db:"name"`
 	CreatedOn   time.Time `db:"created_on"`
 }
 
@@ -157,6 +165,7 @@ type TagListing struct {
 	ID        int       `db:"id"`
 	TagID     int       `db:"tag_id"`
 	ListingID int       `db:"listing_id"`
+	Name      string    `db:"name"`
 	CreatedOn time.Time `db:"created_on"`
 }
 
diff --git a/models/org_link.go b/models/org_link.go
index 8ea8f88..0148c5b 100644
--- a/models/org_link.go
+++ b/models/org_link.go
@@ -37,7 +37,7 @@ func GetOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink,
 		rows, err := q.
 			Columns("ol.id", "ol.title", "ol.url", "ol.description", "ol.base_url_id", "ol.org_id", "ol.user_id",
 				"ol.visibility", "ol.unread", "ol.starred", "ol.archive_url", "ol.type", "ol.hash",
-				"ol.created_on", "ol.updated_on", "o.slug", "u.full_name", fmt.Sprintf("json_agg(t ORDER BY %s)::jsonb", tagOrder), "b.data", "b.counter", "b.hash").
+				"ol.created_on", "ol.updated_on", "o.slug", "u.full_name", fmt.Sprintf("json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END ORDER BY %s)::jsonb", tagOrder), "b.data", "b.counter", "b.hash").
 			From("org_links ol").
 			Join("organizations o ON o.id = ol.org_id").
 			Join("users u ON ol.user_id = u.id").
@@ -325,7 +325,7 @@ func ExportOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*Expor
 		q := opts.GetBuilder(nil)
 		rows, err := q.
 			Columns("ol.id", "ol.title", "ol.url", "ol.description", "ol.visibility",
-				"ol.unread", "ol.starred", "ol.hash", "ol.created_on", fmt.Sprintf("json_agg(t ORDER BY %s)::jsonb", tagOrder)).
+				"ol.unread", "ol.starred", "ol.hash", "ol.created_on", fmt.Sprintf("json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'created_on', t.created_on) END ORDER BY %s)::jsonb", tagOrder)).
 			From("org_links ol").
 			LeftJoin("tag_links tl ON tl.org_link_id = ol.id").
 			LeftJoin("tags t ON t.id = tl.tag_id").
diff --git a/models/organization.go b/models/organization.go
index 61cf9eb..58a6c4e 100644
--- a/models/organization.go
+++ b/models/organization.go
@@ -354,7 +354,7 @@ func (o *Organization) TagCloud(ctx context.Context, order TagCloudOrdering) ([]
 			sq.Eq{"ol.visibility": OrgLinkVisibilityPublic},
 		}
 	}
-	return GetTagCloud(ctx, opts)
+	return GetTagCloud(ctx, opts, o.ID)
 }
 
 // DeleteTagForService expects service value to be verified and tag to be provided.
diff --git a/models/tag.go b/models/tag.go
index 3793dd6..c9cbcfa 100644
--- a/models/tag.go
+++ b/models/tag.go
@@ -204,14 +204,26 @@ func (t *Tag) Delete(ctx context.Context) error {
 	return err
 }
 
-func GetTagCloud(ctx context.Context, opts *database.FilterOptions) ([]*Tag, error) {
+func GetTagCloud(ctx context.Context, opts *database.FilterOptions, orgID int) ([]*Tag, error) {
 	var tags []*Tag
 
 	tz := timezone.ForContext(ctx)
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
+
+		nameColumn := "t.name"
+		if orgID > 0 {
+			nameColumn = fmt.Sprintf(`(
+				SELECT tl2.name FROM tag_links tl2
+				JOIN org_links ol2 ON ol2.id = tl2.org_link_id
+				WHERE tl2.tag_id = t.id AND ol2.org_id = %d
+				ORDER BY tl2.created_on ASC
+				LIMIT 1
+			)`, orgID)
+		}
+
 		rows, err := q.
-			Columns("t.id", "t.name", "t.slug", "t.created_on", "count(*) as tag_count").
+			Columns("t.id", nameColumn, "t.slug", "t.created_on", "count(*) as tag_count").
 			From("tags t").
 			Join("tag_links tl on tl.tag_id = t.id").
 			Join("org_links ol on ol.id = tl.org_link_id").
@@ -273,7 +285,7 @@ func LinkTagCloud[T TaggableModel](ctx context.Context,
 		Filter:  sq.Eq{field: ids},
 		OrderBy: TagCloudOrderString(order),
 	}
-	return GetTagCloud(ctx, opts)
+	return GetTagCloud(ctx, opts, 0)
 }
 
 // GetDefaultTagOrder returns a users preferred tag ordering
diff --git a/models/tag_link_shorts.go b/models/tag_link_shorts.go
index 4b60411..50ff3c8 100644
--- a/models/tag_link_shorts.go
+++ b/models/tag_link_shorts.go
@@ -21,7 +21,7 @@ func GetTagLinkShorts(ctx context.Context, opts *database.FilterOptions) ([]*Tag
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
 		rows, err := q.
-			Columns("id", "link_short_id", "tag_id", "created_on").
+			Columns("id", "link_short_id", "tag_id", "name", "created_on").
 			From("tag_link_shorts").
 			Distinct().
 			PlaceholderFormat(database.GetPlaceholderFormat()).
@@ -37,7 +37,7 @@ func GetTagLinkShorts(ctx context.Context, opts *database.FilterOptions) ([]*Tag
 
 		for rows.Next() {
 			var tl TagLinkShort
-			if err = rows.Scan(&tl.ID, &tl.LinkShortID, &tl.TagID, &tl.CreatedOn); err != nil {
+			if err = rows.Scan(&tl.ID, &tl.LinkShortID, &tl.TagID, &tl.Name, &tl.CreatedOn); err != nil {
 				return err
 			}
 			err = tl.ToLocalTZ(tz)
@@ -60,28 +60,24 @@ func GetTagLinkShort(ctx context.Context, id int) (*TagLinkShort, error) {
 	return tl, err
 }
 
-func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tagIDs []int) error {
-	if len(tagIDs) == 0 {
+func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tags []TagInput) error {
+	if len(tags) == 0 {
 		return nil
 	}
 	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
 		var err error
 		q := sq.
 			Insert("tag_link_shorts").
-			Columns("link_short_id", "tag_id")
+			Columns("link_short_id", "tag_id", "name")
 
-		for _, tagID := range tagIDs {
-			q = q.Values(linkShortID, tagID)
+		for _, tag := range tags {
+			q = q.Values(linkShortID, tag.ID, tag.Name)
 		}
 		_, err = q.
-			Suffix("ON CONFLICT (link_short_id, tag_id) DO NOTHING").
+			Suffix("ON CONFLICT (link_short_id, tag_id) DO UPDATE SET name = EXCLUDED.name").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
 			ExecContext(ctx)
-
-		if err != nil {
-			return err
-		}
 		return err
 	})
 	return err
@@ -114,12 +110,12 @@ func (tl *TagLinkShort) Load(ctx context.Context) error {
 	tz := timezone.ForContext(ctx)
 	err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		err := sq.
-			Select("id", "link_short_id", "tag_id", "created_on").
+			Select("id", "link_short_id", "tag_id", "name", "created_on").
 			From("tag_link_shorts").
 			Where("id = ?", tl.ID).
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
-			ScanContext(ctx, &tl.ID, &tl.LinkShortID, &tl.TagID, &tl.CreatedOn)
+			ScanContext(ctx, &tl.ID, &tl.LinkShortID, &tl.TagID, &tl.Name, &tl.CreatedOn)
 		if err != nil {
 			if err == sql.ErrNoRows {
 				return nil
@@ -139,9 +135,9 @@ func (tl *TagLinkShort) Store(ctx context.Context) error {
 		if tl.ID == 0 {
 			err = sq.
 				Insert("tag_link_shorts").
-				Columns("link_short_id", "tag_id").
-				Values(tl.LinkShortID, tl.TagID).
-				Suffix(`RETURNING id, created_on`).
+				Columns("link_short_id", "tag_id", "name").
+				Values(tl.LinkShortID, tl.TagID, tl.Name).
+				Suffix(`ON CONFLICT (link_short_id, tag_id) DO UPDATE SET name = EXCLUDED.name RETURNING id, created_on`).
 				PlaceholderFormat(database.GetPlaceholderFormat()).
 				RunWith(tx).
 				ScanContext(ctx, &tl.ID, &tl.CreatedOn)
diff --git a/models/tag_links.go b/models/tag_links.go
index 81c4a1a..81f804c 100644
--- a/models/tag_links.go
+++ b/models/tag_links.go
@@ -21,7 +21,7 @@ func GetTagLinks(ctx context.Context, opts *database.FilterOptions) ([]*TagLink,
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
 		rows, err := q.
-			Columns("id", "org_link_id", "tag_id", "created_on").
+			Columns("id", "org_link_id", "tag_id", "name", "created_on").
 			From("tag_links").
 			Distinct().
 			PlaceholderFormat(database.GetPlaceholderFormat()).
@@ -37,7 +37,7 @@ func GetTagLinks(ctx context.Context, opts *database.FilterOptions) ([]*TagLink,
 
 		for rows.Next() {
 			var tl TagLink
-			if err = rows.Scan(&tl.ID, &tl.OrgLinkID, &tl.TagID, &tl.CreatedOn); err != nil {
+			if err = rows.Scan(&tl.ID, &tl.OrgLinkID, &tl.TagID, &tl.Name, &tl.CreatedOn); err != nil {
 				return err
 			}
 			err = tl.ToLocalTZ(tz)
@@ -60,21 +60,21 @@ func GetTagLink(ctx context.Context, id int) (*TagLink, error) {
 	return tl, err
 }
 
-func CreateBatchTagLinks(ctx context.Context, linkID int, tagIDs []int) error {
-	if len(tagIDs) == 0 {
+func CreateBatchTagLinks(ctx context.Context, linkID int, tags []TagInput) error {
+	if len(tags) == 0 {
 		return nil
 	}
 	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
 		var err error
 		q := sq.
 			Insert("tag_links").
-			Columns("org_link_id", "tag_id")
+			Columns("org_link_id", "tag_id", "name")
 
-		for _, tagID := range tagIDs {
-			q = q.Values(linkID, tagID)
+		for _, tag := range tags {
+			q = q.Values(linkID, tag.ID, tag.Name)
 		}
 		_, err = q.
-			Suffix("ON CONFLICT (org_link_id, tag_id) DO NOTHING").
+			Suffix("ON CONFLICT (org_link_id, tag_id) DO UPDATE SET name = EXCLUDED.name").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
 			ExecContext(ctx)
@@ -110,12 +110,12 @@ func (tl *TagLink) Load(ctx context.Context) error {
 	tz := timezone.ForContext(ctx)
 	err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		err := sq.
-			Select("id", "org_link_id", "tag_id", "created_on").
+			Select("id", "org_link_id", "tag_id", "name", "created_on").
 			From("tag_links").
 			Where("id = ?", tl.ID).
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
-			ScanContext(ctx, &tl.ID, &tl.OrgLinkID, &tl.TagID, &tl.CreatedOn)
+			ScanContext(ctx, &tl.ID, &tl.OrgLinkID, &tl.TagID, &tl.Name, &tl.CreatedOn)
 		if err != nil {
 			if err == sql.ErrNoRows {
 				return nil
@@ -135,9 +135,9 @@ func (tl *TagLink) Store(ctx context.Context) error {
 		if tl.ID == 0 {
 			err = sq.
 				Insert("tag_links").
-				Columns("org_link_id", "tag_id").
-				Values(tl.OrgLinkID, tl.TagID).
-				Suffix(`RETURNING id, created_on`).
+				Columns("org_link_id", "tag_id", "name").
+				Values(tl.OrgLinkID, tl.TagID, tl.Name).
+				Suffix(`ON CONFLICT (org_link_id, tag_id) DO UPDATE SET name = EXCLUDED.name RETURNING id, created_on`).
 				PlaceholderFormat(database.GetPlaceholderFormat()).
 				RunWith(tx).
 				ScanContext(ctx, &tl.ID, &tl.CreatedOn)
diff --git a/models/tag_listing.go b/models/tag_listing.go
index df89dc0..dc0939d 100644
--- a/models/tag_listing.go
+++ b/models/tag_listing.go
@@ -21,7 +21,7 @@ func GetTagListings(ctx context.Context, opts *database.FilterOptions) ([]*TagLi
 	if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		q := opts.GetBuilder(nil)
 		rows, err := q.
-			Columns("id", "listing_id", "tag_id", "created_on").
+			Columns("id", "listing_id", "tag_id", "name", "created_on").
 			From("tag_listings").
 			Distinct().
 			PlaceholderFormat(database.GetPlaceholderFormat()).
@@ -37,7 +37,7 @@ func GetTagListings(ctx context.Context, opts *database.FilterOptions) ([]*TagLi
 
 		for rows.Next() {
 			var tl TagListing
-			if err = rows.Scan(&tl.ID, &tl.ListingID, &tl.TagID, &tl.CreatedOn); err != nil {
+			if err = rows.Scan(&tl.ID, &tl.ListingID, &tl.TagID, &tl.Name, &tl.CreatedOn); err != nil {
 				return err
 			}
 			err = tl.ToLocalTZ(tz)
@@ -60,28 +60,24 @@ func GetTagListing(ctx context.Context, id int) (*TagListing, error) {
 	return tl, err
 }
 
-func CreateBatchTagListings(ctx context.Context, linkShortID int, tagIDs []int) error {
-	if len(tagIDs) == 0 {
+func CreateBatchTagListings(ctx context.Context, listingID int, tags []TagInput) error {
+	if len(tags) == 0 {
 		return nil
 	}
 	err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
 		var err error
 		q := sq.
 			Insert("tag_listings").
-			Columns("listing_id", "tag_id")
+			Columns("listing_id", "tag_id", "name")
 
-		for _, tagID := range tagIDs {
-			q = q.Values(linkShortID, tagID)
+		for _, tag := range tags {
+			q = q.Values(listingID, tag.ID, tag.Name)
 		}
 		_, err = q.
-			Suffix("ON CONFLICT (listing_id, tag_id) DO NOTHING").
+			Suffix("ON CONFLICT (listing_id, tag_id) DO UPDATE SET name = EXCLUDED.name").
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
 			ExecContext(ctx)
-
-		if err != nil {
-			return err
-		}
 		return err
 	})
 	return err
@@ -114,12 +110,12 @@ func (tl *TagListing) Load(ctx context.Context) error {
 	tz := timezone.ForContext(ctx)
 	err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error {
 		err := sq.
-			Select("id", "listing_id", "tag_id", "created_on").
+			Select("id", "listing_id", "tag_id", "name", "created_on").
 			From("tag_listings").
 			Where("id = ?", tl.ID).
 			PlaceholderFormat(database.GetPlaceholderFormat()).
 			RunWith(tx).
-			ScanContext(ctx, &tl.ID, &tl.ListingID, &tl.TagID, &tl.CreatedOn)
+			ScanContext(ctx, &tl.ID, &tl.ListingID, &tl.TagID, &tl.Name, &tl.CreatedOn)
 		if err != nil {
 			if err == sql.ErrNoRows {
 				return nil
@@ -139,9 +135,9 @@ func (tl *TagListing) Store(ctx context.Context) error {
 		if tl.ID == 0 {
 			err = sq.
 				Insert("tag_listings").
-				Columns("listing_id", "tag_id").
-				Values(tl.ListingID, tl.TagID).
-				Suffix(`RETURNING id, created_on`).
+				Columns("listing_id", "tag_id", "name").
+				Values(tl.ListingID, tl.TagID, tl.Name).
+				Suffix(`ON CONFLICT (listing_id, tag_id) DO UPDATE SET name = EXCLUDED.name RETURNING id, created_on`).
 				PlaceholderFormat(database.GetPlaceholderFormat()).
 				RunWith(tx).
 				ScanContext(ctx, &tl.ID, &tl.CreatedOn)
-- 
2.52.0

