~netlandish/links-dev

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

[PATCH links] Created Pinboard API to GraphQL bridge. Now you can use any existing Pinboard clients with LinkTaco.

Details
Message ID
<20250719130849.32228-1-peter@netlandish.com>
Sender timestamp
1752908923
DKIM signature
missing
Download raw message
Patch: +2496 -2
Implements: https://todo.code.netlandish.com/~netlandish/links/76
Changelog-added: Pinboard API to GraphQL bridge
---
 CLAUDE.md               |  116 ++++
 cmd/links/main.go       |    6 +-
 pinboard/input.go       |   47 ++
 pinboard/middleware.go  |   45 ++
 pinboard/responses.go   |  122 ++++
 pinboard/routes.go      |  737 ++++++++++++++++++++++++
 pinboard/routes_test.go | 1168 +++++++++++++++++++++++++++++++++++++++
 pinboard/utils.go       |  145 +++++
 pinboard/utils_test.go  |  112 ++++
 9 files changed, 2496 insertions(+), 2 deletions(-)
 create mode 100644 CLAUDE.md
 create mode 100644 pinboard/input.go
 create mode 100644 pinboard/middleware.go
 create mode 100644 pinboard/responses.go
 create mode 100644 pinboard/routes.go
 create mode 100644 pinboard/routes_test.go
 create mode 100644 pinboard/utils.go
 create mode 100644 pinboard/utils_test.go

diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..65c4624
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,116 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

This project is known as Link Taco (aka, LinkTaco, links, link taco, etc.) and
is is deployed live at https://linktaco.com

## Common Development Commands

### Building Services
```bash
# Build all main services
make all

# Build individual services
make links         # Main web service (port 8000)
make links-api     # GraphQL API service (port 8003)
make links-short   # URL shortening service (port 8001)
make links-list    # Link listing service (port 8002)
make links-domains # Domain management service (port 8004)
make links-admin   # Admin service

# Clean built binaries
make clean
```

### Testing
```bash
# Run all tests (sequential)
make test
# or
go test ./... -p 1

# Run tests for specific package
go test ./api/...

# Run a single test
go test -v -run TestDirective ./api/... -count=1

# Verify code quality
go vet ./...
```

### Code Generation
```bash
# Generate GraphQL schema and resolvers
make schema
# or
cd api && go generate ./graph

# Generate translations
make trans
# or
go generate ./internal/translations/translations.go

# Generate data loaders
cd api/loaders && go generate
```

## Architecture Overview

### Service-Oriented Architecture
The application consists of 6 independent services that communicate via GraphQL API:

1. **links** (port 8000) - Main web service for bookmarking and link management
2. **links-api** (port 8003) - Central GraphQL API powering all services
3. **links-short** (port 8001) - URL shortening service (like Bitly)
4. **links-list** (port 8002) - Link listing/bio pages (like Linktree)
5. **links-domains** (port 8004) - Domain verification service for custom domains
6. **links-admin** - Administrative interface

### Technology Stack
- **Backend**: Go 1.22.5+ with Echo v4 web framework
- **API**: GraphQL using gqlgen with complexity limits and file uploads
- **Database**: PostgreSQL with Squirrel query builder
- **Frontend**: Server-side rendered HTML templates (not SPA)
- **Authentication**: Custom auth with OAuth2 support, session management via SCS
- **Payments**: Stripe integration for billing
- **Storage**: Local filesystem or S3-compatible storage
- **Integrations**: Slack/Mattermost bots, email (SMTP/AWS SES)

### Database & Migrations
- Migrations are embedded in the binary and run automatically on startup
- Migration files: `migrations/*.sql`
- Test environment automatically rolls back and re-runs migrations
- Database connection configured in `[database]` section of config file

### GraphQL API Structure
- Schema files: `api/graph/*.graphqls`
- Configuration: `api/gqlgen.yml`
- Generated code: `api/graph/generated.go`
- Custom models bind to `links/models` package
- Directives for authentication and authorization
- Data loaders for N+1 query optimization

### Configuration
- Main config file: `config.ini` (copy from `config.example.ini`)
- Service ports and domains configured in `[links]` section
- Each service can have different listen addresses
- Rate limiting, queue sizes, and limits configurable
- AutoTLS support for SSL certificate management

### Development Workflow
1. Copy `config.example.ini` to `config.ini` and configure
2. Set up PostgreSQL database
3. Run `make all` to build services
4. Services will auto-migrate database on first run
5. Access main service at `http://localhost:8000`
6. GraphQL playground available at `http://localhost:8000/graphql`

### Testing Approach
- Tests use in-memory test server with fresh database
- Each test gets automatic migration rollback/setup
- Test helpers in `cmd/test/helpers.go`
- JSON sample data in module directories for testing
- Tests run sequentially (`-p 1`) to avoid database conflicts
diff --git a/cmd/links/main.go b/cmd/links/main.go
index 131f3e9..b9b9524 100644
--- a/cmd/links/main.go
+++ b/cmd/links/main.go
@@ -24,6 +24,7 @@ import (
	"links/list"
	"links/mattermost"
	"links/models"
	"links/pinboard"
	"links/short"
	"links/slack"

@@ -311,9 +312,8 @@ func run() error {
				return "tag-large"
			} else if count > 4 {
				return "tag-medium"
			} else {
				return "tag-normal"
			}
			return "tag-normal"
		},
		"showCounter": func(obj any) bool {
			return models.ShowLinkCounter(obj)
@@ -365,6 +365,8 @@ func run() error {
	analytics.NewService(analyticsService, links.Render)
	billingService := e.Group("/billing")
	billing.NewService(billingService, links.Render)
	pinboardService := e.Group("/pinboard")
	pinboard.NewService(pinboardService, links.Render)

	oauthConfig := oauth2.ServiceConfig{
		Helper:           &core.OAuth2Helper{},
diff --git a/pinboard/input.go b/pinboard/input.go
new file mode 100644
index 0000000..55a9374
--- /dev/null
+++ b/pinboard/input.go
@@ -0,0 +1,47 @@
package pinboard

// AddPostInput represents the input for /v1/posts/add
type AddPostInput struct {
	URL         string `form:"url" validate:"required,url"`
	Description string `form:"description" validate:"required"`
	Extended    string `form:"extended"`
	Tags        string `form:"tags"`
	Dt          string `form:"dt"`
	Replace     string `form:"replace"`
	Shared      string `form:"shared"`
	Toread      string `form:"toread"`
}

// DeletePostInput represents the input for /v1/posts/delete
type DeletePostInput struct {
	URL string `form:"url" validate:"required,url"`
}

// GetPostInput represents the input for /v1/posts/get
type GetPostInput struct {
	Tag  string `form:"tag"`
	Dt   string `form:"dt"`
	URL  string `form:"url"`
	Meta string `form:"meta"`
}

// RecentPostInput represents the input for /v1/posts/recent
type RecentPostInput struct {
	Tag   string `form:"tag"`
	Count int    `form:"count"`
}

// DatesPostInput represents the input for /v1/posts/dates
type DatesPostInput struct {
	Tag string `form:"tag"`
}

// AllPostInput represents the input for /v1/posts/all
type AllPostInput struct {
	Tag     string `form:"tag"`
	Start   int    `form:"start"`
	Results int    `form:"results"`
	Fromdt  string `form:"fromdt"`
	Todt    string `form:"todt"`
	Meta    string `form:"meta"`
}
\ No newline at end of file
diff --git a/pinboard/middleware.go b/pinboard/middleware.go
new file mode 100644
index 0000000..8a77911
--- /dev/null
+++ b/pinboard/middleware.go
@@ -0,0 +1,45 @@
package pinboard

import (
	"encoding/base64"
	"strings"

	"github.com/labstack/echo/v4"
)

// PinboardAuthMiddleware extracts Pinboard authentication and converts it to LinkTaco format
func PinboardAuthMiddleware() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			var token string

			// 1. Check for auth_token parameter
			authToken := c.QueryParam("auth_token")
			if authToken != "" {
				token = authToken
			} else {
				// 2. Check for HTTP Basic Auth
				auth := c.Request().Header.Get("Authorization")
				if auth != "" && strings.HasPrefix(auth, "Basic ") {
					// Decode Basic auth
					encoded := strings.TrimPrefix(auth, "Basic ")
					decoded, err := base64.StdEncoding.DecodeString(encoded)
					if err == nil {
						parts := strings.SplitN(string(decoded), ":", 2)
						if len(parts) == 2 {
							// Use password as token (Pinboard uses username:token format)
							token = parts[1]
						}
					}
				}
			}

			// If we found a token, convert it to Bearer auth header
			if token != "" {
				c.Request().Header.Set("Authorization", "Bearer "+token)
			}

			return next(c)
		}
	}
}
diff --git a/pinboard/responses.go b/pinboard/responses.go
new file mode 100644
index 0000000..e0e36cb
--- /dev/null
+++ b/pinboard/responses.go
@@ -0,0 +1,122 @@
package pinboard

