~netlandish/links-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[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.

Details
Message ID
<20260114220842.3641-1-peter@netlandish.com>
Sender timestamp
1768406914
DKIM signature
missing
Download raw message
Patch: +131 -94
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
Details
Message ID
<DFONO8LTA4HK.HGNS5GZ8T15O@netlandish.com>
In-Reply-To
<20260114220842.3641-1-peter@netlandish.com> (view parent)
Sender timestamp
1768407150
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   7a60d1d..77c2dcf  master -> master
Reply to thread Export thread (mbox)