Received: from mail.netlandish.com (mail.netlandish.com [174.136.98.166])
	by code.netlandish.com (Postfix) with ESMTP id 5CEB72D7
	for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 18 Apr 2025 14:32:37 +0000 (UTC)
Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.219.178; helo=mail-yb1-f178.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=iF4yRDIU
Received: from mail-yb1-f178.google.com (mail-yb1-f178.google.com [209.85.219.178])
	by mail.netlandish.com (Postfix) with ESMTP id 955F91D642E
	for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 18 Apr 2025 14:32:42 +0000 (UTC)
Received: by mail-yb1-f178.google.com with SMTP id 3f1490d57ef6-e461015fbd4so1660582276.2
        for <~netlandish/links-dev@lists.code.netlandish.com>; Fri, 18 Apr 2025 07:32:42 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=netlandish.com; s=google; t=1744986762; x=1745591562; 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=TxPHsJYDyUegDGsjySgC36P6KsjQ5Tt9lukwJoGfJFY=;
        b=iF4yRDIUl900mZl9qAw5foXWnDufJ+CAnaQk6+Qou0hBoB5GV4Koy0GRFpCmFus9TD
         AIcyd9jraV1dhCXAUvYxA4cET0BjysxLoOad0CdpJZWxJAoim0//tq/cQju47WIiAODF
         ZwsoQrbu+zwxqkH5OUA8c6OvVbEAkJsxDANts=
X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
        d=1e100.net; s=20230601; t=1744986762; x=1745591562;
        h=content-transfer-encoding:mime-version:message-id:date:subject:cc
         :to:from:x-gm-message-state:from:to:cc:subject:date:message-id
         :reply-to;
        bh=TxPHsJYDyUegDGsjySgC36P6KsjQ5Tt9lukwJoGfJFY=;
        b=QtEajsedzu+iqOMqQFSeLJgKbJfh+YWV9dC3AIDOy8lKJ2IQiXE23pnBzIq+hEjlTx
         dkBznV1OtD0UTI9SukM01BOOVROh5ZnfSgd7THGuc+gcwHlpunwlwf1NGOpFbsIa0y3k
         MJLlnjR1CHMFIn/+wjq2oQPqqtYKNir59292Rgv8+JFbuO7iLZJNev/qu+8V0JfegZvS
         Me8ZYdKWf9yZTpOSwIhLjdCXG7ukwhBw+beV4dguX0915sTOZ5c9Q/ZXA6FhWrHfu4N6
         kEqEyLETcqJynUi4C6Q81Q+4TG+gkLfQO5VTH1fr+QagEffY2GPVJsn9CwPZ16gp4Yb4
         nzLA==
X-Gm-Message-State: AOJu0Yw0dRG4h3nCXeIMJPbKE1DMtoRGR3RL5qIA5HSqrttY7zvsKYyi
	34m8nGOHv67pAKCosEKOifqASPF/lOleDOuWUw3eitKTZo6yJogpGU9MOtpzGs7RLJaPw9dCnsN
	b9B4=
X-Gm-Gg: ASbGncvdx/71XAfbQOZxOw9blWP4QBvy7qwiojxeSg/qQ/qutJnq9DHRaM14M9Qwya8
	TMS//NV1rzX8xtxrUCXTDGMXu1rqyhp88E2MrH30uPCkN/kzr7yq4aZ64TYNaPhm884FM0UsSer
	GfTigDHiU3sznyeXLFE83Ae2v43LSJ7YIY5pWA8MOxISjaHBRK2Of1Y0aEcA/W1mcG6T8w7JdOY
	GD4EX767Hn6rYYNOnGCYauKpz+JDKqUpRaiOX0N2+cJaJ+hOciy1Eog1/wnivsWccYqEIwMAo7Q
	bXTxpol/jt9r/p/M4j2xNZZcerAE5VJw7v/0RCE=
X-Google-Smtp-Source: AGHT+IGt2810Fum7Y94sRxIvO1mmlxQxFB+9uEaQwy2vjXMAEj/jOi7PYcwWWPKvtbcN8zDtuf28UA==
X-Received: by 2002:a05:6902:102d:b0:e6b:7c0e:d558 with SMTP id 3f1490d57ef6-e7297dca6famr3337844276.19.1744986761083;
        Fri, 18 Apr 2025 07:32:41 -0700 (PDT)
Received: from localhost ([2803:2d60:1107:87f:1e:2d9a:aa74:b8b2])
        by smtp.gmail.com with ESMTPSA id 3f1490d57ef6-e729585a776sm464005276.17.2025.04.18.07.32.40
        (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);
        Fri, 18 Apr 2025 07:32:40 -0700 (PDT)
From: Peter Sanchez <peter@netlandish.com>
To: ~netlandish/links-dev@lists.code.netlandish.com
Cc: Peter Sanchez <peter@netlandish.com>
Subject: [PATCH links] Showing save count on all relevant listing pages.
Date: Fri, 18 Apr 2025 08:32:32 -0600
Message-ID: <20250418143237.27337-1-peter@netlandish.com>
X-Mailer: git-send-email 2.47.2
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

New "saved" page will list all the users/orgs who bookmarked a specific
link.

Implements: https://todo.code.netlandish.com/~netlandish/links/106
Changelog-added: Save count is displayed on all relevant listing pages.
Changelog-added: New page to view all users/orgs who bookmarked a
  specific link.
---
 api/graph/generated.go        | 811 +++++++++++++++++++++++++++++++++-
 api/graph/model/models_gen.go |   9 +
 api/graph/schema.graphqls     |  14 +
 api/graph/schema.resolvers.go | 113 ++++-
 cmd/links/main.go             |   3 +
 cmd/test/helpers.go           |   3 +
 core/routes.go                | 120 +++++
 models/models.go              |  12 +-
 models/org_link.go            |   7 +-
 models/utils.go               |  16 +-
 templates/feed.html           |   1 +
 templates/link_list.html      |  20 +-
 values.go                     |   1 +
 13 files changed, 1098 insertions(+), 32 deletions(-)