import (
	"encoding/json"
	"encoding/xml"
	"net/http"
	"time"

	"github.com/labstack/echo/v4"
)

// PinboardPost represents a single post in Pinboard format
type PinboardPost struct {
	Href        string `xml:"href,attr" json:"href"`
	Description string `xml:"description,attr" json:"description"`
	Extended    string `xml:"extended,attr" json:"extended"`
	Hash        string `xml:"hash,attr" json:"hash"`
	Tag         string `xml:"tag,attr" json:"tags"`
	Time        string `xml:"time,attr" json:"time"`
	Shared      string `xml:"shared,attr" json:"shared"`
	Toread      string `xml:"toread,attr" json:"toread"`
}

// PinboardPosts represents the posts response
type PinboardPosts struct {
	XMLName xml.Name       `xml:"posts" json:"-"`
	Dt      string         `xml:"dt,attr" json:"date"`
	User    string         `xml:"user,attr" json:"user"`
	Posts   []PinboardPost `xml:"post" json:"posts"`
}

// PinboardResult represents a simple result response
type PinboardResult struct {
	XMLName xml.Name `xml:"result" json:"-"`
	Code    string   `xml:"code,attr" json:"result_code"`
}

// PinboardDates represents the dates response
type PinboardDates struct {
	XMLName xml.Name         `xml:"dates" json:"-"`
	User    string           `xml:"user,attr" json:"user"`
	Tag     string           `xml:"tag,attr" json:"tag"`
	Dates   []PinboardDate   `xml:"date" json:"dates"`
}

// PinboardDate represents a single date entry
type PinboardDate struct {
	Date  string `xml:"date,attr" json:"date"`
	Count int    `xml:"count,attr" json:"count"`
}

// PinboardNote represents a note in Pinboard format
type PinboardNote struct {
	ID         string `xml:"id,attr" json:"id"`
	Title      string `xml:"title,attr" json:"title"`
	Hash       string `xml:"hash,attr" json:"hash"`
	CreatedAt  string `xml:"created_at,attr" json:"created_at"`
	UpdatedAt  string `xml:"updated_at,attr" json:"updated_at"`
	Length     int    `xml:"length,attr" json:"length"`
}

// PinboardNotes represents the notes list response
type PinboardNotes struct {
	XMLName xml.Name       `xml:"notes" json:"-"`
	Count   int            `xml:"count,attr" json:"count"`
	Notes   []PinboardNote `xml:"note" json:"notes"`
}

// PinboardNoteContent represents a single note with content
type PinboardNoteContent struct {
	XMLName   xml.Name `xml:"note" json:"-"`
	ID        string   `xml:"id,attr" json:"id"`
	Title     string   `xml:"title,attr" json:"title"`
	Hash      string   `xml:"hash,attr" json:"hash"`
	CreatedAt string   `xml:"created_at,attr" json:"created_at"`
	UpdatedAt string   `xml:"updated_at,attr" json:"updated_at"`
	Length    int      `xml:"length,attr" json:"length"`
	Text      string   `xml:",chardata" json:"text"`
}

// Helper functions

// formatResponse returns the response in XML or JSON based on format parameter
func formatResponse(c echo.Context, data interface{}) error {
	// Check both query parameter and form value
	format := c.QueryParam("format")
	if format == "" {
		format = c.FormValue("format")
	}
	
	if format == "json" {
		c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
		return json.NewEncoder(c.Response()).Encode(data)
	}

	// Default to XML
	c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationXMLCharsetUTF8)
	c.Response().Write([]byte(xml.Header))
	return xml.NewEncoder(c.Response()).Encode(data)
}

// formatError returns an error response in Pinboard format
func formatError(c echo.Context, message string) error {
	result := &PinboardResult{
		Code: "something went wrong: " + message,
	}
	c.Response().Status = http.StatusOK // Pinboard returns 200 even for errors
	return formatResponse(c, result)
}

// formatSuccess returns a success response in Pinboard format
func formatSuccess(c echo.Context) error {
	result := &PinboardResult{
		Code: "done",
	}
	return formatResponse(c, result)
}

// formatTime converts time to Pinboard format (RFC3339)
func formatTime(t time.Time) string {
	return t.Format(time.RFC3339)
}
\ No newline at end of file
diff --git a/pinboard/routes.go b/pinboard/routes.go
new file mode 100644
index 0000000..231fbd9
--- /dev/null
+++ b/pinboard/routes.go
@@ -0,0 +1,737 @@
package pinboard

import (
	"links"
	"links/accounts"
	"links/core"
	"links/models"
	"net/http"
	"sort"
	"strconv"
	"time"

	"git.sr.ht/~emersion/gqlclient"
	sq "github.com/Masterminds/squirrel"
	"github.com/labstack/echo/v4"
	"netlandish.com/x/gobwebs/auth"
	"netlandish.com/x/gobwebs/database"
	"netlandish.com/x/gobwebs/server"
	"netlandish.com/x/gobwebs/validate"
)

type Service struct {
	server.BaseService
}

func (s *Service) RegisterRoutes() {
	// Add Pinboard auth extraction middleware
	s.Group.Use(PinboardAuthMiddleware())
	s.Group.Use(core.InternalAuthMiddleware(accounts.NewUserFetch()))
	s.Group.Use(auth.AuthRequired())

	// API v1 routes
	v1 := s.Group.Group("/v1")

	// Posts endpoints
	v1.POST("/posts/add", s.PostsAdd).Name = s.RouteName("posts_add")
	v1.POST("/posts/delete", s.PostsDelete).Name = s.RouteName("posts_delete")
	v1.GET("/posts/get", s.PostsGet).Name = s.RouteName("posts_get")
	v1.GET("/posts/recent", s.PostsRecent).Name = s.RouteName("posts_recent")
	v1.GET("/posts/dates", s.PostsDates).Name = s.RouteName("posts_dates")
	v1.GET("/posts/all", s.PostsAll).Name = s.RouteName("posts_all")

	// Notes endpoints
	v1.GET("/notes/list", s.NotesList).Name = s.RouteName("notes_list")
	v1.GET("/notes/:id", s.NotesGet).Name = s.RouteName("notes_get")
}

