~netlandish/links

59358ca8513674b2c31a7a36121fe6178b8dc76f — Peter Sanchez a month ago 2339a37
Add simple copy link to user account functionality.

Implements: https://todo.code.netlandish.com/~netlandish/links/68
M api/graph/generated.go => api/graph/generated.go +63 -0
@@ 260,6 260,7 @@ type ComplexityRoot struct {
		CreatedOn   func(childComplexity int) int
		Description func(childComplexity int) int
		ID          func(childComplexity int) int
		NoteHash    func(childComplexity int) int
		OrgID       func(childComplexity int) int
		OrgSlug     func(childComplexity int) int
		Starred     func(childComplexity int) int


@@ 1622,6 1623,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in

		return e.complexity.OrgLink.ID(childComplexity), true

	case "OrgLink.noteHash":
		if e.complexity.OrgLink.NoteHash == nil {
			break
		}

		return e.complexity.OrgLink.NoteHash(childComplexity), true

	case "OrgLink.orgId":
		if e.complexity.OrgLink.OrgID == nil {
			break


@@ 8754,6 8762,8 @@ func (ec *executionContext) fieldContext_Mutation_addLink(ctx context.Context, f
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 8875,6 8885,8 @@ func (ec *executionContext) fieldContext_Mutation_addNote(ctx context.Context, f
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 9194,6 9206,8 @@ func (ec *executionContext) fieldContext_Mutation_updateLink(ctx context.Context
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 12559,6 12573,47 @@ func (ec *executionContext) fieldContext_OrgLink_type(ctx context.Context, field
	return fc, nil
}

func (ec *executionContext) _OrgLink_noteHash(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrgLink_noteHash(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.NoteHash, nil
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		return graphql.Null
	}
	res := resTmp.(string)
	fc.Result = res
	return ec.marshalOString2string(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_OrgLink_noteHash(ctx 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) _OrgLink_tags(ctx context.Context, field graphql.CollectedField, obj *models.OrgLink) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_OrgLink_tags(ctx, field)
	if err != nil {


@@ 12928,6 12983,8 @@ func (ec *executionContext) fieldContext_OrgLinkCursor_result(ctx context.Contex
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 16550,6 16607,8 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
				return ec.fieldContext_OrgLink_archiveUrl(ctx, field)
			case "type":
				return ec.fieldContext_OrgLink_type(ctx, field)
			case "noteHash":
				return ec.fieldContext_OrgLink_noteHash(ctx, field)
			case "tags":
				return ec.fieldContext_OrgLink_tags(ctx, field)
			case "author":


@@ 24541,6 24600,10 @@ func (ec *executionContext) _OrgLink(ctx context.Context, sel ast.SelectionSet, 
			if out.Values[i] == graphql.Null {
				atomic.AddUint32(&invalids, 1)
			}
		case "noteHash":

			out.Values[i] = ec._OrgLink_noteHash(ctx, field, obj)

		case "tags":

			out.Values[i] = ec._OrgLink_tags(ctx, field, obj)

M api/graph/schema.graphqls => api/graph/schema.graphqls +1 -0
@@ 152,6 152,7 @@ type OrgLink {
    starred: Boolean! @access(scope: LINKS, kind: RO)
    archiveUrl: String!
    type: Int!
    noteHash: String
    tags: [Tag]!
    author: String!
    orgSlug: String! @access(scope: LINKS, kind: RO)

M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +11 -13
@@ 4986,21 4986,19 @@ func (r *queryResolver) GetOrgLink(ctx context.Context, id *int, hash *string) (
	}

	opts := &database.FilterOptions{
		Filter: sq.And{
			sq.Or{
				sq.Eq{"o.owner_id": user.ID}, // If the user is the org owner
				// if the user is a member of the org is able to see private and public links
				sq.And{
					sq.Eq{"ou.user_id": user.ID},
					sq.Eq{"ou.is_active": true},
					sq.Eq{"ol.visibility": []int{
						models.OrgLinkVisibilityPublic,
						models.OrgLinkVisibilityPrivate},
					},
		Filter: sq.Or{
			sq.Eq{"o.owner_id": user.ID}, // If the user is the org owner
			// if the user is a member of the org is able to see private and public links
			sq.And{
				sq.Eq{"ou.user_id": user.ID},
				sq.Eq{"ou.is_active": true},
				sq.Eq{"ol.visibility": []int{
					models.OrgLinkVisibilityPublic,
					models.OrgLinkVisibilityPrivate},
				},
				// Otherwise the user is only able to see public links
				sq.Eq{"ol.visibility": models.OrgLinkVisibilityPublic},
			},
			// Otherwise the user is only able to see public links
			sq.Eq{"ol.visibility": models.OrgLinkVisibilityPublic},
		},
		Limit: 1,
	}

M cmd/links/main.go => cmd/links/main.go +10 -1
@@ 5,6 5,7 @@ import (
	"net/http"
	"net/url"
	"os"
	"slices"
	"strings"
	"text/template"
	"time"


@@ 263,9 264,17 @@ func run() error {
			return links.FromPenniesToFloat(amt)
		},
		"isTagUsedInFilter": func(tag string, activeTags string) bool {
			return strings.Contains(activeTags, tag)
			tags := make([]string, 0)
			for _, t := range strings.Split(activeTags, ",") {
				tags = append(tags, strings.TrimSpace(t))
			}
			if len(tags) == 0 {
				return false
			}
			return slices.Contains(tags, strings.TrimSpace(tag))
		},
		"addQueryElement": links.AddQueryElement,
		"getAddLinkURL":   links.GetAddLinkURL,
	})
	err = srv.LoadTemplatesFS(links.TemplateFS, "templates/*.html", "templates/*.txt")
	if err != nil {

M cmd/test/helpers.go => cmd/test/helpers.go +3 -0
@@ 33,6 33,7 @@ import (
	gaccts "netlandish.com/x/gobwebs/accounts"
	"netlandish.com/x/gobwebs/auth"
	"netlandish.com/x/gobwebs/config"
	gcore "netlandish.com/x/gobwebs/core"
	"netlandish.com/x/gobwebs/crypto"
	"netlandish.com/x/gobwebs/database"
	"netlandish.com/x/gobwebs/email"


@@ 81,6 82,7 @@ func NewWebTestServer(t *testing.T) (*server.Server, *echo.Echo) {
	}
	srv, e, _ := newTestServer(t, "test-web-server", config)
	srv = srv.WithDefaultMiddleware()
	srv.AddStaticFunc(gcore.AddContext)
	srv.AddFuncs(template.FuncMap{
		"trans": core.TmplTranslate,
		"mediaURL": func(path string) string {


@@ 128,6 130,7 @@ func NewWebTestServer(t *testing.T) (*server.Server, *echo.Echo) {
			return strings.Contains(activeTags, tag)
		},
		"addQueryElement": links.AddQueryElement,
		"getAddLinkURL":   links.GetAddLinkURL,
	})
	err = srv.LoadTemplatesFS(links.TemplateFS, "templates/*.html", "templates/*.txt")
	if err != nil {

M core/routes.go => core/routes.go +120 -17
@@ 1216,6 1216,7 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
		"autoCompleteOrgID":  autoCompleteOrgID,
	}
	req := gctx.Request()

	if req.Method == http.MethodPost {
		form := &LinkForm{}
		if err := form.Validate(c); err != nil {


@@ 1228,10 1229,10 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
				return err
			}
		}

		type GraphQLResponse struct {
			Link models.OrgLink `json:"addLink"`
		}

		var result GraphQLResponse
		op := gqlclient.NewOperation(
			`mutation AddLink($title: String!, $url: String!, $description: String,


@@ 1297,10 1298,81 @@ func (s *Service) OrgLinksCreate(c echo.Context) error {
	if c.QueryParam("url") != "" {
		form.URL = c.QueryParam("url")
	}
	if c.QueryParam("tags") != "" {
		form.Tags = c.QueryParam("tags")
	}
	if c.QueryParam("next") == "same" {
		form.Redirect = req.Referer()
	}

	// BaseURL
	id, err := strconv.Atoi(c.QueryParam("burlid"))
	if err == nil {
		opts := &database.FilterOptions{
			Filter: sq.And{
				sq.Eq{"b.id": id},
				sq.Eq{"b.public_ready": true},
			},
		}
		burls, err := models.GetBaseURLs(c.Request().Context(), opts)
		if err != nil {
			return err
		}
		if len(burls) == 0 {
			messages.Error(
				c,
				lt.Translate("Error fetching referenced url: Not found"),
			)
		} else {
			b := burls[0]
			form.Title = b.Title
			form.Description = b.Data.Meta.Description
			form.URL = b.URL
			form.Tags = b.TagsToString()
		}
	} else {
		// OrgLink
		id, err = strconv.Atoi(c.QueryParam("linkid"))
		if err == nil {
			type GraphQLResponse struct {
				Link models.OrgLink `json:"getOrgLink"`
			}
			var result GraphQLResponse

			op := gqlclient.NewOperation(
				`query getOrgLink($id: Int) {
				getOrgLink(id: $id) {
					title
					description
					url
					tags {
						id
						name
						slug
					}
				}
			}`)
			op.Var("id", id)
			err = links.Execute(c.Request().Context(), op, &result)
			if err != nil {
				if graphError, ok := err.(*gqlclient.Error); ok {
					messages.Error(
						c,
						lt.Translate("Error fetching referenced link: %v", graphError.Message),
					)
				} else {
					return err
				}
			} else {
				// Populate form with referenced link data
				link := result.Link
				form.Title = link.Title
				form.Description = link.Description
				form.URL = link.URL
				form.Tags = link.TagsToString()
			}
		}
	}
	gmap["form"] = form
	return links.Render(c, http.StatusOK, "link_create.html", gmap)
}


@@ 1642,6 1714,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {
						starred
						archiveUrl
						type
						noteHash
						tags {
							id
							name


@@ 1665,8 1738,8 @@ func (s *Service) OrgLinksList(c echo.Context) error {
		}`)

	var (
		isOrgLink, advancedSearch, isRSSAuth   bool
		currURL, navFlag, rssURL, followAction string
		isOrgLink, isHome, advancedSearch, isRSSAuth bool
		currURL, navFlag, rssURL, followAction       string
	)
	org := &models.Organization{}
	if c.Path() != c.Echo().Reverse(s.RouteName("recent_link_list")) &&


@@ 1755,6 1828,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {

		} else {
			currURL = c.Echo().Reverse(s.RouteName("home_link_list"))
			isHome = true
		}
		rssURL, err = links.GetRSSURL(c, org, user)
		if err != nil {


@@ 1912,6 1986,7 @@ func (s *Service) OrgLinksList(c echo.Context) error {
		"links":             orgLinks,
		"org":               org,
		"isOrgLink":         isOrgLink,
		"isHome":            isHome,
		"canRead":           canRead,
		"tagFilter":         strings.Replace(tag, ",", ", ", -1),
		"excludeTagFilter":  strings.Replace(excludeTag, ",", ", ", -1),


@@ 2264,13 2339,7 @@ func (s *Service) OrgLinkUpdate(c echo.Context) error {
		OrgSlug:     orgLink.OrgSlug,
		Unread:      orgLink.Unread,
		Starred:     orgLink.Starred,
	}
	if len(orgLink.Tags) > 0 {
		tagNameList := make([]string, 0)
		for _, tag := range orgLink.Tags {
			tagNameList = append(tagNameList, tag.Name)
		}
		form.Tags = strings.Join(tagNameList, ", ")
		Tags:        orgLink.TagsToString(),
	}
	gmap["form"] = form
	return links.Render(c, http.StatusOK, "link_create.html", gmap)


@@ 3064,13 3133,7 @@ func (s *Service) NoteUpdate(c echo.Context) error {
		Visibility:  orgLink.Visibility,
		OrgSlug:     orgLink.OrgSlug,
		Starred:     orgLink.Starred,
	}
	if len(orgLink.Tags) > 0 {
		tagNameList := make([]string, 0)
		for _, tag := range orgLink.Tags {
			tagNameList = append(tagNameList, tag.Name)
		}
		form.Tags = strings.Join(tagNameList, ", ")
		Tags:        orgLink.TagsToString(),
	}
	gmap["form"] = form
	return links.Render(c, http.StatusOK, "note_create.html", gmap)


@@ 3243,6 3306,46 @@ func (s *Service) NoteCreate(c echo.Context) error {
		Visibility: models.OrgLinkVisibilityPublic,
		OrgSlug:    formSlug,
	}

	id := c.QueryParam("linkid")
	if id != "" {
		type GraphQLResponse struct {
			Link models.OrgLink `json:"getOrgLink"`
		}
		var result GraphQLResponse

		op := gqlclient.NewOperation(
			`query getOrgLink($hash: String) {
				getOrgLink(hash: $hash) {
					title
					description
					tags {
						id
						name
						slug
					}
				}
			}`)
		op.Var("hash", id)
		err = links.Execute(c.Request().Context(), op, &result)
		if err != nil {
			if graphError, ok := err.(*gqlclient.Error); ok {
				messages.Error(
					c,
					lt.Translate("Error fetching referenced note: %v", graphError.Message),
				)
			} else {
				return err
			}
		} else {
			// Populate form with referenced link data
			link := result.Link
			form.Title = link.Title
			form.Description = link.Description
			form.Tags = link.TagsToString()
		}
	}

	gmap["form"] = form
	return links.Render(c, http.StatusOK, "note_create.html", gmap)
}

M helpers.go => helpers.go +21 -0
@@ 947,3 947,24 @@ func DecodeRSSUserHash(c echo.Context, hash string) (int, error) {
	}
	return userID, nil
}

type addLinker interface {
	QueryParams() url.Values
}

func GetAddLinkURL(c echo.Context, gURL addLinker) string {
	gctx := c.(*server.Context)
	nextURL := &url.URL{
		Scheme: gctx.Server.Config.Scheme,
		Host:   GetLinksDomain(c),
		Path:   c.Echo().Reverse("core:link_create"),
	}
	if ol, ok := gURL.(*models.OrgLink); ok {
		if ol.Type == models.NoteType {
			nextURL.Path = c.Echo().Reverse("core:note_create")
		}
	}
	qs := gURL.QueryParams()
	nextURL.RawQuery = qs.Encode()
	return nextURL.String()
}

M models/base_url.go => models/base_url.go +14 -0
@@ 7,6 7,7 @@ import (
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"regexp"
	"time"



@@ 217,3 218,16 @@ func (b *BaseURL) UpdateCounter(ctx context.Context, add bool) error {
	})
	return err
}

// TagsToString will convert linked tags to a comma separated string (used in forms)
func (b *BaseURL) TagsToString() string {
	return TagsToString(b.Tags)
}

// QueryParams will return a URL that can auto add information to easily save this BaseURL
// to a users bookmarks
func (b *BaseURL) QueryParams() url.Values {
	qs := url.Values{}
	qs.Set("burlid", fmt.Sprint(b.ID))
	return qs
}

M models/org_link.go => models/org_link.go +18 -0
@@ 5,6 5,7 @@ import (
	"database/sql"
	"encoding/json"
	"fmt"
	"net/url"
	"regexp"
	"time"



@@ 198,6 199,23 @@ func (o *OrgLink) IsPrivate() bool {
	return o.Visibility == OrgLinkVisibilityPrivate
}

// TagsToString will convert linked tags to a comma separated string (used in forms)
func (o *OrgLink) TagsToString() string {
	return TagsToString(o.Tags)
}

// QueryParams will return a URL that can auto add information to easily save this link
// to a users bookmarks
func (o *OrgLink) QueryParams() url.Values {
	qs := url.Values{}
	if o.Type == NoteType {
		qs.Set("linkid", o.NoteHash)
	} else {
		qs.Set("linkid", fmt.Sprint(o.ID))
	}
	return qs
}

// Returns a list of links and total clicks
func GetOrgLinksAnalytics(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink, error) {
	if opts == nil {

M models/utils.go => models/utils.go +14 -0
@@ 8,6 8,7 @@ import (
	"encoding/hex"
	"fmt"
	mrand "math/rand"
	"strings"
	"time"

	sq "github.com/Masterminds/squirrel"


@@ 69,3 70,16 @@ func checkCode(ctx context.Context, tx *sql.Tx, code []byte, table, field string
	}
	return true, nil
}

// TagsToString will convert linked tags to a comma separated string (used in forms)
func TagsToString(t []Tag) string {
	var tags string
	if len(t) > 0 {
		tagNameList := make([]string, 0)
		for _, tag := range t {
			tagNameList = append(tagNameList, tag.Name)
		}
		tags = strings.Join(tagNameList, ", ")
	}
	return tags
}

M templates/link_list.html => templates/link_list.html +11 -10
@@ 173,16 173,17 @@
          {{end}}
        </div>
        {{end}}
        {{if not $.isPopular}}
            <p class="text-grey">
              <small>On</small>
              <time datetime="{{formatDate .CreatedOn}}">
                <small>{{formatDate .CreatedOn}}</small>
              </time>
              <small>{{$.pd.Data.by}}</small>
              <small><a href="{{reverse "core:org_link_list" .OrgSlug}}" class="underline">{{.OrgSlug}}</a></small>
            </p>
        {{end}}
        <p class="text-grey">
            {{if not $.isPopular}}
            <small>On</small>
            <time datetime="{{formatDate .CreatedOn}}">
              <small>{{formatDate .CreatedOn}}</small>
            </time>
            <small>{{$.pd.Data.by}}</small>
            <small><a href="{{reverse "core:org_link_list" .OrgSlug}}" class="underline">{{.OrgSlug}}</a></small>
            {{end}}
	    {{ if not $.isHome }}<small>(<a href="{{ getAddLinkURL $.context . }}" target="_blank">copy link</a>)</small>{{ end }}
        </p>
      </article>
    </li>
    {{else}}