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= 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 To: ~netlandish/links-dev@lists.code.netlandish.com Cc: Peter Sanchez 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 @@ {{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" }}
{{end}}
+{{ end }}
@@ -73,6 +75,20 @@

{{ end }} {{end}} + {{ if eq .navFlag "bookmarks" }} +
+

{{ .pTitle }} + +

+
+
+

+ {{ if .pDescription }}

{{ htmlSafe (truncate .pDescription 200) }}

{{ end }} + {{ .pURL }} +

+
+
+ {{ end }}
    {{range .links}}
  • @@ -99,9 +115,7 @@ {{end}}

    {{truncate .Title 60}} - {{if $.isPopular}} - {{ .Counter }} - {{end}} + {{ if not $.hideSaveCount }}{{ if showCounter . }}{{if $.isPopular}}{{ .Counter }}{{ else }}{{ .BaseURLCounter }}{{ end }}{{ end }}{{ end }}

    {{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