// NewService creates a new Pinboard API service
func NewService(eg *echo.Group, render validate.TemplateRenderFunc) *Service {
	baseService := server.NewService(eg, "pinboard", render)
	service := &Service{BaseService: baseService}
	service.RegisterRoutes()
	return service
}

// Helper to get user's default organization (org_type = USER)
func (s *Service) getUserOrg(c echo.Context) (*models.Organization, error) {
	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	orgs, err := models.GetOrganizations(c.Request().Context(), &database.FilterOptions{
		Filter: sq.And{
			sq.Eq{"o.owner_id": user.ID},
			sq.Eq{"o.org_type": models.OrgTypeUser},
		},
		Limit: 1,
	})
	if err != nil {
		return nil, err
	}
	if len(orgs) == 0 {
		return nil, echo.NewHTTPError(http.StatusNotFound, "No default organization found")
	}
	return orgs[0], nil
}

// PostsAdd handles /v1/posts/add
func (s *Service) PostsAdd(c echo.Context) error {
	var input AddPostInput
	if err := c.Bind(&input); err != nil {
		return formatError(c, "Invalid input parameters")
	}

	// Validate input
	if err := c.Validate(input); err != nil {
		return formatError(c, err.Error())
	}

	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	// Convert fields
	visibility := convertVisibility(input.Shared)
	unread := convertToRead(input.Toread)
	tags := convertTagsToComma(input.Tags)

	// Prepare GraphQL mutation
	type GraphQLResponse struct {
		AddLink models.OrgLink `json:"addLink"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`mutation AddLink($url: String!, $title: String!, $description: String, $tags: String, $visibility: LinkVisibility, $unread: Boolean!, $orgSlug: String!) {
			addLink(input: {
				url: $url,
				title: $title,
				description: $description,
				tags: $tags,
				visibility: $visibility,
				unread: $unread,
				orgSlug: $orgSlug
			}) {
				hash
			}
		}`)

	op.Var("url", input.URL)
	op.Var("title", input.Description)
	op.Var("description", input.Extended)
	op.Var("tags", tags)
	op.Var("visibility", visibility)
	op.Var("unread", unread)
	op.Var("orgSlug", org.Slug)

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		if graphError, ok := err.(*gqlclient.Error); ok {
			return formatError(c, graphError.Error())
		}
		return formatError(c, err.Error())
	}

	return formatSuccess(c)
}

// PostsDelete handles /v1/posts/delete
func (s *Service) PostsDelete(c echo.Context) error {
	var input DeletePostInput
	if err := c.Bind(&input); err != nil {
		return formatError(c, "Invalid input parameters")
	}

	// Validate input
	if err := c.Validate(input); err != nil {
		return formatError(c, err.Error())
	}

	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	// First, find the link by URL
	type GraphQLFindResponse struct {
		GetOrgLinks struct {
			Result []models.OrgLink `json:"result"`
		} `json:"getOrgLinks"`
	}

	var findResult GraphQLFindResponse
	findOp := gqlclient.NewOperation(
		`query GetOrgLinks($orgSlug: String!, $search: String!) {
			getOrgLinks(input: {orgSlug: $orgSlug, search: $search, limit: 1}) {
				result {
					hash
					url
				}
			}
		}`)

	findOp.Var("orgSlug", org.Slug)
	findOp.Var("search", input.URL)

	err = links.Execute(c.Request().Context(), findOp, &findResult)
	if err != nil {
		return formatError(c, "Failed to find link")
	}

	// Check if we found the link
	if len(findResult.GetOrgLinks.Result) == 0 {
		return formatError(c, "Link not found")
	}

	// Find exact URL match
	var linkHash string
	for _, link := range findResult.GetOrgLinks.Result {
		if link.URL == input.URL {
			linkHash = link.Hash
			break
		}
	}

	if linkHash == "" {
		return formatError(c, "Link not found")
	}

	// Now delete the link by hash
	type GraphQLDeleteResponse struct {
		DeleteLink struct {
			Success bool `json:"success"`
		} `json:"deleteLink"`
	}

	var deleteResult GraphQLDeleteResponse
	deleteOp := gqlclient.NewOperation(
		`mutation DeleteLink($hash: String!) {
			deleteLink(hash: $hash) {
				success
			}
		}`)

	deleteOp.Var("hash", linkHash)

	err = links.Execute(c.Request().Context(), deleteOp, &deleteResult)
	if err != nil {
		return formatError(c, err.Error())
	}

	if !deleteResult.DeleteLink.Success {
		return formatError(c, "Failed to delete link")
	}

	return formatSuccess(c)
}

// PostsGet handles /v1/posts/get
func (s *Service) PostsGet(c echo.Context) error {
	var input GetPostInput
	if err := c.Bind(&input); err != nil {
		return formatError(c, "Invalid input parameters")
	}

	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	// Prepare GraphQL query
	type GraphQLResponse struct {
		GetOrgLinks struct {
			Result []models.OrgLink `json:"result"`
		} `json:"getOrgLinks"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrgLinks($orgSlug: String!, $tag: String, $search: String) {
			getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag, search: $search}) {
				result {
					id
					title
					description
					url
					hash
					visibility
					unread
					starred
					tags {
						name
					}
					createdOn
					updatedOn
				}
			}
		}`)

	op.Var("orgSlug", org.Slug)

	// Handle optional parameters
	if input.Tag != "" {
		op.Var("tag", input.Tag)
	}
	if input.URL != "" {
		op.Var("search", input.URL)
	}

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	// Filter by exact URL if specified
	links := result.GetOrgLinks.Result
	if input.URL != "" {
		filtered := []models.OrgLink{}
		for _, link := range links {
			if link.URL == input.URL {
				filtered = append(filtered, link)
				break // Only return first exact match
			}
		}
		links = filtered
	}

	// Filter by date if specified
	if input.Dt != "" {
		// Parse date in YYYY-MM-DD format
		dt, err := time.Parse("2006-01-02", input.Dt)
		if err == nil {
			filtered := []models.OrgLink{}
			for _, link := range links {
				if link.CreatedOn.Format("2006-01-02") == dt.Format("2006-01-02") {
					filtered = append(filtered, link)
				}
			}
			links = filtered
		}
	}

	// Convert to Pinboard format
	posts := make([]PinboardPost, len(links))
	for i, link := range links {
		posts[i] = linkToPost(&link)
	}

	response := &PinboardPosts{
		Dt:    formatTime(time.Now()),
		User:  user.Name,
		Posts: posts,
	}

	return formatResponse(c, response)
}

// PostsRecent handles /v1/posts/recent
func (s *Service) PostsRecent(c echo.Context) error {
	var input RecentPostInput
	if err := c.Bind(&input); err != nil {
		return formatError(c, "Invalid input parameters")
	}

	// Workaround for test environment where c.Bind doesn't parse query params correctly
	if input.Count == 0 && c.QueryParam("count") != "" {
		if val, err := strconv.Atoi(c.QueryParam("count")); err == nil {
			input.Count = val
		}
	}

	// Default count is 15, max is 100
	if input.Count == 0 {
		input.Count = 15
	} else if input.Count > 100 {
		input.Count = 100
	}

	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	// Prepare GraphQL query
	type GraphQLResponse struct {
		GetOrgLinks struct {
			Result []models.OrgLink `json:"result"`
		} `json:"getOrgLinks"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrgLinks($orgSlug: String!, $tag: String, $limit: Int, $order: OrderType) {
			getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag, limit: $limit, order: $order}) {
				result {
					id
					title
					description
					url
					hash
					visibility
					unread
					starred
					tags {
						name
					}
					createdOn
					updatedOn
				}
			}
		}`)

	op.Var("orgSlug", org.Slug)
	op.Var("limit", input.Count)
	op.Var("order", "DESC") // Recent posts should be newest first

	// Handle optional tag parameter
	if input.Tag != "" {
		op.Var("tag", input.Tag)
	}

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	// Convert to Pinboard format
	posts := make([]PinboardPost, len(result.GetOrgLinks.Result))
	for i, link := range result.GetOrgLinks.Result {
		posts[i] = linkToPost(&link)
	}

	response := &PinboardPosts{
		Dt:    formatTime(time.Now()),
		User:  user.Name,
		Posts: posts,
	}

	return formatResponse(c, response)
}

// PostsDates handles /v1/posts/dates
func (s *Service) PostsDates(c echo.Context) error {
	var input DatesPostInput
	if err := c.Bind(&input); err != nil {
		return formatError(c, "Invalid input parameters")
	}

	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	// Prepare GraphQL query - get all links to aggregate by date
	type GraphQLResponse struct {
		GetOrgLinks struct {
			Result []models.OrgLink `json:"result"`
		} `json:"getOrgLinks"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrgLinks($orgSlug: String!, $tag: String) {
			getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag}) {
				result {
					createdOn
				}
			}
		}`)

	op.Var("orgSlug", org.Slug)

	// Handle optional tag parameter
	if input.Tag != "" {
		op.Var("tag", input.Tag)
	}

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	// Aggregate by date
	dateCounts := make(map[string]int)
	for _, link := range result.GetOrgLinks.Result {
		date := link.CreatedOn.Format("2006-01-02")
		dateCounts[date]++
	}

	// Convert to Pinboard format
	dates := make([]PinboardDate, 0, len(dateCounts))
	for date, count := range dateCounts {
		dates = append(dates, PinboardDate{
			Date:  date,
			Count: count,
		})
	}

	// Sort dates in reverse chronological order
	sort.Slice(dates, func(i, j int) bool {
		return dates[i].Date > dates[j].Date
	})

	response := &PinboardDates{
		User:  user.Name,
		Tag:   input.Tag,
		Dates: dates,
	}

	return formatResponse(c, response)
}

// PostsAll handles /v1/posts/all
func (s *Service) PostsAll(c echo.Context) error {
	var input AllPostInput
	if err := c.Bind(&input); err != nil {
		return formatError(c, "Invalid input parameters")
	}
	
	// Workaround for test environment where c.Bind doesn't parse query params correctly
	// Manually parse if values are still zero/empty
	if input.Start == 0 && c.QueryParam("start") != "" {
		if val, err := strconv.Atoi(c.QueryParam("start")); err == nil {
			input.Start = val
		}
	}
	if input.Results == 0 && c.QueryParam("results") != "" {
		if val, err := strconv.Atoi(c.QueryParam("results")); err == nil {
			input.Results = val
		}
	}
	if input.Fromdt == "" && c.QueryParam("fromdt") != "" {
		input.Fromdt = c.QueryParam("fromdt")
	}
	if input.Todt == "" && c.QueryParam("todt") != "" {
		input.Todt = c.QueryParam("todt")
	}
	if input.Tag == "" && c.QueryParam("tag") != "" {
		input.Tag = c.QueryParam("tag")
	}

	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	gctx := c.(*server.Context)
	user := gctx.User.(*models.User)

	// Set default results if not specified
	if input.Results == 0 {
		input.Results = 1000 // Default to a reasonable number
	}

	// Prepare GraphQL query
	type GraphQLResponse struct {
		GetOrgLinks struct {
			Result []models.OrgLink `json:"result"`
		} `json:"getOrgLinks"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrgLinks($orgSlug: String!, $tag: String, $limit: Int) {
			getOrgLinks(input: {orgSlug: $orgSlug, tag: $tag, limit: $limit}) {
				result {
					id
					title
					description
					url
					hash
					visibility
					unread
					starred
					tags {
						name
					}
					createdOn
					updatedOn
				}
			}
		}`)

	op.Var("orgSlug", org.Slug)

	// Handle optional tag parameter
	if input.Tag != "" {
		op.Var("tag", input.Tag)
	}

	// For now, we'll fetch a large number and filter/paginate client-side
	// This is not ideal for large datasets but matches Pinboard's behavior
	op.Var("limit", input.Results+input.Start)

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	links := result.GetOrgLinks.Result

	// Filter by date range if specified
	if input.Fromdt != "" || input.Todt != "" {
		filtered := []models.OrgLink{}

		var fromTime, toTime time.Time
		if input.Fromdt != "" {
			fromTime, _ = time.Parse(time.RFC3339, input.Fromdt)
		}
		if input.Todt != "" {
			toTime, _ = time.Parse(time.RFC3339, input.Todt)
		}

		for _, link := range links {
			include := true
			if !fromTime.IsZero() && link.CreatedOn.Before(fromTime) {
				include = false
			}
			if !toTime.IsZero() && link.CreatedOn.After(toTime) {
				include = false
			}
			if include {
				filtered = append(filtered, link)
			}
		}
		links = filtered
	}

	// Apply offset-based pagination
	if input.Start > 0 && input.Start < len(links) {
		links = links[input.Start:]
	}
	if input.Results > 0 && len(links) > input.Results {
		links = links[:input.Results]
	}

	// Convert to Pinboard format
	posts := make([]PinboardPost, len(links))
	for i, link := range links {
		posts[i] = linkToPost(&link)
	}

	response := &PinboardPosts{
		Dt:    formatTime(time.Now()),
		User:  user.Name,
		Posts: posts,
	}

	return formatResponse(c, response)
}

// NotesList handles /v1/notes/list
func (s *Service) NotesList(c echo.Context) error {
	// Get user's default organization
	org, err := s.getUserOrg(c)
	if err != nil {
		return formatError(c, "Failed to get organization")
	}

	// Prepare GraphQL query
	type GraphQLResponse struct {
		GetOrgLinks struct {
			Result []models.OrgLink `json:"result"`
		} `json:"getOrgLinks"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrgLinks($orgSlug: String!, $filter: String!) {
			getOrgLinks(input: {orgSlug: $orgSlug, filter: $filter}) {
				result {
					id
					title
					description
					hash
					createdOn
					updatedOn
					type
				}
			}
		}`)

	op.Var("orgSlug", org.Slug)
	op.Var("filter", "note") // Filter for notes only

	err = links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	// Convert to Pinboard format
	notes := make([]PinboardNote, 0)
	for _, link := range result.GetOrgLinks.Result {
		if link.Type == models.NoteType {
			notes = append(notes, noteToNote(&link))
		}
	}

	response := &PinboardNotes{
		Count: len(notes),
		Notes: notes,
	}

	return formatResponse(c, response)
}

// NotesGet handles /v1/notes/{id}
func (s *Service) NotesGet(c echo.Context) error {
	id := c.Param("id")
	if id == "" {
		return formatError(c, "Note ID is required")
	}

	// Prepare GraphQL query
	type GraphQLResponse struct {
		GetOrgLink *models.OrgLink `json:"getOrgLink"`
	}

	var result GraphQLResponse
	op := gqlclient.NewOperation(
		`query GetOrgLink($hash: String!) {
			getOrgLink(hash: $hash) {
				id
				title
				description
				hash
				createdOn
				updatedOn
				type
			}
		}`)

	op.Var("hash", id) // ID is the hash in our implementation

	err := links.Execute(c.Request().Context(), op, &result)
	if err != nil {
		return formatError(c, err.Error())
	}

	// Check if note was found and is actually a note
	if result.GetOrgLink == nil {
		return formatError(c, "Note not found")
	}
	if result.GetOrgLink.Type != models.NoteType {
		return formatError(c, "Not a note")
	}

	// Convert to Pinboard format
	response := noteToContent(result.GetOrgLink)

	return formatResponse(c, &response)
}
diff --git a/pinboard/routes_test.go b/pinboard/routes_test.go
new file mode 100644
index 0000000..a64d138
--- /dev/null
+++ b/pinboard/routes_test.go
@@ -0,0 +1,1168 @@
package pinboard_test

import (
	"encoding/base64"
	"encoding/json"
	"encoding/xml"
	"fmt"
	"io"
	"links"
	"links/cmd"
	"links/cmd/test"
	"links/models"
	"links/pinboard"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/jarcoal/httpmock"
	"github.com/labstack/echo/v4"
	"github.com/stretchr/testify/require"
	gaccts "netlandish.com/x/gobwebs/accounts"
	"netlandish.com/x/gobwebs/server"
)

func TestPinboardAuth(t *testing.T) {
	c := require.New(t)
	e := echo.New()

	// Create a test group with only Pinboard auth middleware
	authGroup := e.Group("/test")
	authGroup.Use(pinboard.PinboardAuthMiddleware())

	// Test handler that checks if auth header was set correctly
	authGroup.GET("/check", func(ctx echo.Context) error {
		auth := ctx.Request().Header.Get("Authorization")
		return ctx.String(http.StatusOK, auth)
	})

	t.Run("auth_token parameter", func(t *testing.T) {
		request := httptest.NewRequest(http.MethodGet, "/test/check?auth_token=testtoken123", nil)
		recorder := httptest.NewRecorder()
		e.ServeHTTP(recorder, request)
		c.Equal(http.StatusOK, recorder.Code)
		c.Equal("Bearer testtoken123", recorder.Body.String())
	})

	t.Run("basic auth", func(t *testing.T) {
		request := httptest.NewRequest(http.MethodGet, "/test/check", nil)
		auth := base64.StdEncoding.EncodeToString([]byte("user:testtoken456"))
		request.Header.Set("Authorization", "Basic "+auth)
		recorder := httptest.NewRecorder()
		e.ServeHTTP(recorder, request)
		c.Equal(http.StatusOK, recorder.Code)
		c.Equal("Bearer testtoken456", recorder.Body.String())
	})

	t.Run("no auth provided", func(t *testing.T) {
		request := httptest.NewRequest(http.MethodGet, "/test/check", nil)
		recorder := httptest.NewRecorder()
		e.ServeHTTP(recorder, request)
		c.Equal(http.StatusOK, recorder.Code)
		c.Equal("", recorder.Body.String())
	})
}

func TestPinboardHandlers(t *testing.T) {
	c := require.New(t)
	srv, e := test.NewWebTestServer(t)
	cmd.RunMigrations(t, srv.DB)

	// Use existing test user ID 1 which has 'personal-org' from test migration
	user := test.NewTestUser(1, false, false, true, true)

	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
	defer srv.Shutdown()
	go srv.Run()

	// Helper function to create authenticated context
	createAuthContext := func(request *http.Request, recorder *httptest.ResponseRecorder) *server.Context {
		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		return ctx
	}

	// Helper function to check XML response structure
	checkXMLResponse := func(body string, rootElement string) {
		c.True(strings.HasPrefix(body, "<?xml"))
		c.True(strings.Contains(body, "<"+rootElement))
		c.True(strings.Contains(body, "</"+rootElement+">"))
	}

	// Helper function to check JSON response structure
	checkJSONResponse := func(body string) {
		var js json.RawMessage
		err := json.Unmarshal([]byte(body), &js)
		c.NoError(err, "Response should be valid JSON")
	}

	t.Run("posts/add success", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"addLink": map[string]interface{}{
						"hash": "abc123def456",
					},
				},
			}))

		formData := url.Values{
			"url":         {"https://example.com"},
			"description": {"Example Title"},
			"extended":    {"Extended description"},
			"tags":        {"tag1 tag2 \"tag with spaces\""},
			"shared":      {"yes"},
			"toread":      {"no"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, `code="done"`))
	})

	t.Run("posts/add missing required fields", func(t *testing.T) {
		formData := url.Values{
			"url": {"https://example.com"},
			// Missing description
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, "something went wrong"))
	})

	t.Run("posts/add with JSON format", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"addLink": map[string]interface{}{
						"hash": "abc123def456",
					},
				},
			}))

		formData := url.Values{
			"url":         {"https://example.com"},
			"description": {"Example Title"},
			"format":      {"json"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkJSONResponse(body)
		c.True(strings.Contains(body, `"result_code":"done"`))
	})

	t.Run("posts/delete success", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		// Set up sequential responses
		callCount := 0
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			func(req *http.Request) (*http.Response, error) {
				callCount++
				if callCount == 1 {
					// First call: find the link
					return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
						"data": map[string]interface{}{
							"getOrgLinks": map[string]interface{}{
								"result": []map[string]interface{}{
									{
										"hash": "abc123",
										"url":  "https://example.com",
									},
								},
							},
						},
					})
				}
				// Second call: delete the link
				return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
					"data": map[string]interface{}{
						"deleteLink": map[string]interface{}{
							"success": true,
						},
					},
				})
			})

		formData := url.Values{
			"url": {"https://example.com"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/delete")

		err := test.MakeRequest(srv, pinboardService.PostsDelete, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, `code="done"`))
	})

	t.Run("posts/delete not found", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{},
					},
				},
			}))

		formData := url.Values{
			"url": {"https://notfound.com"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/delete", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/delete")

		err := test.MakeRequest(srv, pinboardService.PostsDelete, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, "Link not found"))
	})

	t.Run("posts/get all", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          1,
								"title":       "Example Title",
								"description": "This is an extended description",
								"url":         "https://example.com",
								"hash":        "abc123",
								"visibility":  "PUBLIC",
								"unread":      false,
								"starred":     false,
								"tags": []map[string]interface{}{
									{"name": "tag1"},
									{"name": "tag2"},
								},
								"createdOn": "2024-01-15T10:30:00Z",
								"updatedOn": "2024-01-15T10:30:00Z",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/get")

		err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "posts")
		c.True(strings.Contains(body, "<post"))
		c.True(strings.Contains(body, "href="))
		c.True(strings.Contains(body, "tag1 tag2"))
	})

	t.Run("posts/get with URL filter", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          1,
								"title":       "Example Title",
								"description": "Description",
								"url":         "https://specific.com",
								"hash":        "xyz789",
								"visibility":  "PRIVATE",
								"unread":      true,
								"starred":     false,
								"tags":        []map[string]interface{}{},
								"createdOn":   "2024-01-15T10:30:00Z",
								"updatedOn":   "2024-01-15T10:30:00Z",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get?url=https://specific.com", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/get")

		err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "posts")
		c.True(strings.Contains(body, "https://specific.com"))
		c.True(strings.Contains(body, `shared="no"`))
		c.True(strings.Contains(body, `toread="yes"`))
	})

	t.Run("posts/recent with count", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		var capturedLimit int
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			func(req *http.Request) (*http.Response, error) {
				body, _ := io.ReadAll(req.Body)
				// Parse the GraphQL request
				var gqlReq struct {
					Query     string                 `json:"query"`
					Variables map[string]interface{} `json:"variables"`
				}
				if err := json.Unmarshal(body, &gqlReq); err == nil {
					if limit, ok := gqlReq.Variables["limit"]; ok {
						if limitFloat, ok := limit.(float64); ok {
							capturedLimit = int(limitFloat)
						}
					}
				}
				return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
					"data": map[string]interface{}{
						"getOrgLinks": map[string]interface{}{
							"result": []map[string]interface{}{},
						},
					},
				})
			})

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/recent?count=50", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/recent")

		err := test.MakeRequest(srv, pinboardService.PostsRecent, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)
		c.Equal(50, capturedLimit)
	})

	t.Run("posts/recent count exceeds max", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		var capturedLimit int
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			func(req *http.Request) (*http.Response, error) {
				body, _ := io.ReadAll(req.Body)
				// Parse the GraphQL request
				var gqlReq struct {
					Query     string                 `json:"query"`
					Variables map[string]interface{} `json:"variables"`
				}
				if err := json.Unmarshal(body, &gqlReq); err == nil {
					if limit, ok := gqlReq.Variables["limit"]; ok {
						if limitFloat, ok := limit.(float64); ok {
							capturedLimit = int(limitFloat)
						}
					}
				}
				return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
					"data": map[string]interface{}{
						"getOrgLinks": map[string]interface{}{
							"result": []map[string]interface{}{},
						},
					},
				})
			})

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/recent?count=200", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/recent")

		err := test.MakeRequest(srv, pinboardService.PostsRecent, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)
		c.Equal(100, capturedLimit) // Should be capped at 100
	})

	t.Run("posts/dates", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{"createdOn": "2024-01-15T10:30:00Z"},
							{"createdOn": "2024-01-15T11:30:00Z"},
							{"createdOn": "2024-01-14T09:00:00Z"},
							{"createdOn": "2024-01-14T15:00:00Z"},
							{"createdOn": "2024-01-13T08:00:00Z"},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/dates", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/dates")

		err := test.MakeRequest(srv, pinboardService.PostsDates, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "dates")
		c.True(strings.Contains(body, "<date"))
		c.True(strings.Contains(body, `date="2024-01-15"`))
		c.True(strings.Contains(body, `count="2"`))
	})

	t.Run("posts/all with date filter", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          1,
								"title":       "Old Link",
								"description": "Description",
								"url":         "https://old.com",
								"hash":        "old123",
								"visibility":  "PUBLIC",
								"unread":      false,
								"starred":     false,
								"tags":        []map[string]interface{}{},
								"createdOn":   "2024-01-10T10:30:00Z",
								"updatedOn":   "2024-01-10T10:30:00Z",
							},
							{
								"id":          2,
								"title":       "New Link",
								"description": "Description",
								"url":         "https://new.com",
								"hash":        "new456",
								"visibility":  "PUBLIC",
								"unread":      false,
								"starred":     false,
								"tags":        []map[string]interface{}{},
								"createdOn":   "2024-01-20T10:30:00Z",
								"updatedOn":   "2024-01-20T10:30:00Z",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/all?fromdt=2024-01-15T00:00:00Z", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/posts/all")

		err := test.MakeRequest(srv, pinboardService.PostsAll, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "posts")
		c.True(strings.Contains(body, "https://new.com"))
		c.False(strings.Contains(body, "https://old.com")) // Should be filtered out
	})

	t.Run("notes/list", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          100,
								"title":       "My First Note",
								"description": "This is the content of the note",
								"hash":        "note123",
								"createdOn":   "2024-01-10T10:00:00Z",
								"updatedOn":   "2024-01-11T11:00:00Z",
								"type":        "NOTE",
							},
							{
								"id":          101,
								"title":       "Another Note",
								"description": "More note content here",
								"hash":        "note456",
								"createdOn":   "2024-01-12T12:00:00Z",
								"updatedOn":   "2024-01-12T12:00:00Z",
								"type":        "NOTE",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/notes/list", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/notes/list")

		err := test.MakeRequest(srv, pinboardService.NotesList, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "notes")
		c.True(strings.Contains(body, `count="2"`))
		c.True(strings.Contains(body, `id="note123"`))
		c.True(strings.Contains(body, `id="note456"`))
	})

	t.Run("notes/get success", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLink": map[string]interface{}{
						"id":          100,
						"title":       "My First Note",
						"description": "This is the full content of the note.\n\nIt has multiple paragraphs.",
						"hash":        "note123",
						"createdOn":   "2024-01-10T10:00:00Z",
						"updatedOn":   "2024-01-11T11:00:00Z",
						"type":        "NOTE",
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/notes/note123", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/notes/:id")
		ctx.SetParamNames("id")
		ctx.SetParamValues("note123")

		err := test.MakeRequest(srv, pinboardService.NotesGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "note")
		c.True(strings.Contains(body, `id="note123"`))
		c.True(strings.Contains(body, "This is the full content"))
	})

	t.Run("notes/get not a note", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLink": map[string]interface{}{
						"id":          100,
						"title":       "A Link",
						"description": "Not a note",
						"hash":        "link123",
						"createdOn":   "2024-01-10T10:00:00Z",
						"updatedOn":   "2024-01-11T11:00:00Z",
						"type":        "LINK",
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/notes/link123", nil)
		recorder := httptest.NewRecorder()

		ctx := createAuthContext(request, recorder)
		ctx.SetPath("/pinboard/v1/notes/:id")
		ctx.SetParamNames("id")
		ctx.SetParamValues("link123")

		err := test.MakeRequest(srv, pinboardService.NotesGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		checkXMLResponse(body, "result")
		c.True(strings.Contains(body, "Not a note"))
	})
}

func TestTagConversion(t *testing.T) {
	c := require.New(t)
	srv, e := test.NewWebTestServer(t)
	cmd.RunMigrations(t, srv.DB)

	// Use existing test user ID 1 which has 'personal-org' from test migration
	user := test.NewTestUser(1, false, false, true, true)

	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
	defer srv.Shutdown()
	go srv.Run()

	t.Run("tags with spaces in posts/add", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		var capturedTags string
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			func(req *http.Request) (*http.Response, error) {
				body, _ := io.ReadAll(req.Body)
				// Extract tags from GraphQL variables
				bodyStr := string(body)
				if strings.Contains(bodyStr, "tags") {
					// Find the tags value in the variables JSON
					start := strings.Index(bodyStr, `"tags":"`) + 8
					if start >= 8 {
						end := strings.Index(bodyStr[start:], `"`)
						if end > 0 {
							capturedTags = bodyStr[start : start+end]
						}
					}
				}
				return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
					"data": map[string]interface{}{
						"addLink": map[string]interface{}{
							"hash": "abc123",
						},
					},
				})
			})

		formData := url.Values{
			"url":         {"https://example.com"},
			"description": {"Example"},
			"tags":        {`simple "tag with spaces" another`},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)

		// Verify tags were converted correctly
		c.Equal("simple,tag with spaces,another", capturedTags)
	})

	t.Run("tags returned with spaces quoted", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          1,
								"title":       "Test",
								"description": "Test",
								"url":         "https://test.com",
								"hash":        "test123",
								"visibility":  "PUBLIC",
								"unread":      false,
								"starred":     false,
								"tags": []map[string]interface{}{
									{"name": "simple"},
									{"name": "tag with spaces"},
									{"name": "another"},
								},
								"createdOn": "2024-01-15T10:30:00Z",
								"updatedOn": "2024-01-15T10:30:00Z",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/get")

		err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)

		body := recorder.Body.String()
		// Check that tags with spaces are quoted (&#34; is the XML entity for ")
		c.True(strings.Contains(body, `tag="simple &#34;tag with spaces&#34; another"`))
	})
}

