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

From: Peter Sanchez <[email-masked]>
Date: Sat, 19 Jul 2025 07:08:43 -0600

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: "[email-masked]",
+		},
+		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

---

From: "Peter Sanchez" <[email-masked]>
Date: Sat, 19 Jul 2025 07:13:23 -0600

Applied.

To [email-masked]:~netlandish/links
   943c898..43ae7d2  master -> master