diff --git a/api/graph/generated.go b/api/graph/generated.go
index 61befad..c964512 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -130,6 +130,15 @@ type ComplexityRoot struct {
 		Status func(childComplexity int) int
 	}
 
+	BookmarkCursor struct {
+		Count       func(childComplexity int) int
+		Description func(childComplexity int) int
+		Result      func(childComplexity int) int
+		TagCloud    func(childComplexity int) int
+		Title       func(childComplexity int) int
+		URL         func(childComplexity int) int
+	}
+
 	DeletePayload struct {
 		Message  func(childComplexity int) int
 		ObjectID func(childComplexity int) int
@@ -277,25 +286,27 @@ type ComplexityRoot struct {
 	}
 
 	OrgLink struct {
-		ArchiveURL  func(childComplexity int) int
-		Author      func(childComplexity int) int
-		BaseURLData func(childComplexity int) int
-		BaseURLID   func(childComplexity int) int
-		CreatedOn   func(childComplexity int) int
-		Description func(childComplexity int) int
-		Hash        func(childComplexity int) int
-		ID          func(childComplexity int) int
-		OrgID       func(childComplexity int) int
-		OrgSlug     func(childComplexity int) int
-		Starred     func(childComplexity int) int
-		Tags        func(childComplexity int) int
-		Title       func(childComplexity int) int
-		Type        func(childComplexity int) int
-		URL         func(childComplexity int) int
-		Unread      func(childComplexity int) int
-		UpdatedOn   func(childComplexity int) int
-		UserID      func(childComplexity int) int
-		Visibility  func(childComplexity int) int
+		ArchiveURL     func(childComplexity int) int
+		Author         func(childComplexity int) int
+		BaseURLCounter func(childComplexity int) int
+		BaseURLData    func(childComplexity int) int
+		BaseURLHash    func(childComplexity int) int
+		BaseURLID      func(childComplexity int) int
+		CreatedOn      func(childComplexity int) int
+		Description    func(childComplexity int) int
+		Hash           func(childComplexity int) int
+		ID             func(childComplexity int) int
+		OrgID          func(childComplexity int) int
+		OrgSlug        func(childComplexity int) int
+		Starred        func(childComplexity int) int
+		Tags           func(childComplexity int) int
+		Title          func(childComplexity int) int
+		Type           func(childComplexity int) int
+		URL            func(childComplexity int) int
+		Unread         func(childComplexity int) int
+		UpdatedOn      func(childComplexity int) int
+		UserID         func(childComplexity int) int
+		Visibility     func(childComplexity int) int
 	}
 
 	OrgLinkCursor struct {
@@ -392,6 +403,7 @@ type ComplexityRoot struct {
 		GetAdminOrgStats      func(childComplexity int, id int) int
 		GetAdminOrganizations func(childComplexity int, input *model.GetAdminOrganizationsInput) int
 		GetAuditLogs          func(childComplexity int, input *model.AuditLogInput) int
+		GetBookmarks          func(childComplexity int, hash string, tags *string) int
 		GetDomain             func(childComplexity int, id int) int
 		GetDomains            func(childComplexity int, orgSlug *string, service *model.DomainService) int
 		GetFeed               func(childComplexity int, input *model.GetFeedInput) int
@@ -532,6 +544,7 @@ type QueryResolver interface {
 	GetPaymentHistory(ctx context.Context, input *model.GetPaymentInput) (*model.PaymentCursor, error)
 	GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) (*model.PopularLinkCursor, error)
 	GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
+	GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error)
 	GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error)
 	GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error)
 	GetDomains(ctx context.Context, orgSlug *string, service *model.DomainService) ([]*models.Domain, error)
@@ -857,6 +870,48 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.BillingSettings.Status(childComplexity), true
 