func TestErrorHandling(t *testing.T) {
	c := require.New(t)
	srv, e := test.NewWebTestServer(t)
	cmd.RunMigrations(t, srv.DB)

	// Create a user with no organizations
	dbCtx := test.NewDBContext(srv.DB, "America/Managua")
	userNoOrg := &models.User{
		BaseUser: gaccts.BaseUser{
			ID:    999,
			Email: "noorg@example.com",
		},
		Name: "No Org User",
	}
	userNoOrg.SetVerified(true)
	userNoOrg.SetAuthenticated(true)
	err := userNoOrg.Store(dbCtx)
	c.NoError(err)

	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
	defer srv.Shutdown()
	go srv.Run()

	t.Run("no organization found", func(t *testing.T) {
		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    userNoOrg,
		}
		ctx.SetPath("/pinboard/v1/posts/get")

		err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		c.True(strings.Contains(body, "something went wrong"))
		c.True(strings.Contains(body, "Failed to get organization"))
	})

	t.Run("graphql error", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"errors": []map[string]interface{}{
					{
						"message": "Internal server error",
						"extensions": map[string]interface{}{
							"code": "INTERNAL_SERVER_ERROR",
						},
					},
				},
			}))

		// Use existing test user ID 2 which has 'api-test-org' from test migration
		user := test.NewTestUser(2, false, false, true, true)

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/get")

		err = test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		body := recorder.Body.String()
		c.True(strings.Contains(body, "something went wrong"))
	})
}

func TestResponseFormats(t *testing.T) {
	c := require.New(t)
	srv, e := test.NewWebTestServer(t)
	cmd.RunMigrations(t, srv.DB)

	// Use existing test user ID 1 which has 'personal-org' from test migration
	user := test.NewTestUser(1, false, false, true, true)

	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
	defer srv.Shutdown()
	go srv.Run()

	t.Run("verify XML structure", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          1,
								"title":       "Test",
								"description": "Test Description",
								"url":         "https://test.com",
								"hash":        "test123",
								"visibility":  "PUBLIC",
								"unread":      false,
								"starred":     false,
								"tags":        []map[string]interface{}{},
								"createdOn":   "2024-01-15T10:30:00Z",
								"updatedOn":   "2024-01-15T10:30:00Z",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get", nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/get")

		err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)

		// Parse XML to verify structure
		var posts pinboard.PinboardPosts
		err = xml.Unmarshal(recorder.Body.Bytes(), &posts)
		c.NoError(err)
		c.NotEmpty(posts.User)
		c.NotEmpty(posts.Dt)
		c.Len(posts.Posts, 1)
		c.Equal("https://test.com", posts.Posts[0].Href)
	})

	t.Run("verify JSON structure", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"getOrgLinks": map[string]interface{}{
						"result": []map[string]interface{}{
							{
								"id":          1,
								"title":       "Test",
								"description": "Test Description",
								"url":         "https://test.com",
								"hash":        "test123",
								"visibility":  "PUBLIC",
								"unread":      false,
								"starred":     false,
								"tags":        []map[string]interface{}{},
								"createdOn":   "2024-01-15T10:30:00Z",
								"updatedOn":   "2024-01-15T10:30:00Z",
							},
						},
					},
				},
			}))

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/get?format=json", nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/get")

		err := test.MakeRequest(srv, pinboardService.PostsGet, ctx)
		c.NoError(err)

		// Parse JSON to verify structure
		var result map[string]interface{}
		err = json.Unmarshal(recorder.Body.Bytes(), &result)
		c.NoError(err)
		c.Contains(result, "date")
		c.Contains(result, "user")
		c.Contains(result, "posts")

		posts := result["posts"].([]interface{})
		c.Len(posts, 1)
		post := posts[0].(map[string]interface{})
		c.Equal("https://test.com", post["href"])
	})
}