+	case "BookmarkCursor.count":
+		if e.complexity.BookmarkCursor.Count == nil {
+			break
+		}
+
+		return e.complexity.BookmarkCursor.Count(childComplexity), true
+
+	case "BookmarkCursor.description":
+		if e.complexity.BookmarkCursor.Description == nil {
+			break
+		}
+
+		return e.complexity.BookmarkCursor.Description(childComplexity), true
+
+	case "BookmarkCursor.result":
+		if e.complexity.BookmarkCursor.Result == nil {
+			break
+		}
+
+		return e.complexity.BookmarkCursor.Result(childComplexity), true
+
+	case "BookmarkCursor.tagCloud":
+		if e.complexity.BookmarkCursor.TagCloud == nil {
+			break
+		}
+
+		return e.complexity.BookmarkCursor.TagCloud(childComplexity), true
+
+	case "BookmarkCursor.title":
+		if e.complexity.BookmarkCursor.Title == nil {
+			break
+		}
+
+		return e.complexity.BookmarkCursor.Title(childComplexity), true
+
+	case "BookmarkCursor.url":
+		if e.complexity.BookmarkCursor.URL == nil {
+			break
+		}
+
+		return e.complexity.BookmarkCursor.URL(childComplexity), true
+
 	case "DeletePayload.message":
 		if e.complexity.DeletePayload.Message == nil {
 			break
@@ -1738,6 +1793,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.OrgLink.Author(childComplexity), true
 
+	case "OrgLink.baseUrlCounter":
+		if e.complexity.OrgLink.BaseURLCounter == nil {
+			break
+		}
+
+		return e.complexity.OrgLink.BaseURLCounter(childComplexity), true
+
 	case "OrgLink.baseUrlData":
 		if e.complexity.OrgLink.BaseURLData == nil {
 			break
@@ -1745,6 +1807,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.OrgLink.BaseURLData(childComplexity), true
 
+	case "OrgLink.baseUrlHash":
+		if e.complexity.OrgLink.BaseURLHash == nil {
+			break
+		}
+
+		return e.complexity.OrgLink.BaseURLHash(childComplexity), true
+
 	case "OrgLink.baseUrlId":
 		if e.complexity.OrgLink.BaseURLID == nil {
 			break
@@ -2307,6 +2376,18 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Query.GetAuditLogs(childComplexity, args["input"].(*model.AuditLogInput)), true
 
+	case "Query.getBookmarks":
+		if e.complexity.Query.GetBookmarks == nil {
+			break
+		}
+
+		args, err := ec.field_Query_getBookmarks_args(context.TODO(), rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Query.GetBookmarks(childComplexity, args["hash"].(string), args["tags"].(*string)), true
+
 	case "Query.getDomain":
 		if e.complexity.Query.GetDomain == nil {
 			break
@@ -4289,6 +4370,65 @@ func (ec *executionContext) field_Query_getAuditLogs_argsInput(
 	return zeroVal, nil
 }
 
+func (ec *executionContext) field_Query_getBookmarks_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
+	var err error
+	args := map[string]interface{}{}
+	arg0, err := ec.field_Query_getBookmarks_argsHash(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["hash"] = arg0
+	arg1, err := ec.field_Query_getBookmarks_argsTags(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["tags"] = arg1
+	return args, nil
+}
+func (ec *executionContext) field_Query_getBookmarks_argsHash(
+	ctx context.Context,
+	rawArgs map[string]interface{},
+) (string, error) {
+	// We won't call the directive if the argument is null.
+	// Set call_argument_directives_with_null to true to call directives
+	// even if the argument is null.
+	_, ok := rawArgs["hash"]
+	if !ok {
+		var zeroVal string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("hash"))
+	if tmp, ok := rawArgs["hash"]; ok {
+		return ec.unmarshalNString2string(ctx, tmp)
+	}
+
+	var zeroVal string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Query_getBookmarks_argsTags(
+	ctx context.Context,
+	rawArgs map[string]interface{},
+) (*string, error) {
+	// We won't call the directive if the argument is null.
+	// Set call_argument_directives_with_null to true to call directives
+	// even if the argument is null.
+	_, ok := rawArgs["tags"]
+	if !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("tags"))
+	if tmp, ok := rawArgs["tags"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
 func (ec *executionContext) field_Query_getDomain_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
@@ -7039,6 +7179,323 @@ func (ec *executionContext) fieldContext_BillingSettings_status(_ context.Contex
 	return fc, nil
 }
 
+func (ec *executionContext) _BookmarkCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.BookmarkCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BookmarkCursor_result(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Result, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.([]*models.OrgLink)
+	fc.Result = res
+	return ec.marshalNOrgLink2ᚕᚖlinksᚋmodelsᚐOrgLink(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BookmarkCursor_result(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BookmarkCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_OrgLink_id(ctx, field)
+			case "title":
+				return ec.fieldContext_OrgLink_title(ctx, field)
+			case "description":
+				return ec.fieldContext_OrgLink_description(ctx, field)
+			case "url":
+				return ec.fieldContext_OrgLink_url(ctx, field)
+			case "hash":
+				return ec.fieldContext_OrgLink_hash(ctx, field)
+			case "baseUrlId":
+				return ec.fieldContext_OrgLink_baseUrlId(ctx, field)
+			case "orgId":
+				return ec.fieldContext_OrgLink_orgId(ctx, field)
+			case "userId":
+				return ec.fieldContext_OrgLink_userId(ctx, field)
+			case "visibility":
+				return ec.fieldContext_OrgLink_visibility(ctx, field)
+			case "unread":
+				return ec.fieldContext_OrgLink_unread(ctx, field)
+			case "starred":
+				return ec.fieldContext_OrgLink_starred(ctx, field)
+			case "archiveUrl":
+				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
+			case "type":
+				return ec.fieldContext_OrgLink_type(ctx, field)
+			case "tags":
+				return ec.fieldContext_OrgLink_tags(ctx, field)
+			case "author":
+				return ec.fieldContext_OrgLink_author(ctx, field)
+			case "orgSlug":
+				return ec.fieldContext_OrgLink_orgSlug(ctx, field)
+			case "createdOn":
+				return ec.fieldContext_OrgLink_createdOn(ctx, field)
+			case "updatedOn":
+				return ec.fieldContext_OrgLink_updatedOn(ctx, field)
+			case "baseUrlData":
+				return ec.fieldContext_OrgLink_baseUrlData(ctx, field)
+			case "baseUrlCounter":
+				return ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+			case "baseUrlHash":
+				return ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type OrgLink", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BookmarkCursor_title(ctx context.Context, field graphql.CollectedField, obj *model.BookmarkCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BookmarkCursor_title(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Title, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BookmarkCursor_title(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BookmarkCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BookmarkCursor_description(ctx context.Context, field graphql.CollectedField, obj *model.BookmarkCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BookmarkCursor_description(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Description, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BookmarkCursor_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BookmarkCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BookmarkCursor_url(ctx context.Context, field graphql.CollectedField, obj *model.BookmarkCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BookmarkCursor_url(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.URL, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BookmarkCursor_url(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BookmarkCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BookmarkCursor_count(ctx context.Context, field graphql.CollectedField, obj *model.BookmarkCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BookmarkCursor_count(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Count, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BookmarkCursor_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BookmarkCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BookmarkCursor_tagCloud(ctx context.Context, field graphql.CollectedField, obj *model.BookmarkCursor) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BookmarkCursor_tagCloud(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.TagCloud, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		return graphql.Null
+	}
+	res := resTmp.([]*models.Tag)
+	fc.Result = res
+	return ec.marshalOTag2ᚕᚖlinksᚋmodelsᚐTag(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BookmarkCursor_tagCloud(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BookmarkCursor",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Tag_id(ctx, field)
+			case "name":
+				return ec.fieldContext_Tag_name(ctx, field)
+			case "slug":
+				return ec.fieldContext_Tag_slug(ctx, field)
+			case "createdOn":
+				return ec.fieldContext_Tag_createdOn(ctx, field)
+			case "count":
+				return ec.fieldContext_Tag_count(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name)
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _DeletePayload_success(ctx context.Context, field graphql.CollectedField, obj *model.DeletePayload) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_DeletePayload_success(ctx, field)
 	if err != nil {
@@ -11132,6 +11589,10 @@ func (ec *executionContext) fieldContext_Mutation_addLink(ctx context.Context, f
 				return ec.fieldContext_OrgLink_updatedOn(ctx, field)
 			case "baseUrlData":
 				return ec.fieldContext_OrgLink_baseUrlData(ctx, field)
+			case "baseUrlCounter":
+				return ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+			case "baseUrlHash":
+				return ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type OrgLink", field.Name)
 		},
@@ -11259,6 +11720,10 @@ func (ec *executionContext) fieldContext_Mutation_updateLink(ctx context.Context
 				return ec.fieldContext_OrgLink_updatedOn(ctx, field)
 			case "baseUrlData":
 				return ec.fieldContext_OrgLink_baseUrlData(ctx, field)
+			case "baseUrlCounter":
+				return ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+			case "baseUrlHash":
+				return ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type OrgLink", field.Name)
 		},
@@ -11481,6 +11946,10 @@ func (ec *executionContext) fieldContext_Mutation_addNote(ctx context.Context, f
 				return ec.fieldContext_OrgLink_updatedOn(ctx, field)
 			case "baseUrlData":
 				return ec.fieldContext_OrgLink_baseUrlData(ctx, field)
+			case "baseUrlCounter":
+				return ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+			case "baseUrlHash":
+				return ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type OrgLink", field.Name)
 		},
@@ -15358,6 +15827,94 @@ func (ec *executionContext) fieldContext_OrgLink_baseUrlData(_ context.Context,
 	return fc, nil
 }
 
+func (ec *executionContext) _OrgLink_baseUrlCounter(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.BaseURLCounter, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(int)
+	fc.Result = res
+	return ec.marshalNInt2int(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_OrgLink_baseUrlCounter(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "OrgLink",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type Int does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _OrgLink_baseUrlHash(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.BaseURLHash, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(string)
+	fc.Result = res
+	return ec.marshalNString2string(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_OrgLink_baseUrlHash(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "OrgLink",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type String does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _OrgLinkCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.OrgLinkCursor) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_OrgLinkCursor_result(ctx, field)
 	if err != nil {
@@ -15435,6 +15992,10 @@ func (ec *executionContext) fieldContext_OrgLinkCursor_result(_ context.Context,
 				return ec.fieldContext_OrgLink_updatedOn(ctx, field)
 			case "baseUrlData":
 				return ec.fieldContext_OrgLink_baseUrlData(ctx, field)
+			case "baseUrlCounter":
+				return ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+			case "baseUrlHash":
+				return ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type OrgLink", field.Name)
 		},
@@ -18913,6 +19474,10 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
 				return ec.fieldContext_OrgLink_updatedOn(ctx, field)
 			case "baseUrlData":
 				return ec.fieldContext_OrgLink_baseUrlData(ctx, field)
+			case "baseUrlCounter":
+				return ec.fieldContext_OrgLink_baseUrlCounter(ctx, field)
+			case "baseUrlHash":
+				return ec.fieldContext_OrgLink_baseUrlHash(ctx, field)
 			}
 			return nil, fmt.Errorf("no field named %q was found under type OrgLink", field.Name)
 		},
@@ -18931,6 +19496,107 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
 	return fc, nil
 }
 
+func (ec *executionContext) _Query_getBookmarks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_Query_getBookmarks(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
+		directive0 := func(rctx context.Context) (interface{}, error) {
+			ctx = rctx // use context from middleware stack in children
+			return ec.resolvers.Query().GetBookmarks(rctx, fc.Args["hash"].(string), fc.Args["tags"].(*string))
+		}
+
+		directive1 := func(ctx context.Context) (interface{}, error) {
+			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "LINKS")
+			if err != nil {
+				var zeroVal *model.BookmarkCursor
+				return zeroVal, err
+			}
+			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
+			if err != nil {
+				var zeroVal *model.BookmarkCursor
+				return zeroVal, err
+			}
+			if ec.directives.Access == nil {
+				var zeroVal *model.BookmarkCursor
+				return zeroVal, errors.New("directive access is not implemented")
+			}
+			return ec.directives.Access(ctx, nil, directive0, scope, kind)
+		}
+
+		tmp, err := directive1(rctx)
+		if err != nil {
+			return nil, graphql.ErrorOnPath(ctx, err)
+		}
+		if tmp == nil {
+			return nil, nil
+		}
+		if data, ok := tmp.(*model.BookmarkCursor); ok {
+			return data, nil
+		}
+		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.BookmarkCursor`, tmp)
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(*model.BookmarkCursor)
+	fc.Result = res
+	return ec.marshalNBookmarkCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐBookmarkCursor(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_Query_getBookmarks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Query",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "result":
+				return ec.fieldContext_BookmarkCursor_result(ctx, field)
+			case "title":
+				return ec.fieldContext_BookmarkCursor_title(ctx, field)
+			case "description":
+				return ec.fieldContext_BookmarkCursor_description(ctx, field)
+			case "url":
+				return ec.fieldContext_BookmarkCursor_url(ctx, field)
+			case "count":
+				return ec.fieldContext_BookmarkCursor_count(ctx, field)
+			case "tagCloud":
+				return ec.fieldContext_BookmarkCursor_tagCloud(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type BookmarkCursor", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Query_getBookmarks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
 func (ec *executionContext) _Query_getOrgLinks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Query_getOrgLinks(ctx, field)
 	if err != nil {
@@ -26956,6 +27622,67 @@ func (ec *executionContext) _BillingSettings(ctx context.Context, sel ast.Select
 	return out
 }
 
+var bookmarkCursorImplementors = []string{"BookmarkCursor"}
+
+func (ec *executionContext) _BookmarkCursor(ctx context.Context, sel ast.SelectionSet, obj *model.BookmarkCursor) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, bookmarkCursorImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("BookmarkCursor")
+		case "result":
+			out.Values[i] = ec._BookmarkCursor_result(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "title":
+			out.Values[i] = ec._BookmarkCursor_title(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "description":
+			out.Values[i] = ec._BookmarkCursor_description(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "url":
+			out.Values[i] = ec._BookmarkCursor_url(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "count":
+			out.Values[i] = ec._BookmarkCursor_count(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "tagCloud":
+			out.Values[i] = ec._BookmarkCursor_tagCloud(ctx, field, obj)
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
 var deletePayloadImplementors = []string{"DeletePayload"}
 
 func (ec *executionContext) _DeletePayload(ctx context.Context, sel ast.SelectionSet, obj *model.DeletePayload) graphql.Marshaler {
@@ -28339,6 +29066,16 @@ func (ec *executionContext) _OrgLink(ctx context.Context, sel ast.SelectionSet,
 			if out.Values[i] == graphql.Null {
 				atomic.AddUint32(&out.Invalids, 1)
 			}
+		case "baseUrlCounter":
+			out.Values[i] = ec._OrgLink_baseUrlCounter(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
+		case "baseUrlHash":
+			out.Values[i] = ec._OrgLink_baseUrlHash(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				atomic.AddUint32(&out.Invalids, 1)
+			}
 		default:
 			panic("unknown field " + strconv.Quote(field.Name))
 		}
@@ -29272,6 +30009,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
 					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
 			}
 
+			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
+		case "getBookmarks":
+			field := field
+
+			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
+				defer func() {
+					if r := recover(); r != nil {
+						ec.Error(ctx, ec.Recover(ctx, r))
+					}
+				}()
+				res = ec._Query_getBookmarks(ctx, field)
+				if res == graphql.Null {
+					atomic.AddUint32(&fs.Invalids, 1)
+				}
+				return res
+			}
+
+			rrm := func(ctx context.Context) graphql.Marshaler {
+				return ec.OperationContext.RootResolverMiddleware(ctx,
+					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
+			}
+
 			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
 		case "getOrgLinks":
 			field := field
@@ -30636,6 +31395,20 @@ func (ec *executionContext) marshalNBaseURLData2linksᚋmodelsᚐBaseURLData(ctx
 	return ec._BaseURLData(ctx, sel, &v)
 }
 
+func (ec *executionContext) marshalNBookmarkCursor2linksᚋapiᚋgraphᚋmodelᚐBookmarkCursor(ctx context.Context, sel ast.SelectionSet, v model.BookmarkCursor) graphql.Marshaler {
+	return ec._BookmarkCursor(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNBookmarkCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐBookmarkCursor(ctx context.Context, sel ast.SelectionSet, v *model.BookmarkCursor) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._BookmarkCursor(ctx, sel, v)
+}
+
 func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v interface{}) (bool, error) {
 	res, err := graphql.UnmarshalBoolean(v)
 	return res, graphql.ErrorOnPath(ctx, err)
diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go
index 2404e55..5d3bc9f 100644
--- a/api/graph/model/models_gen.go
+++ b/api/graph/model/models_gen.go
@@ -120,6 +120,15 @@ type AuditLogInput struct {
 	Limit     *int    `json:"limit,omitempty"`
 }
 
+type BookmarkCursor struct {
+	Result      []*models.OrgLink `json:"result"`
+	Title       string            `json:"title"`
+	Description string            `json:"description"`
+	URL         string            `json:"url"`
+	Count       int               `json:"count"`
+	TagCloud    []*models.Tag     `json:"tagCloud,omitempty"`
+}
+
 type CompleteRegisterInput struct {
 	Name     string `json:"name"`
 	Username string `json:"username"`
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 5987562..e170c97 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -244,6 +244,8 @@ type OrgLink {
     createdOn: Time!
     updatedOn: Time!
     baseUrlData: BaseURLData!
+    baseUrlCounter: Int!
+    baseUrlHash: String!
 }
 
 type LinkShort {
@@ -373,6 +375,15 @@ type UserCursor {
     pageInfo: PageInfo
 }
 
+type BookmarkCursor {
+    result: [OrgLink]!
+    title: String!
+    description: String!
+    url: String!
+    count: Int!
+    tagCloud: [Tag]
+}
+
 type OrgLinkCursor {
     result: [OrgLink]!
     pageInfo: PageInfo
@@ -805,6 +816,9 @@ type Query {
     "Returns a specific organization link"
     getOrgLink(hash: String!): OrgLink @access(scope: LINKS, kind: RO)
 
+    "Returns saved links (bookmarks) for a given URL. Accepts BaseURL hash or full url"
+    getBookmarks(hash: String!, tags: String): BookmarkCursor! @access(scope: LINKS, kind: RO)
+
     "Returns an array of organization links"
     getOrgLinks(input: GetLinkInput): OrgLinkCursor! @access(scope: LINKS, kind: RO)
 
diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index bfab68a..ec38373 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -4778,7 +4778,7 @@ func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
 	return &model.Version{
 		Major:           0,
 		Minor:           3,
-		Patch:           0,
+		Patch:           1,
 		DeprecationDate: nil,
 	}, nil
 }
@@ -5103,6 +5103,117 @@ func (r *queryResolver) GetOrgLink(ctx context.Context, hash string) (*models.Or
 	return orgLinks[0], nil
 }
 
+// GetBookmarks is the resolver for the getBookmarks field.
+func (r *queryResolver) GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error) {
+	tokenUser := oauth2.ForContext(ctx)
+	if tokenUser == nil {
+		return nil, valid.ErrAuthorization
+	}
+	user := tokenUser.User.(*models.User)
+	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
+	lt := localizer.GetLocalizer(lang)
+
+	ctx = timezone.Context(ctx, links.GetUserTZ(user))
+	validator := valid.New(ctx)
+
+	opts := &database.FilterOptions{
+		Filter: sq.Eq{"b.public_ready": true},
+		Limit:  1,
+	}
+	_, err := url.Parse(hash)
+	if err != nil {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Eq{"b.url": links.StripURLFragment(hash)},
+		}
+	} else {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Eq{"b.hash": hash},
+		}
+	}
+
+	burls, err := models.GetBaseURLs(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+	if len(burls) == 0 {
+		validator.Error("%s", lt.Translate("BaseURL Not Found")).
+			WithCode(valid.ErrNotFoundCode)
+		return nil, nil
+	}
+	burl := burls[0]
+
+	cloudOrder := model.CloudOrderTypeNameAsc
+
+	opts = &database.FilterOptions{
+		Filter:  sq.Eq{"ol.base_url_id": burl.ID},
+		OrderBy: "ol.id DESC",
+		Limit:   100,
+	}
+
+	if tags != nil && *tags != "" {
+		f := links.NewTagQuery("tag_links", "org_link_id")
+		subQText, subQVal, err := f.GetSubQuery(tags, nil)
+		if err != nil {
+			return nil, err
+		}
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Expr("ol.id IN ("+subQText+")", subQVal...),
+		}
+	}
+
+	if !user.IsAuthenticated() {
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Eq{"ol.visibility": models.OrgLinkVisibilityPublic},
+		}
+	} else {
+		// User can see public or all links belonging to an org they have at least
+		// read access to.
+		opts.Filter = sq.And{
+			opts.Filter,
+			sq.Or{
+				// link is public
+				sq.Eq{"ol.visibility": models.OrgLinkVisibilityPublic},
+				// current user is owner of link
+				sq.Eq{"ol.user_id": user.ID},
+				// current user is owner of link organization
+				sq.And{
+					sq.Eq{"o.is_active": true},
+					sq.Eq{"o.owner_id": user.ID},
+				},
+				// current user has read permission of link organization
+				sq.And{
+					sq.Eq{"ou.user_id": user.ID},
+					sq.Eq{"o.is_active": true},
+					sq.Eq{"(o.settings->'billing'->>'status')": models.BillingStatusBusiness},
+				},
+			},
+		}
+	}
+
+	orgLinks, err := models.GetOrgLinks(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	ltags, err := models.LinkTagCloud(ctx, orgLinks, models.TagCloudOrdering(cloudOrder))
+	if err != nil {
+		return nil, err
+	}
+
+	return &model.BookmarkCursor{
+		Result:      orgLinks,
+		TagCloud:    ltags,
+		Title:       burl.Title,
+		Description: burl.Data.Meta.Description,
+		Count:       burl.Counter,
+		URL:         burl.URL,
+	}, nil
+}
+
 // GetOrgLinks is the resolver for the getOrgLinks field.
 func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error) {
 	if input == nil {
diff --git a/cmd/links/main.go b/cmd/links/main.go
index 9698153..2e26c81 100644
--- a/cmd/links/main.go
+++ b/cmd/links/main.go
@@ -307,6 +307,9 @@ func run() error {
 				return "tag-normal"
 			}
 		},
+		"showCounter": func(obj any) bool {
+			return models.ShowLinkCounter(obj)
+		},
 	})
 	srv.TemplateAllowOverride(
 		"seoData",
diff --git a/cmd/test/helpers.go b/cmd/test/helpers.go
index 967f17e..1396162 100644
--- a/cmd/test/helpers.go
+++ b/cmd/test/helpers.go
@@ -144,6 +144,9 @@ func NewWebTestServer(t *testing.T) (*server.Server, *echo.Echo) {
 				return "tag-normal"
 			}
 		},
+		"showCounter": func(obj any) bool {
+			return models.ShowLinkCounter(obj)
+		},
 	})
 	err = srv.LoadTemplatesFS(links.TemplateFS, "templates/*.html", "templates/*.txt")
 	if err != nil {
diff --git a/core/routes.go b/core/routes.go
index ce9f659..54fa248 100644
--- a/core/routes.go
+++ b/core/routes.go
@@ -57,6 +57,7 @@ func (s *Service) RegisterRoutes() {
 	s.Group.GET("/link/:hash", s.OrgLinkDetail).Name = s.RouteName("link_detail")
 	s.Group.GET("/click/:hash", s.OrgLinkRedirect).Name = s.RouteName("link_redirect")
 	s.Group.GET("/tag-autocomplete", s.TagAutocomplete).Name = s.RouteName("tag_autocomplete")
+	s.Group.GET("/bookmarks/:hash", s.GetBookmarks).Name = s.RouteName("bookmarks")
 
 	s.Group.Use(auth.AuthRequired())
 	s.Group.GET("/:slug/follow-toggle/:action", s.FollowToggle).Name = s.RouteName("follow-toggle")
@@ -1790,6 +1791,8 @@ func (s *Service) UserFeed(c echo.Context) error {
 						siteName
 					}
 				}
+				baseUrlCounter
+				baseUrlHash
 			}
 			pageInfo {
 				cursor
@@ -1918,6 +1921,121 @@ func (s *Service) UserFeed(c echo.Context) error {
 	return s.Render(c, http.StatusOK, "feed.html", gmap)
 }
 
+// GetBookmarks will show all bookmarks for a specific BaseURL
+func (s *Service) GetBookmarks(c echo.Context) error {
+	lhash := c.Param("hash")
+	if lhash == "" {
+		return echo.NotFoundHandler(c)
+	}
+
+	err := links.TagAbuseRedirect(c)
+	if err != nil {
+		return err
+	}
+
+	type GraphQLResponse struct {
+		OrgLinks struct {
+			Result      []models.OrgLink `json:"result"`
+			Title       string           `json:"title"`
+			Description string           `json:"description"`
+			URL         string           `json:"url"`
+			Count       int              `json:"count"`
+			TagCloud    []models.Tag     `json:"tagCloud"`
+		} `json:"getBookmarks"`
+	}
+
+	var result GraphQLResponse
+	op := gqlclient.NewOperation(
+		`query GetBookmarks($hash: String!, $tags: String) {
+			getBookmarks(hash: $hash, tags: $tags) {
+				result {
+					id
+					title
+					url
+					visibility
+					description
+					userId
+					createdOn
+					author
+					orgSlug
+					unread
+					starred
+					archiveUrl
+					type
+					hash
+					tags {
+						id
+						name
+						slug
+					}
+				}
+				title
+				description
+				url
+				count
+				tagCloud {
+					name
+					slug
+					count
+				}
+			}
+		}`)
+	op.Var("hash", lhash)
+
+	var tag string
+	queries := make(url.Values)
+	if c.QueryParam("tag") != "" {
+		tag = c.QueryParam("tag")
+		op.Var("tags", tag)
+		queries.Add("tag", tag)
+	}
+
+	err = links.Execute(links.LangContext(c), op, &result)
+	if err != nil {
+		if graphError, ok := err.(*gqlclient.Error); ok {
+			err = links.ParseInputErrors(c, graphError, gobwebs.Map{})
+		}
+		return err
+	}
+
+	lt := localizer.GetSessionLocalizer(c)
+	pd := localizer.NewPageData(lt.Translate("Saved Bookmarks"))
+	pd.Data["tags"] = lt.Translate("Tags")
+	pd.Data["no_links"] = lt.Translate("This feed has no links. Booo!")
+	orgLinks := result.OrgLinks.Result
+
+	seoData := links.GetSEOData(c)
+	url := links.GetLinksDomainURL(c)
+	url.Path = c.Echo().Reverse(s.RouteName("bookmarks"))
+	seoData.Description = lt.Translate(
+		"Recent bookmarks for URL %s added on LinkTaco.com",
+		result.OrgLinks.URL,
+	)
+	seoData.Title = pd.Title
+	seoData.Keywords = lt.Translate("recent, public, links, linktaco")
+	seoData.URL = url.String()
+	seoData.TwitterURL = url.String()
+
+	navFlag := "bookmarks"
+	gmap := gobwebs.Map{
+		"pd":            pd,
+		"seoData":       seoData,
+		"links":         orgLinks,
+		"navFlag":       navFlag,
+		"currURL":       seoData.URL,
+		"tagCloud":      result.OrgLinks.TagCloud,
+		"pTitle":        result.OrgLinks.Title,
+		"pDescription":  result.OrgLinks.Description,
+		"pURL":          result.OrgLinks.URL,
+		"pCount":        result.OrgLinks.Count,
+		"queries":       template.URL(queries.Encode()),
+		"tagFilter":     strings.Replace(tag, ",", ", ", -1),
+		"hideSaveCount": true,
+	}
+
+	return s.Render(c, http.StatusOK, "link_list.html", gmap)
+}
+
 // OrgLinksList ...
 func (s *Service) OrgLinksList(c echo.Context) error {
 	gctx := c.(*server.Context)
@@ -1984,6 +2102,8 @@ func (s *Service) OrgLinksList(c echo.Context) error {
 								siteName
 							}
 						}
+						baseUrlCounter
+						baseUrlHash
 					}
 				pageInfo {
 					cursor
diff --git a/models/models.go b/models/models.go
index d343917..f38a5f2 100644
--- a/models/models.go
+++ b/models/models.go
@@ -87,11 +87,13 @@ type OrgLink struct {
 	CreatedOn   time.Time `db:"created_on" json:"createdOn"`
 	UpdatedOn   time.Time `db:"updated_on" json:"updatedOn"`
 
-	OrgSlug     string        `db:"-" json:"orgSlug"`
-	BaseURLData BaseURLData   `db:"-" json:"baseUrlData"`
-	Tags        []Tag         `db:"-" json:"tags"`
-	Clicks      sql.NullInt64 `db:"-" json:"-"`
-	Author      string        `db:"-"`
+	OrgSlug        string        `db:"-" json:"orgSlug"`
+	BaseURLData    BaseURLData   `db:"-" json:"baseUrlData"`
+	BaseURLCounter int           `db:"-" json:"baseUrlCounter"`
+	BaseURLHash    string        `db:"-" json:"baseUrlHash"`
+	Tags           []Tag         `db:"-" json:"tags"`
+	Clicks         sql.NullInt64 `db:"-" json:"-"`
+	Author         string        `db:"-"`
 }
 
 // OrgUser ...
diff --git a/models/org_link.go b/models/org_link.go
index 39238b1..5fa11e1 100644
--- a/models/org_link.go
+++ b/models/org_link.go
@@ -38,7 +38,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", "json_agg(t)::jsonb", "b.data").
+				"ol.created_on", "ol.updated_on", "o.slug", "u.full_name", "json_agg(t)::jsonb", "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").
@@ -47,7 +47,7 @@ func GetOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink,
 			LeftJoin("tag_links tl ON tl.org_link_id = ol.id").
 			LeftJoin("tags t ON t.id = tl.tag_id").
 			LeftJoin("followers f ON f.org_id = ol.org_id").
-			GroupBy("ol.id", "o.slug", "u.full_name", "b.data").
+			GroupBy("ol.id", "o.slug", "u.full_name", "b.data", "b.counter", "b.hash").
 			Distinct().
 			PlaceholderFormat(sq.Dollar).
 			RunWith(tx).
@@ -65,7 +65,8 @@ func GetOrgLinks(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink,
 			var tags string
 			if err = rows.Scan(&o.ID, &o.Title, &o.URL, &o.Description, &o.BaseURLID, &o.OrgID,
 				&o.UserID, &o.Visibility, &o.Unread, &o.Starred, &o.ArchiveURL, &o.Type, &o.Hash,
-				&o.CreatedOn, &o.UpdatedOn, &o.OrgSlug, &o.Author, &tags, &o.BaseURLData); err != nil {
+				&o.CreatedOn, &o.UpdatedOn, &o.OrgSlug, &o.Author, &tags,
+				&o.BaseURLData, &o.BaseURLCounter, &o.BaseURLHash); err != nil {
 				return err
 			}
 			re := regexp.MustCompile(`(,\s)?null,?`)
diff --git a/models/utils.go b/models/utils.go
index be59bc5..7a45864 100644
--- a/models/utils.go
+++ b/models/utils.go
@@ -19,7 +19,7 @@ func getShortCode(ctx context.Context, tx *sql.Tx, table, field string, filter s
 	counter := 0
 
 	for {
-		for i := 0; i < codelen; i++ {
+		for i := range codelen {
 			code[i] = chars[r.Intn(len(chars))]
 		}
 
@@ -81,3 +81,17 @@ func TagsToString(t []Tag) string {
 	}
 	return tags
 }
+
+// ShowLinkCounter says whether or not to show a link counter
+func ShowLinkCounter(obj any) bool {
+	switch v := any(obj).(type) {
+	case *BaseURL, BaseURL:
+		return true
+	case *OrgLink:
+		return v.BaseURLCounter > 0
+	case OrgLink:
+		return v.BaseURLCounter > 0
+	default:
+		return false
+	}
+}
diff --git a/templates/feed.html b/templates/feed.html
index 279245d..9c6aa7a 100644
--- a/templates/feed.html
+++ b/templates/feed.html
@@ -53,6 +53,7 @@
         <div class="d-flex items-center">
           <h3 class="is-marginless mr-1">
             <a class="text-dark" href="{{.URL}}" target="_blank">{{truncate .Title 60}}</a>
+	    {{ if showCounter . }}<a href="{{ reverse "core:bookmarks" .BaseURLHash }}" class="button outline primary tag tag-popular is-small">{{if $.isPopular}}{{ .Counter }}{{ else }}{{ .BaseURLCounter }}{{ end }}</a>{{ end }}
           </h3>
         </div>
         {{if .Description}}
diff --git a/templates/link_list.html b/templates/link_list.html
index fae218b..cf9ff5c 100644
--- a/templates/link_list.html
+++ b/templates/link_list.html
@@ -1,5 +1,6 @@
 {{template "base" .}}
 {{ define "title" }}{{ .title }}{{ end }}
+{{ if ne .navFlag "bookmarks" }}
 <form method="GET"
     action="{{ if .isUserDomain }}/{{ else }}{{if .isPopular}}{{reverse "core:popular_link_list"}}{{else if .isOrgLink}}{{.currURL}}{{else}}{{reverse "core:recent_link_list"}}{{end}}{{ end }}{{if .hasUnreadFilter}}?filter=unread{{else if .hasStarredFilter}}?filter=starred{{end}}"
     id="advanced-search-form"
@@ -41,6 +42,7 @@
     </section>
     {{end}}
 </form>
+{{ end }}
 <section class="card shadow-card container">
   <div class="row">
       <div class="col-9">
@@ -73,6 +75,20 @@
     </p>
     {{ end }}
   {{end}}
+  {{ if eq .navFlag "bookmarks" }}
+  <div class="row">
+    <h3>{{ .pTitle }}
+    <div class="button outline primary tag tag-popular is-small">{{ .pCount }}</div>
+    </h3>
+  </div>
+  <div class="row">
+    <p class="is-marginless">
+    {{ if .pDescription }}<p>{{ htmlSafe (truncate .pDescription 200) }}</p>{{ end }}
+    <small>{{ .pURL }}</small>
+    </p>
+  </div>
+  <hr>
+  {{ end }}
   <ul class="list-unstyled pl-0">
     {{range .links}}
     <li class="mb-1">
@@ -99,9 +115,7 @@
           {{end}}
           <h3 class="is-marginless mr-1">
             <a class="text-dark" href="{{.URL}}" target="_blank">{{truncate .Title 60}}</a>
-            {{if $.isPopular}}
-	      <span class="button outline primary tag tag-popular is-small">{{ .Counter }}</span>
-            {{end}}
+	      {{ if not $.hideSaveCount }}{{ if showCounter . }}<a href="{{ if $.isPopular }}{{ reverse "core:bookmarks" .Hash }}{{ else }}{{ reverse "core:bookmarks" .BaseURLHash }}{{ end }}" class="button outline primary tag tag-popular is-small">{{if $.isPopular}}{{ .Counter }}{{ else }}{{ .BaseURLCounter }}{{ end }}</a>{{ end }}{{ end }}
           </h3>
           {{if $.isOrgLink}}
             {{if eq .UserID $.currentUserID}}
diff --git a/values.go b/values.go
index 73fca25..4d6ce69 100644
--- a/values.go
+++ b/values.go
@@ -95,6 +95,7 @@ var InvalidSlugs = []string{
 	"services",
 	"integrations",
 	"xmpp",
+	"bookmarks",
 }
 
 // Interval used to display data in analytics engagement chart
-- 
2.47.2