func TestEdgeCases(t *testing.T) {
	c := require.New(t)
	srv, e := test.NewWebTestServer(t)
	cmd.RunMigrations(t, srv.DB)

	// Use existing test user ID 1 which has 'personal-org' from test migration
	user := test.NewTestUser(1, false, false, true, true)

	pinboardService := pinboard.NewService(e.Group("/pinboard"), links.Render)
	defer srv.Shutdown()
	go srv.Run()

	t.Run("very long description", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"addLink": map[string]interface{}{
						"hash": "abc123",
					},
				},
			}))

		longDesc := strings.Repeat("a", 1000)
		formData := url.Values{
			"url":         {"https://example.com"},
			"description": {longDesc},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)
	})

	t.Run("URL with special characters", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"addLink": map[string]interface{}{
						"hash": "abc123",
					},
				},
			}))

		formData := url.Values{
			"url":         {"https://example.com/path?query=value&foo=bar#anchor"},
			"description": {"URL with params"},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)
	})

	t.Run("empty tag string", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			httpmock.NewJsonResponderOrPanic(http.StatusOK, map[string]interface{}{
				"data": map[string]interface{}{
					"addLink": map[string]interface{}{
						"hash": "abc123",
					},
				},
			}))

		formData := url.Values{
			"url":         {"https://example.com"},
			"description": {"No tags"},
			"tags":        {""},
		}

		request := httptest.NewRequest(http.MethodPost, "/pinboard/v1/posts/add", strings.NewReader(formData.Encode()))
		request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationForm)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/add")

		err := test.MakeRequest(srv, pinboardService.PostsAdd, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)
	})

	t.Run("pagination at boundary", func(t *testing.T) {
		httpmock.Activate()
		defer httpmock.DeactivateAndReset()

		// Create exactly 10 links
		links := make([]map[string]interface{}, 10)
		for i := 0; i < 10; i++ {
			links[i] = map[string]interface{}{
				"id":          i,
				"title":       fmt.Sprintf("Link %d", i),
				"description": "Test",
				"url":         fmt.Sprintf("https://example%d.com", i),
				"hash":        fmt.Sprintf("hash%d", i),
				"visibility":  "PUBLIC",
				"unread":      false,
				"starred":     false,
				"tags":        []map[string]interface{}{},
				"createdOn":   "2024-01-15T10:30:00Z",
				"updatedOn":   "2024-01-15T10:30:00Z",
			}
		}

		// Mock should return 13 links when we request start=8 with limit=13 (8+5)
		httpmock.RegisterResponder("POST", "http://127.0.0.1:8080/query",
			func(req *http.Request) (*http.Response, error) {
				// Parse the GraphQL query to check the limit
				var gqlReq map[string]interface{}
				json.NewDecoder(req.Body).Decode(&gqlReq)

				// Return all links, handler will paginate
				return httpmock.NewJsonResponse(http.StatusOK, map[string]interface{}{
					"data": map[string]interface{}{
						"getOrgLinks": map[string]interface{}{
							"result": links,
						},
					},
				})
			})

		// Use form values instead of query params
		formData := url.Values{
			"start":   {"8"},
			"results": {"5"},
		}

		request := httptest.NewRequest(http.MethodGet, "/pinboard/v1/posts/all?"+formData.Encode(), nil)
		request.Header.Set("Authorization", "Internal testtoken")
		recorder := httptest.NewRecorder()

		ctx := &server.Context{
			Server:  srv,
			Context: e.NewContext(request, recorder),
			User:    user,
		}
		ctx.SetPath("/pinboard/v1/posts/all")

		err := test.MakeRequest(srv, pinboardService.PostsAll, ctx)
		c.NoError(err)
		c.Equal(http.StatusOK, recorder.Code)

		// Parse response to check we only got 2 posts
		var posts pinboard.PinboardPosts
		err = xml.Unmarshal(recorder.Body.Bytes(), &posts)
		c.NoError(err)

		c.Len(posts.Posts, 2)
		if len(posts.Posts) >= 2 {
			c.True(strings.Contains(posts.Posts[0].Href, "example8.com"))
			c.True(strings.Contains(posts.Posts[1].Href, "example9.com"))
		}
	})
}
diff --git a/pinboard/utils.go b/pinboard/utils.go
new file mode 100644
index 0000000..4a7e0e4
--- /dev/null
+++ b/pinboard/utils.go
@@ -0,0 +1,145 @@
package pinboard

import (
	"links/models"
	"strings"
)

// convertTagsToSpace converts comma-separated tags to space-separated
// Tags with spaces are quoted for Pinboard compatibility
func convertTagsToSpace(tags string) string {
	if tags == "" {
		return ""
	}
	tagList := strings.Split(tags, ",")
	result := make([]string, 0, len(tagList))
	
	for _, tag := range tagList {
		tag = strings.TrimSpace(tag)
		if tag == "" {
			continue
		}
		// If tag contains spaces, quote it
		if strings.Contains(tag, " ") {
			tag = `"` + tag + `"`
		}
		result = append(result, tag)
	}
	return strings.Join(result, " ")
}

// convertTagsToComma converts space-separated tags to comma-separated
// Handles quoted tags that may contain spaces
func convertTagsToComma(tags string) string {
	if tags == "" {
		return ""
	}
	
	var result []string
	var currentTag strings.Builder
	inQuotes := false
	
	for i, char := range tags {
		switch char {
		case '"':
			inQuotes = !inQuotes
		case ' ':
			if !inQuotes {
				if currentTag.Len() > 0 {
					result = append(result, currentTag.String())
					currentTag.Reset()
				}
			} else {
				currentTag.WriteRune(char)
			}
		default:
			currentTag.WriteRune(char)
		}
		
		// Handle end of string
		if i == len(tags)-1 && currentTag.Len() > 0 {
			result = append(result, currentTag.String())
		}
	}
	
	return strings.Join(result, ",")
}

// convertVisibility converts Pinboard shared value to LinkTaco visibility
func convertVisibility(shared string) string {
	if shared == "no" {
		return "PRIVATE"
	}
	return "PUBLIC"
}

// convertToShared converts LinkTaco visibility to Pinboard shared value
func convertToShared(visibility string) string {
	if visibility == models.OrgLinkVisibilityPrivate {
		return "no"
	}
	return "yes"
}

// convertToRead converts Pinboard toread value to LinkTaco unread
func convertToRead(toread string) bool {
	return toread == "yes"
}

// convertToToread converts LinkTaco unread to Pinboard toread value
func convertToToread(unread bool) string {
	if unread {
		return "yes"
	}
	return "no"
}

// linkToPost converts a LinkTaco OrgLink to a Pinboard post
func linkToPost(link *models.OrgLink) PinboardPost {
	tags := make([]string, len(link.Tags))
	for i, tag := range link.Tags {
		tags[i] = tag.Name
	}
	
	// Convert tags to space-separated format with proper quoting
	tagString := ""
	if len(tags) > 0 {
		tagString = convertTagsToSpace(strings.Join(tags, ","))
	}

	return PinboardPost{
		Href:        link.URL,
		Description: link.Title,
		Extended:    link.Description,
		Hash:        link.Hash,
		Tag:         tagString,
		Time:        formatTime(link.CreatedOn),
		Shared:      convertToShared(string(link.Visibility)),
		Toread:      convertToToread(link.Unread),
	}
}

// noteToNote converts a LinkTaco OrgLink (type NOTE) to a Pinboard note
func noteToNote(link *models.OrgLink) PinboardNote {
	return PinboardNote{
		ID:        link.Hash, // Using hash as ID
		Title:     link.Title,
		Hash:      link.Hash,
		CreatedAt: formatTime(link.CreatedOn),
		UpdatedAt: formatTime(link.UpdatedOn),
		Length:    len(link.Description),
	}
}

// noteToContent converts a LinkTaco OrgLink (type NOTE) to a Pinboard note with content
func noteToContent(link *models.OrgLink) PinboardNoteContent {
	return PinboardNoteContent{
		ID:        link.Hash,
		Title:     link.Title,
		Hash:      link.Hash,
		CreatedAt: formatTime(link.CreatedOn),
		UpdatedAt: formatTime(link.UpdatedOn),
		Length:    len(link.Description),
		Text:      link.Description,
	}
}
\ No newline at end of file
diff --git a/pinboard/utils_test.go b/pinboard/utils_test.go
new file mode 100644
index 0000000..0bbebe5
--- /dev/null
+++ b/pinboard/utils_test.go
@@ -0,0 +1,112 @@
package pinboard

import (
	"testing"
)

func TestConvertTagsToComma(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "simple tags",
			input:    "tag1 tag2 tag3",
			expected: "tag1,tag2,tag3",
		},
		{
			name:     "quoted tags with spaces",
			input:    `tag1 "tag with spaces" tag3`,
			expected: "tag1,tag with spaces,tag3",
		},
		{
			name:     "mixed quoted and unquoted",
			input:    `simple "complex tag" another "multi word tag"`,
			expected: "simple,complex tag,another,multi word tag",
		},
		{
			name:     "empty string",
			input:    "",
			expected: "",
		},
		{
			name:     "only spaces",
			input:    "   ",
			expected: "",
		},
		{
			name:     "tags with extra spaces",
			input:    "tag1   tag2     tag3",
			expected: "tag1,tag2,tag3",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := convertTagsToComma(tt.input)
			if result != tt.expected {
				t.Errorf("convertTagsToComma(%q) = %q, want %q", tt.input, result, tt.expected)
			}
		})
	}
}

func TestConvertTagsToSpace(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{
			name:     "simple tags",
			input:    "tag1,tag2,tag3",
			expected: "tag1 tag2 tag3",
		},
		{
			name:     "tags with spaces need quotes",
			input:    "tag1,tag with spaces,tag3",
			expected: `tag1 "tag with spaces" tag3`,
		},
		{
			name:     "multiple tags with spaces",
			input:    "simple,complex tag,another,multi word tag",
			expected: `simple "complex tag" another "multi word tag"`,
		},
		{
			name:     "empty string",
			input:    "",
			expected: "",
		},
		{
			name:     "single tag with space",
			input:    "tag with space",
			expected: `"tag with space"`,
		},
		{
			name:     "tags with extra spaces in CSV",
			input:    "tag1, tag2 , tag3",
			expected: "tag1 tag2 tag3",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := convertTagsToSpace(tt.input)
			if result != tt.expected {
				t.Errorf("convertTagsToSpace(%q) = %q, want %q", tt.input, result, tt.expected)
			}
		})
	}
}

func TestTagConversionRoundTrip(t *testing.T) {
	// Test that converting back and forth preserves tags
	original := "simple,tag with spaces,another"
	spaced := convertTagsToSpace(original)
	backToComma := convertTagsToComma(spaced)
	
	if backToComma != original {
		t.Errorf("Round trip conversion failed: %q -> %q -> %q", original, spaced, backToComma)
	}
}
\ No newline at end of file
-- 
2.49.0
Details
Message ID
<DBG23XU67J8J.1TNQZPS1B8FLZ@netlandish.com>
In-Reply-To
<20250719130849.32228-1-peter@netlandish.com> (view parent)
Sender timestamp
1752909203
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   943c898..43ae7d2  master -> master
Reply to thread Export thread (mbox)