(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

Analytics, new lexicon, improvements!

+6349 -3777
+8 -4
.env.example
··· 1 - # Margin Server Configuration 1 + # Margin Configuration 2 2 3 3 # Server 4 4 PORT=8080 5 5 BASE_URL=https://example.com 6 6 7 - # Database (SQLite file path or PostgreSQL connection string) 8 - DATABASE_URL=margin.db 7 + # Database (PostgreSQL connection string) 8 + DATABASE_URL=postgres://user:pass@localhost:5432/margin 9 9 10 10 # Static Files (path to built frontend) 11 11 STATIC_DIR=../web/dist ··· 13 13 # OAuth private key for signing requests (auto-generated if missing) 14 14 OAUTH_KEY_PATH=/data/oauth_private_key.pem 15 15 16 - 17 16 # Optional: Override default ATProto network URLs (you probably don't need these) 18 17 # BSKY_PUBLIC_API=https://public.api.bsky.app 19 18 # PLC_DIRECTORY_URL=https://plc.directory 20 19 # BLOCK_RELAY_URL=wss://jetstream2.us-east.bsky.network/subscribe 20 + 21 + # Analytics (PostHog) — leave blank to disable 22 + # POSTHOG_PROJECT_TOKEN=phc_ 23 + # POSTHOG_HOST= 24 + # POSTHOG_UI_HOST=https://us.posthog.com
+1 -1
Dockerfile
··· 6 6 COPY web/ ./ 7 7 RUN bun run build 8 8 9 - FROM golang:1.24-alpine AS backend-builder 9 + FROM golang:1.25-alpine AS backend-builder 10 10 11 11 RUN apk add --no-cache gcc musl-dev 12 12
+9 -7
backend/cmd/server/main.go
··· 14 14 "github.com/go-chi/cors" 15 15 "github.com/joho/godotenv" 16 16 17 + "margin.at/internal/analytics" 17 18 "margin.at/internal/api" 18 19 "margin.at/internal/db" 19 20 "margin.at/internal/embeddings" ··· 41 42 if err := database.Migrate(); err != nil { 42 43 logger.Fatal("Failed to run migrations: %v", err) 43 44 } 45 + database.MigrateUnifiedNotes() 44 46 45 47 go func() { 46 48 ticker := time.NewTicker(1 * time.Hour) ··· 58 60 }() 59 61 60 62 embeddingClient := embeddings.NewClient() 61 - if err := database.MigrateRecommendations(); err != nil { 62 - logger.Fatal("Failed to run recommendation migrations: %v", err) 63 - } 64 63 recService := recommendations.NewService(database, embeddingClient) 65 64 logger.Info("Recommendation engine initialized (embeddings enabled: %v)", embeddingClient.IsEnabled()) 66 65 67 66 syncSvc := sync.NewService(database) 68 67 69 - oauthHandler, err := oauth.NewHandler(database, syncSvc) 68 + analyticsCl := analytics.New() 69 + defer analyticsCl.Close() 70 + 71 + oauthHandler, err := oauth.NewHandler(database, syncSvc, analyticsCl) 70 72 if err != nil { 71 73 logger.Fatal("Failed to initialize OAuth: %v", err) 72 74 } ··· 157 159 })) 158 160 159 161 tokenRefresher := api.NewTokenRefresher(database, oauthHandler.GetPrivateKey()) 160 - annotationSvc := api.NewAnnotationService(database, tokenRefresher) 162 + noteWriteSvc := api.NewNoteWriteService(database, tokenRefresher) 161 163 162 - handler := api.NewHandler(database, annotationSvc, tokenRefresher, syncSvc, recService) 164 + handler := api.NewHandler(database, noteWriteSvc, tokenRefresher, syncSvc, recService, analyticsCl) 163 165 handler.RegisterRoutes(r) 164 166 165 167 r.Route("/auth", func(r chi.Router) { ··· 171 173 r.Post("/logout", oauthHandler.HandleLogout) 172 174 r.Get("/session", oauthHandler.HandleSession) 173 175 }) 174 - r.Get("/client-metadata.json", oauthHandler.HandleClientMetadata) 176 + r.Get("/oauth-client-metadata.json", oauthHandler.HandleClientMetadata) 175 177 r.Get("/jwks.json", oauthHandler.HandleJWKS) 176 178 177 179 port := getEnv("PORT", "8081")
+12 -4
backend/go.mod
··· 1 1 module margin.at 2 2 3 - go 1.24.0 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/fxamacker/cbor/v2 v2.9.0 ··· 12 12 github.com/joho/godotenv v1.5.1 13 13 github.com/lib/pq v1.10.9 14 14 github.com/multiformats/go-multihash v0.2.3 15 + github.com/posthog/posthog-go v1.11.2 16 + github.com/pressly/goose/v3 v3.27.0 15 17 ) 16 18 17 19 require ( 18 20 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 21 + github.com/goccy/go-json v0.10.5 // indirect 22 + github.com/google/uuid v1.6.0 // indirect 23 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 19 24 github.com/klauspost/cpuid/v2 v2.0.9 // indirect 25 + github.com/mfridman/interpolate v0.0.2 // indirect 20 26 github.com/minio/sha256-simd v1.0.0 // indirect 21 27 github.com/mr-tron/base58 v1.2.0 // indirect 22 28 github.com/multiformats/go-base32 v0.0.3 // indirect ··· 24 30 github.com/multiformats/go-multibase v0.2.0 // indirect 25 31 github.com/multiformats/go-varint v0.1.0 // indirect 26 32 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 33 + github.com/sethvargo/go-retry v0.3.0 // indirect 27 34 github.com/spaolacci/murmur3 v1.1.0 // indirect 28 - github.com/stretchr/testify v1.10.0 // indirect 29 35 github.com/x448/float16 v0.8.4 // indirect 30 - golang.org/x/crypto v0.35.0 // indirect 31 - golang.org/x/sys v0.30.0 // indirect 36 + go.uber.org/multierr v1.11.0 // indirect 37 + golang.org/x/crypto v0.48.0 // indirect 38 + golang.org/x/sync v0.19.0 // indirect 39 + golang.org/x/sys v0.41.0 // indirect 32 40 lukechampine.com/blake3 v1.1.6 // indirect 33 41 )
+44 -8
backend/go.sum
··· 1 1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 2 2 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 4 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 3 5 github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 4 6 github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 5 7 github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= ··· 8 10 github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 9 11 github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= 10 12 github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= 11 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 + github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 14 + github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 15 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 16 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 17 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 18 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 13 19 github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 14 20 github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 21 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 22 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 15 23 github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= 16 24 github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= 17 25 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= ··· 21 29 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 22 30 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 23 31 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 32 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 33 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 34 + github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= 35 + github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= 24 36 github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= 25 37 github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= 26 38 github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= ··· 35 47 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 36 48 github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= 37 49 github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= 50 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 51 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 38 52 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 39 53 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 54 + github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q= 55 + github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= 56 + github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= 57 + github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= 58 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 59 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 60 + github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= 61 + github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 40 62 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 41 63 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 42 - github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 43 - github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 64 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 65 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 44 66 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 45 67 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 46 - golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 47 - golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 48 - golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 49 - golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 68 + go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 69 + go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 70 + golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= 71 + golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 72 + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= 73 + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= 74 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 75 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 76 + golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= 77 + golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 50 78 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 79 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 80 lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= 53 81 lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= 82 + modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= 83 + modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= 84 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 85 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 86 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 87 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 88 + modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= 89 + modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
+73
backend/internal/analytics/posthog.go
··· 1 + package analytics 2 + 3 + import ( 4 + "os" 5 + 6 + "github.com/posthog/posthog-go" 7 + "margin.at/internal/logger" 8 + ) 9 + 10 + type Client struct { 11 + ph posthog.Client 12 + enabled bool 13 + } 14 + 15 + func New() *Client { 16 + token := os.Getenv("POSTHOG_PROJECT_TOKEN") 17 + if token == "" { 18 + logger.Info("PostHog analytics disabled (POSTHOG_PROJECT_TOKEN not set)") 19 + return &Client{} 20 + } 21 + 22 + host := os.Getenv("POSTHOG_HOST") 23 + if host == "" { 24 + host = "https://us.i.posthog.com" 25 + } 26 + 27 + ph, err := posthog.NewWithConfig(token, posthog.Config{ 28 + Endpoint: host, 29 + }) 30 + if err != nil { 31 + logger.Error("Failed to initialise PostHog client: %v", err) 32 + return &Client{} 33 + } 34 + 35 + logger.Info("PostHog analytics enabled (host: %s)", host) 36 + return &Client{ph: ph, enabled: true} 37 + } 38 + 39 + func (c *Client) Capture(distinctID, event string, properties map[string]interface{}) { 40 + if !c.enabled || c.ph == nil { 41 + return 42 + } 43 + props := posthog.NewProperties() 44 + for k, v := range properties { 45 + props.Set(k, v) 46 + } 47 + 48 + _ = c.ph.Enqueue(posthog.Capture{ 49 + DistinctId: distinctID, 50 + Event: event, 51 + Properties: props, 52 + }) 53 + } 54 + 55 + func (c *Client) Identify(distinctID string, properties map[string]interface{}) { 56 + if !c.enabled || c.ph == nil { 57 + return 58 + } 59 + traits := posthog.NewProperties() 60 + for k, v := range properties { 61 + traits.Set(k, v) 62 + } 63 + _ = c.ph.Enqueue(posthog.Identify{ 64 + DistinctId: distinctID, 65 + Properties: traits, 66 + }) 67 + } 68 + 69 + func (c *Client) Close() { 70 + if c.enabled && c.ph != nil { 71 + c.ph.Close() 72 + } 73 + }
-1147
backend/internal/api/annotations.go
··· 1 - package api 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "regexp" 8 - "strings" 9 - "time" 10 - 11 - "margin.at/internal/db" 12 - "margin.at/internal/logger" 13 - "margin.at/internal/xrpc" 14 - ) 15 - 16 - type AnnotationService struct { 17 - db *db.DB 18 - refresher *TokenRefresher 19 - } 20 - 21 - func NewAnnotationService(database *db.DB, refresher *TokenRefresher) *AnnotationService { 22 - return &AnnotationService{db: database, refresher: refresher} 23 - } 24 - 25 - type CreateAnnotationRequest struct { 26 - URL string `json:"url"` 27 - Text string `json:"text"` 28 - Selector json.RawMessage `json:"selector,omitempty"` 29 - Title string `json:"title,omitempty"` 30 - Tags []string `json:"tags,omitempty"` 31 - Labels []string `json:"labels,omitempty"` 32 - } 33 - 34 - type CreateAnnotationResponse struct { 35 - URI string `json:"uri"` 36 - CID string `json:"cid"` 37 - } 38 - 39 - func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Request) { 40 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 41 - if err != nil { 42 - WriteUnauthorized(w, err.Error()) 43 - return 44 - } 45 - 46 - var req CreateAnnotationRequest 47 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 48 - WriteBadRequest(w, "Invalid request body") 49 - return 50 - } 51 - 52 - if req.URL == "" { 53 - WriteBadRequest(w, "URL is required") 54 - return 55 - } 56 - 57 - if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 { 58 - WriteBadRequest(w, "Must provide text, selector, or tags") 59 - return 60 - } 61 - 62 - if len(req.Text) > 3000 { 63 - WriteBadRequest(w, "Text too long (max 3000 chars)") 64 - return 65 - } 66 - 67 - for i, t := range req.Tags { 68 - req.Tags[i] = strings.ToLower(t) 69 - } 70 - 71 - urlHash := db.HashURL(req.URL) 72 - 73 - motivation := "commenting" 74 - if req.Selector != nil && req.Text == "" { 75 - motivation = "highlighting" 76 - } else if len(req.Tags) > 0 { 77 - motivation = "tagging" 78 - } 79 - 80 - var facets []xrpc.Facet 81 - var mentionedDIDs []string 82 - 83 - mentionRegex := regexp.MustCompile(`(^|\s|@)@([a-zA-Z0-9.-]+)(\b)`) 84 - matches := mentionRegex.FindAllStringSubmatchIndex(req.Text, -1) 85 - 86 - for _, m := range matches { 87 - handle := req.Text[m[4]:m[5]] 88 - 89 - if !strings.Contains(handle, ".") { 90 - continue 91 - } 92 - 93 - var did string 94 - err := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 95 - var resolveErr error 96 - did, resolveErr = client.ResolveHandle(r.Context(), handle) 97 - return resolveErr 98 - }) 99 - 100 - if err == nil && did != "" { 101 - start := m[2] 102 - end := m[5] 103 - 104 - facets = append(facets, xrpc.Facet{ 105 - Index: xrpc.FacetIndex{ 106 - ByteStart: start, 107 - ByteEnd: end, 108 - }, 109 - Features: []xrpc.FacetFeature{ 110 - { 111 - Type: "app.bsky.richtext.facet#mention", 112 - Did: did, 113 - }, 114 - }, 115 - }) 116 - mentionedDIDs = append(mentionedDIDs, did) 117 - } 118 - } 119 - 120 - urlRegex := regexp.MustCompile(`(https?://[^\s]+)`) 121 - urlMatches := urlRegex.FindAllStringIndex(req.Text, -1) 122 - 123 - for _, m := range urlMatches { 124 - facets = append(facets, xrpc.Facet{ 125 - Index: xrpc.FacetIndex{ 126 - ByteStart: m[0], 127 - ByteEnd: m[1], 128 - }, 129 - Features: []xrpc.FacetFeature{ 130 - { 131 - Type: "app.bsky.richtext.facet#link", 132 - Uri: req.Text[m[0]:m[1]], 133 - }, 134 - }, 135 - }) 136 - } 137 - 138 - record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) 139 - if len(req.Tags) > 0 { 140 - record.Tags = req.Tags 141 - } 142 - if len(facets) > 0 { 143 - record.Facets = facets 144 - } 145 - 146 - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 147 - var validLabels []string 148 - for _, l := range req.Labels { 149 - if validSelfLabels[l] { 150 - validLabels = append(validLabels, l) 151 - } 152 - } 153 - record.Labels = xrpc.NewSelfLabels(validLabels) 154 - 155 - var result *xrpc.CreateRecordOutput 156 - 157 - if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil { 158 - w.Header().Set("Content-Type", "application/json") 159 - json.NewEncoder(w).Encode(CreateAnnotationResponse{ 160 - URI: existing.URI, 161 - CID: *existing.CID, 162 - }) 163 - return 164 - } 165 - 166 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 167 - var createErr error 168 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) 169 - return createErr 170 - }) 171 - if err != nil { 172 - HandleAPIError(w, r, err, "Failed to create annotation: ", http.StatusInternalServerError) 173 - return 174 - } 175 - 176 - for _, mentionedDID := range mentionedDIDs { 177 - if mentionedDID != session.DID { 178 - s.db.CreateNotification(&db.Notification{ 179 - RecipientDID: mentionedDID, 180 - ActorDID: session.DID, 181 - Type: "mention", 182 - SubjectURI: result.URI, 183 - CreatedAt: time.Now(), 184 - }) 185 - } 186 - } 187 - 188 - bodyValue := req.Text 189 - var bodyValuePtr, targetTitlePtr, selectorJSONPtr *string 190 - if bodyValue != "" { 191 - bodyValuePtr = &bodyValue 192 - } 193 - if req.Title != "" { 194 - targetTitlePtr = &req.Title 195 - } 196 - if req.Selector != nil { 197 - selectorBytes, _ := json.Marshal(req.Selector) 198 - selectorStr := string(selectorBytes) 199 - selectorJSONPtr = &selectorStr 200 - } 201 - 202 - var tagsJSONPtr *string 203 - if len(req.Tags) > 0 { 204 - tagsBytes, _ := json.Marshal(req.Tags) 205 - tagsStr := string(tagsBytes) 206 - tagsJSONPtr = &tagsStr 207 - } 208 - 209 - cid := result.CID 210 - did := session.DID 211 - annotation := &db.Annotation{ 212 - URI: result.URI, 213 - CID: &cid, 214 - AuthorDID: did, 215 - Motivation: motivation, 216 - BodyValue: bodyValuePtr, 217 - TargetSource: req.URL, 218 - TargetHash: urlHash, 219 - TargetTitle: targetTitlePtr, 220 - SelectorJSON: selectorJSONPtr, 221 - TagsJSON: tagsJSONPtr, 222 - CreatedAt: time.Now(), 223 - IndexedAt: time.Now(), 224 - } 225 - 226 - if err := s.db.CreateAnnotation(annotation); err != nil { 227 - logger.Error("Warning: failed to index annotation in local DB: %v", err) 228 - } 229 - 230 - for _, label := range validLabels { 231 - if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { 232 - logger.Error("Warning: failed to create self-label %s: %v", label, err) 233 - } 234 - } 235 - 236 - WriteSuccess(w, CreateAnnotationResponse{ 237 - URI: result.URI, 238 - CID: result.CID, 239 - }) 240 - } 241 - 242 - func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) { 243 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 244 - if err != nil { 245 - WriteUnauthorized(w, err.Error()) 246 - return 247 - } 248 - 249 - rkey := r.URL.Query().Get("rkey") 250 - collectionType := r.URL.Query().Get("type") 251 - 252 - if rkey == "" { 253 - WriteBadRequest(w, "rkey required") 254 - return 255 - } 256 - 257 - did := session.DID 258 - 259 - collection := xrpc.CollectionAnnotation 260 - if collectionType == "reply" { 261 - collection = xrpc.CollectionReply 262 - } else { 263 - candidateCollections := []string{xrpc.CollectionAnnotation, "network.cosmik.card"} 264 - for _, col := range candidateCollections { 265 - uri := "at://" + did + "/" + col + "/" + rkey 266 - if _, dbErr := s.db.GetAnnotationByURI(uri); dbErr == nil { 267 - collection = col 268 - break 269 - } 270 - } 271 - } 272 - 273 - pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 274 - return client.DeleteRecord(r.Context(), did, collection, rkey) 275 - }) 276 - if pdsErr != nil { 277 - logger.Error("PDS delete failed (will still clean local DB): %v", pdsErr) 278 - } 279 - 280 - // Always clean up local DB regardless of PDS result 281 - if collectionType == "reply" { 282 - uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey 283 - s.db.DeleteReply(uri) 284 - } else { 285 - uri := "at://" + did + "/" + collection + "/" + rkey 286 - s.db.DeleteAnnotation(uri) 287 - } 288 - 289 - WriteSuccess(w, map[string]bool{"success": true}) 290 - } 291 - 292 - type UpdateAnnotationRequest struct { 293 - Text string `json:"text"` 294 - Tags []string `json:"tags"` 295 - Labels []string `json:"labels,omitempty"` 296 - } 297 - 298 - func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { 299 - uri := r.URL.Query().Get("uri") 300 - if uri == "" { 301 - WriteBadRequest(w, "uri query parameter required") 302 - return 303 - } 304 - 305 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 306 - if err != nil { 307 - WriteUnauthorized(w, err.Error()) 308 - return 309 - } 310 - 311 - annotation, err := s.db.GetAnnotationByURI(uri) 312 - if err != nil || annotation == nil { 313 - WriteNotFound(w, "Annotation not found") 314 - return 315 - } 316 - 317 - if annotation.AuthorDID != session.DID { 318 - WriteForbidden(w, "Not authorized to edit this annotation") 319 - return 320 - } 321 - 322 - var req UpdateAnnotationRequest 323 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 324 - WriteBadRequest(w, "Invalid request body") 325 - return 326 - } 327 - 328 - parts := parseATURI(uri) 329 - if len(parts) < 3 { 330 - WriteBadRequest(w, "Invalid URI format") 331 - return 332 - } 333 - rkey := parts[2] 334 - 335 - for i, t := range req.Tags { 336 - req.Tags[i] = strings.ToLower(t) 337 - } 338 - 339 - tagsJSON := "" 340 - if len(req.Tags) > 0 { 341 - tagsBytes, _ := json.Marshal(req.Tags) 342 - tagsJSON = string(tagsBytes) 343 - } 344 - 345 - if annotation.BodyValue != nil { 346 - previousContent := *annotation.BodyValue 347 - logger.Info("[DEBUG] Saving edit history for %s. Previous content: %s", uri, previousContent) 348 - if err := s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID); err != nil { 349 - logger.Error("Failed to save edit history for %s: %v", uri, err) 350 - } else { 351 - logger.Info("[DEBUG] Successfully saved edit history for %s", uri) 352 - } 353 - } else { 354 - logger.Info("[DEBUG] Annotation BodyValue is nil for %s", uri) 355 - } 356 - 357 - var result *xrpc.PutRecordOutput 358 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 359 - existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 360 - if getErr != nil { 361 - return fmt.Errorf("failed to fetch existing record: %w", getErr) 362 - } 363 - 364 - var record xrpc.AnnotationRecord 365 - if err := json.Unmarshal(existing.Value, &record); err != nil { 366 - return fmt.Errorf("failed to parse existing record: %w", err) 367 - } 368 - 369 - record.Body = &xrpc.AnnotationBody{ 370 - Value: req.Text, 371 - Format: "text/plain", 372 - } 373 - if len(req.Tags) > 0 { 374 - record.Tags = req.Tags 375 - } else { 376 - record.Tags = nil 377 - } 378 - 379 - updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 380 - var updateLabels []string 381 - for _, l := range req.Labels { 382 - if updateValidLabels[l] { 383 - updateLabels = append(updateLabels, l) 384 - } 385 - } 386 - record.Labels = xrpc.NewSelfLabels(updateLabels) 387 - 388 - if err := record.Validate(); err != nil { 389 - return fmt.Errorf("validation failed: %w", err) 390 - } 391 - 392 - var updateErr error 393 - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 394 - if updateErr != nil { 395 - logger.Error("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr) 396 - _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) 397 - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) 398 - } 399 - return updateErr 400 - }) 401 - 402 - if err != nil { 403 - logger.Error("[UpdateAnnotation] Failed: %v", err) 404 - HandleAPIError(w, r, err, "Failed to update record: ", http.StatusInternalServerError) 405 - return 406 - } 407 - 408 - s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID) 409 - 410 - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 411 - var validLabels []string 412 - for _, l := range req.Labels { 413 - if validSelfLabels[l] { 414 - validLabels = append(validLabels, l) 415 - } 416 - } 417 - if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { 418 - logger.Error("Warning: failed to sync self-labels: %v", err) 419 - } 420 - 421 - WriteSuccess(w, map[string]interface{}{ 422 - "success": true, 423 - "uri": result.URI, 424 - "cid": result.CID, 425 - }) 426 - } 427 - 428 - func parseATURI(uri string) []string { 429 - 430 - if len(uri) < 5 || uri[:5] != "at://" { 431 - return nil 432 - } 433 - return strings.Split(uri[5:], "/") 434 - } 435 - 436 - type CreateLikeRequest struct { 437 - SubjectURI string `json:"subjectUri"` 438 - SubjectCID string `json:"subjectCid"` 439 - } 440 - 441 - func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Request) { 442 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 443 - if err != nil { 444 - WriteUnauthorized(w, err.Error()) 445 - return 446 - } 447 - 448 - var req CreateLikeRequest 449 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 450 - WriteBadRequest(w, "Invalid request body") 451 - return 452 - } 453 - 454 - if req.SubjectURI == "" || req.SubjectCID == "" { 455 - WriteBadRequest(w, "subjectUri and subjectCid are required") 456 - return 457 - } 458 - 459 - existingLike, _ := s.db.GetLikeByUserAndSubject(session.DID, req.SubjectURI) 460 - if existingLike != nil { 461 - w.Header().Set("Content-Type", "application/json") 462 - json.NewEncoder(w).Encode(map[string]string{"uri": existingLike.URI, "existing": "true"}) 463 - return 464 - } 465 - 466 - record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID) 467 - 468 - if err := record.Validate(); err != nil { 469 - WriteBadRequest(w, "Validation error: "+err.Error()) 470 - return 471 - } 472 - 473 - var result *xrpc.CreateRecordOutput 474 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 475 - var createErr error 476 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionLike, record) 477 - return createErr 478 - }) 479 - if err != nil { 480 - HandleAPIError(w, r, err, "Failed to create like: ", http.StatusInternalServerError) 481 - return 482 - } 483 - 484 - did := session.DID 485 - like := &db.Like{ 486 - URI: result.URI, 487 - AuthorDID: did, 488 - SubjectURI: req.SubjectURI, 489 - CreatedAt: time.Now(), 490 - IndexedAt: time.Now(), 491 - } 492 - s.db.CreateLike(like) 493 - 494 - if authorDID, err := s.db.GetAuthorByURI(req.SubjectURI); err == nil && authorDID != did { 495 - s.db.CreateNotification(&db.Notification{ 496 - RecipientDID: authorDID, 497 - ActorDID: did, 498 - Type: "like", 499 - SubjectURI: req.SubjectURI, 500 - CreatedAt: time.Now(), 501 - }) 502 - } 503 - 504 - WriteSuccess(w, map[string]string{"uri": result.URI}) 505 - } 506 - 507 - func (s *AnnotationService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) { 508 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 509 - if err != nil { 510 - WriteUnauthorized(w, err.Error()) 511 - return 512 - } 513 - 514 - subjectURI := r.URL.Query().Get("uri") 515 - if subjectURI == "" { 516 - WriteBadRequest(w, "uri query parameter required") 517 - return 518 - } 519 - 520 - userLike, err := s.db.GetLikeByUserAndSubject(session.DID, subjectURI) 521 - if err != nil { 522 - WriteNotFound(w, "Like not found") 523 - return 524 - } 525 - 526 - parts := strings.Split(userLike.URI, "/") 527 - rkey := parts[len(parts)-1] 528 - 529 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 530 - return client.DeleteRecord(r.Context(), did, xrpc.CollectionLike, rkey) 531 - }) 532 - if err != nil { 533 - HandleAPIError(w, r, err, "Failed to delete like: ", http.StatusInternalServerError) 534 - return 535 - } 536 - 537 - s.db.DeleteLike(userLike.URI) 538 - 539 - WriteSuccess(w, map[string]bool{"success": true}) 540 - } 541 - 542 - type CreateReplyRequest struct { 543 - ParentURI string `json:"parentUri"` 544 - ParentCID string `json:"parentCid"` 545 - RootURI string `json:"rootUri"` 546 - RootCID string `json:"rootCid"` 547 - Text string `json:"text"` 548 - } 549 - 550 - func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) { 551 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 552 - if err != nil { 553 - WriteUnauthorized(w, err.Error()) 554 - return 555 - } 556 - 557 - var req CreateReplyRequest 558 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 559 - WriteBadRequest(w, "Invalid request body") 560 - return 561 - } 562 - 563 - if req.ParentURI == "" || req.ParentCID == "" { 564 - WriteBadRequest(w, "parentUri and parentCid are required") 565 - return 566 - } 567 - if req.RootURI == "" || req.RootCID == "" { 568 - WriteBadRequest(w, "rootUri and rootCid are required") 569 - return 570 - } 571 - if req.Text == "" { 572 - WriteBadRequest(w, "text is required") 573 - return 574 - } 575 - 576 - record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text) 577 - 578 - if err := record.Validate(); err != nil { 579 - WriteBadRequest(w, "Validation error: "+err.Error()) 580 - return 581 - } 582 - 583 - var result *xrpc.CreateRecordOutput 584 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 585 - var createErr error 586 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionReply, record) 587 - return createErr 588 - }) 589 - if err != nil { 590 - HandleAPIError(w, r, err, "Failed to create reply: ", http.StatusInternalServerError) 591 - return 592 - } 593 - 594 - reply := &db.Reply{ 595 - URI: result.URI, 596 - AuthorDID: session.DID, 597 - ParentURI: req.ParentURI, 598 - RootURI: req.RootURI, 599 - Text: req.Text, 600 - CreatedAt: time.Now(), 601 - IndexedAt: time.Now(), 602 - CID: &result.CID, 603 - } 604 - s.db.CreateReply(reply) 605 - 606 - if authorDID, err := s.db.GetAuthorByURI(req.ParentURI); err == nil && authorDID != session.DID { 607 - s.db.CreateNotification(&db.Notification{ 608 - RecipientDID: authorDID, 609 - ActorDID: session.DID, 610 - Type: "reply", 611 - SubjectURI: result.URI, 612 - CreatedAt: time.Now(), 613 - }) 614 - } 615 - 616 - if req.RootURI != req.ParentURI { 617 - if rootAuthorDID, err := s.db.GetAuthorByURI(req.RootURI); err == nil && rootAuthorDID != session.DID { 618 - parentAuthorDID, _ := s.db.GetAuthorByURI(req.ParentURI) 619 - if rootAuthorDID != parentAuthorDID { 620 - s.db.CreateNotification(&db.Notification{ 621 - RecipientDID: rootAuthorDID, 622 - ActorDID: session.DID, 623 - Type: "reply", 624 - SubjectURI: result.URI, 625 - CreatedAt: time.Now(), 626 - }) 627 - } 628 - } 629 - } 630 - 631 - WriteSuccess(w, map[string]string{"uri": result.URI}) 632 - } 633 - 634 - func (s *AnnotationService) DeleteReply(w http.ResponseWriter, r *http.Request) { 635 - uri := r.URL.Query().Get("uri") 636 - if uri == "" { 637 - WriteBadRequest(w, "uri query parameter required") 638 - return 639 - } 640 - 641 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 642 - if err != nil { 643 - WriteUnauthorized(w, err.Error()) 644 - return 645 - } 646 - 647 - reply, err := s.db.GetReplyByURI(uri) 648 - if err != nil || reply == nil { 649 - WriteNotFound(w, "reply not found") 650 - return 651 - } 652 - 653 - if reply.AuthorDID != session.DID { 654 - WriteForbidden(w, "not authorized to delete this reply") 655 - return 656 - } 657 - 658 - parts := strings.Split(uri, "/") 659 - if len(parts) >= 2 { 660 - rkey := parts[len(parts)-1] 661 - _ = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 662 - return client.DeleteRecord(r.Context(), did, "at.margin.reply", rkey) 663 - }) 664 - } 665 - 666 - s.db.DeleteReply(uri) 667 - 668 - WriteSuccess(w, map[string]bool{"success": true}) 669 - } 670 - 671 - type CreateHighlightRequest struct { 672 - URL string `json:"url"` 673 - Title string `json:"title,omitempty"` 674 - Selector json.RawMessage `json:"selector"` 675 - Color string `json:"color,omitempty"` 676 - Tags []string `json:"tags,omitempty"` 677 - Labels []string `json:"labels,omitempty"` 678 - } 679 - 680 - func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { 681 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 682 - if err != nil { 683 - WriteUnauthorized(w, err.Error()) 684 - return 685 - } 686 - 687 - var req CreateHighlightRequest 688 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 689 - WriteBadRequest(w, "Invalid request body") 690 - return 691 - } 692 - 693 - if req.URL == "" || req.Selector == nil { 694 - WriteBadRequest(w, "URL and selector are required") 695 - return 696 - } 697 - 698 - for i, t := range req.Tags { 699 - req.Tags[i] = strings.ToLower(t) 700 - } 701 - 702 - urlHash := db.HashURL(req.URL) 703 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) 704 - 705 - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 706 - var validLabels []string 707 - for _, l := range req.Labels { 708 - if validSelfLabels[l] { 709 - validLabels = append(validLabels, l) 710 - } 711 - } 712 - record.Labels = xrpc.NewSelfLabels(validLabels) 713 - 714 - if err := record.Validate(); err != nil { 715 - WriteBadRequest(w, "Validation error: "+err.Error()) 716 - return 717 - } 718 - 719 - var result *xrpc.CreateRecordOutput 720 - 721 - if existing, err := s.checkDuplicateHighlight(session.DID, req.URL, req.Selector); err == nil && existing != nil { 722 - w.Header().Set("Content-Type", "application/json") 723 - json.NewEncoder(w).Encode(map[string]string{"uri": existing.URI, "cid": *existing.CID}) 724 - return 725 - } 726 - 727 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 728 - var createErr error 729 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 730 - return createErr 731 - }) 732 - if err != nil { 733 - HandleAPIError(w, r, err, "Failed to create highlight: ", http.StatusInternalServerError) 734 - return 735 - } 736 - 737 - var selectorJSONPtr *string 738 - if len(record.Target.Selector) > 0 { 739 - selectorStr := string(record.Target.Selector) 740 - selectorJSONPtr = &selectorStr 741 - } 742 - 743 - var titlePtr *string 744 - if req.Title != "" { 745 - titlePtr = &req.Title 746 - } 747 - 748 - var colorPtr *string 749 - if req.Color != "" { 750 - colorPtr = &req.Color 751 - } 752 - 753 - var tagsJSONPtr *string 754 - if len(req.Tags) > 0 { 755 - tagsBytes, _ := json.Marshal(req.Tags) 756 - tagsStr := string(tagsBytes) 757 - tagsJSONPtr = &tagsStr 758 - } 759 - 760 - cid := result.CID 761 - highlight := &db.Highlight{ 762 - URI: result.URI, 763 - AuthorDID: session.DID, 764 - TargetSource: req.URL, 765 - TargetHash: urlHash, 766 - TargetTitle: titlePtr, 767 - SelectorJSON: selectorJSONPtr, 768 - Color: colorPtr, 769 - TagsJSON: tagsJSONPtr, 770 - CreatedAt: time.Now(), 771 - IndexedAt: time.Now(), 772 - CID: &cid, 773 - } 774 - if err := s.db.CreateHighlight(highlight); err != nil { 775 - WriteInternalError(w, "Failed to index highlight") 776 - return 777 - } 778 - 779 - for _, label := range validLabels { 780 - if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { 781 - logger.Error("Warning: failed to create self-label %s: %v", label, err) 782 - } 783 - } 784 - 785 - WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) 786 - } 787 - 788 - type CreateBookmarkRequest struct { 789 - URL string `json:"url"` 790 - Title string `json:"title,omitempty"` 791 - Description string `json:"description,omitempty"` 792 - Tags []string `json:"tags,omitempty"` 793 - } 794 - 795 - func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { 796 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 797 - if err != nil { 798 - WriteUnauthorized(w, err.Error()) 799 - return 800 - } 801 - 802 - var req CreateBookmarkRequest 803 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 804 - WriteBadRequest(w, "Invalid request body") 805 - return 806 - } 807 - 808 - if req.URL == "" { 809 - WriteBadRequest(w, "URL is required") 810 - return 811 - } 812 - 813 - for i, t := range req.Tags { 814 - req.Tags[i] = strings.ToLower(t) 815 - } 816 - 817 - urlHash := db.HashURL(req.URL) 818 - record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 819 - if len(req.Tags) > 0 { 820 - record.Tags = req.Tags 821 - } 822 - 823 - if err := record.Validate(); err != nil { 824 - WriteBadRequest(w, "Validation error: "+err.Error()) 825 - return 826 - } 827 - 828 - var result *xrpc.CreateRecordOutput 829 - 830 - if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { 831 - WriteConflict(w, "Bookmark already exists") 832 - return 833 - } 834 - 835 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 836 - var createErr error 837 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 838 - return createErr 839 - }) 840 - if err != nil { 841 - HandleAPIError(w, r, err, "Failed to create bookmark: ", http.StatusInternalServerError) 842 - return 843 - } 844 - 845 - var titlePtr *string 846 - if req.Title != "" { 847 - titlePtr = &req.Title 848 - } 849 - var descPtr *string 850 - if req.Description != "" { 851 - descPtr = &req.Description 852 - } 853 - 854 - var tagsJSONPtr *string 855 - if len(req.Tags) > 0 { 856 - tagsBytes, _ := json.Marshal(req.Tags) 857 - tagsStr := string(tagsBytes) 858 - tagsJSONPtr = &tagsStr 859 - } 860 - 861 - cid := result.CID 862 - bookmark := &db.Bookmark{ 863 - URI: result.URI, 864 - AuthorDID: session.DID, 865 - Source: req.URL, 866 - SourceHash: urlHash, 867 - Title: titlePtr, 868 - Description: descPtr, 869 - TagsJSON: tagsJSONPtr, 870 - CreatedAt: time.Now(), 871 - IndexedAt: time.Now(), 872 - CID: &cid, 873 - } 874 - s.db.CreateBookmark(bookmark) 875 - 876 - WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) 877 - } 878 - 879 - func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { 880 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 881 - if err != nil { 882 - WriteUnauthorized(w, err.Error()) 883 - return 884 - } 885 - 886 - rkey := r.URL.Query().Get("rkey") 887 - if rkey == "" { 888 - WriteBadRequest(w, "rkey required") 889 - return 890 - } 891 - 892 - pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 893 - return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 894 - }) 895 - if pdsErr != nil { 896 - logger.Error("PDS delete highlight failed (will still clean local DB): %v", pdsErr) 897 - } 898 - 899 - uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey 900 - s.db.DeleteHighlight(uri) 901 - 902 - WriteSuccess(w, map[string]bool{"success": true}) 903 - } 904 - 905 - func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { 906 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 907 - if err != nil { 908 - WriteUnauthorized(w, err.Error()) 909 - return 910 - } 911 - 912 - rkey := r.URL.Query().Get("rkey") 913 - if rkey == "" { 914 - WriteBadRequest(w, "rkey required") 915 - return 916 - } 917 - 918 - pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 919 - return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 920 - }) 921 - if pdsErr != nil { 922 - logger.Error("PDS delete bookmark failed (will still clean local DB): %v", pdsErr) 923 - } 924 - 925 - uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey 926 - s.db.DeleteBookmark(uri) 927 - 928 - WriteSuccess(w, map[string]bool{"success": true}) 929 - } 930 - 931 - type UpdateHighlightRequest struct { 932 - Color string `json:"color"` 933 - Tags []string `json:"tags,omitempty"` 934 - Labels []string `json:"labels,omitempty"` 935 - } 936 - 937 - func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { 938 - uri := r.URL.Query().Get("uri") 939 - if uri == "" { 940 - WriteBadRequest(w, "uri query parameter required") 941 - return 942 - } 943 - 944 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 945 - if err != nil { 946 - WriteUnauthorized(w, err.Error()) 947 - return 948 - } 949 - 950 - if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 951 - WriteForbidden(w, "Not authorized") 952 - return 953 - } 954 - 955 - var req UpdateHighlightRequest 956 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 957 - WriteBadRequest(w, "Invalid request body") 958 - return 959 - } 960 - 961 - parts := parseATURI(uri) 962 - if len(parts) < 3 { 963 - WriteBadRequest(w, "Invalid URI") 964 - return 965 - } 966 - rkey := parts[2] 967 - 968 - for i, t := range req.Tags { 969 - req.Tags[i] = strings.ToLower(t) 970 - } 971 - 972 - var result *xrpc.PutRecordOutput 973 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 974 - existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 975 - if getErr != nil { 976 - return fmt.Errorf("failed to fetch record: %w", getErr) 977 - } 978 - 979 - var record xrpc.HighlightRecord 980 - json.Unmarshal(existing.Value, &record) 981 - 982 - if req.Color != "" { 983 - record.Color = req.Color 984 - } 985 - if req.Tags != nil { 986 - record.Tags = req.Tags 987 - } 988 - 989 - updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 990 - var updateLabels []string 991 - for _, l := range req.Labels { 992 - if updateValidLabels[l] { 993 - updateLabels = append(updateLabels, l) 994 - } 995 - } 996 - record.Labels = xrpc.NewSelfLabels(updateLabels) 997 - 998 - if err := record.Validate(); err != nil { 999 - return fmt.Errorf("validation failed: %w", err) 1000 - } 1001 - 1002 - var updateErr error 1003 - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 1004 - if updateErr != nil { 1005 - logger.Error("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr) 1006 - _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) 1007 - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) 1008 - } 1009 - return updateErr 1010 - }) 1011 - 1012 - if err != nil { 1013 - HandleAPIError(w, r, err, "Failed to update: ", http.StatusInternalServerError) 1014 - return 1015 - } 1016 - 1017 - tagsJSON := "" 1018 - if req.Tags != nil { 1019 - b, _ := json.Marshal(req.Tags) 1020 - tagsJSON = string(b) 1021 - } 1022 - s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) 1023 - 1024 - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 1025 - var validLabels []string 1026 - for _, l := range req.Labels { 1027 - if validSelfLabels[l] { 1028 - validLabels = append(validLabels, l) 1029 - } 1030 - } 1031 - if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { 1032 - logger.Error("Warning: failed to sync self-labels: %v", err) 1033 - } 1034 - 1035 - WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1036 - } 1037 - 1038 - type UpdateBookmarkRequest struct { 1039 - Title string `json:"title"` 1040 - Description string `json:"description"` 1041 - Tags []string `json:"tags,omitempty"` 1042 - Labels []string `json:"labels,omitempty"` 1043 - } 1044 - 1045 - func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { 1046 - uri := r.URL.Query().Get("uri") 1047 - if uri == "" { 1048 - WriteBadRequest(w, "uri query parameter required") 1049 - return 1050 - } 1051 - 1052 - session, err := s.refresher.GetSessionWithAutoRefresh(r) 1053 - if err != nil { 1054 - WriteUnauthorized(w, err.Error()) 1055 - return 1056 - } 1057 - 1058 - if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 1059 - WriteForbidden(w, "Not authorized") 1060 - return 1061 - } 1062 - 1063 - var req UpdateBookmarkRequest 1064 - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 1065 - WriteBadRequest(w, "Invalid request body") 1066 - return 1067 - } 1068 - 1069 - parts := parseATURI(uri) 1070 - if len(parts) < 3 { 1071 - WriteBadRequest(w, "Invalid URI") 1072 - return 1073 - } 1074 - rkey := parts[2] 1075 - 1076 - var result *xrpc.PutRecordOutput 1077 - for i, t := range req.Tags { 1078 - req.Tags[i] = strings.ToLower(t) 1079 - } 1080 - 1081 - err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 1082 - existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 1083 - if getErr != nil { 1084 - return fmt.Errorf("failed to fetch record: %w", getErr) 1085 - } 1086 - 1087 - var record xrpc.BookmarkRecord 1088 - json.Unmarshal(existing.Value, &record) 1089 - 1090 - if req.Title != "" { 1091 - record.Title = req.Title 1092 - } 1093 - if req.Description != "" { 1094 - record.Description = req.Description 1095 - } 1096 - if req.Tags != nil { 1097 - record.Tags = req.Tags 1098 - } 1099 - 1100 - updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 1101 - var updateLabels []string 1102 - for _, l := range req.Labels { 1103 - if updateValidLabels[l] { 1104 - updateLabels = append(updateLabels, l) 1105 - } 1106 - } 1107 - record.Labels = xrpc.NewSelfLabels(updateLabels) 1108 - 1109 - if err := record.Validate(); err != nil { 1110 - return fmt.Errorf("validation failed: %w", err) 1111 - } 1112 - 1113 - var updateErr error 1114 - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 1115 - if updateErr != nil { 1116 - logger.Error("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr) 1117 - _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) 1118 - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) 1119 - } 1120 - return updateErr 1121 - }) 1122 - 1123 - if err != nil { 1124 - HandleAPIError(w, r, err, "Failed to update: ", http.StatusInternalServerError) 1125 - return 1126 - } 1127 - 1128 - tagsJSON := "" 1129 - if req.Tags != nil { 1130 - b, _ := json.Marshal(req.Tags) 1131 - tagsJSON = string(b) 1132 - } 1133 - s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) 1134 - 1135 - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} 1136 - var validLabels []string 1137 - for _, l := range req.Labels { 1138 - if validSelfLabels[l] { 1139 - validLabels = append(validLabels, l) 1140 - } 1141 - } 1142 - if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { 1143 - logger.Error("Warning: failed to sync self-labels: %v", err) 1144 - } 1145 - 1146 - WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1147 - }
-60
backend/internal/api/annotations_helpers.go
··· 1 - package api 2 - 3 - import ( 4 - "encoding/json" 5 - "time" 6 - 7 - "margin.at/internal/db" 8 - ) 9 - 10 - func (s *AnnotationService) checkDuplicateAnnotation(did, url, text string) (*db.Annotation, error) { 11 - recentAnnos, err := s.db.GetAnnotationsByAuthor(did, 5, 0) 12 - if err != nil { 13 - return nil, err 14 - } 15 - for _, a := range recentAnnos { 16 - if a.TargetSource == url && 17 - ((a.BodyValue == nil && text == "") || (a.BodyValue != nil && *a.BodyValue == text)) && 18 - time.Since(a.CreatedAt) < 10*time.Second { 19 - return &a, nil 20 - } 21 - } 22 - return nil, nil 23 - } 24 - 25 - func (s *AnnotationService) checkDuplicateHighlight(did, url string, selector json.RawMessage) (*db.Highlight, error) { 26 - recentHighs, err := s.db.GetHighlightsByAuthor(did, 5, 0) 27 - if err != nil { 28 - return nil, err 29 - } 30 - for _, h := range recentHighs { 31 - matchSelector := false 32 - if h.SelectorJSON == nil && selector == nil { 33 - matchSelector = true 34 - } else if h.SelectorJSON != nil && selector != nil { 35 - selectorBytes, _ := json.Marshal(selector) 36 - if *h.SelectorJSON == string(selectorBytes) { 37 - matchSelector = true 38 - } 39 - } 40 - 41 - if h.TargetSource == url && matchSelector && time.Since(h.CreatedAt) < 10*time.Second { 42 - return &h, nil 43 - } 44 - } 45 - return nil, nil 46 - } 47 - 48 - func (s *AnnotationService) checkDuplicateBookmark(did, url string) (*db.Bookmark, error) { 49 - urlHash := db.HashURL(url) 50 - bookmarks, err := s.db.GetBookmarksByTargetHash(urlHash, 50, 0) 51 - if err != nil { 52 - return nil, err 53 - } 54 - for _, b := range bookmarks { 55 - if b.AuthorDID == did && b.Source == url { 56 - return &b, nil 57 - } 58 - } 59 - return nil, nil 60 - }
+168 -36
backend/internal/api/apikey.go
··· 1 1 package api 2 2 3 3 import ( 4 + "context" 4 5 "crypto/rand" 5 6 "crypto/sha256" 6 7 "crypto/x509" ··· 154 155 } 155 156 156 157 type QuickBookmarkRequest struct { 157 - URL string `json:"url"` 158 - Title string `json:"title,omitempty"` 159 - Description string `json:"description,omitempty"` 158 + URL string `json:"url"` 159 + Title string `json:"title,omitempty"` 160 + Description string `json:"description,omitempty"` 161 + Tags []string `json:"tags,omitempty"` 160 162 } 161 163 162 164 func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { ··· 179 181 180 182 session, err := h.getSessionByDID(apiKey.OwnerDID) 181 183 if err != nil { 184 + logger.Error("[QuickBookmark] session lookup failed for DID %s: %v", apiKey.OwnerDID, err) 182 185 WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") 183 186 return 184 187 } 185 188 189 + for i, t := range req.Tags { 190 + req.Tags[i] = strings.ToLower(t) 191 + } 192 + 186 193 urlHash := db.HashURL(req.URL) 187 - record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) 194 + record := xrpc.NewNoteRecord(req.URL, urlHash, "", nil, req.Title, "", req.Description, "bookmarking") 195 + if len(req.Tags) > 0 { 196 + record.Tags = req.Tags 197 + } 188 198 189 199 if err := record.Validate(); err != nil { 200 + logger.Error("[QuickBookmark] record validation failed for DID %s url=%s: %v", apiKey.OwnerDID, req.URL, err) 190 201 WriteBadRequest(w, "Validation error: "+err.Error()) 191 202 return 192 203 } 193 204 205 + logger.Info("[QuickBookmark] creating record for DID %s url=%s", apiKey.OwnerDID, req.URL) 206 + 194 207 var result *xrpc.CreateRecordOutput 195 208 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 196 209 var createErr error 197 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) 210 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 198 211 return createErr 199 212 }) 200 213 if err != nil { 214 + logger.Error("[QuickBookmark] PDS CreateRecord failed for DID %s url=%s: %v", apiKey.OwnerDID, req.URL, err) 201 215 WriteInternalError(w, "Failed to create bookmark") 202 216 return 203 217 } 218 + 219 + logger.Info("[QuickBookmark] created record URI=%s for DID %s", result.URI, apiKey.OwnerDID) 204 220 205 221 h.db.UpdateAPIKeyLastUsed(apiKey.ID) 206 222 207 - var titlePtr, descPtr *string 223 + capturedTags := append([]string(nil), req.Tags...) 224 + go func(did, url string) { 225 + prefs, dbErr := h.db.GetPreferences(did) 226 + communityEnabled := dbErr == nil && prefs != nil && (prefs.EnableCommunityBookmarks == nil || *prefs.EnableCommunityBookmarks) 227 + if !communityEnabled { 228 + return 229 + } 230 + sess, err := h.getSessionByDID(did) 231 + if err != nil { 232 + return 233 + } 234 + client := h.refresher.CreateClientFromSession(sess) 235 + communityRecord := map[string]interface{}{ 236 + "$type": xrpc.CollectionCommunityBookmark, 237 + "subject": url, 238 + "createdAt": time.Now().UTC().Format(time.RFC3339), 239 + } 240 + if len(capturedTags) > 0 { 241 + communityRecord["tags"] = capturedTags 242 + } 243 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 244 + defer cancel() 245 + _, _ = client.CreateRecord(ctx, did, xrpc.CollectionCommunityBookmark, communityRecord) 246 + }(apiKey.OwnerDID, req.URL) 247 + 248 + var titlePtr, bodyValuePtr, tagsJSONPtr *string 208 249 if req.Title != "" { 209 250 titlePtr = &req.Title 210 251 } 211 252 if req.Description != "" { 212 - descPtr = &req.Description 253 + bodyValuePtr = &req.Description 254 + } 255 + if len(req.Tags) > 0 { 256 + b, _ := json.Marshal(req.Tags) 257 + s := string(b) 258 + tagsJSONPtr = &s 213 259 } 214 260 215 261 cid := result.CID 216 - bookmark := &db.Bookmark{ 217 - URI: result.URI, 218 - AuthorDID: apiKey.OwnerDID, 219 - Source: req.URL, 220 - SourceHash: urlHash, 221 - Title: titlePtr, 222 - Description: descPtr, 223 - CreatedAt: time.Now(), 224 - IndexedAt: time.Now(), 225 - CID: &cid, 262 + note := &db.Note{ 263 + URI: result.URI, 264 + AuthorDID: apiKey.OwnerDID, 265 + Motivation: "bookmarking", 266 + TargetSource: req.URL, 267 + TargetHash: urlHash, 268 + TargetTitle: titlePtr, 269 + BodyValue: bodyValuePtr, 270 + TagsJSON: tagsJSONPtr, 271 + CreatedAt: time.Now(), 272 + IndexedAt: time.Now(), 273 + CID: &cid, 226 274 } 227 - h.db.CreateBookmark(bookmark) 275 + h.db.CreateNote(note) 228 276 229 277 WriteSuccess(w, map[string]string{ 230 278 "uri": result.URI, ··· 236 284 type QuickSaveRequest struct { 237 285 URL string `json:"url"` 238 286 Text string `json:"text,omitempty"` 287 + Title string `json:"title,omitempty"` 239 288 Selector json.RawMessage `json:"selector,omitempty"` 240 289 Color string `json:"color,omitempty"` 290 + Tags []string `json:"tags,omitempty"` 241 291 } 242 292 243 293 func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { ··· 274 324 var result *xrpc.CreateRecordOutput 275 325 var createErr error 276 326 327 + for i, t := range req.Tags { 328 + req.Tags[i] = strings.ToLower(t) 329 + } 330 + 331 + var tagsJSONPtr *string 332 + if len(req.Tags) > 0 { 333 + b, _ := json.Marshal(req.Tags) 334 + s := string(b) 335 + tagsJSONPtr = &s 336 + } 337 + 277 338 if isHighlight { 278 339 color := req.Color 279 340 if color == "" { 280 341 color = "yellow" 281 342 } 282 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 343 + record := xrpc.NewNoteRecord(req.URL, urlHash, "", req.Selector, req.Title, color, "", "highlighting") 344 + if len(req.Tags) > 0 { 345 + record.Tags = req.Tags 346 + } 283 347 284 348 if err := record.Validate(); err != nil { 285 349 WriteBadRequest(w, "Validation error: "+err.Error()) ··· 287 351 } 288 352 289 353 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 290 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 354 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 291 355 return createErr 292 356 }) 293 357 if err == nil { ··· 296 360 selectorStr := string(selectorJSON) 297 361 colorPtr := &color 298 362 299 - highlight := &db.Highlight{ 363 + var titlePtr *string 364 + if req.Title != "" { 365 + titlePtr = &req.Title 366 + } 367 + 368 + note := &db.Note{ 300 369 URI: result.URI, 301 370 AuthorDID: apiKey.OwnerDID, 371 + Motivation: "highlighting", 302 372 TargetSource: req.URL, 303 373 TargetHash: urlHash, 374 + TargetTitle: titlePtr, 304 375 SelectorJSON: &selectorStr, 305 376 Color: colorPtr, 377 + TagsJSON: tagsJSONPtr, 306 378 CreatedAt: time.Now(), 307 379 IndexedAt: time.Now(), 308 380 CID: &result.CID, 309 381 } 310 382 go func() { 311 - if err := h.db.CreateHighlight(highlight); err != nil { 312 - fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 383 + if err := h.db.CreateNote(note); err != nil { 384 + logger.Error("Warning: failed to index highlight note in local DB: %v", err) 313 385 } 314 386 }() 315 387 } 316 388 317 389 } else { 318 - record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, req.Selector, "") 390 + record := xrpc.NewNoteRecord(req.URL, urlHash, req.Text, req.Selector, req.Title, "", "", "commenting") 391 + if len(req.Tags) > 0 { 392 + record.Tags = req.Tags 393 + } 319 394 320 395 if err := record.Validate(); err != nil { 321 396 WriteBadRequest(w, "Validation error: "+err.Error()) ··· 323 398 } 324 399 325 400 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 326 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) 401 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 327 402 return createErr 328 403 }) 329 404 if err == nil { ··· 336 411 selectorStrPtr = &s 337 412 } 338 413 339 - bodyValue := req.Text 340 - var bodyValuePtr *string 341 - if bodyValue != "" { 342 - bodyValuePtr = &bodyValue 414 + var bodyValuePtr, titlePtr *string 415 + if req.Text != "" { 416 + bodyValuePtr = &req.Text 417 + } 418 + if req.Title != "" { 419 + titlePtr = &req.Title 343 420 } 344 421 345 - annotation := &db.Annotation{ 422 + note := &db.Note{ 346 423 URI: result.URI, 347 424 AuthorDID: apiKey.OwnerDID, 348 425 Motivation: "commenting", 349 426 BodyValue: bodyValuePtr, 350 427 TargetSource: req.URL, 351 428 TargetHash: urlHash, 429 + TargetTitle: titlePtr, 352 430 SelectorJSON: selectorStrPtr, 431 + TagsJSON: tagsJSONPtr, 353 432 CreatedAt: time.Now(), 354 433 IndexedAt: time.Now(), 355 434 CID: &result.CID, 356 435 } 357 436 go func() { 358 - h.db.CreateAnnotation(annotation) 437 + h.db.CreateNote(note) 359 438 }() 360 439 } 361 440 } ··· 374 453 375 454 type QuickHighlightRequest struct { 376 455 URL string `json:"url"` 456 + Title string `json:"title,omitempty"` 377 457 Selector interface{} `json:"selector"` 378 458 Color string `json:"color,omitempty"` 459 + Tags []string `json:"tags,omitempty"` 379 460 } 380 461 381 462 func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { ··· 400 481 if err != nil { 401 482 WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") 402 483 return 484 + } 485 + 486 + for i, t := range req.Tags { 487 + req.Tags[i] = strings.ToLower(t) 403 488 } 404 489 405 490 urlHash := db.HashURL(req.URL) ··· 408 493 color = "yellow" 409 494 } 410 495 411 - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) 496 + record := xrpc.NewNoteRecord(req.URL, urlHash, "", req.Selector, req.Title, color, "", "highlighting") 497 + if len(req.Tags) > 0 { 498 + record.Tags = req.Tags 499 + } 412 500 413 501 if err := record.Validate(); err != nil { 414 502 WriteBadRequest(w, "Validation error: "+err.Error()) ··· 418 506 var result *xrpc.CreateRecordOutput 419 507 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 420 508 var createErr error 421 - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) 509 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 422 510 return createErr 423 511 }) 424 512 if err != nil { ··· 432 520 selectorStr := string(selectorJSON) 433 521 colorPtr := &color 434 522 435 - highlight := &db.Highlight{ 523 + var titlePtr, tagsJSONPtr *string 524 + if req.Title != "" { 525 + titlePtr = &req.Title 526 + } 527 + if len(req.Tags) > 0 { 528 + b, _ := json.Marshal(req.Tags) 529 + s := string(b) 530 + tagsJSONPtr = &s 531 + } 532 + 533 + note := &db.Note{ 436 534 URI: result.URI, 437 535 AuthorDID: apiKey.OwnerDID, 536 + Motivation: "highlighting", 438 537 TargetSource: req.URL, 439 538 TargetHash: urlHash, 539 + TargetTitle: titlePtr, 440 540 SelectorJSON: &selectorStr, 441 541 Color: colorPtr, 542 + TagsJSON: tagsJSONPtr, 442 543 CreatedAt: time.Now(), 443 544 IndexedAt: time.Now(), 444 545 CID: &result.CID, 445 546 } 446 - if err := h.db.CreateHighlight(highlight); err != nil { 447 - fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) 547 + if err := h.db.CreateNote(note); err != nil { 548 + logger.Error("Warning: failed to index highlight note in local DB: %v", err) 448 549 } 449 550 450 551 WriteSuccess(w, map[string]string{ 451 552 "uri": result.URI, 452 553 "cid": result.CID, 453 554 "message": "Highlight created successfully", 555 + }) 556 + } 557 + 558 + func (h *APIKeyHandler) GetMe(w http.ResponseWriter, r *http.Request) { 559 + apiKey, err := h.authenticateAPIKey(r) 560 + if err == nil { 561 + session, err := h.getSessionByDID(apiKey.OwnerDID) 562 + if err != nil { 563 + WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") 564 + return 565 + } 566 + WriteSuccess(w, map[string]string{ 567 + "did": session.DID, 568 + "handle": session.Handle, 569 + }) 570 + return 571 + } 572 + 573 + token := r.Header.Get("X-Session-Token") 574 + if token == "" { 575 + WriteUnauthorized(w, "Unauthorized") 576 + return 577 + } 578 + did, handle, _, _, _, err := h.db.GetSession(token) 579 + if err != nil { 580 + WriteUnauthorized(w, "Invalid session") 581 + return 582 + } 583 + WriteSuccess(w, map[string]string{ 584 + "did": did, 585 + "handle": handle, 454 586 }) 455 587 } 456 588
+376 -781
backend/internal/api/handler.go
··· 7 7 "io" 8 8 "net/http" 9 9 "net/url" 10 - "sort" 11 10 "strconv" 12 11 "strings" 13 12 "sync" ··· 15 14 16 15 "github.com/go-chi/chi/v5" 17 16 17 + "margin.at/internal/analytics" 18 18 "margin.at/internal/config" 19 19 "margin.at/internal/db" 20 + "margin.at/internal/domain" 20 21 "margin.at/internal/logger" 21 22 "margin.at/internal/recommendations" 23 + "margin.at/internal/repository/postgres" 24 + "margin.at/internal/service" 22 25 internal_sync "margin.at/internal/sync" 23 26 "margin.at/internal/xrpc" 24 27 ) ··· 77 80 } 78 81 79 82 type Handler struct { 80 - db *db.DB 81 - annotationService *AnnotationService 82 - refresher *TokenRefresher 83 - apiKeys *APIKeyHandler 84 - syncService *internal_sync.Service 85 - moderation *ModerationHandler 86 - recommendations *recommendations.Service 87 - metaCache *urlMetaCache 88 - metaSem chan struct{} 83 + db *db.DB 84 + noteRepo domain.NoteRepository 85 + engagementRepo domain.EngagementRepository 86 + notificationRepo domain.NotificationRepository 87 + sessionRepo domain.SessionRepository 88 + noteWriter *NoteWriteService 89 + refresher *TokenRefresher 90 + apiKeys *APIKeyHandler 91 + syncService *internal_sync.Service 92 + moderation *ModerationHandler 93 + recommendations *recommendations.Service 94 + feedSvc *service.FeedService 95 + hydration *service.HydrationService 96 + metaCache *urlMetaCache 97 + metaSem chan struct{} 98 + analytics *analytics.Client 89 99 } 90 100 91 - func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher, syncService *internal_sync.Service, recService *recommendations.Service) *Handler { 101 + func NewHandler(database *db.DB, noteWriter *NoteWriteService, refresher *TokenRefresher, syncService *internal_sync.Service, recService *recommendations.Service, ac *analytics.Client) *Handler { 102 + noteRepo := postgres.NewNoteRepository(database.DB) 103 + engagementRepo := postgres.NewEngagementRepository(database.DB) 104 + notificationRepo := postgres.NewNotificationRepository(database.DB) 105 + sessionRepo := postgres.NewSessionRepository(database.DB) 106 + profileRepo := &fullProfileRepository{db: database} // rich resolution: cache → DB → bsky.social 107 + profileSvc := service.NewProfileService(profileRepo) // service-lifetime TTL cache on top 108 + hydration := service.NewHydrationService(engagementRepo, profileSvc) 109 + feedSvc := service.NewFeedService(noteRepo, hydration, database) 110 + 92 111 return &Handler{ 93 - db: database, 94 - annotationService: annotationService, 95 - refresher: refresher, 96 - apiKeys: NewAPIKeyHandler(database, refresher), 97 - syncService: syncService, 98 - moderation: NewModerationHandler(database, refresher), 99 - recommendations: recService, 100 - metaCache: newURLMetaCache(), 101 - metaSem: make(chan struct{}, 5), 112 + db: database, 113 + noteRepo: noteRepo, 114 + engagementRepo: engagementRepo, 115 + notificationRepo: notificationRepo, 116 + sessionRepo: sessionRepo, 117 + noteWriter: noteWriter, 118 + refresher: refresher, 119 + apiKeys: NewAPIKeyHandler(database, refresher), 120 + syncService: syncService, 121 + moderation: NewModerationHandler(database, refresher), 122 + recommendations: recService, 123 + feedSvc: feedSvc, 124 + hydration: hydration, 125 + metaCache: newURLMetaCache(), 126 + metaSem: make(chan struct{}, 5), 127 + analytics: ac, 102 128 } 103 129 } 104 130 ··· 113 139 r.Get("/annotations/feed", h.GetFeed) 114 140 r.Get("/annotation", h.GetAnnotation) 115 141 r.Get("/annotations/history", h.GetEditHistory) 116 - r.Post("/annotations", h.annotationService.CreateAnnotation) 117 - r.Put("/annotations", h.annotationService.UpdateAnnotation) 118 - r.Delete("/annotations", h.annotationService.DeleteAnnotation) 119 - r.Post("/annotations/like", h.annotationService.LikeAnnotation) 120 - r.Delete("/annotations/like", h.annotationService.UnlikeAnnotation) 121 - r.Post("/annotations/reply", h.annotationService.CreateReply) 122 - r.Delete("/annotations/reply", h.annotationService.DeleteReply) 142 + r.Post("/annotations", h.noteWriter.CreateAnnotation) 143 + r.Put("/annotations", h.noteWriter.UpdateAnnotation) 144 + r.Delete("/annotations", h.noteWriter.DeleteAnnotation) 145 + r.Post("/annotations/like", h.noteWriter.LikeAnnotation) 146 + r.Delete("/annotations/like", h.noteWriter.UnlikeAnnotation) 147 + r.Post("/annotations/reply", h.noteWriter.CreateReply) 148 + r.Delete("/annotations/reply", h.noteWriter.DeleteReply) 123 149 r.Get("/replies", h.GetReplies) 124 150 r.Get("/likes", h.GetLikeCount) 125 151 126 152 // Highlights 127 153 r.Get("/highlights", h.GetHighlights) 128 - r.Post("/highlights", h.annotationService.CreateHighlight) 129 - r.Put("/highlights", h.annotationService.UpdateHighlight) 130 - r.Delete("/highlights", h.annotationService.DeleteHighlight) 154 + r.Post("/highlights", h.noteWriter.CreateHighlight) 155 + r.Put("/highlights", h.noteWriter.UpdateHighlight) 156 + r.Delete("/highlights", h.noteWriter.DeleteAnnotation) 131 157 132 158 // Bookmarks 133 159 r.Get("/bookmarks", h.GetBookmarks) 134 - r.Post("/bookmarks", h.annotationService.CreateBookmark) 135 - r.Put("/bookmarks", h.annotationService.UpdateBookmark) 136 - r.Delete("/bookmarks", h.annotationService.DeleteBookmark) 160 + r.Post("/bookmarks", h.noteWriter.CreateBookmark) 161 + r.Put("/bookmarks", h.noteWriter.UpdateBookmark) 162 + r.Delete("/bookmarks", h.noteWriter.DeleteAnnotation) 137 163 138 164 // Collections 139 165 r.Post("/collections", collectionService.CreateCollection) ··· 148 174 149 175 // Targets & discovery 150 176 r.Get("/targets", h.GetByTarget) 177 + r.Get("/targets/hash", h.GetByTargetHash) 151 178 r.Get("/discover", h.DiscoverForURL) 152 179 r.Get("/url-metadata", h.GetURLMetadata) 153 180 ··· 182 209 r.Post("/sync", h.SyncAll) 183 210 184 211 // API keys 212 + r.Get("/me", h.apiKeys.GetMe) 185 213 r.Post("/keys", h.apiKeys.CreateKey) 186 214 r.Get("/keys", h.apiKeys.ListKeys) 187 215 r.Delete("/keys/{id}", h.apiKeys.DeleteKey) 188 216 r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) 189 217 r.Post("/quick/save", h.apiKeys.QuickSave) 218 + r.Post("/quick/highlight", h.apiKeys.QuickHighlight) 190 219 191 220 // Moderation 192 221 r.Post("/moderation/block", h.moderation.BlockUser) ··· 208 237 209 238 // Admin 210 239 r.Post("/admin/backfill", h.AdminBackfill) 240 + 241 + // Analytics proxy 242 + r.Post("/analytics/capture", h.CaptureEvent) 211 243 }) 212 244 } 213 245 ··· 216 248 WriteSuccess(w, map[string]string{"status": "ok", "version": "1.0"}) 217 249 } 218 250 251 + func (h *Handler) CaptureEvent(w http.ResponseWriter, r *http.Request) { 252 + var body struct { 253 + Event string `json:"event"` 254 + DistinctID string `json:"distinct_id"` 255 + Properties map[string]interface{} `json:"properties"` 256 + } 257 + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { 258 + http.Error(w, "invalid body", http.StatusBadRequest) 259 + return 260 + } 261 + if body.Event == "" { 262 + http.Error(w, "event is required", http.StatusBadRequest) 263 + return 264 + } 265 + if body.DistinctID == "" { 266 + body.DistinctID = "anonymous_extension" 267 + } 268 + if h.analytics != nil { 269 + if body.Properties == nil { 270 + body.Properties = map[string]interface{}{} 271 + } 272 + body.Properties["$lib"] = "margin-extension" 273 + h.analytics.Capture(body.DistinctID, body.Event, body.Properties) 274 + } 275 + w.WriteHeader(http.StatusNoContent) 276 + } 277 + 219 278 func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { 220 279 source := r.URL.Query().Get("source") 221 280 if source == "" { 222 281 source = r.URL.Query().Get("url") 223 282 } 224 - 225 283 limit := parseIntParam(r, "limit", 50) 226 284 offset := parseIntParam(r, "offset", 0) 227 285 motivation := r.URL.Query().Get("motivation") 228 286 tag := r.URL.Query().Get("tag") 229 287 230 - var annotations []db.Annotation 231 - var err error 232 - 288 + filter := db.NoteFilter{Limit: limit, Offset: offset} 233 289 if source != "" { 234 - urlHash := db.HashURL(source) 235 - annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 236 - } else if motivation != "" { 237 - annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) 238 - } else if tag != "" { 239 - annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) 240 - } else { 241 - annotations, err = h.db.GetRecentAnnotations(limit, offset) 290 + filter.TargetHash = db.HashURL(source) 291 + } 292 + if motivation != "" { 293 + filter.Motivations = []string{motivation} 294 + } 295 + if tag != "" { 296 + filter.Tag = tag 242 297 } 243 298 299 + notes, err := h.noteRepo.List(r.Context(), filter) 244 300 if err != nil { 245 301 WriteInternalError(w, "Internal server error") 246 302 return 247 303 } 248 304 249 - enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 305 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 306 + items := make([]service.APINote, len(notes)) 307 + for i, n := range notes { 308 + items[i] = h.hydration.ToAPINote(n, lc) 309 + } 250 310 251 311 WriteSuccess(w, map[string]interface{}{ 252 312 "@context": "http://www.w3.org/ns/anno.jsonld", 253 313 "type": "AnnotationCollection", 254 - "items": enriched, 255 - "totalItems": len(enriched), 314 + "items": items, 315 + "totalItems": len(items), 256 316 }) 257 317 } 258 318 259 - func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 260 - limit := parseIntParam(r, "limit", 50) 261 - offset := parseIntParam(r, "offset", 0) 262 - tag := strings.ToLower(r.URL.Query().Get("tag")) 263 - creator := r.URL.Query().Get("creator") 264 - feedType := r.URL.Query().Get("type") 265 - 266 - viewerDID := h.getViewerDID(r) 267 - 268 - var annotations []db.Annotation 269 - var highlights []db.Highlight 270 - var bookmarks []db.Bookmark 271 - var collectionItems []db.CollectionItem 272 - var err error 273 - 274 - motivation := r.URL.Query().Get("motivation") 275 - 276 - fetchLimit := limit + offset 277 - 278 - perTypeFetchLimit := fetchLimit 279 - if motivation == "" { 280 - perTypeFetchLimit = fetchLimit/2 + 10 319 + func parseFeedType(s string) db.FeedType { 320 + switch s { 321 + case "popular": 322 + return db.FeedTypePopular 323 + case "shelved": 324 + return db.FeedTypeShelved 325 + case "margin": 326 + return db.FeedTypeMargin 327 + case "semble": 328 + return db.FeedTypeSemble 329 + default: 330 + return db.FeedTypeRecent 281 331 } 332 + } 282 333 283 - if tag != "" { 284 - if creator != "" { 285 - if motivation == "" || motivation == "commenting" { 286 - switch feedType { 287 - case "margin": 288 - annotations, _ = h.db.GetMarginAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) 289 - case "semble": 290 - annotations, _ = h.db.GetSembleAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) 291 - default: 292 - annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) 293 - } 294 - } 295 - if motivation == "" || motivation == "highlighting" { 296 - switch feedType { 297 - case "margin": 298 - highlights, _ = h.db.GetMarginHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) 299 - case "semble": 300 - highlights, _ = h.db.GetSembleHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) 301 - default: 302 - highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) 303 - } 304 - } 305 - if motivation == "" || motivation == "bookmarking" { 306 - switch feedType { 307 - case "margin": 308 - bookmarks, _ = h.db.GetMarginBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) 309 - case "semble": 310 - bookmarks, _ = h.db.GetSembleBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) 311 - default: 312 - bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) 313 - } 314 - } 315 - collectionItems = []db.CollectionItem{} 316 - } else { 317 - if motivation == "" || motivation == "commenting" { 318 - switch feedType { 319 - case "margin": 320 - annotations, _ = h.db.GetMarginAnnotationsByTag(tag, fetchLimit, 0) 321 - case "semble": 322 - annotations, _ = h.db.GetSembleAnnotationsByTag(tag, fetchLimit, 0) 323 - default: 324 - annotations, _ = h.db.GetAnnotationsByTag(tag, fetchLimit, 0) 325 - } 326 - } 327 - if motivation == "" || motivation == "highlighting" { 328 - switch feedType { 329 - case "margin": 330 - highlights, _ = h.db.GetMarginHighlightsByTag(tag, fetchLimit, 0) 331 - case "semble": 332 - highlights, _ = h.db.GetSembleHighlightsByTag(tag, fetchLimit, 0) 333 - default: 334 - highlights, _ = h.db.GetHighlightsByTag(tag, fetchLimit, 0) 335 - } 336 - } 337 - if motivation == "" || motivation == "bookmarking" { 338 - switch feedType { 339 - case "margin": 340 - bookmarks, _ = h.db.GetMarginBookmarksByTag(tag, fetchLimit, 0) 341 - case "semble": 342 - bookmarks, _ = h.db.GetSembleBookmarksByTag(tag, fetchLimit, 0) 343 - default: 344 - bookmarks, _ = h.db.GetBookmarksByTag(tag, fetchLimit, 0) 345 - } 346 - } 347 - collectionItems = []db.CollectionItem{} 348 - } 349 - } else if creator != "" { 350 - if motivation == "" || motivation == "commenting" { 351 - switch feedType { 352 - case "margin": 353 - annotations, _ = h.db.GetMarginAnnotationsByAuthor(creator, fetchLimit, 0) 354 - case "semble": 355 - annotations, _ = h.db.GetSembleAnnotationsByAuthor(creator, fetchLimit, 0) 356 - default: 357 - annotations, _ = h.db.GetAnnotationsByAuthor(creator, fetchLimit, 0) 358 - } 359 - } 360 - if motivation == "" || motivation == "highlighting" { 361 - switch feedType { 362 - case "margin": 363 - highlights, _ = h.db.GetMarginHighlightsByAuthor(creator, fetchLimit, 0) 364 - case "semble": 365 - highlights, _ = h.db.GetSembleHighlightsByAuthor(creator, fetchLimit, 0) 366 - default: 367 - highlights, _ = h.db.GetHighlightsByAuthor(creator, fetchLimit, 0) 368 - } 369 - } 370 - if motivation == "" || motivation == "bookmarking" { 371 - switch feedType { 372 - case "margin": 373 - bookmarks, _ = h.db.GetMarginBookmarksByAuthor(creator, fetchLimit, 0) 374 - case "semble": 375 - bookmarks, _ = h.db.GetSembleBookmarksByAuthor(creator, fetchLimit, 0) 376 - default: 377 - bookmarks, _ = h.db.GetBookmarksByAuthor(creator, fetchLimit, 0) 378 - } 379 - } 380 - collectionItems = []db.CollectionItem{} 381 - } else { 382 - typeLim := fetchLimit 383 - if motivation == "" { 384 - typeLim = perTypeFetchLimit 385 - } 386 - if motivation == "" || motivation == "commenting" { 387 - switch feedType { 388 - case "margin": 389 - annotations, _ = h.db.GetMarginAnnotations(typeLim, 0) 390 - case "semble": 391 - annotations, _ = h.db.GetSembleAnnotations(typeLim, 0) 392 - case "popular": 393 - annotations, _ = h.db.GetPopularAnnotations(typeLim, 0) 394 - case "shelved": 395 - annotations, _ = h.db.GetShelvedAnnotations(typeLim, 0) 396 - default: 397 - annotations, _ = h.db.GetRecentAnnotations(typeLim, 0) 398 - } 399 - } 400 - if motivation == "" || motivation == "highlighting" { 401 - switch feedType { 402 - case "margin": 403 - highlights, _ = h.db.GetMarginHighlights(typeLim, 0) 404 - case "semble": 405 - highlights, _ = h.db.GetSembleHighlights(typeLim, 0) 406 - case "popular": 407 - highlights, _ = h.db.GetPopularHighlights(typeLim, 0) 408 - case "shelved": 409 - highlights, _ = h.db.GetShelvedHighlights(typeLim, 0) 410 - default: 411 - highlights, _ = h.db.GetRecentHighlights(typeLim, 0) 412 - } 413 - } 414 - if motivation == "" || motivation == "bookmarking" { 415 - switch feedType { 416 - case "margin": 417 - bookmarks, _ = h.db.GetMarginBookmarks(typeLim, 0) 418 - case "semble": 419 - bookmarks, _ = h.db.GetSembleBookmarks(typeLim, 0) 420 - case "popular": 421 - bookmarks, _ = h.db.GetPopularBookmarks(typeLim, 0) 422 - case "shelved": 423 - bookmarks, _ = h.db.GetShelvedBookmarks(typeLim, 0) 424 - default: 425 - bookmarks, _ = h.db.GetRecentBookmarks(typeLim, 0) 426 - } 427 - } 428 - if motivation == "" { 429 - switch feedType { 430 - case "popular": 431 - collectionItems, err = h.db.GetPopularCollectionItems(typeLim, 0) 432 - case "shelved": 433 - collectionItems, err = h.db.GetShelvedCollectionItems(typeLim, 0) 434 - default: 435 - collectionItems, err = h.db.GetRecentCollectionItems(typeLim, 0) 436 - } 437 - if err != nil { 438 - logger.Error("Error fetching collection items: %v", err) 439 - } 334 + func notesByMotivation(notes []service.APINote) (annotations, highlights, bookmarks []service.APINote) { 335 + annotations = []service.APINote{} 336 + highlights = []service.APINote{} 337 + bookmarks = []service.APINote{} 338 + for _, n := range notes { 339 + switch n.Motivation { 340 + case "highlighting": 341 + highlights = append(highlights, n) 342 + case "bookmarking": 343 + bookmarks = append(bookmarks, n) 344 + default: 345 + annotations = append(annotations, n) 440 346 } 441 347 } 348 + return 349 + } 442 350 443 - allDIDs := make(map[string]bool) 444 - for _, a := range annotations { 445 - allDIDs[a.AuthorDID] = true 446 - } 447 - for _, h := range highlights { 448 - allDIDs[h.AuthorDID] = true 449 - } 450 - for _, b := range bookmarks { 451 - allDIDs[b.AuthorDID] = true 452 - } 453 - for _, ci := range collectionItems { 454 - allDIDs[ci.AuthorDID] = true 455 - } 456 - didSlice := make([]string, 0, len(allDIDs)) 457 - for did := range allDIDs { 458 - didSlice = append(didSlice, did) 459 - } 460 - profiles := fetchProfilesForDIDs(h.db, didSlice) 461 - shared := &hydrationData{profiles: profiles} 462 - 463 - var ( 464 - authAnnos []APIAnnotation 465 - authHighs []APIHighlight 466 - authBooks []APIBookmark 467 - authCollectionItems []APICollectionItem 468 - wg sync.WaitGroup 469 - ) 470 - 471 - wg.Add(3) 472 - go func() { 473 - defer wg.Done() 474 - authAnnos, _ = hydrateAnnotationsWithData(h.db, annotations, viewerDID, shared) 475 - }() 476 - go func() { 477 - defer wg.Done() 478 - authHighs, _ = hydrateHighlightsWithData(h.db, highlights, viewerDID, shared) 479 - }() 480 - go func() { 481 - defer wg.Done() 482 - authBooks, _ = hydrateBookmarksWithData(h.db, bookmarks, viewerDID, shared) 483 - }() 484 - 485 - if len(collectionItems) > 0 { 486 - var sembleURIs []string 487 - for _, item := range collectionItems { 488 - if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 489 - sembleURIs = append(sembleURIs, item.AnnotationURI) 490 - } 491 - } 492 - if len(sembleURIs) > 0 { 493 - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) 494 - defer cancel() 495 - ensureSembleCardsIndexed(ctx, h.db, sembleURIs) 351 + func mergeNotes(a, b []db.Note) []db.Note { 352 + seen := make(map[string]bool, len(a)) 353 + result := make([]db.Note, 0, len(a)+len(b)) 354 + for _, n := range a { 355 + if !seen[n.URI] { 356 + seen[n.URI] = true 357 + result = append(result, n) 496 358 } 497 - wg.Add(1) 498 - go func() { 499 - defer wg.Done() 500 - authCollectionItems, _ = hydrateCollectionItemsWithData(h.db, collectionItems, viewerDID, shared) 501 - }() 502 359 } 503 - 504 - wg.Wait() 505 - 506 - collectionItemURIs := make(map[string]string) 507 - for _, ci := range authCollectionItems { 508 - var annotationURI string 509 - if ci.Annotation != nil { 510 - annotationURI = ci.Annotation.ID 511 - } else if ci.Highlight != nil { 512 - annotationURI = ci.Highlight.ID 513 - } else if ci.Bookmark != nil { 514 - annotationURI = ci.Bookmark.ID 515 - } 516 - if annotationURI != "" { 517 - collectionItemURIs[annotationURI] = ci.Author.DID 360 + for _, n := range b { 361 + if !seen[n.URI] { 362 + seen[n.URI] = true 363 + result = append(result, n) 518 364 } 519 365 } 520 - 521 - var feed []interface{} 522 - for _, a := range authAnnos { 523 - if addedBy, exists := collectionItemURIs[a.ID]; exists && addedBy == a.Author.DID { 524 - continue 525 - } 526 - feed = append(feed, a) 527 - } 528 - for _, h := range authHighs { 529 - if addedBy, exists := collectionItemURIs[h.ID]; exists && addedBy == h.Author.DID { 530 - continue 531 - } 532 - feed = append(feed, h) 533 - } 534 - for _, b := range authBooks { 535 - if addedBy, exists := collectionItemURIs[b.ID]; exists && addedBy == b.Author.DID { 536 - continue 537 - } 538 - feed = append(feed, b) 539 - } 540 - for _, ci := range authCollectionItems { 541 - feed = append(feed, ci) 542 - } 543 - 544 - if feedType != "" && feedType != "all" && feedType != "my-feed" { 545 - var filtered []interface{} 546 - for _, item := range feed { 547 - isSemble := false 548 - var uri string 549 - switch v := item.(type) { 550 - case APIAnnotation: 551 - uri = v.ID 552 - case APIHighlight: 553 - uri = v.ID 554 - case APIBookmark: 555 - uri = v.ID 556 - case APICollectionItem: 557 - if v.Annotation != nil { 558 - uri = v.Annotation.ID 559 - } else if v.Highlight != nil { 560 - uri = v.Highlight.ID 561 - } else if v.Bookmark != nil { 562 - uri = v.Bookmark.ID 563 - } else { 564 - uri = v.ID 565 - } 566 - } 567 - if strings.Contains(uri, "network.cosmik") { 568 - isSemble = true 569 - } 570 - 571 - switch feedType { 572 - case "semble": 573 - if isSemble { 574 - filtered = append(filtered, item) 575 - } 576 - case "margin": 577 - if !isSemble { 578 - filtered = append(filtered, item) 579 - } 580 - case "popular", "shelved": 581 - filtered = append(filtered, item) 582 - } 583 - } 584 - feed = filtered 585 - } 586 - 587 - feed = h.filterFeedByModeration(feed, viewerDID) 366 + return result 367 + } 588 368 589 - switch feedType { 590 - case "popular": 591 - sortFeedByPopularity(feed) 592 - default: 593 - sortFeed(feed) 594 - } 369 + func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { 370 + limit := parseIntParam(r, "limit", 50) 371 + offset := parseIntParam(r, "offset", 0) 372 + tag := strings.ToLower(r.URL.Query().Get("tag")) 373 + creator := r.URL.Query().Get("creator") 374 + motivation := r.URL.Query().Get("motivation") 375 + feedTypeStr := r.URL.Query().Get("type") 595 376 596 - logger.Info("[DEBUG] FeedType: %s, Total Items before slice: %d", feedType, len(feed)) 597 - if len(feed) > 0 { 598 - first := feed[0] 599 - switch v := first.(type) { 600 - case APIAnnotation: 601 - logger.Info("[DEBUG] First Item (Annotation): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) 602 - case APIHighlight: 603 - logger.Info("[DEBUG] First Item (Highlight): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) 604 - } 377 + var motivations []string 378 + if motivation != "" { 379 + motivations = []string{motivation} 605 380 } 606 381 607 - if offset < len(feed) { 608 - feed = feed[offset:] 609 - } else { 610 - feed = []interface{}{} 382 + req := service.FeedRequest{ 383 + ViewerDID: h.getViewerDID(r), 384 + Motivations: motivations, 385 + AuthorDID: creator, 386 + Tag: tag, 387 + FeedType: parseFeedType(feedTypeStr), 388 + Limit: limit, 389 + Offset: offset, 611 390 } 612 391 613 - if len(feed) > limit { 614 - feed = feed[:limit] 392 + resp, err := h.feedSvc.GetFeed(r.Context(), req) 393 + if err != nil { 394 + WriteInternalError(w, "Internal server error") 395 + return 615 396 } 616 397 617 398 WriteSuccess(w, map[string]interface{}{ 618 399 "@context": "http://www.w3.org/ns/anno.jsonld", 619 400 "type": "Collection", 620 - "items": feed, 621 - "totalItems": len(feed), 622 - }) 623 - } 624 - 625 - func sortFeed(feed []interface{}) { 626 - sort.Slice(feed, func(i, j int) bool { 627 - t1 := getCreatedAt(feed[i]) 628 - t2 := getCreatedAt(feed[j]) 629 - return t1.After(t2) 401 + "items": resp.Items, 402 + "totalItems": resp.TotalItems, 630 403 }) 631 404 } 632 - 633 - func getCreatedAt(item interface{}) time.Time { 634 - switch v := item.(type) { 635 - case APIAnnotation: 636 - return v.CreatedAt 637 - case APIHighlight: 638 - return v.CreatedAt 639 - case APIBookmark: 640 - return v.CreatedAt 641 - case APICollectionItem: 642 - return v.CreatedAt 643 - default: 644 - return time.Time{} 645 - } 646 - } 647 - 648 - func sortFeedByPopularity(feed []interface{}) { 649 - sort.Slice(feed, func(i, j int) bool { 650 - p1 := getPopularity(feed[i]) 651 - p2 := getPopularity(feed[j]) 652 - if p1 != p2 { 653 - return p1 > p2 654 - } 655 - t1 := getCreatedAt(feed[i]) 656 - t2 := getCreatedAt(feed[j]) 657 - return t1.After(t2) 658 - }) 659 - } 660 - 661 - func getPopularity(item interface{}) int { 662 - switch v := item.(type) { 663 - case APIAnnotation: 664 - return v.LikeCount + v.ReplyCount 665 - case APIHighlight: 666 - return v.LikeCount + v.ReplyCount 667 - case APIBookmark: 668 - return v.LikeCount + v.ReplyCount 669 - case APICollectionItem: 670 - pop := 0 671 - if v.Annotation != nil { 672 - pop += v.Annotation.LikeCount + v.Annotation.ReplyCount 673 - } 674 - if v.Highlight != nil { 675 - pop += v.Highlight.LikeCount + v.Highlight.ReplyCount 676 - } 677 - if v.Bookmark != nil { 678 - pop += v.Bookmark.LikeCount + v.Bookmark.ReplyCount 679 - } 680 - return pop 681 - default: 682 - return 0 683 - } 684 - } 685 - 686 405 func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { 687 406 uri := r.URL.Query().Get("uri") 688 407 if uri == "" { ··· 690 409 return 691 410 } 692 411 693 - serveResponse := func(data interface{}, context string) { 694 - w.Header().Set("Content-Type", "application/json") 695 - response := map[string]interface{}{ 696 - "@context": context, 697 - } 698 - jsonData, _ := json.Marshal(data) 699 - json.Unmarshal(jsonData, &response) 700 - json.NewEncoder(w).Encode(response) 412 + note, err := h.noteRepo.GetByURI(r.Context(), uri) 413 + if err != nil { 414 + WriteInternalError(w, "Internal server error") 415 + return 701 416 } 702 417 703 - if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { 704 - if annotation.CID == nil || *annotation.CID == "" { 705 - parts := parseATURI(uri) 706 - if len(parts) >= 3 { 707 - did := parts[0] 708 - collection := parts[1] 709 - rkey := parts[2] 710 - 711 - session, err := h.refresher.GetSessionWithAutoRefresh(r) 712 - if err == nil { 713 - _ = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 714 - record, getErr := client.GetRecord(r.Context(), did, collection, rkey) 715 - if getErr == nil { 716 - h.db.UpdateAnnotation(uri, *annotation.BodyValue, *annotation.TagsJSON, record.CID) 717 - cid := record.CID 718 - annotation.CID = &cid 719 - } 720 - return nil 721 - }) 722 - } 723 - } 724 - } 725 - 726 - if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 727 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 728 - return 729 - } 418 + if note == nil && strings.Contains(uri, "at.margin.annotation") { 419 + altURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 420 + note, _ = h.noteRepo.GetByURI(r.Context(), altURI) 730 421 } 731 422 732 - if highlight, err := h.db.GetHighlightByURI(uri); err == nil { 733 - if highlight.CID == nil || *highlight.CID == "" { 734 - parts := parseATURI(uri) 735 - if len(parts) >= 3 { 736 - did := parts[0] 737 - collection := parts[1] 738 - rkey := parts[2] 739 - 740 - session, err := h.refresher.GetSessionWithAutoRefresh(r) 741 - if err == nil { 742 - _ = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 743 - record, getErr := client.GetRecord(r.Context(), did, collection, rkey) 744 - if getErr == nil { 745 - tagsJSON := "" 746 - if highlight.TagsJSON != nil { 747 - tagsJSON = *highlight.TagsJSON 748 - } 749 - color := "" 750 - if highlight.Color != nil { 751 - color = *highlight.Color 752 - } 753 - h.db.UpdateHighlight(uri, color, tagsJSON, record.CID) 754 - cid := record.CID 755 - highlight.CID = &cid 756 - } 757 - return nil 758 - }) 759 - } 760 - } 761 - } 762 - 763 - if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 764 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 765 - return 423 + if note == nil && strings.HasPrefix(uri, "at://") && 424 + (strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark")) { 425 + parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") 426 + if len(parts) >= 3 { 427 + sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", parts[0], parts[len(parts)-1]) 428 + note, _ = h.noteRepo.GetByURI(r.Context(), sembleURI) 766 429 } 767 430 } 768 431 769 - if strings.Contains(uri, "at.margin.annotation") { 770 - highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) 771 - if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { 772 - if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { 773 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 774 - return 775 - } 776 - } 432 + if note == nil && strings.Contains(uri, "at.margin.annotation") { 433 + altURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 434 + note, _ = h.noteRepo.GetByURI(r.Context(), altURI) 777 435 } 778 436 779 - if strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark") { 780 - if strings.HasPrefix(uri, "at://") { 781 - uriWithoutScheme := strings.TrimPrefix(uri, "at://") 782 - parts := strings.Split(uriWithoutScheme, "/") 783 - if len(parts) >= 3 { 784 - did := parts[0] 785 - rkey := parts[len(parts)-1] 786 - 787 - sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", did, rkey) 788 - 789 - if annotation, err := h.db.GetAnnotationByURI(sembleURI); err == nil { 790 - if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { 791 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 792 - return 793 - } 794 - } 795 - 796 - if bookmark, err := h.db.GetBookmarkByURI(sembleURI); err == nil { 797 - if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 798 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 799 - return 800 - } 801 - } 802 - } 803 - } 437 + if note == nil { 438 + WriteNotFound(w, "Note not found") 439 + return 804 440 } 805 441 806 - if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { 807 - if bookmark.CID == nil || *bookmark.CID == "" { 808 - parts := parseATURI(uri) 809 - if len(parts) >= 3 { 810 - did := parts[0] 811 - collection := parts[1] 812 - rkey := parts[2] 813 - 814 - session, err := h.refresher.GetSessionWithAutoRefresh(r) 815 - if err == nil { 816 - _ = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 817 - record, getErr := client.GetRecord(r.Context(), did, collection, rkey) 818 - if getErr == nil { 819 - tagsJSON := "" 820 - if bookmark.TagsJSON != nil { 821 - tagsJSON = *bookmark.TagsJSON 822 - } 823 - title := "" 824 - if bookmark.Title != nil { 825 - title = *bookmark.Title 826 - } 827 - desc := "" 828 - if bookmark.Description != nil { 829 - desc = *bookmark.Description 830 - } 831 - h.db.UpdateBookmark(uri, title, desc, tagsJSON, record.CID) 832 - cid := record.CID 833 - bookmark.CID = &cid 834 - } 835 - return nil 836 - }) 837 - } 838 - } 839 - } 840 - 841 - if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 842 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 843 - return 844 - } 845 - } 846 - 847 - if strings.Contains(uri, "at.margin.annotation") { 848 - bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) 849 - if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { 850 - if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { 851 - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") 852 - return 853 - } 854 - } 855 - } 442 + lc, _ := h.hydration.Load(r.Context(), []db.Note{*note}, h.getViewerDID(r)) 443 + apiNote := h.hydration.ToAPINote(*note, lc) 856 444 857 - WriteNotFound(w, "Annotation, Highlight, or Bookmark not found") 858 - 445 + w.Header().Set("Content-Type", "application/json") 446 + response := map[string]interface{}{"@context": "http://www.w3.org/ns/anno.jsonld"} 447 + jsonData, _ := json.Marshal(apiNote) 448 + json.Unmarshal(jsonData, &response) 449 + json.NewEncoder(w).Encode(response) 859 450 } 860 451 861 452 func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { ··· 874 465 urlHash := db.HashURL(source) 875 466 rawHash := db.HashString(source) 876 467 877 - annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) 878 - highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) 879 - bookmarks, _ := h.db.GetBookmarksByTargetHash(urlHash, limit, offset) 880 - 468 + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: urlHash, Limit: limit, Offset: offset}) 469 + if err != nil { 470 + WriteInternalError(w, "Internal server error") 471 + return 472 + } 881 473 if rawHash != urlHash { 882 - rawAnnotations, _ := h.db.GetAnnotationsByTargetHash(rawHash, limit, offset) 883 - rawHighlights, _ := h.db.GetHighlightsByTargetHash(rawHash, limit, offset) 884 - rawBookmarks, _ := h.db.GetBookmarksByTargetHash(rawHash, limit, offset) 885 - 886 - annotations = mergeAnnotations(annotations, rawAnnotations) 887 - highlights = mergeHighlights(highlights, rawHighlights) 888 - bookmarks = mergeBookmarks(bookmarks, rawBookmarks) 474 + rawNotes, _ := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: rawHash, Limit: limit, Offset: offset}) 475 + notes = mergeNotes(notes, rawNotes) 889 476 } 890 477 891 - enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 892 - enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 893 - enrichedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 894 - 895 - totalItems := len(enrichedAnnotations) + len(enrichedHighlights) + len(enrichedBookmarks) 478 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 479 + items := make([]service.APINote, len(notes)) 480 + for i, n := range notes { 481 + items[i] = h.hydration.ToAPINote(n, lc) 482 + } 483 + annotations, highlights, bookmarks := notesByMotivation(items) 896 484 897 - if totalItems == 0 { 485 + if len(items) == 0 { 898 486 w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300") 899 487 } else { 900 488 w.Header().Set("Cache-Control", "private, max-age=0, no-store") ··· 904 492 "@context": "http://www.w3.org/ns/anno.jsonld", 905 493 "source": source, 906 494 "sourceHash": urlHash, 907 - "annotations": enrichedAnnotations, 908 - "highlights": enrichedHighlights, 909 - "bookmarks": enrichedBookmarks, 495 + "annotations": annotations, 496 + "highlights": highlights, 497 + "bookmarks": bookmarks, 498 + }) 499 + } 500 + 501 + func (h *Handler) GetByTargetHash(w http.ResponseWriter, r *http.Request) { 502 + hashes := r.URL.Query()["h"] 503 + if len(hashes) == 0 { 504 + WriteBadRequest(w, "at least one hash parameter (h) required") 505 + return 506 + } 507 + 508 + limit := parseIntParam(r, "limit", 50) 509 + offset := parseIntParam(r, "offset", 0) 510 + 511 + var notes []db.Note 512 + for _, hash := range hashes { 513 + if len(hash) != 64 { 514 + continue 515 + } 516 + hashNotes, _ := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: hash, Limit: limit, Offset: offset}) 517 + notes = mergeNotes(notes, hashNotes) 518 + } 519 + 520 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 521 + items := make([]service.APINote, len(notes)) 522 + for i, n := range notes { 523 + items[i] = h.hydration.ToAPINote(n, lc) 524 + } 525 + annotations, highlights, bookmarks := notesByMotivation(items) 526 + 527 + if len(items) == 0 { 528 + w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300") 529 + } else { 530 + w.Header().Set("Cache-Control", "private, max-age=0, no-store") 531 + } 532 + 533 + WriteSuccess(w, map[string]interface{}{ 534 + "@context": "http://www.w3.org/ns/anno.jsonld", 535 + "annotations": annotations, 536 + "highlights": highlights, 537 + "bookmarks": bookmarks, 910 538 }) 911 539 } 912 540 ··· 1020 648 limit := parseIntParam(r, "limit", 50) 1021 649 offset := parseIntParam(r, "offset", 0) 1022 650 1023 - var highlights []db.Highlight 1024 - var err error 1025 - 651 + filter := db.NoteFilter{Motivations: []string{"highlighting"}, Limit: limit, Offset: offset} 1026 652 if did != "" { 1027 - highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 1028 - } else if tag != "" { 1029 - highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) 1030 - } else { 1031 - highlights, err = h.db.GetRecentHighlights(limit, offset) 653 + filter.AuthorDID = did 654 + } 655 + if tag != "" { 656 + filter.Tag = tag 1032 657 } 1033 658 659 + notes, err := h.noteRepo.List(r.Context(), filter) 1034 660 if err != nil { 1035 661 WriteInternalError(w, "Internal server error") 1036 662 return 1037 663 } 1038 664 1039 - enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 665 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 666 + items := make([]service.APINote, len(notes)) 667 + for i, n := range notes { 668 + items[i] = h.hydration.ToAPINote(n, lc) 669 + } 1040 670 1041 671 WriteSuccess(w, map[string]interface{}{ 1042 672 "@context": "http://www.w3.org/ns/anno.jsonld", 1043 673 "type": "HighlightCollection", 1044 - "items": enriched, 1045 - "totalItems": len(enriched), 674 + "items": items, 675 + "totalItems": len(items), 1046 676 }) 1047 677 } 1048 678 ··· 1056 686 return 1057 687 } 1058 688 1059 - bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) 689 + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ 690 + Motivations: []string{"bookmarking"}, 691 + AuthorDID: did, 692 + Limit: limit, 693 + Offset: offset, 694 + }) 1060 695 if err != nil { 1061 696 WriteInternalError(w, "Internal server error") 1062 697 return 1063 698 } 1064 699 1065 - enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 700 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 701 + items := make([]service.APINote, len(notes)) 702 + for i, n := range notes { 703 + items[i] = h.hydration.ToAPINote(n, lc) 704 + } 1066 705 1067 706 WriteSuccess(w, map[string]interface{}{ 1068 707 "@context": "http://www.w3.org/ns/anno.jsonld", 1069 708 "type": "BookmarkCollection", 1070 - "items": enriched, 1071 - "totalItems": len(enriched), 709 + "items": items, 710 + "totalItems": len(items), 1072 711 }) 1073 712 } 1074 713 ··· 1079 718 } 1080 719 limit := parseIntParam(r, "limit", 50) 1081 720 offset := parseIntParam(r, "offset", 0) 1082 - 1083 - var annotations []db.Annotation 1084 - var err error 1085 - 1086 721 viewerDID := h.getViewerDID(r) 1087 722 1088 723 if offset == 0 && viewerDID != "" && did == viewerDID { ··· 1093 728 }() 1094 729 } 1095 730 1096 - annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) 1097 - 731 + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ 732 + AuthorDID: did, 733 + Motivations: []string{"commenting"}, 734 + Limit: limit, 735 + Offset: offset, 736 + }) 1098 737 if err != nil { 1099 738 WriteInternalError(w, "Internal server error") 1100 739 return 1101 740 } 1102 741 1103 - enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 742 + lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) 743 + items := make([]service.APINote, len(notes)) 744 + for i, n := range notes { 745 + items[i] = h.hydration.ToAPINote(n, lc) 746 + } 1104 747 1105 748 WriteSuccess(w, map[string]interface{}{ 1106 749 "@context": "http://www.w3.org/ns/anno.jsonld", 1107 750 "type": "AnnotationCollection", 1108 751 "creator": did, 1109 - "items": enriched, 1110 - "totalItems": len(enriched), 752 + "items": items, 753 + "totalItems": len(items), 1111 754 }) 1112 755 } 1113 756 ··· 1118 761 } 1119 762 limit := parseIntParam(r, "limit", 50) 1120 763 offset := parseIntParam(r, "offset", 0) 1121 - 1122 - var highlights []db.Highlight 1123 - var err error 1124 - 1125 764 viewerDID := h.getViewerDID(r) 1126 765 1127 766 if offset == 0 && viewerDID != "" && did == viewerDID { ··· 1132 771 }() 1133 772 } 1134 773 1135 - highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) 1136 - 774 + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ 775 + AuthorDID: did, 776 + Motivations: []string{"highlighting"}, 777 + Limit: limit, 778 + Offset: offset, 779 + }) 1137 780 if err != nil { 1138 781 WriteInternalError(w, "Internal server error") 1139 782 return 1140 783 } 1141 784 1142 - enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 785 + lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) 786 + items := make([]service.APINote, len(notes)) 787 + for i, n := range notes { 788 + items[i] = h.hydration.ToAPINote(n, lc) 789 + } 1143 790 1144 791 WriteSuccess(w, map[string]interface{}{ 1145 792 "@context": "http://www.w3.org/ns/anno.jsonld", 1146 793 "type": "HighlightCollection", 1147 794 "creator": did, 1148 - "items": enriched, 1149 - "totalItems": len(enriched), 795 + "items": items, 796 + "totalItems": len(items), 1150 797 }) 1151 798 } 1152 799 ··· 1157 804 } 1158 805 limit := parseIntParam(r, "limit", 50) 1159 806 offset := parseIntParam(r, "offset", 0) 1160 - 1161 - var bookmarks []db.Bookmark 1162 - var err error 1163 - 1164 807 viewerDID := h.getViewerDID(r) 1165 808 1166 809 if offset == 0 && viewerDID != "" && did == viewerDID { ··· 1171 814 }() 1172 815 } 1173 816 1174 - bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) 1175 - 817 + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ 818 + AuthorDID: did, 819 + Motivations: []string{"bookmarking"}, 820 + Limit: limit, 821 + Offset: offset, 822 + }) 1176 823 if err != nil { 1177 824 WriteInternalError(w, "Internal server error") 1178 825 return 1179 826 } 1180 827 1181 - enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) 828 + lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) 829 + items := make([]service.APINote, len(notes)) 830 + for i, n := range notes { 831 + items[i] = h.hydration.ToAPINote(n, lc) 832 + } 1182 833 1183 834 WriteSuccess(w, map[string]interface{}{ 1184 835 "@context": "http://www.w3.org/ns/anno.jsonld", 1185 836 "type": "BookmarkCollection", 1186 837 "creator": did, 1187 - "items": enriched, 1188 - "totalItems": len(enriched), 838 + "items": items, 839 + "totalItems": len(items), 1189 840 }) 1190 841 } 1191 842 ··· 1209 860 1210 861 urlHash := db.HashURL(source) 1211 862 1212 - annotations, _ := h.db.GetAnnotationsByAuthorAndTargetHash(did, urlHash, limit, offset) 1213 - highlights, _ := h.db.GetHighlightsByAuthorAndTargetHash(did, urlHash, limit, offset) 863 + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ 864 + AuthorDID: did, 865 + TargetHash: urlHash, 866 + Motivations: []string{"commenting", "highlighting"}, 867 + Limit: limit, 868 + Offset: offset, 869 + }) 870 + if err != nil { 871 + WriteInternalError(w, "Internal server error") 872 + return 873 + } 1214 874 1215 - enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) 1216 - enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) 875 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 876 + items := make([]service.APINote, len(notes)) 877 + for i, n := range notes { 878 + items[i] = h.hydration.ToAPINote(n, lc) 879 + } 880 + annotations, highlights, _ := notesByMotivation(items) 1217 881 1218 882 WriteSuccess(w, map[string]interface{}{ 1219 883 "@context": "http://www.w3.org/ns/anno.jsonld", 1220 884 "creator": did, 1221 885 "source": source, 1222 886 "sourceHash": urlHash, 1223 - "annotations": enrichedAnnotations, 1224 - "highlights": enrichedHighlights, 887 + "annotations": annotations, 888 + "highlights": highlights, 1225 889 }) 1226 890 } 1227 891 ··· 1256 920 return 1257 921 } 1258 922 1259 - count, err := h.db.GetLikeCount(uri) 923 + count, err := h.engagementRepo.GetLikeCount(r.Context(), uri) 1260 924 if err != nil { 1261 925 WriteInternalError(w, "Internal server error") 1262 926 return ··· 1267 931 if err == nil && cookie != nil { 1268 932 session, err := h.refresher.GetSessionWithAutoRefresh(r) 1269 933 if err == nil { 1270 - userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri) 934 + userLike, err := h.noteRepo.GetLikeByUserAndSubject(r.Context(), session.DID, uri) 1271 935 if err == nil && userLike != nil { 1272 936 liked = true 1273 937 } ··· 1523 1187 limit := parseIntParam(r, "limit", 50) 1524 1188 offset := parseIntParam(r, "offset", 0) 1525 1189 1526 - notifications, err := h.db.GetNotifications(session.DID, limit, offset) 1190 + notifications, err := h.notificationRepo.GetNotifications(r.Context(), session.DID, limit, offset) 1527 1191 if err != nil { 1528 1192 WriteInternalError(w, "Failed to get notifications") 1529 1193 return ··· 1549 1213 return 1550 1214 } 1551 1215 1552 - count, err := h.db.GetUnreadNotificationCount(session.DID) 1216 + count, err := h.notificationRepo.GetUnreadNotificationCount(r.Context(), session.DID) 1553 1217 if err != nil { 1554 1218 WriteInternalError(w, "Failed to get count") 1555 1219 return ··· 1565 1229 return 1566 1230 } 1567 1231 1568 - if err := h.db.MarkNotificationsRead(session.DID); err != nil { 1232 + if err := h.notificationRepo.MarkNotificationsRead(r.Context(), session.DID); err != nil { 1569 1233 WriteInternalError(w, "Failed to mark as read") 1570 1234 return 1571 1235 } ··· 1577 1241 if err != nil { 1578 1242 return "" 1579 1243 } 1580 - did, _, _, _, _, err := h.db.GetSession(cookie.Value) 1244 + did, _, _, _, _, err := h.sessionRepo.GetSession(r.Context(), cookie.Value) 1581 1245 if err != nil { 1582 1246 return "" 1583 1247 } 1584 1248 return did 1585 1249 } 1586 1250 1587 - func getItemAuthorDID(item interface{}) string { 1588 - switch v := item.(type) { 1589 - case APIAnnotation: 1590 - return v.Author.DID 1591 - case APIHighlight: 1592 - return v.Author.DID 1593 - case APIBookmark: 1594 - return v.Author.DID 1595 - case APICollectionItem: 1596 - return v.Author.DID 1597 - default: 1598 - return "" 1599 - } 1251 + type fullProfileRepository struct { 1252 + db *db.DB 1600 1253 } 1601 1254 1602 - func (h *Handler) filterFeedByModeration(feed []interface{}, viewerDID string) []interface{} { 1603 - if viewerDID == "" { 1604 - return feed 1605 - } 1606 - 1607 - hiddenDIDs, err := h.db.GetAllHiddenDIDs(viewerDID) 1608 - if err != nil || len(hiddenDIDs) == 0 { 1609 - return feed 1610 - } 1611 - 1612 - var filtered []interface{} 1613 - for _, item := range feed { 1614 - authorDID := getItemAuthorDID(item) 1615 - if authorDID != "" && hiddenDIDs[authorDID] { 1616 - continue 1255 + func (r *fullProfileRepository) GetProfiles(_ context.Context, dids []string) (map[string]domain.Author, error) { 1256 + raw := fetchProfilesForDIDs(r.db, dids) 1257 + result := make(map[string]domain.Author, len(raw)) 1258 + for did, a := range raw { 1259 + result[did] = domain.Author{ 1260 + DID: a.DID, 1261 + Handle: a.Handle, 1262 + DisplayName: a.DisplayName, 1263 + Avatar: a.Avatar, 1617 1264 } 1618 - filtered = append(filtered, item) 1619 1265 } 1620 - return filtered 1266 + return result, nil 1621 1267 } 1622 1268 1623 - func mergeAnnotations(a, b []db.Annotation) []db.Annotation { 1624 - seen := make(map[string]bool) 1625 - var result []db.Annotation 1626 - for _, item := range a { 1627 - if !seen[item.URI] { 1628 - seen[item.URI] = true 1629 - result = append(result, item) 1630 - } 1631 - } 1632 - for _, item := range b { 1633 - if !seen[item.URI] { 1634 - seen[item.URI] = true 1635 - result = append(result, item) 1636 - } 1637 - } 1638 - return result 1269 + func (r *fullProfileRepository) GetProfile(_ context.Context, did string) (*domain.Profile, error) { 1270 + return r.db.GetProfile(did) 1639 1271 } 1640 1272 1641 - func mergeHighlights(a, b []db.Highlight) []db.Highlight { 1642 - seen := make(map[string]bool) 1643 - var result []db.Highlight 1644 - for _, item := range a { 1645 - if !seen[item.URI] { 1646 - seen[item.URI] = true 1647 - result = append(result, item) 1648 - } 1649 - } 1650 - for _, item := range b { 1651 - if !seen[item.URI] { 1652 - seen[item.URI] = true 1653 - result = append(result, item) 1654 - } 1655 - } 1656 - return result 1657 - } 1658 - 1659 - func mergeBookmarks(a, b []db.Bookmark) []db.Bookmark { 1660 - seen := make(map[string]bool) 1661 - var result []db.Bookmark 1662 - for _, item := range a { 1663 - if !seen[item.URI] { 1664 - seen[item.URI] = true 1665 - result = append(result, item) 1666 - } 1667 - } 1668 - for _, item := range b { 1669 - if !seen[item.URI] { 1670 - seen[item.URI] = true 1671 - result = append(result, item) 1672 - } 1673 - } 1674 - return result 1273 + func (r *fullProfileRepository) UpsertProfile(_ context.Context, p *domain.Profile) error { 1274 + return r.db.UpsertProfile(p) 1675 1275 } 1676 1276 1677 1277 func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { ··· 1684 1284 creator := r.URL.Query().Get("creator") 1685 1285 limit := parseIntParam(r, "limit", 50) 1686 1286 offset := parseIntParam(r, "offset", 0) 1687 - viewerDID := h.getViewerDID(r) 1688 1287 1689 - annotations, _ := h.db.SearchAnnotations(query, creator, limit, offset) 1690 - highlights, _ := h.db.SearchHighlights(query, creator, limit, offset) 1691 - bookmarks, _ := h.db.SearchBookmarks(query, creator, limit, offset) 1692 - 1693 - hydratedAnnotations, _ := hydrateAnnotations(h.db, annotations, viewerDID) 1694 - hydratedHighlights, _ := hydrateHighlights(h.db, highlights, viewerDID) 1695 - hydratedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) 1696 - 1697 - var feed []interface{} 1698 - for _, a := range hydratedAnnotations { 1699 - feed = append(feed, a) 1700 - } 1701 - for _, hl := range hydratedHighlights { 1702 - feed = append(feed, hl) 1288 + filter := db.NoteFilter{Query: query, Limit: limit, Offset: offset} 1289 + if creator != "" { 1290 + filter.AuthorDID = creator 1703 1291 } 1704 - for _, b := range hydratedBookmarks { 1705 - feed = append(feed, b) 1292 + 1293 + notes, err := h.noteRepo.List(r.Context(), filter) 1294 + if err != nil { 1295 + WriteInternalError(w, "Internal server error") 1296 + return 1706 1297 } 1707 1298 1708 - sortFeed(feed) 1299 + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) 1300 + items := make([]service.APINote, len(notes)) 1301 + for i, n := range notes { 1302 + items[i] = h.hydration.ToAPINote(n, lc) 1303 + } 1709 1304 1710 1305 WriteSuccess(w, map[string]interface{}{ 1711 - "items": feed, 1712 - "fetchedCount": len(feed), 1306 + "items": items, 1307 + "fetchedCount": len(items), 1713 1308 }) 1714 1309 } 1715 1310
+1415
backend/internal/api/notes.go
··· 1 + package api 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "regexp" 9 + "strings" 10 + "time" 11 + 12 + "margin.at/internal/db" 13 + "margin.at/internal/domain" 14 + "margin.at/internal/logger" 15 + "margin.at/internal/xrpc" 16 + ) 17 + 18 + type NoteIndexDB interface { 19 + CreateNote(n *domain.Note) error 20 + GetNoteByURI(uri string) (*domain.Note, error) 21 + DeleteNote(uri string) error 22 + UpdateNoteAnnotation(uri, bodyValue, tagsJSON, cid string) error 23 + UpdateNoteHighlight(uri, color, tagsJSON, cid string) error 24 + UpdateNoteBookmark(uri, title, description, tagsJSON, cid string) error 25 + CreateAnnotation(a *domain.Annotation) error 26 + GetAnnotationByURI(uri string) (*domain.Annotation, error) 27 + GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]domain.Annotation, error) 28 + UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error 29 + DeleteAnnotation(uri string) error 30 + CreateHighlight(h *domain.Highlight) error 31 + GetHighlightByURI(uri string) (*domain.Highlight, error) 32 + GetHighlightsByAuthor(authorDID string, limit, offset int) ([]domain.Highlight, error) 33 + UpdateHighlight(uri, color, tagsJSON, cid string) error 34 + DeleteHighlight(uri string) error 35 + CreateBookmark(b *domain.Bookmark) error 36 + GetBookmarkByURI(uri string) (*domain.Bookmark, error) 37 + GetBookmarksByTargetHash(targetHash string, limit, offset int) ([]domain.Bookmark, error) 38 + UpdateBookmark(uri, title, description, tagsJSON, cid string) error 39 + DeleteBookmark(uri string) error 40 + CreateLike(l *domain.Like) error 41 + GetLikeByUserAndSubject(userDID, subjectURI string) (*domain.Like, error) 42 + DeleteLike(uri string) error 43 + CreateReply(rep *domain.Reply) error 44 + GetReplyByURI(uri string) (*domain.Reply, error) 45 + DeleteReply(uri string) error 46 + CreateNotification(n *domain.Notification) error 47 + GetAuthorByURI(uri string) (string, error) 48 + GetPreferences(did string) (*domain.Preferences, error) 49 + SyncSelfLabels(authorDID, uri string, labels []string) error 50 + CreateContentLabel(src, uri, val, createdBy string) error 51 + SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error 52 + HashURL(rawURL string) string 53 + CommunityBookmarkExists(authorDID, targetHash, tagsJSON string) (bool, error) 54 + } 55 + 56 + type dbAdapter struct{ d *db.DB } 57 + 58 + func (a *dbAdapter) CreateNote(n *domain.Note) error { return a.d.CreateNote(n) } 59 + func (a *dbAdapter) GetNoteByURI(uri string) (*domain.Note, error) { return a.d.GetNoteByURI(uri) } 60 + func (a *dbAdapter) DeleteNote(uri string) error { return a.d.DeleteNote(uri) } 61 + func (a *dbAdapter) UpdateNoteAnnotation(uri, body, tags, cid string) error { 62 + return a.d.UpdateNoteAnnotation(uri, body, tags, cid) 63 + } 64 + func (a *dbAdapter) UpdateNoteHighlight(uri, color, tags, cid string) error { 65 + return a.d.UpdateNoteHighlight(uri, color, tags, cid) 66 + } 67 + func (a *dbAdapter) UpdateNoteBookmark(uri, title, desc, tags, cid string) error { 68 + return a.d.UpdateNoteBookmark(uri, title, desc, tags, cid) 69 + } 70 + func (a *dbAdapter) CreateAnnotation(ann *domain.Annotation) error { return a.d.CreateAnnotation(ann) } 71 + func (a *dbAdapter) GetAnnotationByURI(uri string) (*domain.Annotation, error) { 72 + return a.d.GetAnnotationByURI(uri) 73 + } 74 + func (a *dbAdapter) GetAnnotationsByAuthor(did string, limit, offset int) ([]domain.Annotation, error) { 75 + return a.d.GetAnnotationsByAuthor(did, limit, offset) 76 + } 77 + func (a *dbAdapter) UpdateAnnotation(uri, body, tags, cid string) error { 78 + return a.d.UpdateAnnotation(uri, body, tags, cid) 79 + } 80 + func (a *dbAdapter) DeleteAnnotation(uri string) error { return a.d.DeleteAnnotation(uri) } 81 + func (a *dbAdapter) CreateHighlight(h *domain.Highlight) error { return a.d.CreateHighlight(h) } 82 + func (a *dbAdapter) GetHighlightByURI(uri string) (*domain.Highlight, error) { 83 + return a.d.GetHighlightByURI(uri) 84 + } 85 + func (a *dbAdapter) GetHighlightsByAuthor(did string, limit, offset int) ([]domain.Highlight, error) { 86 + return a.d.GetHighlightsByAuthor(did, limit, offset) 87 + } 88 + func (a *dbAdapter) UpdateHighlight(uri, color, tags, cid string) error { 89 + return a.d.UpdateHighlight(uri, color, tags, cid) 90 + } 91 + func (a *dbAdapter) DeleteHighlight(uri string) error { return a.d.DeleteHighlight(uri) } 92 + func (a *dbAdapter) CreateBookmark(b *domain.Bookmark) error { return a.d.CreateBookmark(b) } 93 + func (a *dbAdapter) GetBookmarkByURI(uri string) (*domain.Bookmark, error) { 94 + return a.d.GetBookmarkByURI(uri) 95 + } 96 + func (a *dbAdapter) GetBookmarksByTargetHash(hash string, limit, offset int) ([]domain.Bookmark, error) { 97 + return a.d.GetBookmarksByTargetHash(hash, limit, offset) 98 + } 99 + func (a *dbAdapter) UpdateBookmark(uri, title, desc, tags, cid string) error { 100 + return a.d.UpdateBookmark(uri, title, desc, tags, cid) 101 + } 102 + func (a *dbAdapter) DeleteBookmark(uri string) error { return a.d.DeleteBookmark(uri) } 103 + func (a *dbAdapter) CreateLike(l *domain.Like) error { return a.d.CreateLike(l) } 104 + func (a *dbAdapter) GetLikeByUserAndSubject(did, sub string) (*domain.Like, error) { 105 + return a.d.GetLikeByUserAndSubject(did, sub) 106 + } 107 + func (a *dbAdapter) DeleteLike(uri string) error { return a.d.DeleteLike(uri) } 108 + func (a *dbAdapter) CreateReply(rep *domain.Reply) error { return a.d.CreateReply(rep) } 109 + func (a *dbAdapter) GetReplyByURI(uri string) (*domain.Reply, error) { 110 + return a.d.GetReplyByURI(uri) 111 + } 112 + func (a *dbAdapter) DeleteReply(uri string) error { return a.d.DeleteReply(uri) } 113 + func (a *dbAdapter) CreateNotification(n *domain.Notification) error { 114 + return a.d.CreateNotification(n) 115 + } 116 + func (a *dbAdapter) GetAuthorByURI(uri string) (string, error) { return a.d.GetAuthorByURI(uri) } 117 + func (a *dbAdapter) GetPreferences(did string) (*domain.Preferences, error) { 118 + return a.d.GetPreferences(did) 119 + } 120 + func (a *dbAdapter) SyncSelfLabels(author, uri string, labels []string) error { 121 + return a.d.SyncSelfLabels(author, uri, labels) 122 + } 123 + func (a *dbAdapter) CreateContentLabel(src, uri, val, by string) error { 124 + return a.d.CreateContentLabel(src, uri, val, by) 125 + } 126 + func (a *dbAdapter) SaveEditHistory(uri, rt, prev string, cid *string) error { 127 + return a.d.SaveEditHistory(uri, rt, prev, cid) 128 + } 129 + func (a *dbAdapter) HashURL(rawURL string) string { return db.HashURL(rawURL) } 130 + func (a *dbAdapter) CommunityBookmarkExists(did, hash, tags string) (bool, error) { 131 + return a.d.CommunityBookmarkExists(did, hash, tags) 132 + } 133 + 134 + type NoteWriteService struct { 135 + db NoteIndexDB 136 + refresher *TokenRefresher 137 + } 138 + 139 + func NewNoteWriteService(database *db.DB, refresher *TokenRefresher) *NoteWriteService { 140 + return &NoteWriteService{db: &dbAdapter{d: database}, refresher: refresher} 141 + } 142 + 143 + type CreateAnnotationRequest struct { 144 + URL string `json:"url"` 145 + Text string `json:"text"` 146 + Selector json.RawMessage `json:"selector,omitempty"` 147 + Title string `json:"title,omitempty"` 148 + Tags []string `json:"tags,omitempty"` 149 + Labels []string `json:"labels,omitempty"` 150 + } 151 + 152 + type CreateAnnotationResponse struct { 153 + URI string `json:"uri"` 154 + CID string `json:"cid"` 155 + } 156 + 157 + func (s *NoteWriteService) CreateAnnotation(w http.ResponseWriter, r *http.Request) { 158 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 159 + if err != nil { 160 + WriteUnauthorized(w, err.Error()) 161 + return 162 + } 163 + 164 + var req CreateAnnotationRequest 165 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 166 + WriteBadRequest(w, "Invalid request body") 167 + return 168 + } 169 + 170 + if req.URL == "" { 171 + WriteBadRequest(w, "URL is required") 172 + return 173 + } 174 + 175 + if req.Text == "" && req.Selector == nil && len(req.Tags) == 0 { 176 + WriteBadRequest(w, "Must provide text, selector, or tags") 177 + return 178 + } 179 + 180 + if len(req.Text) > 3000 { 181 + WriteBadRequest(w, "Text too long (max 3000 chars)") 182 + return 183 + } 184 + 185 + for i, t := range req.Tags { 186 + req.Tags[i] = strings.ToLower(t) 187 + } 188 + 189 + urlHash := db.HashURL(req.URL) 190 + 191 + motivation := "commenting" 192 + if req.Selector != nil && req.Text == "" { 193 + motivation = "highlighting" 194 + } else if len(req.Tags) > 0 { 195 + motivation = "tagging" 196 + } 197 + 198 + var facets []xrpc.Facet 199 + var mentionedDIDs []string 200 + 201 + mentionRegex := regexp.MustCompile(`(^|\s|@)@([a-zA-Z0-9.-]+)(\b)`) 202 + matches := mentionRegex.FindAllStringSubmatchIndex(req.Text, -1) 203 + 204 + for _, m := range matches { 205 + handle := req.Text[m[4]:m[5]] 206 + 207 + if !strings.Contains(handle, ".") { 208 + continue 209 + } 210 + 211 + var did string 212 + err := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { 213 + var resolveErr error 214 + did, resolveErr = client.ResolveHandle(r.Context(), handle) 215 + return resolveErr 216 + }) 217 + 218 + if err == nil && did != "" { 219 + start := m[2] 220 + end := m[5] 221 + 222 + facets = append(facets, xrpc.Facet{ 223 + Index: xrpc.FacetIndex{ 224 + ByteStart: start, 225 + ByteEnd: end, 226 + }, 227 + Features: []xrpc.FacetFeature{ 228 + { 229 + Type: "app.bsky.richtext.facet#mention", 230 + Did: did, 231 + }, 232 + }, 233 + }) 234 + mentionedDIDs = append(mentionedDIDs, did) 235 + } 236 + } 237 + 238 + urlRegex := regexp.MustCompile(`(https?://[^\s]+)`) 239 + urlMatches := urlRegex.FindAllStringIndex(req.Text, -1) 240 + 241 + for _, m := range urlMatches { 242 + facets = append(facets, xrpc.Facet{ 243 + Index: xrpc.FacetIndex{ 244 + ByteStart: m[0], 245 + ByteEnd: m[1], 246 + }, 247 + Features: []xrpc.FacetFeature{ 248 + { 249 + Type: "app.bsky.richtext.facet#link", 250 + Uri: req.Text[m[0]:m[1]], 251 + }, 252 + }, 253 + }) 254 + } 255 + 256 + record := xrpc.NewNoteRecord(req.URL, urlHash, req.Text, req.Selector, req.Title, "", "", motivation) 257 + if len(req.Tags) > 0 { 258 + record.Tags = req.Tags 259 + } 260 + if len(facets) > 0 { 261 + record.Facets = facets 262 + } 263 + 264 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 265 + 266 + var result *xrpc.CreateRecordOutput 267 + 268 + if existing, err := s.checkDuplicateAnnotation(session.DID, req.URL, req.Text); err == nil && existing != nil { 269 + w.Header().Set("Content-Type", "application/json") 270 + json.NewEncoder(w).Encode(CreateAnnotationResponse{ 271 + URI: existing.URI, 272 + CID: *existing.CID, 273 + }) 274 + return 275 + } 276 + 277 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 278 + var createErr error 279 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 280 + return createErr 281 + }) 282 + if err != nil { 283 + HandleAPIError(w, r, err, "Failed to create annotation: ", http.StatusInternalServerError) 284 + return 285 + } 286 + 287 + for _, mentionedDID := range mentionedDIDs { 288 + if mentionedDID != session.DID { 289 + s.db.CreateNotification(&db.Notification{ 290 + RecipientDID: mentionedDID, 291 + ActorDID: session.DID, 292 + Type: "mention", 293 + SubjectURI: result.URI, 294 + CreatedAt: time.Now(), 295 + }) 296 + } 297 + } 298 + 299 + bodyValue := req.Text 300 + var bodyValuePtr, targetTitlePtr, selectorJSONPtr *string 301 + if bodyValue != "" { 302 + bodyValuePtr = &bodyValue 303 + } 304 + if req.Title != "" { 305 + targetTitlePtr = &req.Title 306 + } 307 + if req.Selector != nil { 308 + selectorBytes, _ := json.Marshal(req.Selector) 309 + selectorStr := string(selectorBytes) 310 + selectorJSONPtr = &selectorStr 311 + } 312 + 313 + var tagsJSONPtr *string 314 + if len(req.Tags) > 0 { 315 + tagsBytes, _ := json.Marshal(req.Tags) 316 + tagsStr := string(tagsBytes) 317 + tagsJSONPtr = &tagsStr 318 + } 319 + 320 + cid := result.CID 321 + did := session.DID 322 + note := &db.Note{ 323 + URI: result.URI, 324 + CID: &cid, 325 + AuthorDID: did, 326 + Motivation: motivation, 327 + BodyValue: bodyValuePtr, 328 + TargetSource: req.URL, 329 + TargetHash: urlHash, 330 + TargetTitle: targetTitlePtr, 331 + SelectorJSON: selectorJSONPtr, 332 + TagsJSON: tagsJSONPtr, 333 + CreatedAt: time.Now(), 334 + IndexedAt: time.Now(), 335 + } 336 + 337 + if err := s.db.CreateNote(note); err != nil { 338 + logger.Error("Warning: failed to index note in local DB: %v", err) 339 + } 340 + 341 + for _, label := range filterSelfLabels(req.Labels) { 342 + if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { 343 + logger.Error("Warning: failed to create self-label %s: %v", label, err) 344 + } 345 + } 346 + 347 + WriteSuccess(w, CreateAnnotationResponse{ 348 + URI: result.URI, 349 + CID: result.CID, 350 + }) 351 + } 352 + 353 + func (s *NoteWriteService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) { 354 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 355 + if err != nil { 356 + WriteUnauthorized(w, err.Error()) 357 + return 358 + } 359 + 360 + rkey := r.URL.Query().Get("rkey") 361 + collectionType := r.URL.Query().Get("type") 362 + 363 + if rkey == "" { 364 + WriteBadRequest(w, "rkey required") 365 + return 366 + } 367 + 368 + did := session.DID 369 + 370 + collection := xrpc.CollectionAnnotation 371 + if collectionType == "reply" { 372 + collection = xrpc.CollectionReply 373 + } else { 374 + candidateCollections := []string{xrpc.CollectionNote, xrpc.CollectionAnnotation, xrpc.CollectionHighlight, xrpc.CollectionBookmark, xrpc.CollectionCommunityBookmark, "network.cosmik.card"} 375 + for _, col := range candidateCollections { 376 + uri := "at://" + did + "/" + col + "/" + rkey 377 + if note, dbErr := s.db.GetNoteByURI(uri); dbErr == nil && note != nil { 378 + collection = col 379 + break 380 + } else if _, dbErr := s.db.GetAnnotationByURI(uri); dbErr == nil { 381 + collection = col 382 + break 383 + } 384 + } 385 + } 386 + 387 + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 388 + return client.DeleteRecord(r.Context(), did, collection, rkey) 389 + }) 390 + if pdsErr != nil { 391 + logger.Error("PDS delete failed (will still clean local DB): %v", pdsErr) 392 + } 393 + 394 + if collectionType == "reply" { 395 + uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey 396 + s.db.DeleteReply(uri) 397 + } else { 398 + uri := "at://" + did + "/" + collection + "/" + rkey 399 + s.db.DeleteAnnotation(uri) 400 + s.db.DeleteHighlight(uri) 401 + s.db.DeleteBookmark(uri) 402 + s.db.DeleteNote(uri) 403 + } 404 + 405 + WriteSuccess(w, map[string]bool{"success": true}) 406 + } 407 + 408 + type UpdateAnnotationRequest struct { 409 + Text string `json:"text"` 410 + Tags []string `json:"tags"` 411 + Labels []string `json:"labels,omitempty"` 412 + } 413 + 414 + func (s *NoteWriteService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { 415 + uri := r.URL.Query().Get("uri") 416 + if uri == "" { 417 + WriteBadRequest(w, "uri query parameter required") 418 + return 419 + } 420 + 421 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 422 + if err != nil { 423 + WriteUnauthorized(w, err.Error()) 424 + return 425 + } 426 + 427 + annotation, err := s.db.GetAnnotationByURI(uri) 428 + if err != nil || annotation == nil { 429 + WriteNotFound(w, "Annotation not found") 430 + return 431 + } 432 + 433 + if annotation.AuthorDID != session.DID { 434 + WriteForbidden(w, "Not authorized to edit this annotation") 435 + return 436 + } 437 + 438 + var req UpdateAnnotationRequest 439 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 440 + WriteBadRequest(w, "Invalid request body") 441 + return 442 + } 443 + 444 + parts := parseATURI(uri) 445 + if len(parts) < 3 { 446 + WriteBadRequest(w, "Invalid URI format") 447 + return 448 + } 449 + rkey := parts[2] 450 + 451 + for i, t := range req.Tags { 452 + req.Tags[i] = strings.ToLower(t) 453 + } 454 + 455 + tagsJSON := "" 456 + if len(req.Tags) > 0 { 457 + tagsBytes, _ := json.Marshal(req.Tags) 458 + tagsJSON = string(tagsBytes) 459 + } 460 + 461 + if annotation.BodyValue != nil { 462 + previousContent := *annotation.BodyValue 463 + logger.Info("[DEBUG] Saving edit history for %s. Previous content: %s", uri, previousContent) 464 + if err := s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID); err != nil { 465 + logger.Error("Failed to save edit history for %s: %v", uri, err) 466 + } else { 467 + logger.Info("[DEBUG] Successfully saved edit history for %s", uri) 468 + } 469 + } else { 470 + logger.Info("[DEBUG] Annotation BodyValue is nil for %s", uri) 471 + } 472 + 473 + var result *xrpc.PutRecordOutput 474 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 475 + collection := parts[1] 476 + existing, getErr := client.GetRecord(r.Context(), did, collection, rkey) 477 + if getErr != nil { 478 + return fmt.Errorf("failed to fetch existing record: %w", getErr) 479 + } 480 + 481 + var updateErr error 482 + if collection == xrpc.CollectionNote { 483 + var record xrpc.NoteRecord 484 + if err := json.Unmarshal(existing.Value, &record); err != nil { 485 + return fmt.Errorf("failed to parse existing record: %w", err) 486 + } 487 + record.Body = &xrpc.AnnotationBody{ 488 + Value: req.Text, 489 + Format: "text/plain", 490 + } 491 + if len(req.Tags) > 0 { 492 + record.Tags = req.Tags 493 + } else { 494 + record.Tags = nil 495 + } 496 + 497 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 498 + 499 + if err := record.Validate(); err != nil { 500 + return fmt.Errorf("validation failed: %w", err) 501 + } 502 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 503 + if updateErr != nil { 504 + _ = client.DeleteRecord(r.Context(), did, collection, rkey) 505 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 506 + } 507 + } else { 508 + var record xrpc.AnnotationRecord 509 + if err := json.Unmarshal(existing.Value, &record); err != nil { 510 + return fmt.Errorf("failed to parse existing record: %w", err) 511 + } 512 + record.Body = &xrpc.AnnotationBody{ 513 + Value: req.Text, 514 + Format: "text/plain", 515 + } 516 + if len(req.Tags) > 0 { 517 + record.Tags = req.Tags 518 + } else { 519 + record.Tags = nil 520 + } 521 + 522 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 523 + 524 + if err := record.Validate(); err != nil { 525 + return fmt.Errorf("validation failed: %w", err) 526 + } 527 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 528 + if updateErr != nil { 529 + _ = client.DeleteRecord(r.Context(), did, collection, rkey) 530 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 531 + } 532 + } 533 + return updateErr 534 + }) 535 + 536 + if err != nil { 537 + logger.Error("[UpdateAnnotation] Failed: %v", err) 538 + HandleAPIError(w, r, err, "Failed to update record: ", http.StatusInternalServerError) 539 + return 540 + } 541 + 542 + if parts[1] == xrpc.CollectionNote { 543 + s.db.UpdateNoteAnnotation(uri, req.Text, tagsJSON, result.CID) 544 + } else { 545 + s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID) 546 + } 547 + 548 + if err := s.db.SyncSelfLabels(session.DID, uri, filterSelfLabels(req.Labels)); err != nil { 549 + logger.Error("Warning: failed to sync self-labels: %v", err) 550 + } 551 + 552 + WriteSuccess(w, map[string]interface{}{ 553 + "success": true, 554 + "uri": result.URI, 555 + "cid": result.CID, 556 + }) 557 + } 558 + 559 + func parseATURI(uri string) []string { 560 + 561 + if len(uri) < 5 || uri[:5] != "at://" { 562 + return nil 563 + } 564 + return strings.Split(uri[5:], "/") 565 + } 566 + 567 + type CreateLikeRequest struct { 568 + SubjectURI string `json:"subjectUri"` 569 + SubjectCID string `json:"subjectCid"` 570 + } 571 + 572 + func (s *NoteWriteService) LikeAnnotation(w http.ResponseWriter, r *http.Request) { 573 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 574 + if err != nil { 575 + WriteUnauthorized(w, err.Error()) 576 + return 577 + } 578 + 579 + var req CreateLikeRequest 580 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 581 + WriteBadRequest(w, "Invalid request body") 582 + return 583 + } 584 + 585 + if req.SubjectURI == "" || req.SubjectCID == "" { 586 + WriteBadRequest(w, "subjectUri and subjectCid are required") 587 + return 588 + } 589 + 590 + existingLike, _ := s.db.GetLikeByUserAndSubject(session.DID, req.SubjectURI) 591 + if existingLike != nil { 592 + w.Header().Set("Content-Type", "application/json") 593 + json.NewEncoder(w).Encode(map[string]string{"uri": existingLike.URI, "existing": "true"}) 594 + return 595 + } 596 + 597 + record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID) 598 + 599 + if err := record.Validate(); err != nil { 600 + WriteBadRequest(w, "Validation error: "+err.Error()) 601 + return 602 + } 603 + 604 + var result *xrpc.CreateRecordOutput 605 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 606 + var createErr error 607 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionLike, record) 608 + return createErr 609 + }) 610 + if err != nil { 611 + HandleAPIError(w, r, err, "Failed to create like: ", http.StatusInternalServerError) 612 + return 613 + } 614 + 615 + did := session.DID 616 + like := &db.Like{ 617 + URI: result.URI, 618 + AuthorDID: did, 619 + SubjectURI: req.SubjectURI, 620 + CreatedAt: time.Now(), 621 + IndexedAt: time.Now(), 622 + } 623 + s.db.CreateLike(like) 624 + 625 + if authorDID, err := s.db.GetAuthorByURI(req.SubjectURI); err == nil && authorDID != did { 626 + s.db.CreateNotification(&db.Notification{ 627 + RecipientDID: authorDID, 628 + ActorDID: did, 629 + Type: "like", 630 + SubjectURI: req.SubjectURI, 631 + CreatedAt: time.Now(), 632 + }) 633 + } 634 + 635 + WriteSuccess(w, map[string]string{"uri": result.URI}) 636 + } 637 + 638 + func (s *NoteWriteService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) { 639 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 640 + if err != nil { 641 + WriteUnauthorized(w, err.Error()) 642 + return 643 + } 644 + 645 + subjectURI := r.URL.Query().Get("uri") 646 + if subjectURI == "" { 647 + WriteBadRequest(w, "uri query parameter required") 648 + return 649 + } 650 + 651 + userLike, err := s.db.GetLikeByUserAndSubject(session.DID, subjectURI) 652 + if err != nil { 653 + WriteNotFound(w, "Like not found") 654 + return 655 + } 656 + 657 + parts := strings.Split(userLike.URI, "/") 658 + rkey := parts[len(parts)-1] 659 + 660 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 661 + return client.DeleteRecord(r.Context(), did, xrpc.CollectionLike, rkey) 662 + }) 663 + if err != nil { 664 + HandleAPIError(w, r, err, "Failed to delete like: ", http.StatusInternalServerError) 665 + return 666 + } 667 + 668 + s.db.DeleteLike(userLike.URI) 669 + 670 + WriteSuccess(w, map[string]bool{"success": true}) 671 + } 672 + 673 + type CreateReplyRequest struct { 674 + ParentURI string `json:"parentUri"` 675 + ParentCID string `json:"parentCid"` 676 + RootURI string `json:"rootUri"` 677 + RootCID string `json:"rootCid"` 678 + Text string `json:"text"` 679 + } 680 + 681 + func (s *NoteWriteService) CreateReply(w http.ResponseWriter, r *http.Request) { 682 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 683 + if err != nil { 684 + WriteUnauthorized(w, err.Error()) 685 + return 686 + } 687 + 688 + var req CreateReplyRequest 689 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 690 + WriteBadRequest(w, "Invalid request body") 691 + return 692 + } 693 + 694 + if req.ParentURI == "" || req.ParentCID == "" { 695 + WriteBadRequest(w, "parentUri and parentCid are required") 696 + return 697 + } 698 + if req.RootURI == "" || req.RootCID == "" { 699 + WriteBadRequest(w, "rootUri and rootCid are required") 700 + return 701 + } 702 + if req.Text == "" { 703 + WriteBadRequest(w, "text is required") 704 + return 705 + } 706 + 707 + record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text) 708 + 709 + if err := record.Validate(); err != nil { 710 + WriteBadRequest(w, "Validation error: "+err.Error()) 711 + return 712 + } 713 + 714 + var result *xrpc.CreateRecordOutput 715 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 716 + var createErr error 717 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionReply, record) 718 + return createErr 719 + }) 720 + if err != nil { 721 + HandleAPIError(w, r, err, "Failed to create reply: ", http.StatusInternalServerError) 722 + return 723 + } 724 + 725 + reply := &db.Reply{ 726 + URI: result.URI, 727 + AuthorDID: session.DID, 728 + ParentURI: req.ParentURI, 729 + RootURI: req.RootURI, 730 + Text: req.Text, 731 + CreatedAt: time.Now(), 732 + IndexedAt: time.Now(), 733 + CID: &result.CID, 734 + } 735 + s.db.CreateReply(reply) 736 + 737 + if authorDID, err := s.db.GetAuthorByURI(req.ParentURI); err == nil && authorDID != session.DID { 738 + s.db.CreateNotification(&db.Notification{ 739 + RecipientDID: authorDID, 740 + ActorDID: session.DID, 741 + Type: "reply", 742 + SubjectURI: result.URI, 743 + CreatedAt: time.Now(), 744 + }) 745 + } 746 + 747 + if req.RootURI != req.ParentURI { 748 + if rootAuthorDID, err := s.db.GetAuthorByURI(req.RootURI); err == nil && rootAuthorDID != session.DID { 749 + parentAuthorDID, _ := s.db.GetAuthorByURI(req.ParentURI) 750 + if rootAuthorDID != parentAuthorDID { 751 + s.db.CreateNotification(&db.Notification{ 752 + RecipientDID: rootAuthorDID, 753 + ActorDID: session.DID, 754 + Type: "reply", 755 + SubjectURI: result.URI, 756 + CreatedAt: time.Now(), 757 + }) 758 + } 759 + } 760 + } 761 + 762 + WriteSuccess(w, map[string]string{"uri": result.URI}) 763 + } 764 + 765 + func (s *NoteWriteService) DeleteReply(w http.ResponseWriter, r *http.Request) { 766 + uri := r.URL.Query().Get("uri") 767 + if uri == "" { 768 + WriteBadRequest(w, "uri query parameter required") 769 + return 770 + } 771 + 772 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 773 + if err != nil { 774 + WriteUnauthorized(w, err.Error()) 775 + return 776 + } 777 + 778 + reply, err := s.db.GetReplyByURI(uri) 779 + if err != nil || reply == nil { 780 + WriteNotFound(w, "reply not found") 781 + return 782 + } 783 + 784 + if reply.AuthorDID != session.DID { 785 + WriteForbidden(w, "not authorized to delete this reply") 786 + return 787 + } 788 + 789 + parts := strings.Split(uri, "/") 790 + if len(parts) >= 2 { 791 + rkey := parts[len(parts)-1] 792 + _ = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 793 + return client.DeleteRecord(r.Context(), did, "at.margin.reply", rkey) 794 + }) 795 + } 796 + 797 + s.db.DeleteReply(uri) 798 + 799 + WriteSuccess(w, map[string]bool{"success": true}) 800 + } 801 + 802 + type CreateHighlightRequest struct { 803 + URL string `json:"url"` 804 + Title string `json:"title,omitempty"` 805 + Selector json.RawMessage `json:"selector"` 806 + Color string `json:"color,omitempty"` 807 + Tags []string `json:"tags,omitempty"` 808 + Labels []string `json:"labels,omitempty"` 809 + } 810 + 811 + func (s *NoteWriteService) CreateHighlight(w http.ResponseWriter, r *http.Request) { 812 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 813 + if err != nil { 814 + WriteUnauthorized(w, err.Error()) 815 + return 816 + } 817 + 818 + var req CreateHighlightRequest 819 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 820 + WriteBadRequest(w, "Invalid request body") 821 + return 822 + } 823 + 824 + if req.URL == "" || req.Selector == nil { 825 + WriteBadRequest(w, "URL and selector are required") 826 + return 827 + } 828 + 829 + for i, t := range req.Tags { 830 + req.Tags[i] = strings.ToLower(t) 831 + } 832 + 833 + urlHash := db.HashURL(req.URL) 834 + record := xrpc.NewNoteRecord(req.URL, urlHash, "", req.Selector, req.Title, req.Color, "", "highlighting") 835 + if len(req.Tags) > 0 { 836 + record.Tags = req.Tags 837 + } 838 + 839 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 840 + 841 + if err := record.Validate(); err != nil { 842 + WriteBadRequest(w, "Validation error: "+err.Error()) 843 + return 844 + } 845 + 846 + var result *xrpc.CreateRecordOutput 847 + 848 + if existing, err := s.checkDuplicateHighlight(session.DID, req.URL, req.Selector); err == nil && existing != nil { 849 + w.Header().Set("Content-Type", "application/json") 850 + json.NewEncoder(w).Encode(map[string]string{"uri": existing.URI, "cid": *existing.CID}) 851 + return 852 + } 853 + 854 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 855 + var createErr error 856 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 857 + return createErr 858 + }) 859 + if err != nil { 860 + HandleAPIError(w, r, err, "Failed to create highlight: ", http.StatusInternalServerError) 861 + return 862 + } 863 + 864 + var selectorJSONPtr *string 865 + if len(record.Target.Selector) > 0 { 866 + selectorStr := string(record.Target.Selector) 867 + selectorJSONPtr = &selectorStr 868 + } 869 + 870 + var titlePtr *string 871 + if req.Title != "" { 872 + titlePtr = &req.Title 873 + } 874 + 875 + var colorPtr *string 876 + if req.Color != "" { 877 + colorPtr = &req.Color 878 + } 879 + 880 + var tagsJSONPtr *string 881 + if len(req.Tags) > 0 { 882 + tagsBytes, _ := json.Marshal(req.Tags) 883 + tagsStr := string(tagsBytes) 884 + tagsJSONPtr = &tagsStr 885 + } 886 + 887 + cid := result.CID 888 + note := &db.Note{ 889 + URI: result.URI, 890 + AuthorDID: session.DID, 891 + Motivation: "highlighting", 892 + TargetSource: req.URL, 893 + TargetHash: urlHash, 894 + TargetTitle: titlePtr, 895 + SelectorJSON: selectorJSONPtr, 896 + Color: colorPtr, 897 + TagsJSON: tagsJSONPtr, 898 + CreatedAt: time.Now(), 899 + IndexedAt: time.Now(), 900 + CID: &cid, 901 + } 902 + if err := s.db.CreateNote(note); err != nil { 903 + WriteInternalError(w, "Failed to index highlight node") 904 + return 905 + } 906 + 907 + for _, label := range filterSelfLabels(req.Labels) { 908 + if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { 909 + logger.Error("Warning: failed to create self-label %s: %v", label, err) 910 + } 911 + } 912 + 913 + WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) 914 + } 915 + 916 + type CreateBookmarkRequest struct { 917 + URL string `json:"url"` 918 + Title string `json:"title,omitempty"` 919 + Description string `json:"description,omitempty"` 920 + Tags []string `json:"tags,omitempty"` 921 + } 922 + 923 + func (s *NoteWriteService) CreateBookmark(w http.ResponseWriter, r *http.Request) { 924 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 925 + if err != nil { 926 + WriteUnauthorized(w, err.Error()) 927 + return 928 + } 929 + 930 + var req CreateBookmarkRequest 931 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 932 + WriteBadRequest(w, "Invalid request body") 933 + return 934 + } 935 + 936 + if req.URL == "" { 937 + WriteBadRequest(w, "URL is required") 938 + return 939 + } 940 + 941 + for i, t := range req.Tags { 942 + req.Tags[i] = strings.ToLower(t) 943 + } 944 + 945 + urlHash := db.HashURL(req.URL) 946 + record := xrpc.NewNoteRecord(req.URL, urlHash, "", nil, req.Title, "", req.Description, "bookmarking") 947 + if len(req.Tags) > 0 { 948 + record.Tags = req.Tags 949 + } 950 + 951 + if err := record.Validate(); err != nil { 952 + WriteBadRequest(w, "Validation error: "+err.Error()) 953 + return 954 + } 955 + 956 + var result *xrpc.CreateRecordOutput 957 + 958 + if existing, err := s.checkDuplicateBookmark(session.DID, req.URL); err == nil && existing != nil { 959 + WriteConflict(w, "Bookmark already exists") 960 + return 961 + } 962 + 963 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 964 + var createErr error 965 + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) 966 + return createErr 967 + }) 968 + if err != nil { 969 + HandleAPIError(w, r, err, "Failed to create bookmark: ", http.StatusInternalServerError) 970 + return 971 + } 972 + 973 + capturedSession := session 974 + capturedTags := append([]string(nil), req.Tags...) 975 + capturedURL := req.URL 976 + capturedURLHash := urlHash 977 + go func() { 978 + prefs, dbErr := s.db.GetPreferences(capturedSession.DID) 979 + communityEnabled := dbErr == nil && prefs != nil && (prefs.EnableCommunityBookmarks == nil || *prefs.EnableCommunityBookmarks) 980 + if !communityEnabled { 981 + return 982 + } 983 + 984 + tagsJSON := "" 985 + if len(capturedTags) > 0 { 986 + if b, err := json.Marshal(capturedTags); err == nil { 987 + tagsJSON = string(b) 988 + } 989 + } 990 + if exists, err := s.db.CommunityBookmarkExists(capturedSession.DID, capturedURLHash, tagsJSON); err == nil && exists { 991 + return 992 + } 993 + 994 + client := s.refresher.CreateClientFromSession(capturedSession) 995 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 996 + defer cancel() 997 + communityRecord := map[string]interface{}{ 998 + "$type": xrpc.CollectionCommunityBookmark, 999 + "subject": capturedURL, 1000 + "createdAt": time.Now().UTC().Format(time.RFC3339), 1001 + } 1002 + if len(capturedTags) > 0 { 1003 + communityRecord["tags"] = capturedTags 1004 + } 1005 + _, _ = client.CreateRecord(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, communityRecord) 1006 + }() 1007 + 1008 + var titlePtr *string 1009 + if req.Title != "" { 1010 + titlePtr = &req.Title 1011 + } 1012 + var descPtr *string 1013 + if req.Description != "" { 1014 + descPtr = &req.Description 1015 + } 1016 + 1017 + var tagsJSONPtr *string 1018 + if len(req.Tags) > 0 { 1019 + tagsBytes, _ := json.Marshal(req.Tags) 1020 + tagsStr := string(tagsBytes) 1021 + tagsJSONPtr = &tagsStr 1022 + } 1023 + 1024 + cid := result.CID 1025 + note := &db.Note{ 1026 + URI: result.URI, 1027 + AuthorDID: session.DID, 1028 + Motivation: "bookmarking", 1029 + TargetSource: req.URL, 1030 + TargetHash: urlHash, 1031 + TargetTitle: titlePtr, 1032 + BodyValue: descPtr, 1033 + TagsJSON: tagsJSONPtr, 1034 + CreatedAt: time.Now(), 1035 + IndexedAt: time.Now(), 1036 + CID: &cid, 1037 + } 1038 + if err := s.db.CreateNote(note); err != nil { 1039 + logger.Error("Warning: failed to index bookmark in local DB: %v", err) 1040 + } 1041 + 1042 + WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) 1043 + } 1044 + 1045 + func (s *NoteWriteService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { 1046 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 1047 + if err != nil { 1048 + WriteUnauthorized(w, err.Error()) 1049 + return 1050 + } 1051 + 1052 + rkey := r.URL.Query().Get("rkey") 1053 + if rkey == "" { 1054 + WriteBadRequest(w, "rkey required") 1055 + return 1056 + } 1057 + 1058 + did := session.DID 1059 + collection := xrpc.CollectionNote 1060 + for _, col := range []string{xrpc.CollectionNote, xrpc.CollectionHighlight} { 1061 + uri := "at://" + did + "/" + col + "/" + rkey 1062 + if note, dbErr := s.db.GetNoteByURI(uri); dbErr == nil && note != nil { 1063 + collection = col 1064 + break 1065 + } else if _, dbErr := s.db.GetHighlightByURI(uri); dbErr == nil { 1066 + collection = col 1067 + break 1068 + } 1069 + } 1070 + 1071 + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 1072 + return client.DeleteRecord(r.Context(), did, collection, rkey) 1073 + }) 1074 + if pdsErr != nil { 1075 + logger.Error("PDS delete highlight failed (will still clean local DB): %v", pdsErr) 1076 + } 1077 + 1078 + uri := "at://" + did + "/" + collection + "/" + rkey 1079 + s.db.DeleteHighlight(uri) 1080 + s.db.DeleteNote(uri) 1081 + 1082 + WriteSuccess(w, map[string]bool{"success": true}) 1083 + } 1084 + 1085 + func (s *NoteWriteService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { 1086 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 1087 + if err != nil { 1088 + WriteUnauthorized(w, err.Error()) 1089 + return 1090 + } 1091 + 1092 + rkey := r.URL.Query().Get("rkey") 1093 + if rkey == "" { 1094 + WriteBadRequest(w, "rkey required") 1095 + return 1096 + } 1097 + 1098 + did := session.DID 1099 + collection := xrpc.CollectionNote 1100 + for _, col := range []string{xrpc.CollectionNote, xrpc.CollectionBookmark, xrpc.CollectionCommunityBookmark} { 1101 + uri := "at://" + did + "/" + col + "/" + rkey 1102 + if note, dbErr := s.db.GetNoteByURI(uri); dbErr == nil && note != nil { 1103 + collection = col 1104 + break 1105 + } else if _, dbErr := s.db.GetBookmarkByURI(uri); dbErr == nil { 1106 + collection = col 1107 + break 1108 + } 1109 + } 1110 + 1111 + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 1112 + return client.DeleteRecord(r.Context(), did, collection, rkey) 1113 + }) 1114 + if pdsErr != nil { 1115 + logger.Error("PDS delete bookmark failed (will still clean local DB): %v", pdsErr) 1116 + } 1117 + 1118 + uri := "at://" + did + "/" + collection + "/" + rkey 1119 + s.db.DeleteBookmark(uri) 1120 + s.db.DeleteNote(uri) 1121 + 1122 + WriteSuccess(w, map[string]bool{"success": true}) 1123 + } 1124 + 1125 + type UpdateHighlightRequest struct { 1126 + Color string `json:"color"` 1127 + Tags []string `json:"tags,omitempty"` 1128 + Labels []string `json:"labels,omitempty"` 1129 + } 1130 + 1131 + func (s *NoteWriteService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { 1132 + uri := r.URL.Query().Get("uri") 1133 + if uri == "" { 1134 + WriteBadRequest(w, "uri query parameter required") 1135 + return 1136 + } 1137 + 1138 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 1139 + if err != nil { 1140 + WriteUnauthorized(w, err.Error()) 1141 + return 1142 + } 1143 + 1144 + if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 1145 + WriteForbidden(w, "Not authorized") 1146 + return 1147 + } 1148 + 1149 + var req UpdateHighlightRequest 1150 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 1151 + WriteBadRequest(w, "Invalid request body") 1152 + return 1153 + } 1154 + 1155 + parts := parseATURI(uri) 1156 + if len(parts) < 3 { 1157 + WriteBadRequest(w, "Invalid URI") 1158 + return 1159 + } 1160 + rkey := parts[2] 1161 + 1162 + for i, t := range req.Tags { 1163 + req.Tags[i] = strings.ToLower(t) 1164 + } 1165 + 1166 + var result *xrpc.PutRecordOutput 1167 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 1168 + collection := parts[1] 1169 + existing, getErr := client.GetRecord(r.Context(), did, collection, rkey) 1170 + if getErr != nil { 1171 + return fmt.Errorf("failed to fetch record: %w", getErr) 1172 + } 1173 + 1174 + var updateErr error 1175 + if collection == xrpc.CollectionNote { 1176 + var record xrpc.NoteRecord 1177 + json.Unmarshal(existing.Value, &record) 1178 + 1179 + if req.Color != "" { 1180 + record.Color = req.Color 1181 + } 1182 + if req.Tags != nil { 1183 + record.Tags = req.Tags 1184 + } 1185 + 1186 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 1187 + 1188 + if err := record.Validate(); err != nil { 1189 + return fmt.Errorf("validation failed: %w", err) 1190 + } 1191 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1192 + if updateErr != nil { 1193 + _ = client.DeleteRecord(r.Context(), did, collection, rkey) 1194 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1195 + } 1196 + } else { 1197 + var record xrpc.HighlightRecord 1198 + json.Unmarshal(existing.Value, &record) 1199 + 1200 + if req.Color != "" { 1201 + record.Color = req.Color 1202 + } 1203 + if req.Tags != nil { 1204 + record.Tags = req.Tags 1205 + } 1206 + 1207 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 1208 + 1209 + if err := record.Validate(); err != nil { 1210 + return fmt.Errorf("validation failed: %w", err) 1211 + } 1212 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1213 + if updateErr != nil { 1214 + _ = client.DeleteRecord(r.Context(), did, collection, rkey) 1215 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1216 + } 1217 + } 1218 + return updateErr 1219 + }) 1220 + 1221 + if err != nil { 1222 + HandleAPIError(w, r, err, "Failed to update: ", http.StatusInternalServerError) 1223 + return 1224 + } 1225 + 1226 + tagsJSON := "" 1227 + if req.Tags != nil { 1228 + b, _ := json.Marshal(req.Tags) 1229 + tagsJSON = string(b) 1230 + } 1231 + if parts[1] == xrpc.CollectionNote { 1232 + s.db.UpdateNoteHighlight(uri, req.Color, tagsJSON, result.CID) 1233 + } else { 1234 + s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) 1235 + } 1236 + 1237 + if err := s.db.SyncSelfLabels(session.DID, uri, filterSelfLabels(req.Labels)); err != nil { 1238 + logger.Error("Warning: failed to sync self-labels: %v", err) 1239 + } 1240 + 1241 + WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1242 + } 1243 + 1244 + type UpdateBookmarkRequest struct { 1245 + Title string `json:"title"` 1246 + Description string `json:"description"` 1247 + Tags []string `json:"tags,omitempty"` 1248 + Labels []string `json:"labels,omitempty"` 1249 + } 1250 + 1251 + func (s *NoteWriteService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { 1252 + uri := r.URL.Query().Get("uri") 1253 + if uri == "" { 1254 + WriteBadRequest(w, "uri query parameter required") 1255 + return 1256 + } 1257 + 1258 + session, err := s.refresher.GetSessionWithAutoRefresh(r) 1259 + if err != nil { 1260 + WriteUnauthorized(w, err.Error()) 1261 + return 1262 + } 1263 + 1264 + if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) { 1265 + WriteForbidden(w, "Not authorized") 1266 + return 1267 + } 1268 + 1269 + var req UpdateBookmarkRequest 1270 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 1271 + WriteBadRequest(w, "Invalid request body") 1272 + return 1273 + } 1274 + 1275 + parts := parseATURI(uri) 1276 + if len(parts) < 3 { 1277 + WriteBadRequest(w, "Invalid URI") 1278 + return 1279 + } 1280 + rkey := parts[2] 1281 + 1282 + var result *xrpc.PutRecordOutput 1283 + for i, t := range req.Tags { 1284 + req.Tags[i] = strings.ToLower(t) 1285 + } 1286 + 1287 + err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { 1288 + collection := parts[1] 1289 + existing, getErr := client.GetRecord(r.Context(), did, collection, rkey) 1290 + if getErr != nil { 1291 + return fmt.Errorf("failed to fetch record: %w", getErr) 1292 + } 1293 + 1294 + var updateErr error 1295 + if collection == xrpc.CollectionNote { 1296 + var record xrpc.NoteRecord 1297 + json.Unmarshal(existing.Value, &record) 1298 + 1299 + if req.Title != "" { 1300 + record.Target.Title = req.Title 1301 + } 1302 + if req.Description != "" { 1303 + if record.Body == nil { 1304 + record.Body = &xrpc.AnnotationBody{Format: "text/plain"} 1305 + } 1306 + record.Body.Value = req.Description 1307 + } 1308 + if req.Tags != nil { 1309 + record.Tags = req.Tags 1310 + } 1311 + 1312 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 1313 + 1314 + if err := record.Validate(); err != nil { 1315 + return fmt.Errorf("validation failed: %w", err) 1316 + } 1317 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1318 + if updateErr != nil { 1319 + _ = client.DeleteRecord(r.Context(), did, collection, rkey) 1320 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1321 + } 1322 + } else { 1323 + var record xrpc.BookmarkRecord 1324 + json.Unmarshal(existing.Value, &record) 1325 + 1326 + if req.Title != "" { 1327 + record.Title = req.Title 1328 + } 1329 + if req.Description != "" { 1330 + record.Description = req.Description 1331 + } 1332 + if req.Tags != nil { 1333 + record.Tags = req.Tags 1334 + } 1335 + 1336 + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) 1337 + 1338 + if err := record.Validate(); err != nil { 1339 + return fmt.Errorf("validation failed: %w", err) 1340 + } 1341 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1342 + if updateErr != nil { 1343 + _ = client.DeleteRecord(r.Context(), did, collection, rkey) 1344 + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) 1345 + } 1346 + } 1347 + return updateErr 1348 + }) 1349 + 1350 + if err != nil { 1351 + HandleAPIError(w, r, err, "Failed to update: ", http.StatusInternalServerError) 1352 + return 1353 + } 1354 + 1355 + tagsJSON := "" 1356 + if req.Tags != nil { 1357 + b, _ := json.Marshal(req.Tags) 1358 + tagsJSON = string(b) 1359 + } 1360 + if parts[1] == xrpc.CollectionNote { 1361 + s.db.UpdateNoteBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) 1362 + } else { 1363 + s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) 1364 + } 1365 + 1366 + if err := s.db.SyncSelfLabels(session.DID, uri, filterSelfLabels(req.Labels)); err != nil { 1367 + logger.Error("Warning: failed to sync self-labels: %v", err) 1368 + } 1369 + 1370 + if req.Tags != nil { 1371 + capturedSession := session 1372 + capturedTags := append([]string(nil), req.Tags...) 1373 + capturedURI := uri 1374 + go func() { 1375 + note, err := s.db.GetNoteByURI(capturedURI) 1376 + if err != nil || note == nil || note.TargetSource == "" { 1377 + return 1378 + } 1379 + targetURL := note.TargetSource 1380 + client := s.refresher.CreateClientFromSession(capturedSession) 1381 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 1382 + defer cancel() 1383 + records, err := client.ListRecords(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, 100) 1384 + if err != nil { 1385 + return 1386 + } 1387 + for _, rec := range records.Records { 1388 + var cb struct { 1389 + Subject string `json:"subject"` 1390 + CreatedAt string `json:"createdAt"` 1391 + Tags []string `json:"tags"` 1392 + } 1393 + if err := json.Unmarshal(rec.Value, &cb); err != nil { 1394 + continue 1395 + } 1396 + if cb.Subject != targetURL { 1397 + continue 1398 + } 1399 + parts := parseATURI(rec.URI) 1400 + if len(parts) < 3 { 1401 + continue 1402 + } 1403 + _, _ = client.PutRecord(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, parts[2], map[string]interface{}{ 1404 + "$type": xrpc.CollectionCommunityBookmark, 1405 + "subject": cb.Subject, 1406 + "createdAt": cb.CreatedAt, 1407 + "tags": capturedTags, 1408 + }) 1409 + return 1410 + } 1411 + }() 1412 + } 1413 + 1414 + WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) 1415 + }
+103
backend/internal/api/notes_helpers.go
··· 1 + package api 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "time" 7 + 8 + "margin.at/internal/domain" 9 + "margin.at/internal/xrpc" 10 + ) 11 + 12 + var validSelfLabelSet = map[string]bool{ 13 + "sexual": true, 14 + "nudity": true, 15 + "violence": true, 16 + "gore": true, 17 + "spam": true, 18 + "misleading": true, 19 + } 20 + 21 + func filterSelfLabels(labels []string) []string { 22 + if len(labels) == 0 { 23 + return nil 24 + } 25 + out := make([]string, 0, len(labels)) 26 + for _, l := range labels { 27 + if validSelfLabelSet[l] { 28 + out = append(out, l) 29 + } 30 + } 31 + return out 32 + } 33 + 34 + func putRecordWithRetry( 35 + ctx context.Context, 36 + client *xrpc.Client, 37 + did, collection, rkey string, 38 + record interface{ Validate() error }, 39 + ) (*xrpc.PutRecordOutput, error) { 40 + if err := record.Validate(); err != nil { 41 + return nil, err 42 + } 43 + result, err := client.PutRecord(ctx, did, collection, rkey, record) 44 + if err != nil { 45 + _ = client.DeleteRecord(ctx, did, collection, rkey) 46 + result, err = client.PutRecord(ctx, did, collection, rkey, record) 47 + } 48 + return result, err 49 + } 50 + 51 + func (s *NoteWriteService) checkDuplicateAnnotation(did, url, text string) (*domain.Annotation, error) { 52 + recent, err := s.db.GetAnnotationsByAuthor(did, 5, 0) 53 + if err != nil { 54 + return nil, err 55 + } 56 + for i := range recent { 57 + a := &recent[i] 58 + if a.TargetSource == url && 59 + ((a.BodyValue == nil && text == "") || (a.BodyValue != nil && *a.BodyValue == text)) && 60 + time.Since(a.CreatedAt) < 10*time.Second { 61 + return a, nil 62 + } 63 + } 64 + return nil, nil 65 + } 66 + 67 + func (s *NoteWriteService) checkDuplicateHighlight(did, url string, selector json.RawMessage) (*domain.Highlight, error) { 68 + recent, err := s.db.GetHighlightsByAuthor(did, 5, 0) 69 + if err != nil { 70 + return nil, err 71 + } 72 + for i := range recent { 73 + h := &recent[i] 74 + if h.TargetSource != url || time.Since(h.CreatedAt) >= 10*time.Second { 75 + continue 76 + } 77 + if selector == nil && h.SelectorJSON == nil { 78 + return h, nil 79 + } 80 + if selector != nil && h.SelectorJSON != nil { 81 + b, _ := json.Marshal(selector) 82 + if *h.SelectorJSON == string(b) { 83 + return h, nil 84 + } 85 + } 86 + } 87 + return nil, nil 88 + } 89 + 90 + func (s *NoteWriteService) checkDuplicateBookmark(did, url string) (*domain.Bookmark, error) { 91 + urlHash := s.db.HashURL(url) 92 + bookmarks, err := s.db.GetBookmarksByTargetHash(urlHash, 50, 0) 93 + if err != nil { 94 + return nil, err 95 + } 96 + for i := range bookmarks { 97 + b := &bookmarks[i] 98 + if b.AuthorDID == did && b.Source == url { 99 + return b, nil 100 + } 101 + } 102 + return nil, nil 103 + }
+9 -1
backend/internal/api/preferences.go
··· 27 27 SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers"` 28 28 LabelPreferences []LabelPreference `json:"labelPreferences"` 29 29 DisableExternalLinkWarning bool `json:"disableExternalLinkWarning"` 30 + EnableCommunityBookmarks bool `json:"enableCommunityBookmarks"` 30 31 } 31 32 32 33 func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { ··· 72 73 disableWarning = *prefs.DisableExternalLinkWarning 73 74 } 74 75 76 + enableCommunityBookmarks := true 77 + if prefs != nil && prefs.EnableCommunityBookmarks != nil { 78 + enableCommunityBookmarks = *prefs.EnableCommunityBookmarks 79 + } 80 + 75 81 WriteSuccess(w, PreferencesResponse{ 76 82 ExternalLinkSkippedHostnames: hostnames, 77 83 SubscribedLabelers: labelers, 78 84 LabelPreferences: labelPrefs, 79 85 DisableExternalLinkWarning: disableWarning, 86 + EnableCommunityBookmarks: enableCommunityBookmarks, 80 87 }) 81 88 } 82 89 ··· 110 117 }) 111 118 } 112 119 113 - record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs, &input.DisableExternalLinkWarning) 120 + record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs, &input.DisableExternalLinkWarning, &input.EnableCommunityBookmarks) 114 121 if err := record.Validate(); err != nil { 115 122 WriteBadRequest(w, fmt.Sprintf("Invalid record: %v", err)) 116 123 return ··· 152 159 SubscribedLabelers: subscribedLabelersPtr, 153 160 LabelPreferences: labelPrefsPtr, 154 161 DisableExternalLinkWarning: &input.DisableExternalLinkWarning, 162 + EnableCommunityBookmarks: &input.EnableCommunityBookmarks, 155 163 CreatedAt: createdAt, 156 164 IndexedAt: time.Now(), 157 165 })
+1 -1
backend/internal/api/token_refresh.go
··· 48 48 baseURL = baseURL[:len(baseURL)-1] 49 49 } 50 50 51 - clientID := baseURL + "/client-metadata.json" 51 + clientID := baseURL + "/oauth-client-metadata.json" 52 52 redirectURI := baseURL + "/auth/callback" 53 53 54 54 return oauth.NewClient(clientID, redirectURI, tr.privateKey)
+34 -763
backend/internal/db/db.go
··· 8 8 "time" 9 9 10 10 _ "github.com/lib/pq" 11 + "margin.at/internal/domain" 11 12 ) 12 13 13 14 type DB struct { 14 15 *sql.DB 15 16 } 16 17 17 - type Annotation struct { 18 - URI string `json:"uri"` 19 - AuthorDID string `json:"authorDid"` 20 - Motivation string `json:"motivation,omitempty"` 21 - BodyValue *string `json:"bodyValue,omitempty"` 22 - BodyFormat *string `json:"bodyFormat,omitempty"` 23 - BodyURI *string `json:"bodyUri,omitempty"` 24 - TargetSource string `json:"targetSource"` 25 - TargetHash string `json:"targetHash"` 26 - TargetTitle *string `json:"targetTitle,omitempty"` 27 - SelectorJSON *string `json:"selector,omitempty"` 28 - TagsJSON *string `json:"tags,omitempty"` 29 - CreatedAt time.Time `json:"createdAt"` 30 - IndexedAt time.Time `json:"indexedAt"` 31 - CID *string `json:"cid,omitempty"` 32 - } 33 - 34 - type Selector struct { 35 - Type string `json:"type"` 36 - Exact string `json:"exact,omitempty"` 37 - Prefix string `json:"prefix,omitempty"` 38 - Suffix string `json:"suffix,omitempty"` 39 - Start *int `json:"start,omitempty"` 40 - End *int `json:"end,omitempty"` 41 - Value string `json:"value,omitempty"` 42 - } 43 - 44 - type Highlight struct { 45 - URI string `json:"uri"` 46 - AuthorDID string `json:"authorDid"` 47 - TargetSource string `json:"targetSource"` 48 - TargetHash string `json:"targetHash"` 49 - TargetTitle *string `json:"targetTitle,omitempty"` 50 - SelectorJSON *string `json:"selector,omitempty"` 51 - Color *string `json:"color,omitempty"` 52 - TagsJSON *string `json:"tags,omitempty"` 53 - CreatedAt time.Time `json:"createdAt"` 54 - IndexedAt time.Time `json:"indexedAt"` 55 - CID *string `json:"cid,omitempty"` 56 - } 57 - 58 - type Bookmark struct { 59 - URI string `json:"uri"` 60 - AuthorDID string `json:"authorDid"` 61 - Source string `json:"source"` 62 - SourceHash string `json:"sourceHash"` 63 - Title *string `json:"title,omitempty"` 64 - Description *string `json:"description,omitempty"` 65 - TagsJSON *string `json:"tags,omitempty"` 66 - CreatedAt time.Time `json:"createdAt"` 67 - IndexedAt time.Time `json:"indexedAt"` 68 - CID *string `json:"cid,omitempty"` 69 - } 70 - 71 - type Reply struct { 72 - URI string `json:"uri"` 73 - AuthorDID string `json:"authorDid"` 74 - ParentURI string `json:"parentUri"` 75 - RootURI string `json:"rootUri"` 76 - Text string `json:"text"` 77 - Format *string `json:"format,omitempty"` 78 - CreatedAt time.Time `json:"createdAt"` 79 - IndexedAt time.Time `json:"indexedAt"` 80 - CID *string `json:"cid,omitempty"` 81 - } 82 - 83 - type Like struct { 84 - URI string `json:"uri"` 85 - AuthorDID string `json:"authorDid"` 86 - SubjectURI string `json:"subjectUri"` 87 - CreatedAt time.Time `json:"createdAt"` 88 - IndexedAt time.Time `json:"indexedAt"` 89 - } 90 - 91 - type Collection struct { 92 - URI string `json:"uri"` 93 - AuthorDID string `json:"authorDid"` 94 - Name string `json:"name"` 95 - Description *string `json:"description,omitempty"` 96 - Icon *string `json:"icon,omitempty"` 97 - CreatedAt time.Time `json:"createdAt"` 98 - IndexedAt time.Time `json:"indexedAt"` 99 - } 100 - 101 - type CollectionItem struct { 102 - URI string `json:"uri"` 103 - AuthorDID string `json:"authorDid"` 104 - CollectionURI string `json:"collectionUri"` 105 - AnnotationURI string `json:"annotationUri"` 106 - Position int `json:"position"` 107 - CreatedAt time.Time `json:"createdAt"` 108 - IndexedAt time.Time `json:"indexedAt"` 109 - } 110 - 111 - type Notification struct { 112 - ID int `json:"id"` 113 - RecipientDID string `json:"recipientDid"` 114 - ActorDID string `json:"actorDid"` 115 - Type string `json:"type"` 116 - SubjectURI string `json:"subjectUri"` 117 - CreatedAt time.Time `json:"createdAt"` 118 - ReadAt *time.Time `json:"readAt,omitempty"` 119 - } 120 - 121 - type APIKey struct { 122 - ID string `json:"id"` 123 - OwnerDID string `json:"ownerDid"` 124 - Name string `json:"name"` 125 - KeyHash string `json:"-"` 126 - CreatedAt time.Time `json:"createdAt"` 127 - LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` 128 - URI string `json:"uri"` 129 - CID *string `json:"cid,omitempty"` 130 - IndexedAt time.Time `json:"indexedAt"` 131 - } 132 - 133 - type Profile struct { 134 - URI string `json:"uri"` 135 - AuthorDID string `json:"authorDid"` 136 - DisplayName *string `json:"displayName,omitempty"` 137 - Avatar *string `json:"avatar,omitempty"` 138 - Bio *string `json:"bio,omitempty"` 139 - Website *string `json:"website,omitempty"` 140 - LinksJSON *string `json:"links,omitempty"` 141 - CreatedAt time.Time `json:"createdAt"` 142 - IndexedAt time.Time `json:"indexedAt"` 143 - CID *string `json:"cid,omitempty"` 144 - } 145 - 146 - type Preferences struct { 147 - URI string `json:"uri"` 148 - AuthorDID string `json:"authorDid"` 149 - ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` 150 - SubscribedLabelers *string `json:"subscribedLabelers,omitempty"` 151 - LabelPreferences *string `json:"labelPreferences,omitempty"` 152 - DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` 153 - CreatedAt time.Time `json:"createdAt"` 154 - IndexedAt time.Time `json:"indexedAt"` 155 - CID *string `json:"cid,omitempty"` 156 - } 157 - 158 - type Block struct { 159 - ID int `json:"id"` 160 - ActorDID string `json:"actorDid"` 161 - SubjectDID string `json:"subjectDid"` 162 - CreatedAt time.Time `json:"createdAt"` 163 - } 164 - 165 - type Mute struct { 166 - ID int `json:"id"` 167 - ActorDID string `json:"actorDid"` 168 - SubjectDID string `json:"subjectDid"` 169 - CreatedAt time.Time `json:"createdAt"` 170 - } 171 - 172 - type ModerationReport struct { 173 - ID int `json:"id"` 174 - ReporterDID string `json:"reporterDid"` 175 - SubjectDID string `json:"subjectDid"` 176 - SubjectURI *string `json:"subjectUri,omitempty"` 177 - ReasonType string `json:"reasonType"` 178 - ReasonText *string `json:"reasonText,omitempty"` 179 - Status string `json:"status"` 180 - CreatedAt time.Time `json:"createdAt"` 181 - ResolvedAt *time.Time `json:"resolvedAt,omitempty"` 182 - ResolvedBy *string `json:"resolvedBy,omitempty"` 183 - } 184 - 185 - type ModerationAction struct { 186 - ID int `json:"id"` 187 - ReportID int `json:"reportId"` 188 - ActorDID string `json:"actorDid"` 189 - Action string `json:"action"` 190 - Comment *string `json:"comment,omitempty"` 191 - CreatedAt time.Time `json:"createdAt"` 192 - } 193 - 194 - type ContentLabel struct { 195 - ID int `json:"id"` 196 - Src string `json:"src"` 197 - URI string `json:"uri"` 198 - Val string `json:"val"` 199 - Neg bool `json:"neg"` 200 - CreatedBy string `json:"createdBy"` 201 - CreatedAt time.Time `json:"createdAt"` 202 - } 18 + type ( 19 + Note = domain.Note 20 + Annotation = domain.Annotation 21 + Selector = domain.Selector 22 + Highlight = domain.Highlight 23 + Bookmark = domain.Bookmark 24 + Reply = domain.Reply 25 + Like = domain.Like 26 + Collection = domain.Collection 27 + CollectionItem = domain.CollectionItem 28 + Notification = domain.Notification 29 + APIKey = domain.APIKey 30 + Profile = domain.Profile 31 + Preferences = domain.Preferences 32 + Block = domain.Block 33 + Mute = domain.Mute 34 + ModerationReport = domain.ModerationReport 35 + ModerationAction = domain.ModerationAction 36 + ContentLabel = domain.ContentLabel 37 + ) 203 38 204 39 func New(dsn string) (*DB, error) { 205 40 if !strings.HasPrefix(dsn, "postgres://") && !strings.HasPrefix(dsn, "postgresql://") { 206 - return nil, fmt.Errorf("only PostgreSQL is supported, DSN must start with postgres:// or postgresql://") 41 + return nil, fmt.Errorf("only PostgreSQL is supported; DSN must start with postgres:// or postgresql://") 207 42 } 208 43 209 - db, err := sql.Open("postgres", dsn) 44 + sqlDB, err := sql.Open("postgres", dsn) 210 45 if err != nil { 211 - return nil, fmt.Errorf("failed to open database connection: %w", err) 46 + return nil, fmt.Errorf("open database: %w", err) 212 47 } 213 48 214 - db.SetMaxOpenConns(25) 215 - db.SetMaxIdleConns(10) 216 - db.SetConnMaxLifetime(5 * time.Minute) 217 - db.SetConnMaxIdleTime(2 * time.Minute) 49 + sqlDB.SetMaxOpenConns(25) 50 + sqlDB.SetMaxIdleConns(10) 51 + sqlDB.SetConnMaxLifetime(5 * time.Minute) 52 + sqlDB.SetConnMaxIdleTime(2 * time.Minute) 218 53 219 - if err := db.Ping(); err != nil { 220 - return nil, fmt.Errorf("failed to ping database: %w", err) 54 + if err := sqlDB.Ping(); err != nil { 55 + return nil, fmt.Errorf("ping database: %w", err) 221 56 } 222 57 223 - return &DB{DB: db}, nil 58 + return &DB{DB: sqlDB}, nil 224 59 } 225 60 226 - func (db *DB) Migrate() error { 227 - _, err := db.Exec(` 228 - CREATE TABLE IF NOT EXISTS annotations ( 229 - uri TEXT PRIMARY KEY, 230 - author_did TEXT NOT NULL, 231 - motivation TEXT, 232 - body_value TEXT, 233 - body_format TEXT DEFAULT 'text/plain', 234 - body_uri TEXT, 235 - target_source TEXT NOT NULL, 236 - target_hash TEXT NOT NULL, 237 - target_title TEXT, 238 - selector_json TEXT, 239 - tags_json TEXT, 240 - created_at TIMESTAMP NOT NULL, 241 - indexed_at TIMESTAMP NOT NULL, 242 - cid TEXT 243 - )`) 244 - if err != nil { 245 - return err 246 - } 247 - 248 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_target_hash ON annotations(target_hash)`) 249 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_target_source ON annotations(target_source)`) 250 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_author_did ON annotations(author_did)`) 251 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_motivation ON annotations(motivation)`) 252 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`) 253 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_author_created ON annotations(author_did, created_at DESC)`) 254 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_uri_pattern ON annotations(uri text_pattern_ops)`) 255 - 256 - db.Exec(`CREATE TABLE IF NOT EXISTS highlights ( 257 - uri TEXT PRIMARY KEY, 258 - author_did TEXT NOT NULL, 259 - target_source TEXT NOT NULL, 260 - target_hash TEXT NOT NULL, 261 - target_title TEXT, 262 - selector_json TEXT, 263 - color TEXT, 264 - tags_json TEXT, 265 - created_at TIMESTAMP NOT NULL, 266 - indexed_at TIMESTAMP NOT NULL, 267 - cid TEXT 268 - )`) 269 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_target_hash ON highlights(target_hash)`) 270 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_author_did ON highlights(author_did)`) 271 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_created_at ON highlights(created_at DESC)`) 272 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_author_created ON highlights(author_did, created_at DESC)`) 273 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_uri_pattern ON highlights(uri text_pattern_ops)`) 274 - 275 - db.Exec(`CREATE TABLE IF NOT EXISTS bookmarks ( 276 - uri TEXT PRIMARY KEY, 277 - author_did TEXT NOT NULL, 278 - source TEXT NOT NULL, 279 - source_hash TEXT NOT NULL, 280 - title TEXT, 281 - description TEXT, 282 - tags_json TEXT, 283 - created_at TIMESTAMP NOT NULL, 284 - indexed_at TIMESTAMP NOT NULL, 285 - cid TEXT 286 - )`) 287 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_source_hash ON bookmarks(source_hash)`) 288 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_author_did ON bookmarks(author_did)`) 289 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at DESC)`) 290 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_author_created ON bookmarks(author_did, created_at DESC)`) 291 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_uri_pattern ON bookmarks(uri text_pattern_ops)`) 292 - 293 - db.Exec(`CREATE TABLE IF NOT EXISTS replies ( 294 - uri TEXT PRIMARY KEY, 295 - author_did TEXT NOT NULL, 296 - parent_uri TEXT NOT NULL, 297 - root_uri TEXT NOT NULL, 298 - text TEXT NOT NULL, 299 - format TEXT DEFAULT 'text/plain', 300 - created_at TIMESTAMP NOT NULL, 301 - indexed_at TIMESTAMP NOT NULL, 302 - cid TEXT 303 - )`) 304 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_parent_uri ON replies(parent_uri)`) 305 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_root_uri ON replies(root_uri)`) 306 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_created_at ON replies(created_at DESC)`) 307 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_author_did ON replies(author_did)`) 308 - 309 - db.Exec(`CREATE TABLE IF NOT EXISTS likes ( 310 - uri TEXT PRIMARY KEY, 311 - author_did TEXT NOT NULL, 312 - subject_uri TEXT NOT NULL, 313 - created_at TIMESTAMP NOT NULL, 314 - indexed_at TIMESTAMP NOT NULL 315 - )`) 316 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri)`) 317 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did)`) 318 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_subject ON likes(author_did, subject_uri)`) 319 - 320 - db.Exec(`CREATE TABLE IF NOT EXISTS collections ( 321 - uri TEXT PRIMARY KEY, 322 - author_did TEXT NOT NULL, 323 - name TEXT NOT NULL, 324 - description TEXT, 325 - icon TEXT, 326 - created_at TIMESTAMP NOT NULL, 327 - indexed_at TIMESTAMP NOT NULL 328 - )`) 329 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collections_author_did ON collections(author_did)`) 330 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collections_created_at ON collections(created_at DESC)`) 331 - 332 - db.Exec(`CREATE TABLE IF NOT EXISTS collection_items ( 333 - uri TEXT PRIMARY KEY, 334 - author_did TEXT NOT NULL, 335 - collection_uri TEXT NOT NULL, 336 - annotation_uri TEXT NOT NULL, 337 - position INTEGER DEFAULT 0, 338 - created_at TIMESTAMP NOT NULL, 339 - indexed_at TIMESTAMP NOT NULL 340 - )`) 341 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_uri)`) 342 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_annotation ON collection_items(annotation_uri)`) 343 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_created_at ON collection_items(created_at DESC)`) 344 - 345 - db.Exec(`CREATE TABLE IF NOT EXISTS sessions ( 346 - id TEXT PRIMARY KEY, 347 - did TEXT NOT NULL, 348 - handle TEXT NOT NULL, 349 - access_token TEXT NOT NULL, 350 - refresh_token TEXT NOT NULL, 351 - dpop_key TEXT, 352 - created_at TIMESTAMP NOT NULL, 353 - expires_at TIMESTAMP NOT NULL 354 - )`) 355 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_sessions_did ON sessions(did)`) 356 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`) 357 - 358 - db.Exec(`CREATE TABLE IF NOT EXISTS edit_history ( 359 - id SERIAL PRIMARY KEY, 360 - uri TEXT NOT NULL, 361 - record_type TEXT NOT NULL, 362 - previous_content TEXT NOT NULL, 363 - previous_cid TEXT, 364 - edited_at TIMESTAMP NOT NULL 365 - )`) 366 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_edit_history_uri ON edit_history(uri)`) 367 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_edit_history_edited_at ON edit_history(edited_at DESC)`) 368 - 369 - db.Exec(`CREATE TABLE IF NOT EXISTS notifications ( 370 - id SERIAL PRIMARY KEY, 371 - recipient_did TEXT NOT NULL, 372 - actor_did TEXT NOT NULL, 373 - type TEXT NOT NULL, 374 - subject_uri TEXT NOT NULL, 375 - created_at TIMESTAMP NOT NULL, 376 - read_at TIMESTAMP 377 - )`) 378 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) 379 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) 380 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(recipient_did) WHERE read_at IS NULL`) 381 - 382 - db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( 383 - id TEXT PRIMARY KEY, 384 - owner_did TEXT NOT NULL, 385 - name TEXT NOT NULL, 386 - key_hash TEXT NOT NULL, 387 - created_at TIMESTAMP NOT NULL, 388 - last_used_at TIMESTAMP, 389 - uri TEXT, 390 - cid TEXT, 391 - indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 392 - )`) 393 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) 394 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) 395 - 396 - db.Exec(`CREATE TABLE IF NOT EXISTS profiles ( 397 - uri TEXT PRIMARY KEY, 398 - author_did TEXT NOT NULL, 399 - display_name TEXT, 400 - avatar TEXT, 401 - bio TEXT, 402 - website TEXT, 403 - links_json TEXT, 404 - created_at TIMESTAMP NOT NULL, 405 - indexed_at TIMESTAMP NOT NULL, 406 - cid TEXT 407 - )`) 408 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did)`) 409 - 410 - db.Exec(`CREATE TABLE IF NOT EXISTS preferences ( 411 - uri TEXT PRIMARY KEY, 412 - author_did TEXT NOT NULL, 413 - external_link_skipped_hostnames TEXT, 414 - subscribed_labelers TEXT, 415 - label_preferences TEXT, 416 - disable_external_link_warning BOOLEAN, 417 - created_at TIMESTAMP NOT NULL, 418 - indexed_at TIMESTAMP NOT NULL, 419 - cid TEXT 420 - )`) 421 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_preferences_author_did ON preferences(author_did)`) 422 - 423 - db.runMigrations() 424 - 425 - db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( 426 - id TEXT PRIMARY KEY, 427 - last_cursor BIGINT NOT NULL, 428 - updated_at TIMESTAMP NOT NULL 429 - )`) 430 - 431 - db.Exec(`CREATE TABLE IF NOT EXISTS blocks ( 432 - id SERIAL PRIMARY KEY, 433 - actor_did TEXT NOT NULL, 434 - subject_did TEXT NOT NULL, 435 - created_at TIMESTAMP NOT NULL, 436 - UNIQUE(actor_did, subject_did) 437 - )`) 438 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_actor ON blocks(actor_did)`) 439 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_subject ON blocks(subject_did)`) 440 - 441 - db.Exec(`CREATE TABLE IF NOT EXISTS mutes ( 442 - id SERIAL PRIMARY KEY, 443 - actor_did TEXT NOT NULL, 444 - subject_did TEXT NOT NULL, 445 - created_at TIMESTAMP NOT NULL, 446 - UNIQUE(actor_did, subject_did) 447 - )`) 448 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_actor ON mutes(actor_did)`) 449 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_subject ON mutes(subject_did)`) 450 - 451 - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports ( 452 - id SERIAL PRIMARY KEY, 453 - reporter_did TEXT NOT NULL, 454 - subject_did TEXT NOT NULL, 455 - subject_uri TEXT, 456 - reason_type TEXT NOT NULL, 457 - reason_text TEXT, 458 - status TEXT NOT NULL DEFAULT 'pending', 459 - created_at TIMESTAMP NOT NULL, 460 - resolved_at TIMESTAMP, 461 - resolved_by TEXT 462 - )`) 463 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`) 464 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`) 465 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`) 466 - 467 - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions ( 468 - id SERIAL PRIMARY KEY, 469 - report_id INTEGER NOT NULL, 470 - actor_did TEXT NOT NULL, 471 - action TEXT NOT NULL, 472 - comment TEXT, 473 - created_at TIMESTAMP NOT NULL 474 - )`) 475 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`) 476 - 477 - db.Exec(`CREATE TABLE IF NOT EXISTS content_labels ( 478 - id SERIAL PRIMARY KEY, 479 - src TEXT NOT NULL, 480 - uri TEXT NOT NULL, 481 - val TEXT NOT NULL, 482 - neg INTEGER NOT NULL DEFAULT 0, 483 - created_by TEXT NOT NULL, 484 - created_at TIMESTAMP NOT NULL 485 - )`) 486 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`) 487 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`) 488 - 489 - db.Exec(`CREATE TABLE IF NOT EXISTS publications ( 490 - uri TEXT PRIMARY KEY, 491 - author_did TEXT NOT NULL, 492 - url TEXT NOT NULL, 493 - name TEXT NOT NULL, 494 - description TEXT, 495 - show_in_discover BOOLEAN NOT NULL DEFAULT true, 496 - indexed_at TIMESTAMP NOT NULL 497 - )`) 498 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_publications_author ON publications(author_did)`) 499 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_publications_url ON publications(url)`) 500 - 501 - db.Exec(`CREATE TABLE IF NOT EXISTS documents ( 502 - uri TEXT PRIMARY KEY, 503 - author_did TEXT NOT NULL, 504 - site TEXT NOT NULL, 505 - path TEXT, 506 - title TEXT NOT NULL, 507 - description TEXT, 508 - text_content TEXT, 509 - tags_json TEXT, 510 - canonical_url TEXT, 511 - published_at TIMESTAMP NOT NULL, 512 - indexed_at TIMESTAMP NOT NULL 513 - )`) 514 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_author ON documents(author_did)`) 515 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_site ON documents(site)`) 516 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_canonical ON documents(canonical_url)`) 517 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_published ON documents(published_at DESC)`) 518 - 519 - db.runMigrations() 520 - 521 - return nil 522 - } 523 - 524 - func (db *DB) GetProfilesByDIDs(dids []string) (map[string]*Profile, error) { 525 - if len(dids) == 0 { 526 - return nil, nil 527 - } 528 - 529 - placeholders := make([]string, len(dids)) 530 - args := make([]interface{}, len(dids)) 531 - for i, did := range dids { 532 - placeholders[i] = fmt.Sprintf("$%d", i+1) 533 - args[i] = did 534 - } 535 - 536 - query := `SELECT uri, author_did, display_name, bio, avatar, website, links_json, created_at, indexed_at FROM profiles WHERE author_did IN (` + strings.Join(placeholders, ",") + ")" 537 - 538 - rows, err := db.Query(query, args...) 539 - if err != nil { 540 - return nil, err 541 - } 542 - defer rows.Close() 543 - 544 - profiles := make(map[string]*Profile) 545 - for rows.Next() { 546 - var p Profile 547 - if err := rows.Scan(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Bio, &p.Avatar, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt); err != nil { 548 - continue 549 - } 550 - profiles[p.AuthorDID] = &p 551 - } 552 - 553 - return profiles, nil 554 - } 555 - 556 - func (db *DB) GetCursor(id string) (int64, error) { 557 - var cursor int64 558 - err := db.QueryRow("SELECT last_cursor FROM cursors WHERE id = $1", id).Scan(&cursor) 559 - if err == sql.ErrNoRows { 560 - return 0, nil 561 - } 562 - if err != nil { 563 - return 0, err 564 - } 565 - return cursor, nil 566 - } 567 - 568 - func (db *DB) SetCursor(id string, cursor int64) error { 569 - query := ` 570 - INSERT INTO cursors (id, last_cursor, updated_at) 571 - VALUES ($1, $2, $3) 572 - ON CONFLICT(id) DO UPDATE SET 573 - last_cursor = EXCLUDED.last_cursor, 574 - updated_at = EXCLUDED.updated_at 575 - ` 576 - _, err := db.Exec(query, id, cursor, time.Now()) 577 - return err 578 - } 579 - 580 - func (db *DB) GetProfile(did string) (*Profile, error) { 581 - var p Profile 582 - err := db.QueryRow("SELECT uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at FROM profiles WHERE author_did = $1", did).Scan( 583 - &p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, 584 - ) 585 - if err == sql.ErrNoRows { 586 - return nil, nil 587 - } 588 - if err != nil { 589 - return nil, err 590 - } 591 - return &p, nil 592 - } 593 - 594 - func (db *DB) UpsertProfile(p *Profile) error { 595 - query := ` 596 - INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) 597 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 598 - ON CONFLICT(uri) DO UPDATE SET 599 - display_name = EXCLUDED.display_name, 600 - avatar = EXCLUDED.avatar, 601 - bio = EXCLUDED.bio, 602 - website = EXCLUDED.website, 603 - links_json = EXCLUDED.links_json, 604 - indexed_at = EXCLUDED.indexed_at 605 - ` 606 - _, err := db.Exec(query, p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) 607 - return err 608 - } 609 - 610 - func (db *DB) DeleteProfile(uri string) error { 611 - _, err := db.Exec("DELETE FROM profiles WHERE uri = $1", uri) 612 - return err 613 - } 614 - 615 - func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { 616 - var uri string 617 - err := db.QueryRow("SELECT uri FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID).Scan(&uri) 618 - if err != nil { 619 - if err == sql.ErrNoRows { 620 - return "", nil 621 - } 622 - return "", err 623 - } 624 - 625 - _, err = db.Exec("DELETE FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID) 626 - return uri, err 627 - } 628 - 629 - func (db *DB) GetPreferences(did string) (*Preferences, error) { 630 - var p Preferences 631 - err := db.QueryRow("SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, disable_external_link_warning, created_at, indexed_at, cid FROM preferences WHERE author_did = $1", did).Scan( 632 - &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, &p.LabelPreferences, &p.DisableExternalLinkWarning, &p.CreatedAt, &p.IndexedAt, &p.CID, 633 - ) 634 - if err == sql.ErrNoRows { 635 - return nil, nil 636 - } 637 - if err != nil { 638 - return nil, err 639 - } 640 - return &p, nil 641 - } 642 - 643 - func (db *DB) UpsertPreferences(p *Preferences) error { 644 - query := ` 645 - INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, disable_external_link_warning, created_at, indexed_at, cid) 646 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 647 - ON CONFLICT(uri) DO UPDATE SET 648 - external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, 649 - subscribed_labelers = EXCLUDED.subscribed_labelers, 650 - label_preferences = EXCLUDED.label_preferences, 651 - disable_external_link_warning = EXCLUDED.disable_external_link_warning, 652 - indexed_at = EXCLUDED.indexed_at, 653 - cid = EXCLUDED.cid 654 - ` 655 - _, err := db.Exec(query, p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, p.LabelPreferences, p.DisableExternalLinkWarning, p.CreatedAt, p.IndexedAt, p.CID) 656 - return err 657 - } 658 - 659 - func (db *DB) DeleteAPIKeyByURI(uri string) error { 660 - _, err := db.Exec("DELETE FROM api_keys WHERE uri = $1", uri) 661 - return err 662 - } 663 - 664 - func (db *DB) DeletePreferences(uri string) error { 665 - _, err := db.Exec("DELETE FROM preferences WHERE uri = $1", uri) 666 - return err 667 - } 668 - 669 - func (db *DB) GetAPIKeyURIs(ownerDID string) ([]string, error) { 670 - rows, err := db.Query("SELECT uri FROM api_keys WHERE owner_did = $1 AND uri IS NOT NULL AND uri != ''", ownerDID) 671 - if err != nil { 672 - return nil, err 673 - } 674 - defer rows.Close() 675 - var uris []string 676 - for rows.Next() { 677 - var uri string 678 - if err := rows.Scan(&uri); err != nil { 679 - return nil, err 680 - } 681 - uris = append(uris, uri) 682 - } 683 - return uris, nil 684 - } 685 - 686 - func (db *DB) GetPreferenceURIs(did string) ([]string, error) { 687 - rows, err := db.Query("SELECT uri FROM preferences WHERE author_did = $1 AND uri IS NOT NULL AND uri != ''", did) 688 - if err != nil { 689 - return nil, err 690 - } 691 - defer rows.Close() 692 - var uris []string 693 - for rows.Next() { 694 - var uri string 695 - if err := rows.Scan(&uri); err != nil { 696 - return nil, err 697 - } 698 - uris = append(uris, uri) 699 - } 700 - return uris, nil 701 - } 702 - 703 - func (db *DB) runMigrations() { 704 - db.Exec(`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS dpop_key TEXT`) 705 - 706 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS motivation TEXT`) 707 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_value TEXT`) 708 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_format TEXT DEFAULT 'text/plain'`) 709 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_uri TEXT`) 710 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_source TEXT`) 711 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_hash TEXT`) 712 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_title TEXT`) 713 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS selector_json TEXT`) 714 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS tags_json TEXT`) 715 - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS cid TEXT`) 716 - 717 - db.Exec(`UPDATE annotations SET target_source = url WHERE target_source IS NULL AND url IS NOT NULL`) 718 - db.Exec(`UPDATE annotations SET target_hash = url_hash WHERE target_hash IS NULL AND url_hash IS NOT NULL`) 719 - db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) 720 - db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) 721 - db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) 722 - 723 - db.Exec(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS website TEXT`) 724 - db.Exec(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_name TEXT`) 725 - db.Exec(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS avatar TEXT`) 726 - 727 - db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`) 728 - 729 - db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS uri TEXT`) 730 - db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS cid TEXT`) 731 - db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`) 732 - 733 - db.migrateModeration() 734 - 735 - db.Exec(`ALTER TABLE preferences ADD COLUMN IF NOT EXISTS subscribed_labelers TEXT`) 736 - db.Exec(`ALTER TABLE preferences ADD COLUMN IF NOT EXISTS label_preferences TEXT`) 737 - db.Exec(`ALTER TABLE preferences ADD COLUMN IF NOT EXISTS disable_external_link_warning BOOLEAN`) 738 - } 739 - 740 - func (db *DB) migrateModeration() { 741 - _, err := db.Exec(`SELECT subject_did FROM moderation_reports LIMIT 0`) 742 - if err != nil { 743 - db.Exec(`DROP TABLE IF EXISTS moderation_reports`) 744 - db.Exec(`DROP TABLE IF EXISTS moderation_actions`) 745 - 746 - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports ( 747 - id SERIAL PRIMARY KEY, 748 - reporter_did TEXT NOT NULL, 749 - subject_did TEXT NOT NULL, 750 - subject_uri TEXT, 751 - reason_type TEXT NOT NULL, 752 - reason_text TEXT, 753 - status TEXT NOT NULL DEFAULT 'pending', 754 - created_at TIMESTAMP NOT NULL, 755 - resolved_at TIMESTAMP, 756 - resolved_by TEXT 757 - )`) 758 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`) 759 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`) 760 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`) 761 - 762 - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions ( 763 - id SERIAL PRIMARY KEY, 764 - report_id INTEGER NOT NULL, 765 - actor_did TEXT NOT NULL, 766 - action TEXT NOT NULL, 767 - comment TEXT, 768 - created_at TIMESTAMP NOT NULL 769 - )`) 770 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`) 771 - } 772 - 773 - db.Exec(`CREATE TABLE IF NOT EXISTS content_labels ( 774 - id SERIAL PRIMARY KEY, 775 - src TEXT NOT NULL, 776 - uri TEXT NOT NULL, 777 - val TEXT NOT NULL, 778 - neg INTEGER NOT NULL DEFAULT 0, 779 - created_by TEXT NOT NULL, 780 - created_at TIMESTAMP NOT NULL 781 - )`) 782 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`) 783 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`) 784 - } 785 - 786 - func (db *DB) Close() error { 787 - return db.DB.Close() 788 - } 61 + func (db *DB) Close() error { return db.DB.Close() } 789 62 790 63 func ParseSelector(selectorJSON *string) (*Selector, error) { 791 64 if selectorJSON == nil || *selectorJSON == "" { 792 65 return nil, nil 793 66 } 794 67 var s Selector 795 - err := json.Unmarshal([]byte(*selectorJSON), &s) 796 - if err != nil { 68 + if err := json.Unmarshal([]byte(*selectorJSON), &s); err != nil { 797 69 return nil, err 798 70 } 799 71 return &s, nil ··· 804 76 return nil, nil 805 77 } 806 78 var tags []string 807 - err := json.Unmarshal([]byte(*tagsJSON), &tags) 808 - if err != nil { 79 + if err := json.Unmarshal([]byte(*tagsJSON), &tags); err != nil { 809 80 return nil, err 810 81 } 811 82 return tags, nil
+230
backend/internal/db/filter.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + "time" 8 + 9 + "margin.at/internal/domain" 10 + ) 11 + 12 + type FeedType = domain.FeedType 13 + type NoteFilter = domain.NoteFilter 14 + 15 + const ( 16 + FeedTypeRecent = domain.FeedTypeRecent 17 + FeedTypePopular = domain.FeedTypePopular 18 + FeedTypeShelved = domain.FeedTypeShelved 19 + FeedTypeMargin = domain.FeedTypeMargin 20 + FeedTypeSemble = domain.FeedTypeSemble 21 + ) 22 + 23 + func (db *DB) ListNotes(f NoteFilter) ([]Note, error) { 24 + var where []string 25 + var args []interface{} 26 + n := 1 27 + 28 + if len(f.Motivations) == 1 { 29 + where = append(where, fmt.Sprintf("motivation = $%d", n)) 30 + args = append(args, f.Motivations[0]) 31 + n++ 32 + } else if len(f.Motivations) > 1 { 33 + placeholders := make([]string, len(f.Motivations)) 34 + for i, m := range f.Motivations { 35 + placeholders[i] = fmt.Sprintf("$%d", n) 36 + args = append(args, m) 37 + n++ 38 + } 39 + where = append(where, fmt.Sprintf("motivation IN (%s)", strings.Join(placeholders, ", "))) 40 + } 41 + 42 + if f.AuthorDID != "" { 43 + where = append(where, fmt.Sprintf("author_did = $%d", n)) 44 + args = append(args, f.AuthorDID) 45 + n++ 46 + } 47 + 48 + if f.TargetHash != "" { 49 + where = append(where, fmt.Sprintf("target_hash = $%d", n)) 50 + args = append(args, f.TargetHash) 51 + n++ 52 + } 53 + 54 + if f.Tag != "" { 55 + where = append(where, fmt.Sprintf( 56 + "tags_json IS NOT NULL AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = lower($%d))", 57 + n, 58 + )) 59 + args = append(args, f.Tag) 60 + n++ 61 + } 62 + 63 + if f.Query != "" { 64 + pattern := "%" + escapeLike(f.Query) + "%" 65 + where = append(where, fmt.Sprintf( 66 + "(body_value ILIKE $%d OR target_source ILIKE $%d OR target_title ILIKE $%d OR tags_json::text ILIKE $%d)", 67 + n, n+1, n+2, n+3, 68 + )) 69 + args = append(args, pattern, pattern, pattern, pattern) 70 + n += 4 71 + } 72 + 73 + switch f.FeedType { 74 + case FeedTypeMargin: 75 + where = append(where, "uri NOT LIKE '%network.cosmik%'") 76 + case FeedTypeSemble: 77 + where = append(where, "uri LIKE '%network.cosmik%'") 78 + case FeedTypePopular: 79 + since := time.Now().AddDate(0, 0, -14) 80 + where = append(where, fmt.Sprintf("created_at > $%d", n)) 81 + args = append(args, since) 82 + n++ 83 + case FeedTypeShelved: 84 + olderThan := time.Now().AddDate(0, 0, -1) 85 + since := time.Now().AddDate(0, 0, -14) 86 + where = append(where, fmt.Sprintf("created_at < $%d AND created_at > $%d", n, n+1)) 87 + where = append(where, "NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = uri)") 88 + where = append(where, "NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = uri)") 89 + args = append(args, olderThan, since) 90 + n += 2 91 + } 92 + 93 + whereClause := "" 94 + if len(where) > 0 { 95 + whereClause = "WHERE " + strings.Join(where, " AND ") 96 + } 97 + 98 + orderClause := "ORDER BY created_at DESC" 99 + switch f.FeedType { 100 + case FeedTypePopular: 101 + orderClause = `ORDER BY ( 102 + SELECT COUNT(*) FROM likes WHERE subject_uri = uri 103 + ) + ( 104 + SELECT COUNT(*) FROM replies WHERE root_uri = uri 105 + ) DESC, created_at DESC` 106 + case FeedTypeShelved: 107 + orderClause = "ORDER BY RANDOM()" 108 + } 109 + 110 + limit := f.Limit 111 + if limit <= 0 { 112 + limit = 50 113 + } 114 + 115 + query := fmt.Sprintf(` 116 + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, 117 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 118 + FROM unified_notes 119 + %s 120 + %s 121 + LIMIT $%d OFFSET $%d 122 + `, whereClause, orderClause, n, n+1) 123 + 124 + args = append(args, limit, f.Offset) 125 + 126 + rows, err := db.Query(query, args...) 127 + if err != nil { 128 + return nil, err 129 + } 130 + defer rows.Close() 131 + return scanNotes(rows) 132 + } 133 + 134 + func (db *DB) GetNoteByURIFromUnified(uri string) (*Note, error) { 135 + query := ` 136 + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, 137 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 138 + FROM unified_notes 139 + WHERE uri = $1 140 + ` 141 + var note Note 142 + err := db.QueryRow(query, uri).Scan( 143 + &note.URI, &note.AuthorDID, &note.Motivation, &note.Color, &note.Description, 144 + &note.BodyValue, &note.BodyFormat, &note.BodyURI, 145 + &note.TargetSource, &note.TargetHash, &note.TargetTitle, 146 + &note.SelectorJSON, &note.TagsJSON, &note.CreatedAt, &note.IndexedAt, &note.CID, 147 + ) 148 + if err == sql.ErrNoRows { 149 + return nil, nil 150 + } 151 + if err != nil { 152 + return nil, err 153 + } 154 + return &note, nil 155 + } 156 + 157 + func scanNotes(rows interface { 158 + Next() bool 159 + Scan(...interface{}) error 160 + }) ([]Note, error) { 161 + var notes []Note 162 + for rows.Next() { 163 + var note Note 164 + if err := rows.Scan( 165 + &note.URI, &note.AuthorDID, &note.Motivation, &note.Color, &note.Description, 166 + &note.BodyValue, &note.BodyFormat, &note.BodyURI, 167 + &note.TargetSource, &note.TargetHash, &note.TargetTitle, 168 + &note.SelectorJSON, &note.TagsJSON, &note.CreatedAt, &note.IndexedAt, &note.CID, 169 + ); err != nil { 170 + return nil, err 171 + } 172 + notes = append(notes, note) 173 + } 174 + return notes, nil 175 + } 176 + 177 + func (db *DB) MigrateUnifiedNotes() { 178 + db.Exec(` 179 + CREATE OR REPLACE VIEW unified_notes AS 180 + -- New notes table (primary path) 181 + SELECT 182 + uri, author_did, 183 + COALESCE(motivation, 'commenting') AS motivation, 184 + color, description, body_value, body_format, body_uri, 185 + target_source, target_hash, target_title, selector_json, tags_json, 186 + created_at, indexed_at, cid 187 + FROM notes 188 + UNION ALL 189 + -- Legacy annotations (motivation is 'commenting' or similar, never 'highlighting'/'bookmarking') 190 + SELECT 191 + uri, author_did, 192 + COALESCE(motivation, 'commenting') AS motivation, 193 + NULL::TEXT AS color, 194 + NULL::TEXT AS description, 195 + body_value, body_format, body_uri, 196 + target_source, target_hash, target_title, selector_json, tags_json, 197 + created_at, indexed_at, cid 198 + FROM annotations 199 + UNION ALL 200 + -- Legacy highlights 201 + SELECT 202 + uri, author_did, 203 + 'highlighting' AS motivation, 204 + color, 205 + NULL::TEXT AS description, 206 + NULL::TEXT AS body_value, 207 + 'text/plain' AS body_format, 208 + NULL::TEXT AS body_uri, 209 + target_source, target_hash, target_title, selector_json, tags_json, 210 + created_at, indexed_at, cid 211 + FROM highlights 212 + UNION ALL 213 + -- Legacy bookmarks (column mapping to Note layout) 214 + SELECT 215 + uri, author_did, 216 + 'bookmarking' AS motivation, 217 + NULL::TEXT AS color, 218 + description, 219 + NULL::TEXT AS body_value, 220 + 'text/plain' AS body_format, 221 + NULL::TEXT AS body_uri, 222 + source AS target_source, 223 + source_hash AS target_hash, 224 + title AS target_title, 225 + NULL::TEXT AS selector_json, 226 + tags_json, 227 + created_at, indexed_at, cid 228 + FROM bookmarks 229 + `) 230 + }
+46
backend/internal/db/migrate.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "embed" 6 + "time" 7 + 8 + "github.com/pressly/goose/v3" 9 + ) 10 + 11 + //go:embed migrations 12 + var migrationsFS embed.FS 13 + 14 + func (db *DB) Migrate() error { 15 + goose.SetBaseFS(migrationsFS) 16 + 17 + if err := goose.SetDialect("postgres"); err != nil { 18 + return err 19 + } 20 + 21 + return goose.Up(db.DB, "migrations") 22 + } 23 + 24 + func (db *DB) GetCursor(id string) (int64, error) { 25 + var cursor int64 26 + err := db.QueryRow("SELECT last_cursor FROM cursors WHERE id = $1", id).Scan(&cursor) 27 + switch err { 28 + case nil: 29 + return cursor, nil 30 + case sql.ErrNoRows: 31 + return 0, nil 32 + default: 33 + return 0, err 34 + } 35 + } 36 + 37 + func (db *DB) SetCursor(id string, cursor int64) error { 38 + _, err := db.Exec(` 39 + INSERT INTO cursors (id, last_cursor, updated_at) 40 + VALUES ($1, $2, $3) 41 + ON CONFLICT(id) DO UPDATE SET 42 + last_cursor = EXCLUDED.last_cursor, 43 + updated_at = EXCLUDED.updated_at 44 + `, id, cursor, time.Now()) 45 + return err 46 + }
+204
backend/internal/db/migrations/00001_core_tables.sql
··· 1 + -- +goose Up 2 + CREATE TABLE IF NOT EXISTS notes ( 3 + uri TEXT PRIMARY KEY, 4 + author_did TEXT NOT NULL, 5 + motivation TEXT, 6 + color TEXT, 7 + description TEXT, 8 + body_value TEXT, 9 + body_format TEXT DEFAULT 'text/plain', 10 + body_uri TEXT, 11 + target_source TEXT NOT NULL, 12 + target_hash TEXT NOT NULL, 13 + target_title TEXT, 14 + selector_json TEXT, 15 + tags_json TEXT, 16 + created_at TIMESTAMP NOT NULL, 17 + indexed_at TIMESTAMP NOT NULL, 18 + cid TEXT 19 + ); 20 + CREATE INDEX IF NOT EXISTS idx_notes_target_hash ON notes(target_hash); 21 + CREATE INDEX IF NOT EXISTS idx_notes_target_source ON notes(target_source); 22 + CREATE INDEX IF NOT EXISTS idx_notes_author_did ON notes(author_did); 23 + CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at DESC); 24 + 25 + CREATE TABLE IF NOT EXISTS annotations ( 26 + uri TEXT PRIMARY KEY, 27 + author_did TEXT NOT NULL, 28 + motivation TEXT, 29 + body_value TEXT, 30 + body_format TEXT DEFAULT 'text/plain', 31 + body_uri TEXT, 32 + target_source TEXT, 33 + target_hash TEXT, 34 + target_title TEXT, 35 + selector_json TEXT, 36 + tags_json TEXT, 37 + created_at TIMESTAMP NOT NULL, 38 + indexed_at TIMESTAMP NOT NULL, 39 + cid TEXT 40 + ); 41 + CREATE INDEX IF NOT EXISTS idx_annotations_target_hash ON annotations(target_hash); 42 + CREATE INDEX IF NOT EXISTS idx_annotations_target_source ON annotations(target_source); 43 + CREATE INDEX IF NOT EXISTS idx_annotations_author_did ON annotations(author_did); 44 + CREATE INDEX IF NOT EXISTS idx_annotations_motivation ON annotations(motivation); 45 + CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC); 46 + CREATE INDEX IF NOT EXISTS idx_annotations_author_created ON annotations(author_did, created_at DESC); 47 + CREATE INDEX IF NOT EXISTS idx_annotations_uri_pattern ON annotations(uri text_pattern_ops); 48 + 49 + CREATE TABLE IF NOT EXISTS highlights ( 50 + uri TEXT PRIMARY KEY, 51 + author_did TEXT NOT NULL, 52 + target_source TEXT NOT NULL, 53 + target_hash TEXT NOT NULL, 54 + target_title TEXT, 55 + selector_json TEXT, 56 + color TEXT, 57 + tags_json TEXT, 58 + created_at TIMESTAMP NOT NULL, 59 + indexed_at TIMESTAMP NOT NULL, 60 + cid TEXT 61 + ); 62 + CREATE INDEX IF NOT EXISTS idx_highlights_target_hash ON highlights(target_hash); 63 + CREATE INDEX IF NOT EXISTS idx_highlights_author_did ON highlights(author_did); 64 + CREATE INDEX IF NOT EXISTS idx_highlights_created_at ON highlights(created_at DESC); 65 + CREATE INDEX IF NOT EXISTS idx_highlights_author_created ON highlights(author_did, created_at DESC); 66 + CREATE INDEX IF NOT EXISTS idx_highlights_uri_pattern ON highlights(uri text_pattern_ops); 67 + 68 + CREATE TABLE IF NOT EXISTS bookmarks ( 69 + uri TEXT PRIMARY KEY, 70 + author_did TEXT NOT NULL, 71 + source TEXT NOT NULL, 72 + source_hash TEXT NOT NULL, 73 + title TEXT, 74 + description TEXT, 75 + tags_json TEXT, 76 + created_at TIMESTAMP NOT NULL, 77 + indexed_at TIMESTAMP NOT NULL, 78 + cid TEXT 79 + ); 80 + CREATE INDEX IF NOT EXISTS idx_bookmarks_source_hash ON bookmarks(source_hash); 81 + CREATE INDEX IF NOT EXISTS idx_bookmarks_author_did ON bookmarks(author_did); 82 + CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at DESC); 83 + CREATE INDEX IF NOT EXISTS idx_bookmarks_author_created ON bookmarks(author_did, created_at DESC); 84 + CREATE INDEX IF NOT EXISTS idx_bookmarks_uri_pattern ON bookmarks(uri text_pattern_ops); 85 + 86 + CREATE TABLE IF NOT EXISTS replies ( 87 + uri TEXT PRIMARY KEY, 88 + author_did TEXT NOT NULL, 89 + parent_uri TEXT NOT NULL, 90 + root_uri TEXT NOT NULL, 91 + text TEXT NOT NULL, 92 + format TEXT DEFAULT 'text/plain', 93 + created_at TIMESTAMP NOT NULL, 94 + indexed_at TIMESTAMP NOT NULL, 95 + cid TEXT 96 + ); 97 + CREATE INDEX IF NOT EXISTS idx_replies_parent_uri ON replies(parent_uri); 98 + CREATE INDEX IF NOT EXISTS idx_replies_root_uri ON replies(root_uri); 99 + CREATE INDEX IF NOT EXISTS idx_replies_created_at ON replies(created_at DESC); 100 + CREATE INDEX IF NOT EXISTS idx_replies_author_did ON replies(author_did); 101 + 102 + CREATE TABLE IF NOT EXISTS likes ( 103 + uri TEXT PRIMARY KEY, 104 + author_did TEXT NOT NULL, 105 + subject_uri TEXT NOT NULL, 106 + created_at TIMESTAMP NOT NULL, 107 + indexed_at TIMESTAMP NOT NULL 108 + ); 109 + CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri); 110 + CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did); 111 + CREATE INDEX IF NOT EXISTS idx_likes_author_subject ON likes(author_did, subject_uri); 112 + 113 + CREATE TABLE IF NOT EXISTS sessions ( 114 + id TEXT PRIMARY KEY, 115 + did TEXT NOT NULL, 116 + handle TEXT NOT NULL, 117 + access_token TEXT NOT NULL, 118 + refresh_token TEXT NOT NULL, 119 + dpop_key TEXT, 120 + created_at TIMESTAMP NOT NULL, 121 + expires_at TIMESTAMP NOT NULL 122 + ); 123 + CREATE INDEX IF NOT EXISTS idx_sessions_did ON sessions(did); 124 + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); 125 + 126 + CREATE TABLE IF NOT EXISTS edit_history ( 127 + id SERIAL PRIMARY KEY, 128 + uri TEXT NOT NULL, 129 + record_type TEXT NOT NULL, 130 + previous_content TEXT NOT NULL, 131 + previous_cid TEXT, 132 + edited_at TIMESTAMP NOT NULL 133 + ); 134 + CREATE INDEX IF NOT EXISTS idx_edit_history_uri ON edit_history(uri); 135 + CREATE INDEX IF NOT EXISTS idx_edit_history_edited_at ON edit_history(edited_at DESC); 136 + 137 + CREATE TABLE IF NOT EXISTS notifications ( 138 + id SERIAL PRIMARY KEY, 139 + recipient_did TEXT NOT NULL, 140 + actor_did TEXT NOT NULL, 141 + type TEXT NOT NULL, 142 + subject_uri TEXT NOT NULL, 143 + created_at TIMESTAMP NOT NULL, 144 + read_at TIMESTAMP 145 + ); 146 + CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did); 147 + CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC); 148 + CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(recipient_did) WHERE read_at IS NULL; 149 + 150 + CREATE TABLE IF NOT EXISTS api_keys ( 151 + id TEXT PRIMARY KEY, 152 + owner_did TEXT NOT NULL, 153 + name TEXT NOT NULL, 154 + key_hash TEXT NOT NULL, 155 + created_at TIMESTAMP NOT NULL, 156 + last_used_at TIMESTAMP, 157 + uri TEXT, 158 + cid TEXT, 159 + indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 160 + ); 161 + CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did); 162 + CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); 163 + 164 + CREATE TABLE IF NOT EXISTS profiles ( 165 + uri TEXT PRIMARY KEY, 166 + author_did TEXT NOT NULL, 167 + display_name TEXT, 168 + avatar TEXT, 169 + bio TEXT, 170 + website TEXT, 171 + links_json TEXT, 172 + created_at TIMESTAMP NOT NULL, 173 + indexed_at TIMESTAMP NOT NULL, 174 + cid TEXT 175 + ); 176 + CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did); 177 + 178 + CREATE TABLE IF NOT EXISTS preferences ( 179 + uri TEXT PRIMARY KEY, 180 + author_did TEXT NOT NULL, 181 + external_link_skipped_hostnames TEXT, 182 + subscribed_labelers TEXT, 183 + label_preferences TEXT, 184 + disable_external_link_warning BOOLEAN, 185 + enable_community_bookmarks BOOLEAN DEFAULT false, 186 + created_at TIMESTAMP NOT NULL, 187 + indexed_at TIMESTAMP NOT NULL, 188 + cid TEXT 189 + ); 190 + CREATE INDEX IF NOT EXISTS idx_preferences_author_did ON preferences(author_did); 191 + 192 + -- +goose Down 193 + DROP TABLE IF EXISTS preferences; 194 + DROP TABLE IF EXISTS profiles; 195 + DROP TABLE IF EXISTS api_keys; 196 + DROP TABLE IF EXISTS notifications; 197 + DROP TABLE IF EXISTS edit_history; 198 + DROP TABLE IF EXISTS sessions; 199 + DROP TABLE IF EXISTS likes; 200 + DROP TABLE IF EXISTS replies; 201 + DROP TABLE IF EXISTS bookmarks; 202 + DROP TABLE IF EXISTS highlights; 203 + DROP TABLE IF EXISTS annotations; 204 + DROP TABLE IF EXISTS notes;
+131
backend/internal/db/migrations/00002_support_tables.sql
··· 1 + -- +goose Up 2 + CREATE TABLE IF NOT EXISTS cursors ( 3 + id TEXT PRIMARY KEY, 4 + last_cursor BIGINT NOT NULL, 5 + updated_at TIMESTAMP NOT NULL 6 + ); 7 + 8 + CREATE TABLE IF NOT EXISTS collections ( 9 + uri TEXT PRIMARY KEY, 10 + author_did TEXT NOT NULL, 11 + name TEXT NOT NULL, 12 + description TEXT, 13 + icon TEXT, 14 + created_at TIMESTAMP NOT NULL, 15 + indexed_at TIMESTAMP NOT NULL 16 + ); 17 + CREATE INDEX IF NOT EXISTS idx_collections_author_did ON collections(author_did); 18 + CREATE INDEX IF NOT EXISTS idx_collections_created_at ON collections(created_at DESC); 19 + 20 + CREATE TABLE IF NOT EXISTS collection_items ( 21 + uri TEXT PRIMARY KEY, 22 + author_did TEXT NOT NULL, 23 + collection_uri TEXT NOT NULL, 24 + annotation_uri TEXT NOT NULL, 25 + position INTEGER DEFAULT 0, 26 + created_at TIMESTAMP NOT NULL, 27 + indexed_at TIMESTAMP NOT NULL 28 + ); 29 + CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_uri); 30 + CREATE INDEX IF NOT EXISTS idx_collection_items_annotation ON collection_items(annotation_uri); 31 + CREATE INDEX IF NOT EXISTS idx_collection_items_created_at ON collection_items(created_at DESC); 32 + 33 + CREATE TABLE IF NOT EXISTS blocks ( 34 + id SERIAL PRIMARY KEY, 35 + actor_did TEXT NOT NULL, 36 + subject_did TEXT NOT NULL, 37 + created_at TIMESTAMP NOT NULL, 38 + UNIQUE(actor_did, subject_did) 39 + ); 40 + CREATE INDEX IF NOT EXISTS idx_blocks_actor ON blocks(actor_did); 41 + CREATE INDEX IF NOT EXISTS idx_blocks_subject ON blocks(subject_did); 42 + 43 + CREATE TABLE IF NOT EXISTS mutes ( 44 + id SERIAL PRIMARY KEY, 45 + actor_did TEXT NOT NULL, 46 + subject_did TEXT NOT NULL, 47 + created_at TIMESTAMP NOT NULL, 48 + UNIQUE(actor_did, subject_did) 49 + ); 50 + CREATE INDEX IF NOT EXISTS idx_mutes_actor ON mutes(actor_did); 51 + CREATE INDEX IF NOT EXISTS idx_mutes_subject ON mutes(subject_did); 52 + 53 + CREATE TABLE IF NOT EXISTS content_labels ( 54 + id SERIAL PRIMARY KEY, 55 + src TEXT NOT NULL, 56 + uri TEXT NOT NULL, 57 + val TEXT NOT NULL, 58 + neg INTEGER NOT NULL DEFAULT 0, 59 + created_by TEXT NOT NULL, 60 + created_at TIMESTAMP NOT NULL 61 + ); 62 + CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri); 63 + CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src); 64 + 65 + CREATE TABLE IF NOT EXISTS moderation_reports ( 66 + id SERIAL PRIMARY KEY, 67 + reporter_did TEXT NOT NULL, 68 + subject_did TEXT NOT NULL, 69 + subject_uri TEXT, 70 + reason_type TEXT NOT NULL, 71 + reason_text TEXT, 72 + status TEXT NOT NULL DEFAULT 'pending', 73 + created_at TIMESTAMP NOT NULL, 74 + resolved_at TIMESTAMP, 75 + resolved_by TEXT 76 + ); 77 + CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status); 78 + CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did); 79 + CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did); 80 + 81 + CREATE TABLE IF NOT EXISTS moderation_actions ( 82 + id SERIAL PRIMARY KEY, 83 + report_id INTEGER NOT NULL, 84 + actor_did TEXT NOT NULL, 85 + action TEXT NOT NULL, 86 + comment TEXT, 87 + created_at TIMESTAMP NOT NULL 88 + ); 89 + CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id); 90 + 91 + CREATE TABLE IF NOT EXISTS publications ( 92 + uri TEXT PRIMARY KEY, 93 + author_did TEXT NOT NULL, 94 + url TEXT NOT NULL, 95 + name TEXT NOT NULL, 96 + description TEXT, 97 + show_in_discover BOOLEAN NOT NULL DEFAULT true, 98 + indexed_at TIMESTAMP NOT NULL 99 + ); 100 + CREATE INDEX IF NOT EXISTS idx_publications_author ON publications(author_did); 101 + CREATE INDEX IF NOT EXISTS idx_publications_url ON publications(url); 102 + 103 + CREATE TABLE IF NOT EXISTS documents ( 104 + uri TEXT PRIMARY KEY, 105 + author_did TEXT NOT NULL, 106 + site TEXT NOT NULL, 107 + path TEXT, 108 + title TEXT NOT NULL, 109 + description TEXT, 110 + text_content TEXT, 111 + tags_json TEXT, 112 + canonical_url TEXT, 113 + published_at TIMESTAMP NOT NULL, 114 + indexed_at TIMESTAMP NOT NULL 115 + ); 116 + CREATE INDEX IF NOT EXISTS idx_documents_author ON documents(author_did); 117 + CREATE INDEX IF NOT EXISTS idx_documents_site ON documents(site); 118 + CREATE INDEX IF NOT EXISTS idx_documents_canonical ON documents(canonical_url); 119 + CREATE INDEX IF NOT EXISTS idx_documents_published ON documents(published_at DESC); 120 + 121 + -- +goose Down 122 + DROP TABLE IF EXISTS documents; 123 + DROP TABLE IF EXISTS publications; 124 + DROP TABLE IF EXISTS moderation_actions; 125 + DROP TABLE IF EXISTS moderation_reports; 126 + DROP TABLE IF EXISTS content_labels; 127 + DROP TABLE IF EXISTS mutes; 128 + DROP TABLE IF EXISTS blocks; 129 + DROP TABLE IF EXISTS collection_items; 130 + DROP TABLE IF EXISTS collections; 131 + DROP TABLE IF EXISTS cursors;
+43
backend/internal/db/migrations/00003_alter_columns.sql
··· 1 + -- +goose Up 2 + ALTER TABLE sessions ADD COLUMN IF NOT EXISTS dpop_key TEXT; 3 + 4 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS motivation TEXT; 5 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_value TEXT; 6 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_format TEXT DEFAULT 'text/plain'; 7 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_uri TEXT; 8 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_source TEXT; 9 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_hash TEXT; 10 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_title TEXT; 11 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS selector_json TEXT; 12 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS tags_json TEXT; 13 + ALTER TABLE annotations ADD COLUMN IF NOT EXISTS cid TEXT; 14 + 15 + -- +goose StatementBegin 16 + DO $$ 17 + BEGIN 18 + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'annotations' AND column_name = 'url') THEN 19 + UPDATE annotations SET target_source = url WHERE target_source IS NULL AND url IS NOT NULL; 20 + UPDATE annotations SET target_hash = url_hash WHERE target_hash IS NULL AND url_hash IS NOT NULL; 21 + UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL; 22 + UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL; 23 + END IF; 24 + UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL; 25 + END $$; 26 + -- +goose StatementEnd 27 + 28 + ALTER TABLE profiles ADD COLUMN IF NOT EXISTS website TEXT; 29 + ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_name TEXT; 30 + ALTER TABLE profiles ADD COLUMN IF NOT EXISTS avatar TEXT; 31 + 32 + ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT; 33 + 34 + ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS uri TEXT; 35 + ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS cid TEXT; 36 + ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; 37 + 38 + ALTER TABLE preferences ADD COLUMN IF NOT EXISTS subscribed_labelers TEXT; 39 + ALTER TABLE preferences ADD COLUMN IF NOT EXISTS label_preferences TEXT; 40 + ALTER TABLE preferences ADD COLUMN IF NOT EXISTS disable_external_link_warning BOOLEAN; 41 + ALTER TABLE preferences ADD COLUMN IF NOT EXISTS enable_community_bookmarks BOOLEAN DEFAULT false; 42 + 43 + -- +goose Down
+33
backend/internal/db/migrations/00004_views.sql
··· 1 + -- +goose Up 2 + -- +goose StatementBegin 3 + CREATE OR REPLACE VIEW all_highlights AS 4 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 5 + FROM highlights 6 + UNION ALL 7 + SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 8 + FROM notes WHERE motivation = 'highlighting'; 9 + -- +goose StatementEnd 10 + 11 + -- +goose StatementBegin 12 + CREATE OR REPLACE VIEW all_annotations AS 13 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 14 + FROM annotations 15 + UNION ALL 16 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 17 + FROM notes WHERE motivation NOT IN ('highlighting', 'bookmarking'); 18 + -- +goose StatementEnd 19 + 20 + -- +goose StatementBegin 21 + CREATE OR REPLACE VIEW all_bookmarks AS 22 + SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 23 + FROM bookmarks 24 + UNION ALL 25 + SELECT uri, author_did, target_source AS source, target_hash AS source_hash, target_title AS title, 26 + COALESCE(body_value, description) AS description, tags_json, created_at, indexed_at, cid 27 + FROM notes WHERE motivation = 'bookmarking'; 28 + -- +goose StatementEnd 29 + 30 + -- +goose Down 31 + DROP VIEW IF EXISTS all_bookmarks; 32 + DROP VIEW IF EXISTS all_annotations; 33 + DROP VIEW IF EXISTS all_highlights;
+29
backend/internal/db/migrations/00005_recommendations.sql
··· 1 + -- +goose Up 2 + CREATE TABLE IF NOT EXISTS document_embeddings ( 3 + document_uri TEXT PRIMARY KEY, 4 + embedding TEXT NOT NULL, 5 + updated_at TIMESTAMP NOT NULL 6 + ); 7 + 8 + CREATE TABLE IF NOT EXISTS annotation_embeddings ( 9 + annotation_uri TEXT PRIMARY KEY, 10 + author_did TEXT NOT NULL, 11 + document_uri TEXT, 12 + embedding TEXT NOT NULL, 13 + updated_at TIMESTAMP NOT NULL 14 + ); 15 + CREATE INDEX IF NOT EXISTS idx_ann_emb_author ON annotation_embeddings(author_did); 16 + CREATE INDEX IF NOT EXISTS idx_ann_emb_document ON annotation_embeddings(document_uri); 17 + 18 + CREATE TABLE IF NOT EXISTS user_profiles ( 19 + author_did TEXT PRIMARY KEY, 20 + embedding TEXT NOT NULL, 21 + tag_affinities TEXT DEFAULT '{}', 22 + annotation_count INTEGER NOT NULL DEFAULT 0, 23 + updated_at TIMESTAMP NOT NULL 24 + ); 25 + 26 + -- +goose Down 27 + DROP TABLE IF EXISTS user_profiles; 28 + DROP TABLE IF EXISTS annotation_embeddings; 29 + DROP TABLE IF EXISTS document_embeddings;
+20 -20
backend/internal/db/queries_annotations.go
··· 28 28 var a Annotation 29 29 err := db.QueryRow(` 30 30 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 31 - FROM annotations 31 + FROM all_annotations 32 32 WHERE uri = $1 33 33 `, uri).Scan(&a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID) 34 34 if err != nil { ··· 40 40 func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { 41 41 rows, err := db.Query(` 42 42 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 43 - FROM annotations 43 + FROM all_annotations 44 44 WHERE target_hash = $1 45 45 ORDER BY created_at DESC 46 46 LIMIT $2 OFFSET $3 ··· 56 56 func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { 57 57 rows, err := db.Query(` 58 58 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 59 - FROM annotations 59 + FROM all_annotations 60 60 WHERE author_did = $1 61 61 ORDER BY created_at DESC 62 62 LIMIT $2 OFFSET $3 ··· 72 72 func (db *DB) GetMarginAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { 73 73 rows, err := db.Query(` 74 74 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 75 - FROM annotations 75 + FROM all_annotations 76 76 WHERE author_did = $1 AND uri NOT LIKE '%network.cosmik%' 77 77 ORDER BY created_at DESC 78 78 LIMIT $2 OFFSET $3 ··· 88 88 func (db *DB) GetSembleAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { 89 89 rows, err := db.Query(` 90 90 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 91 - FROM annotations 91 + FROM all_annotations 92 92 WHERE author_did = $1 AND uri LIKE '%network.cosmik%' 93 93 ORDER BY created_at DESC 94 94 LIMIT $2 OFFSET $3 ··· 104 104 func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { 105 105 rows, err := db.Query(` 106 106 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 107 - FROM annotations 107 + FROM all_annotations 108 108 WHERE motivation = $1 109 109 ORDER BY created_at DESC 110 110 LIMIT $2 OFFSET $3 ··· 120 120 func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { 121 121 rows, err := db.Query(` 122 122 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 123 - FROM annotations 123 + FROM all_annotations 124 124 ORDER BY created_at DESC 125 125 LIMIT $1 OFFSET $2 126 126 `, limit, offset) ··· 139 139 a.uri, a.author_did, a.motivation, a.body_value, a.body_format, 140 140 a.body_uri, a.target_source, a.target_hash, a.target_title, 141 141 a.selector_json, a.tags_json, a.created_at, a.indexed_at, a.cid 142 - FROM annotations a 142 + FROM all_annotations a 143 143 LEFT JOIN LATERAL ( 144 144 SELECT COUNT(*) as cnt FROM likes WHERE subject_uri = a.uri 145 145 ) l ON true ··· 166 166 a.uri, a.author_did, a.motivation, a.body_value, a.body_format, 167 167 a.body_uri, a.target_source, a.target_hash, a.target_title, 168 168 a.selector_json, a.tags_json, a.created_at, a.indexed_at, a.cid 169 - FROM annotations a 169 + FROM all_annotations a 170 170 WHERE a.created_at < $1 AND a.created_at > $2 171 171 AND NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = a.uri) 172 172 AND NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = a.uri) ··· 184 184 func (db *DB) GetMarginAnnotations(limit, offset int) ([]Annotation, error) { 185 185 rows, err := db.Query(` 186 186 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 187 - FROM annotations 187 + FROM all_annotations 188 188 WHERE uri NOT LIKE '%network.cosmik%' 189 189 ORDER BY created_at DESC 190 190 LIMIT $1 OFFSET $2 ··· 200 200 func (db *DB) GetSembleAnnotations(limit, offset int) ([]Annotation, error) { 201 201 rows, err := db.Query(` 202 202 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 203 - FROM annotations 203 + FROM all_annotations 204 204 WHERE uri LIKE '%network.cosmik%' 205 205 ORDER BY created_at DESC 206 206 LIMIT $1 OFFSET $2 ··· 216 216 func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 217 217 rows, err := db.Query(` 218 218 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 219 - FROM annotations 219 + FROM all_annotations 220 220 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) 221 221 ORDER BY created_at DESC 222 222 LIMIT $2 OFFSET $3 ··· 232 232 func (db *DB) GetMarginAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 233 233 rows, err := db.Query(` 234 234 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 235 - FROM annotations 235 + FROM all_annotations 236 236 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri NOT LIKE '%network.cosmik%' 237 237 ORDER BY created_at DESC 238 238 LIMIT $2 OFFSET $3 ··· 248 248 func (db *DB) GetSembleAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { 249 249 rows, err := db.Query(` 250 250 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 251 - FROM annotations 251 + FROM all_annotations 252 252 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri LIKE '%network.cosmik%' 253 253 ORDER BY created_at DESC 254 254 LIMIT $2 OFFSET $3 ··· 278 278 func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 279 279 rows, err := db.Query(` 280 280 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 281 - FROM annotations 281 + FROM all_annotations 282 282 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) 283 283 ORDER BY created_at DESC 284 284 LIMIT $3 OFFSET $4 ··· 294 294 func (db *DB) GetMarginAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 295 295 rows, err := db.Query(` 296 296 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 297 - FROM annotations 297 + FROM all_annotations 298 298 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) AND uri NOT LIKE '%network.cosmik%' 299 299 ORDER BY created_at DESC 300 300 LIMIT $3 OFFSET $4 ··· 310 310 func (db *DB) GetSembleAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { 311 311 rows, err := db.Query(` 312 312 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 313 - FROM annotations 313 + FROM all_annotations 314 314 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) AND uri LIKE '%network.cosmik%' 315 315 ORDER BY created_at DESC 316 316 LIMIT $3 OFFSET $4 ··· 326 326 func (db *DB) GetAnnotationsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Annotation, error) { 327 327 rows, err := db.Query(` 328 328 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 329 - FROM annotations 329 + FROM all_annotations 330 330 WHERE author_did = $1 AND target_hash = $2 331 331 ORDER BY created_at DESC 332 332 LIMIT $3 OFFSET $4 ··· 346 346 347 347 query := ` 348 348 SELECT uri, author_did, motivation, body_value, body_format, body_uri, target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 349 - FROM annotations 349 + FROM all_annotations 350 350 WHERE uri = ANY($1) 351 351 ` 352 352 ··· 361 361 362 362 func (db *DB) GetAnnotationURIs(authorDID string) ([]string, error) { 363 363 rows, err := db.Query(` 364 - SELECT uri FROM annotations WHERE author_did = $1 364 + SELECT uri FROM all_annotations WHERE author_did = $1 365 365 `, authorDID) 366 366 if err != nil { 367 367 return nil, err
+18 -18
backend/internal/db/queries_bookmarks.go
··· 24 24 var b Bookmark 25 25 err := db.QueryRow(` 26 26 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 27 - FROM bookmarks 27 + FROM all_bookmarks 28 28 WHERE uri = $1 29 29 `, uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) 30 30 if err != nil { ··· 36 36 func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { 37 37 rows, err := db.Query(` 38 38 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 39 - FROM bookmarks 39 + FROM all_bookmarks 40 40 ORDER BY created_at DESC 41 41 LIMIT $1 OFFSET $2 42 42 `, limit, offset) ··· 54 54 SELECT 55 55 b.uri, b.author_did, b.source, b.source_hash, b.title, 56 56 b.description, b.tags_json, b.created_at, b.indexed_at, b.cid 57 - FROM bookmarks b 57 + FROM all_bookmarks b 58 58 LEFT JOIN LATERAL ( 59 59 SELECT COUNT(*) as cnt FROM likes WHERE subject_uri = b.uri 60 60 ) l ON true ··· 80 80 SELECT 81 81 b.uri, b.author_did, b.source, b.source_hash, b.title, 82 82 b.description, b.tags_json, b.created_at, b.indexed_at, b.cid 83 - FROM bookmarks b 83 + FROM all_bookmarks b 84 84 WHERE b.created_at < $1 AND b.created_at > $2 85 85 AND NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = b.uri) 86 86 AND NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = b.uri) ··· 98 98 func (db *DB) GetMarginBookmarks(limit, offset int) ([]Bookmark, error) { 99 99 rows, err := db.Query(` 100 100 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 101 - FROM bookmarks 101 + FROM all_bookmarks 102 102 WHERE uri NOT LIKE '%network.cosmik%' 103 103 ORDER BY created_at DESC 104 104 LIMIT $1 OFFSET $2 ··· 114 114 func (db *DB) GetSembleBookmarks(limit, offset int) ([]Bookmark, error) { 115 115 rows, err := db.Query(` 116 116 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 117 - FROM bookmarks 117 + FROM all_bookmarks 118 118 WHERE uri LIKE '%network.cosmik%' 119 119 ORDER BY created_at DESC 120 120 LIMIT $1 OFFSET $2 ··· 130 130 func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 131 131 rows, err := db.Query(` 132 132 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 133 - FROM bookmarks 133 + FROM all_bookmarks 134 134 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) 135 135 ORDER BY created_at DESC 136 136 LIMIT $2 OFFSET $3 ··· 146 146 func (db *DB) GetMarginBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 147 147 rows, err := db.Query(` 148 148 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 149 - FROM bookmarks 149 + FROM all_bookmarks 150 150 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri NOT LIKE '%network.cosmik%' 151 151 ORDER BY created_at DESC 152 152 LIMIT $2 OFFSET $3 ··· 162 162 func (db *DB) GetSembleBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { 163 163 rows, err := db.Query(` 164 164 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 165 - FROM bookmarks 165 + FROM all_bookmarks 166 166 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri LIKE '%network.cosmik%' 167 167 ORDER BY created_at DESC 168 168 LIMIT $2 OFFSET $3 ··· 178 178 func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 179 179 rows, err := db.Query(` 180 180 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 181 - FROM bookmarks 181 + FROM all_bookmarks 182 182 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) 183 183 ORDER BY created_at DESC 184 184 LIMIT $3 OFFSET $4 ··· 194 194 func (db *DB) GetMarginBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 195 195 rows, err := db.Query(` 196 196 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 197 - FROM bookmarks 197 + FROM all_bookmarks 198 198 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) AND uri NOT LIKE '%network.cosmik%' 199 199 ORDER BY created_at DESC 200 200 LIMIT $3 OFFSET $4 ··· 210 210 func (db *DB) GetSembleBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { 211 211 rows, err := db.Query(` 212 212 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 213 - FROM bookmarks 213 + FROM all_bookmarks 214 214 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) AND uri LIKE '%network.cosmik%' 215 215 ORDER BY created_at DESC 216 216 LIMIT $3 OFFSET $4 ··· 226 226 func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { 227 227 rows, err := db.Query(` 228 228 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 229 - FROM bookmarks 229 + FROM all_bookmarks 230 230 WHERE author_did = $1 231 231 ORDER BY created_at DESC 232 232 LIMIT $2 OFFSET $3 ··· 242 242 func (db *DB) GetMarginBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { 243 243 rows, err := db.Query(` 244 244 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 245 - FROM bookmarks 245 + FROM all_bookmarks 246 246 WHERE author_did = $1 AND uri NOT LIKE '%network.cosmik%' 247 247 ORDER BY created_at DESC 248 248 LIMIT $2 OFFSET $3 ··· 258 258 func (db *DB) GetSembleBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { 259 259 rows, err := db.Query(` 260 260 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 261 - FROM bookmarks 261 + FROM all_bookmarks 262 262 WHERE author_did = $1 AND uri LIKE '%network.cosmik%' 263 263 ORDER BY created_at DESC 264 264 LIMIT $2 OFFSET $3 ··· 292 292 293 293 rows, err := db.Query(` 294 294 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 295 - FROM bookmarks 295 + FROM all_bookmarks 296 296 WHERE uri = ANY($1) 297 297 `, pqStringArray(uris)) 298 298 if err != nil { ··· 305 305 306 306 func (db *DB) GetBookmarkURIs(authorDID string) ([]string, error) { 307 307 rows, err := db.Query(` 308 - SELECT uri FROM bookmarks WHERE author_did = $1 308 + SELECT uri FROM all_bookmarks WHERE author_did = $1 309 309 `, authorDID) 310 310 if err != nil { 311 311 return nil, err ··· 326 326 func (db *DB) GetBookmarksByTargetHash(targetHash string, limit, offset int) ([]Bookmark, error) { 327 327 rows, err := db.Query(` 328 328 SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid 329 - FROM bookmarks 329 + FROM all_bookmarks 330 330 WHERE source_hash = $1 331 331 ORDER BY created_at DESC 332 332 LIMIT $2 OFFSET $3
+19 -19
backend/internal/db/queries_highlights.go
··· 25 25 var h Highlight 26 26 err := db.QueryRow(` 27 27 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 28 - FROM highlights 28 + FROM all_highlights 29 29 WHERE uri = $1 30 30 `, uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) 31 31 if err != nil { ··· 37 37 func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { 38 38 rows, err := db.Query(` 39 39 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 40 - FROM highlights 40 + FROM all_highlights 41 41 ORDER BY created_at DESC 42 42 LIMIT $1 OFFSET $2 43 43 `, limit, offset) ··· 55 55 SELECT 56 56 h.uri, h.author_did, h.target_source, h.target_hash, h.target_title, 57 57 h.selector_json, h.color, h.tags_json, h.created_at, h.indexed_at, h.cid 58 - FROM highlights h 58 + FROM all_highlights h 59 59 LEFT JOIN LATERAL ( 60 60 SELECT COUNT(*) as cnt FROM likes WHERE subject_uri = h.uri 61 61 ) l ON true ··· 81 81 SELECT 82 82 h.uri, h.author_did, h.target_source, h.target_hash, h.target_title, 83 83 h.selector_json, h.color, h.tags_json, h.created_at, h.indexed_at, h.cid 84 - FROM highlights h 84 + FROM all_highlights h 85 85 WHERE h.created_at < $1 AND h.created_at > $2 86 86 AND NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = h.uri) 87 87 AND NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = h.uri) ··· 99 99 func (db *DB) GetMarginHighlights(limit, offset int) ([]Highlight, error) { 100 100 rows, err := db.Query(` 101 101 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 102 - FROM highlights 102 + FROM all_highlights 103 103 WHERE uri NOT LIKE '%network.cosmik%' 104 104 ORDER BY created_at DESC 105 105 LIMIT $1 OFFSET $2 ··· 115 115 func (db *DB) GetSembleHighlights(limit, offset int) ([]Highlight, error) { 116 116 rows, err := db.Query(` 117 117 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 118 - FROM highlights 118 + FROM all_highlights 119 119 WHERE uri LIKE '%network.cosmik%' 120 120 ORDER BY created_at DESC 121 121 LIMIT $1 OFFSET $2 ··· 131 131 func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 132 132 rows, err := db.Query(` 133 133 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 134 - FROM highlights 134 + FROM all_highlights 135 135 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) 136 136 ORDER BY created_at DESC 137 137 LIMIT $2 OFFSET $3 ··· 147 147 func (db *DB) GetMarginHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 148 148 rows, err := db.Query(` 149 149 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 150 - FROM highlights 150 + FROM all_highlights 151 151 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri NOT LIKE '%network.cosmik%' 152 152 ORDER BY created_at DESC 153 153 LIMIT $2 OFFSET $3 ··· 163 163 func (db *DB) GetSembleHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { 164 164 rows, err := db.Query(` 165 165 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 166 - FROM highlights 166 + FROM all_highlights 167 167 WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri LIKE '%network.cosmik%' 168 168 ORDER BY created_at DESC 169 169 LIMIT $2 OFFSET $3 ··· 179 179 func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 180 180 rows, err := db.Query(` 181 181 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 182 - FROM highlights 182 + FROM all_highlights 183 183 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) 184 184 ORDER BY created_at DESC 185 185 LIMIT $3 OFFSET $4 ··· 195 195 func (db *DB) GetMarginHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 196 196 rows, err := db.Query(` 197 197 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 198 - FROM highlights 198 + FROM all_highlights 199 199 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) AND uri NOT LIKE '%network.cosmik%' 200 200 ORDER BY created_at DESC 201 201 LIMIT $3 OFFSET $4 ··· 211 211 func (db *DB) GetSembleHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { 212 212 rows, err := db.Query(` 213 213 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 214 - FROM highlights 214 + FROM all_highlights 215 215 WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) AND uri LIKE '%network.cosmik%' 216 216 ORDER BY created_at DESC 217 217 LIMIT $3 OFFSET $4 ··· 227 227 func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { 228 228 rows, err := db.Query(` 229 229 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 230 - FROM highlights 230 + FROM all_highlights 231 231 WHERE target_hash = $1 232 232 ORDER BY created_at DESC 233 233 LIMIT $2 OFFSET $3 ··· 243 243 func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { 244 244 rows, err := db.Query(` 245 245 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 246 - FROM highlights 246 + FROM all_highlights 247 247 WHERE author_did = $1 248 248 ORDER BY created_at DESC 249 249 LIMIT $2 OFFSET $3 ··· 259 259 func (db *DB) GetMarginHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { 260 260 rows, err := db.Query(` 261 261 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 262 - FROM highlights 262 + FROM all_highlights 263 263 WHERE author_did = $1 AND uri NOT LIKE '%network.cosmik%' 264 264 ORDER BY created_at DESC 265 265 LIMIT $2 OFFSET $3 ··· 275 275 func (db *DB) GetSembleHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { 276 276 rows, err := db.Query(` 277 277 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 278 - FROM highlights 278 + FROM all_highlights 279 279 WHERE author_did = $1 AND uri LIKE '%network.cosmik%' 280 280 ORDER BY created_at DESC 281 281 LIMIT $2 OFFSET $3 ··· 291 291 func (db *DB) GetHighlightsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Highlight, error) { 292 292 rows, err := db.Query(` 293 293 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 294 - FROM highlights 294 + FROM all_highlights 295 295 WHERE author_did = $1 AND target_hash = $2 296 296 ORDER BY created_at DESC 297 297 LIMIT $3 OFFSET $4 ··· 325 325 326 326 rows, err := db.Query(` 327 327 SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid 328 - FROM highlights 328 + FROM all_highlights 329 329 WHERE uri = ANY($1) 330 330 `, pqStringArray(uris)) 331 331 if err != nil { ··· 338 338 339 339 func (db *DB) GetHighlightURIs(authorDID string) ([]string, error) { 340 340 rows, err := db.Query(` 341 - SELECT uri FROM highlights WHERE author_did = $1 341 + SELECT uri FROM all_highlights WHERE author_did = $1 342 342 `, authorDID) 343 343 if err != nil { 344 344 return nil, err
+130
backend/internal/db/queries_notes.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "time" 6 + ) 7 + 8 + func (db *DB) CreateNote(n *Note) error { 9 + query := ` 10 + INSERT INTO notes ( 11 + uri, author_did, motivation, color, description, body_value, body_format, body_uri, 12 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 13 + ) VALUES ( 14 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 15 + ) ON CONFLICT (uri) DO UPDATE SET 16 + motivation = EXCLUDED.motivation, 17 + color = EXCLUDED.color, 18 + description = EXCLUDED.description, 19 + body_value = EXCLUDED.body_value, 20 + body_format = EXCLUDED.body_format, 21 + body_uri = EXCLUDED.body_uri, 22 + target_source = EXCLUDED.target_source, 23 + target_hash = EXCLUDED.target_hash, 24 + target_title = EXCLUDED.target_title, 25 + selector_json = EXCLUDED.selector_json, 26 + tags_json = EXCLUDED.tags_json, 27 + indexed_at = EXCLUDED.indexed_at, 28 + cid = EXCLUDED.cid 29 + ` 30 + _, err := db.Exec(query, 31 + n.URI, n.AuthorDID, n.Motivation, n.Color, n.Description, n.BodyValue, n.BodyFormat, n.BodyURI, 32 + n.TargetSource, n.TargetHash, n.TargetTitle, n.SelectorJSON, n.TagsJSON, n.CreatedAt, n.IndexedAt, n.CID, 33 + ) 34 + return err 35 + } 36 + 37 + func (db *DB) GetNoteByURI(uri string) (*Note, error) { 38 + query := ` 39 + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, 40 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 41 + FROM notes WHERE uri = $1 42 + ` 43 + var n Note 44 + err := db.QueryRow(query, uri).Scan( 45 + &n.URI, &n.AuthorDID, &n.Motivation, &n.Color, &n.Description, &n.BodyValue, &n.BodyFormat, &n.BodyURI, 46 + &n.TargetSource, &n.TargetHash, &n.TargetTitle, &n.SelectorJSON, &n.TagsJSON, &n.CreatedAt, &n.IndexedAt, &n.CID, 47 + ) 48 + if err == sql.ErrNoRows { 49 + return nil, nil 50 + } 51 + if err != nil { 52 + return nil, err 53 + } 54 + return &n, nil 55 + } 56 + 57 + func (db *DB) MarginNoteBookmarkExists(authorDID, targetHash string) (bool, error) { 58 + var dummy int 59 + err := db.QueryRow(` 60 + SELECT 1 FROM notes 61 + WHERE author_did = $1 62 + AND target_hash = $2 63 + AND motivation = 'bookmarking' 64 + AND uri LIKE 'at://%/at.margin.note/%' 65 + LIMIT 1 66 + `, authorDID, targetHash).Scan(&dummy) 67 + if err == sql.ErrNoRows { 68 + return false, nil 69 + } 70 + if err != nil { 71 + return false, err 72 + } 73 + return true, nil 74 + } 75 + 76 + func (db *DB) CommunityBookmarkExists(authorDID, targetHash, tagsJSON string) (bool, error) { 77 + query := ` 78 + SELECT 1 FROM notes 79 + WHERE author_did = $1 80 + AND target_hash = $2 81 + AND uri LIKE 'at://%/community.lexicon.bookmarks.bookmark/%' 82 + AND COALESCE(tags_json, '[]') = COALESCE($3, '[]') 83 + LIMIT 1 84 + ` 85 + normalized := tagsJSON 86 + if normalized == "" { 87 + normalized = "[]" 88 + } 89 + var dummy int 90 + err := db.QueryRow(query, authorDID, targetHash, normalized).Scan(&dummy) 91 + if err == sql.ErrNoRows { 92 + return false, nil 93 + } 94 + if err != nil { 95 + return false, err 96 + } 97 + return true, nil 98 + } 99 + 100 + func (db *DB) DeleteNote(uri string) error { 101 + _, err := db.Exec("DELETE FROM notes WHERE uri = $1", uri) 102 + return err 103 + } 104 + 105 + func (db *DB) UpdateNoteAnnotation(uri, bodyValue, tagsJSON, cid string) error { 106 + _, err := db.Exec(` 107 + UPDATE notes 108 + SET body_value = $1, tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 109 + WHERE uri = $5 110 + `, bodyValue, tagsJSON, cid, time.Now(), uri) 111 + return err 112 + } 113 + 114 + func (db *DB) UpdateNoteHighlight(uri, color, tagsJSON, cid string) error { 115 + _, err := db.Exec(` 116 + UPDATE notes 117 + SET color = NULLIF($1, ''), tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 118 + WHERE uri = $5 119 + `, color, tagsJSON, cid, time.Now(), uri) 120 + return err 121 + } 122 + 123 + func (db *DB) UpdateNoteBookmark(uri, title, description, tagsJSON, cid string) error { 124 + _, err := db.Exec(` 125 + UPDATE notes 126 + SET target_title = NULLIF($1, ''), body_value = NULLIF($2, ''), tags_json = NULLIF($3, ''), cid = $4, indexed_at = $5 127 + WHERE uri = $6 128 + `, title, description, tagsJSON, cid, time.Now(), uri) 129 + return err 130 + }
+180
backend/internal/db/queries_profiles.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + ) 8 + 9 + func (db *DB) GetProfile(did string) (*Profile, error) { 10 + var p Profile 11 + err := db.QueryRow( 12 + `SELECT uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at 13 + FROM profiles WHERE author_did = $1`, did, 14 + ).Scan(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt) 15 + switch err { 16 + case nil: 17 + return &p, nil 18 + case sql.ErrNoRows: 19 + return nil, nil 20 + default: 21 + return nil, err 22 + } 23 + } 24 + 25 + func (db *DB) GetProfilesByDIDs(dids []string) (map[string]*Profile, error) { 26 + if len(dids) == 0 { 27 + return nil, nil 28 + } 29 + 30 + placeholders := make([]string, len(dids)) 31 + args := make([]interface{}, len(dids)) 32 + for i, did := range dids { 33 + placeholders[i] = fmt.Sprintf("$%d", i+1) 34 + args[i] = did 35 + } 36 + 37 + rows, err := db.Query( 38 + `SELECT uri, author_did, display_name, bio, avatar, website, links_json, created_at, indexed_at 39 + FROM profiles WHERE author_did IN (`+strings.Join(placeholders, ",")+`)`, 40 + args..., 41 + ) 42 + if err != nil { 43 + return nil, err 44 + } 45 + defer rows.Close() 46 + 47 + profiles := make(map[string]*Profile) 48 + for rows.Next() { 49 + var p Profile 50 + if err := rows.Scan(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Bio, &p.Avatar, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt); err != nil { 51 + continue 52 + } 53 + profiles[p.AuthorDID] = &p 54 + } 55 + return profiles, rows.Err() 56 + } 57 + 58 + func (db *DB) UpsertProfile(p *Profile) error { 59 + _, err := db.Exec(` 60 + INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) 61 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 62 + ON CONFLICT(uri) DO UPDATE SET 63 + display_name = EXCLUDED.display_name, 64 + avatar = EXCLUDED.avatar, 65 + bio = EXCLUDED.bio, 66 + website = EXCLUDED.website, 67 + links_json = EXCLUDED.links_json, 68 + indexed_at = EXCLUDED.indexed_at 69 + `, p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) 70 + return err 71 + } 72 + 73 + func (db *DB) DeleteProfile(uri string) error { 74 + _, err := db.Exec("DELETE FROM profiles WHERE uri = $1", uri) 75 + return err 76 + } 77 + 78 + func (db *DB) GetPreferences(did string) (*Preferences, error) { 79 + var p Preferences 80 + err := db.QueryRow( 81 + `SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, 82 + label_preferences, disable_external_link_warning, enable_community_bookmarks, 83 + created_at, indexed_at, cid 84 + FROM preferences WHERE author_did = $1`, did, 85 + ).Scan( 86 + &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, 87 + &p.LabelPreferences, &p.DisableExternalLinkWarning, &p.EnableCommunityBookmarks, 88 + &p.CreatedAt, &p.IndexedAt, &p.CID, 89 + ) 90 + switch err { 91 + case nil: 92 + return &p, nil 93 + case sql.ErrNoRows: 94 + return nil, nil 95 + default: 96 + return nil, err 97 + } 98 + } 99 + 100 + func (db *DB) UpsertPreferences(p *Preferences) error { 101 + _, err := db.Exec(` 102 + INSERT INTO preferences ( 103 + uri, author_did, external_link_skipped_hostnames, subscribed_labelers, 104 + label_preferences, disable_external_link_warning, enable_community_bookmarks, 105 + created_at, indexed_at, cid 106 + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) 107 + ON CONFLICT(uri) DO UPDATE SET 108 + external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, 109 + subscribed_labelers = EXCLUDED.subscribed_labelers, 110 + label_preferences = EXCLUDED.label_preferences, 111 + disable_external_link_warning = EXCLUDED.disable_external_link_warning, 112 + enable_community_bookmarks = EXCLUDED.enable_community_bookmarks, 113 + indexed_at = EXCLUDED.indexed_at, 114 + cid = EXCLUDED.cid 115 + `, p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, 116 + p.LabelPreferences, p.DisableExternalLinkWarning, p.EnableCommunityBookmarks, 117 + p.CreatedAt, p.IndexedAt, p.CID) 118 + return err 119 + } 120 + 121 + func (db *DB) DeletePreferences(uri string) error { 122 + _, err := db.Exec("DELETE FROM preferences WHERE uri = $1", uri) 123 + return err 124 + } 125 + 126 + func (db *DB) GetPreferenceURIs(did string) ([]string, error) { 127 + rows, err := db.Query( 128 + "SELECT uri FROM preferences WHERE author_did = $1 AND uri IS NOT NULL AND uri != ''", did, 129 + ) 130 + if err != nil { 131 + return nil, err 132 + } 133 + defer rows.Close() 134 + var uris []string 135 + for rows.Next() { 136 + var uri string 137 + if err := rows.Scan(&uri); err != nil { 138 + return nil, err 139 + } 140 + uris = append(uris, uri) 141 + } 142 + return uris, rows.Err() 143 + } 144 + 145 + func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { 146 + var uri string 147 + err := db.QueryRow("SELECT uri FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID).Scan(&uri) 148 + if err != nil { 149 + if err == sql.ErrNoRows { 150 + return "", nil 151 + } 152 + return "", err 153 + } 154 + _, err = db.Exec("DELETE FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID) 155 + return uri, err 156 + } 157 + 158 + func (db *DB) DeleteAPIKeyByURI(uri string) error { 159 + _, err := db.Exec("DELETE FROM api_keys WHERE uri = $1", uri) 160 + return err 161 + } 162 + 163 + func (db *DB) GetAPIKeyURIs(ownerDID string) ([]string, error) { 164 + rows, err := db.Query( 165 + "SELECT uri FROM api_keys WHERE owner_did = $1 AND uri IS NOT NULL AND uri != ''", ownerDID, 166 + ) 167 + if err != nil { 168 + return nil, err 169 + } 170 + defer rows.Close() 171 + var uris []string 172 + for rows.Next() { 173 + var uri string 174 + if err := rows.Scan(&uri); err != nil { 175 + return nil, err 176 + } 177 + uris = append(uris, uri) 178 + } 179 + return uris, rows.Err() 180 + }
-40
backend/internal/db/queries_recommendations.go
··· 54 54 UpdatedAt time.Time `json:"updatedAt"` 55 55 } 56 56 57 - func (db *DB) MigrateRecommendations() error { 58 - _, err := db.Exec(` 59 - CREATE TABLE IF NOT EXISTS document_embeddings ( 60 - document_uri TEXT PRIMARY KEY, 61 - embedding TEXT NOT NULL, 62 - updated_at TIMESTAMP NOT NULL 63 - )`) 64 - if err != nil { 65 - return fmt.Errorf("create document_embeddings table: %w", err) 66 - } 67 - 68 - _, err = db.Exec(` 69 - CREATE TABLE IF NOT EXISTS annotation_embeddings ( 70 - annotation_uri TEXT PRIMARY KEY, 71 - author_did TEXT NOT NULL, 72 - document_uri TEXT, 73 - embedding TEXT NOT NULL, 74 - updated_at TIMESTAMP NOT NULL 75 - )`) 76 - if err != nil { 77 - return fmt.Errorf("create annotation_embeddings table: %w", err) 78 - } 79 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_ann_emb_author ON annotation_embeddings(author_did)`) 80 - db.Exec(`CREATE INDEX IF NOT EXISTS idx_ann_emb_document ON annotation_embeddings(document_uri)`) 81 - 82 - _, err = db.Exec(` 83 - CREATE TABLE IF NOT EXISTS user_profiles ( 84 - author_did TEXT PRIMARY KEY, 85 - embedding TEXT NOT NULL, 86 - tag_affinities TEXT DEFAULT '{}', 87 - annotation_count INTEGER NOT NULL DEFAULT 0, 88 - updated_at TIMESTAMP NOT NULL 89 - )`) 90 - if err != nil { 91 - return fmt.Errorf("create user_profiles table: %w", err) 92 - } 93 - 94 - return nil 95 - } 96 - 97 57 func (db *DB) UpsertPublication(p *Publication) error { 98 58 query := ` 99 59 INSERT INTO publications (uri, author_did, url, name, description, show_in_discover, indexed_at)
+9
backend/internal/db/queries_sessions.go
··· 35 35 _, err := db.Exec(`DELETE FROM sessions WHERE expires_at <= $1`, time.Now()) 36 36 return err 37 37 } 38 + 39 + func (db *DB) CountSessionsByDID(did string) (int, error) { 40 + var n int 41 + err := db.QueryRow( 42 + `SELECT COUNT(*) FROM sessions WHERE did = $1 AND expires_at > $2`, 43 + did, time.Now(), 44 + ).Scan(&n) 45 + return n, err 46 + }
+22
backend/internal/domain/filters.go
··· 1 + package domain 2 + 3 + type FeedType string 4 + 5 + const ( 6 + FeedTypeRecent FeedType = "recent" 7 + FeedTypePopular FeedType = "popular" 8 + FeedTypeShelved FeedType = "shelved" 9 + FeedTypeMargin FeedType = "margin" 10 + FeedTypeSemble FeedType = "semble" 11 + ) 12 + 13 + type NoteFilter struct { 14 + Motivations []string 15 + AuthorDID string 16 + TargetHash string 17 + Tag string 18 + FeedType FeedType 19 + Query string 20 + Limit int 21 + Offset int 22 + }
+65
backend/internal/domain/interfaces.go
··· 1 + package domain 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + type Author struct { 9 + DID string `json:"did"` 10 + Handle string `json:"handle"` 11 + DisplayName string `json:"displayName,omitempty"` 12 + Avatar string `json:"avatar,omitempty"` 13 + } 14 + 15 + type NoteRepository interface { 16 + List(ctx context.Context, f NoteFilter) ([]Note, error) 17 + GetByURI(ctx context.Context, uri string) (*Note, error) 18 + GetLikeByUserAndSubject(ctx context.Context, did, subjectURI string) (*Like, error) 19 + CreateNote(ctx context.Context, note *Note) error 20 + DeleteNote(ctx context.Context, uri string) error 21 + UpdateNoteAnnotation(ctx context.Context, uri, text, tags string, cid *string) error 22 + CreateLike(ctx context.Context, like *Like) error 23 + DeleteLike(ctx context.Context, uri string) error 24 + CreateReply(ctx context.Context, reply *Reply) error 25 + GetReplyByURI(ctx context.Context, uri string) (*Reply, error) 26 + DeleteReply(ctx context.Context, uri string) error 27 + DeleteAnnotation(ctx context.Context, uri string) error 28 + DeleteHighlight(ctx context.Context, uri string) error 29 + DeleteBookmark(ctx context.Context, uri string) error 30 + UpdateAnnotation(ctx context.Context, uri, text, tags string, cid *string) error 31 + GetAnnotationByURI(ctx context.Context, uri string) (*Annotation, error) 32 + CheckDuplicateAnnotation(ctx context.Context, did, url, text string) (*Annotation, error) 33 + CheckDuplicateHighlight(ctx context.Context, did, url string, selector []byte) (*Highlight, error) 34 + } 35 + 36 + type EngagementRepository interface { 37 + GetLikeCount(ctx context.Context, uri string) (int, error) 38 + GetLikeCounts(ctx context.Context, uris []string) (map[string]int, error) 39 + GetReplyCounts(ctx context.Context, uris []string) (map[string]int, error) 40 + GetViewerLikes(ctx context.Context, viewerDID string, uris []string) (map[string]bool, error) 41 + GetLabelsForURIs(ctx context.Context, uris []string, labelerDIDs []string) (map[string][]ContentLabel, error) 42 + GetLabelsForDIDs(ctx context.Context, dids []string, labelerDIDs []string) (map[string][]ContentLabel, error) 43 + GetLatestEditTimes(ctx context.Context, uris []string) (map[string]time.Time, error) 44 + } 45 + 46 + type ProfileRepository interface { 47 + GetProfiles(ctx context.Context, dids []string) (map[string]Author, error) 48 + GetProfile(ctx context.Context, did string) (*Profile, error) 49 + UpsertProfile(ctx context.Context, p *Profile) error 50 + } 51 + 52 + type NotificationRepository interface { 53 + GetNotifications(ctx context.Context, recipientDID string, limit, offset int) ([]Notification, error) 54 + GetUnreadNotificationCount(ctx context.Context, recipientDID string) (int, error) 55 + MarkNotificationsRead(ctx context.Context, recipientDID string) error 56 + CreateNotification(ctx context.Context, n *Notification) error 57 + } 58 + 59 + type SessionRepository interface { 60 + GetSession(ctx context.Context, id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) 61 + } 62 + 63 + type NoteService interface{} 64 + 65 + type ProfileService interface{}
+212
backend/internal/domain/models.go
··· 1 + package domain 2 + 3 + import ( 4 + "time" 5 + ) 6 + 7 + type Note struct { 8 + URI string `json:"uri"` 9 + AuthorDID string `json:"authorDid"` 10 + Motivation string `json:"motivation,omitempty"` 11 + Color *string `json:"color,omitempty"` 12 + Description *string `json:"description,omitempty"` 13 + BodyValue *string `json:"bodyValue,omitempty"` 14 + BodyFormat *string `json:"bodyFormat,omitempty"` 15 + BodyURI *string `json:"bodyUri,omitempty"` 16 + TargetSource string `json:"targetSource"` 17 + TargetHash string `json:"targetHash"` 18 + TargetTitle *string `json:"targetTitle,omitempty"` 19 + SelectorJSON *string `json:"selector,omitempty"` 20 + TagsJSON *string `json:"tags,omitempty"` 21 + CreatedAt time.Time `json:"createdAt"` 22 + IndexedAt time.Time `json:"indexedAt"` 23 + CID *string `json:"cid,omitempty"` 24 + } 25 + 26 + type Annotation struct { 27 + URI string `json:"uri"` 28 + AuthorDID string `json:"authorDid"` 29 + Motivation string `json:"motivation,omitempty"` 30 + BodyValue *string `json:"bodyValue,omitempty"` 31 + BodyFormat *string `json:"bodyFormat,omitempty"` 32 + BodyURI *string `json:"bodyUri,omitempty"` 33 + TargetSource string `json:"targetSource"` 34 + TargetHash string `json:"targetHash"` 35 + TargetTitle *string `json:"targetTitle,omitempty"` 36 + SelectorJSON *string `json:"selector,omitempty"` 37 + TagsJSON *string `json:"tags,omitempty"` 38 + CreatedAt time.Time `json:"createdAt"` 39 + IndexedAt time.Time `json:"indexedAt"` 40 + CID *string `json:"cid,omitempty"` 41 + } 42 + 43 + type Selector struct { 44 + Type string `json:"type"` 45 + Exact string `json:"exact,omitempty"` 46 + Prefix string `json:"prefix,omitempty"` 47 + Suffix string `json:"suffix,omitempty"` 48 + Start *int `json:"start,omitempty"` 49 + End *int `json:"end,omitempty"` 50 + Value string `json:"value,omitempty"` 51 + } 52 + 53 + type Highlight struct { 54 + URI string `json:"uri"` 55 + AuthorDID string `json:"authorDid"` 56 + TargetSource string `json:"targetSource"` 57 + TargetHash string `json:"targetHash"` 58 + TargetTitle *string `json:"targetTitle,omitempty"` 59 + SelectorJSON *string `json:"selector,omitempty"` 60 + Color *string `json:"color,omitempty"` 61 + TagsJSON *string `json:"tags,omitempty"` 62 + CreatedAt time.Time `json:"createdAt"` 63 + IndexedAt time.Time `json:"indexedAt"` 64 + CID *string `json:"cid,omitempty"` 65 + } 66 + 67 + type Bookmark struct { 68 + URI string `json:"uri"` 69 + AuthorDID string `json:"authorDid"` 70 + Source string `json:"source"` 71 + SourceHash string `json:"sourceHash"` 72 + Title *string `json:"title,omitempty"` 73 + Description *string `json:"description,omitempty"` 74 + TagsJSON *string `json:"tags,omitempty"` 75 + CreatedAt time.Time `json:"createdAt"` 76 + IndexedAt time.Time `json:"indexedAt"` 77 + CID *string `json:"cid,omitempty"` 78 + } 79 + 80 + type Reply struct { 81 + URI string `json:"uri"` 82 + AuthorDID string `json:"authorDid"` 83 + ParentURI string `json:"parentUri"` 84 + RootURI string `json:"rootUri"` 85 + Text string `json:"text"` 86 + Format *string `json:"format,omitempty"` 87 + CreatedAt time.Time `json:"createdAt"` 88 + IndexedAt time.Time `json:"indexedAt"` 89 + CID *string `json:"cid,omitempty"` 90 + } 91 + 92 + type Like struct { 93 + URI string `json:"uri"` 94 + AuthorDID string `json:"authorDid"` 95 + SubjectURI string `json:"subjectUri"` 96 + CreatedAt time.Time `json:"createdAt"` 97 + IndexedAt time.Time `json:"indexedAt"` 98 + } 99 + 100 + type Collection struct { 101 + URI string `json:"uri"` 102 + AuthorDID string `json:"authorDid"` 103 + Name string `json:"name"` 104 + Description *string `json:"description,omitempty"` 105 + Icon *string `json:"icon,omitempty"` 106 + CreatedAt time.Time `json:"createdAt"` 107 + IndexedAt time.Time `json:"indexedAt"` 108 + } 109 + 110 + type CollectionItem struct { 111 + URI string `json:"uri"` 112 + AuthorDID string `json:"authorDid"` 113 + CollectionURI string `json:"collectionUri"` 114 + AnnotationURI string `json:"annotationUri"` 115 + Position int `json:"position"` 116 + CreatedAt time.Time `json:"createdAt"` 117 + IndexedAt time.Time `json:"indexedAt"` 118 + } 119 + 120 + type Notification struct { 121 + ID int `json:"id"` 122 + RecipientDID string `json:"recipientDid"` 123 + ActorDID string `json:"actorDid"` 124 + Type string `json:"type"` 125 + SubjectURI string `json:"subjectUri"` 126 + CreatedAt time.Time `json:"createdAt"` 127 + ReadAt *time.Time `json:"readAt,omitempty"` 128 + } 129 + 130 + type APIKey struct { 131 + ID string `json:"id"` 132 + OwnerDID string `json:"ownerDid"` 133 + Name string `json:"name"` 134 + KeyHash string `json:"-"` 135 + CreatedAt time.Time `json:"createdAt"` 136 + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` 137 + URI string `json:"uri"` 138 + CID *string `json:"cid,omitempty"` 139 + IndexedAt time.Time `json:"indexedAt"` 140 + } 141 + 142 + type Profile struct { 143 + URI string `json:"uri"` 144 + AuthorDID string `json:"authorDid"` 145 + DisplayName *string `json:"displayName,omitempty"` 146 + Avatar *string `json:"avatar,omitempty"` 147 + Bio *string `json:"bio,omitempty"` 148 + Website *string `json:"website,omitempty"` 149 + LinksJSON *string `json:"links,omitempty"` 150 + CreatedAt time.Time `json:"createdAt"` 151 + IndexedAt time.Time `json:"indexedAt"` 152 + CID *string `json:"cid,omitempty"` 153 + } 154 + 155 + type Preferences struct { 156 + URI string `json:"uri"` 157 + AuthorDID string `json:"authorDid"` 158 + ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` 159 + SubscribedLabelers *string `json:"subscribedLabelers,omitempty"` 160 + LabelPreferences *string `json:"labelPreferences,omitempty"` 161 + DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` 162 + EnableCommunityBookmarks *bool `json:"enableCommunityBookmarks,omitempty"` 163 + CreatedAt time.Time `json:"createdAt"` 164 + IndexedAt time.Time `json:"indexedAt"` 165 + CID *string `json:"cid,omitempty"` 166 + } 167 + 168 + type Block struct { 169 + ID int `json:"id"` 170 + ActorDID string `json:"actorDid"` 171 + SubjectDID string `json:"subjectDid"` 172 + CreatedAt time.Time `json:"createdAt"` 173 + } 174 + 175 + type Mute struct { 176 + ID int `json:"id"` 177 + ActorDID string `json:"actorDid"` 178 + SubjectDID string `json:"subjectDid"` 179 + CreatedAt time.Time `json:"createdAt"` 180 + } 181 + 182 + type ModerationReport struct { 183 + ID int `json:"id"` 184 + ReporterDID string `json:"reporterDid"` 185 + SubjectDID string `json:"subjectDid"` 186 + SubjectURI *string `json:"subjectUri,omitempty"` 187 + ReasonType string `json:"reasonType"` 188 + ReasonText *string `json:"reasonText,omitempty"` 189 + Status string `json:"status"` 190 + CreatedAt time.Time `json:"createdAt"` 191 + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` 192 + ResolvedBy *string `json:"resolvedBy,omitempty"` 193 + } 194 + 195 + type ModerationAction struct { 196 + ID int `json:"id"` 197 + ReportID int `json:"reportId"` 198 + ActorDID string `json:"actorDid"` 199 + Action string `json:"action"` 200 + Comment *string `json:"comment,omitempty"` 201 + CreatedAt time.Time `json:"createdAt"` 202 + } 203 + 204 + type ContentLabel struct { 205 + ID int `json:"id"` 206 + Src string `json:"src"` 207 + URI string `json:"uri"` 208 + Val string `json:"val"` 209 + Neg bool `json:"neg"` 210 + CreatedBy string `json:"createdBy"` 211 + CreatedAt time.Time `json:"createdAt"` 212 + }
+158
backend/internal/firehose/ingester.go
··· 93 93 i.RegisterHandler(CollectionSembleCollection, i.handleSembleCollection) 94 94 i.RegisterHandler(xrpc.CollectionSembleCollectionLink, i.handleSembleCollectionLink) 95 95 i.RegisterHandler(CollectionDocument, i.handleDocument) 96 + i.RegisterHandler(xrpc.CollectionNote, i.handleNote) 97 + i.RegisterHandler(xrpc.CollectionCommunityBookmark, i.handleCommunityBookmark) 96 98 97 99 return i 98 100 } ··· 325 327 i.db.RemoveFromCollection(uri) 326 328 case CollectionDocument: 327 329 i.db.DeleteDocument(uri) 330 + case xrpc.CollectionNote: 331 + i.db.DeleteNote(uri) 332 + case xrpc.CollectionCommunityBookmark: 333 + i.db.DeleteNote(uri) 328 334 } 329 335 } 330 336 ··· 460 466 } 461 467 } 462 468 469 + func (i *Ingester) handleNote(event *FirehoseEvent) { 470 + var record struct { 471 + Motivation string `json:"motivation"` 472 + Color string `json:"color"` 473 + Description string `json:"description"` 474 + Body struct { 475 + Value string `json:"value"` 476 + Format string `json:"format"` 477 + URI string `json:"uri"` 478 + } `json:"body"` 479 + Target struct { 480 + Source string `json:"source"` 481 + SourceHash string `json:"sourceHash"` 482 + Title string `json:"title"` 483 + Selector json.RawMessage `json:"selector"` 484 + } `json:"target"` 485 + Tags []string `json:"tags"` 486 + CreatedAt string `json:"createdAt"` 487 + } 488 + 489 + if err := json.Unmarshal(event.Record, &record); err != nil { 490 + return 491 + } 492 + 493 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 494 + 495 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 496 + if err != nil { 497 + createdAt = time.Now() 498 + } 499 + 500 + targetSource := record.Target.Source 501 + var targetHash string 502 + if targetSource != "" { 503 + targetHash = db.HashURL(targetSource) 504 + } 505 + 506 + motivation := record.Motivation 507 + if motivation == "" { 508 + motivation = "commenting" 509 + } 510 + 511 + bodyText := record.Body.Value 512 + if bodyText == "" && record.Description != "" { 513 + bodyText = record.Description 514 + } 515 + 516 + var bodyValuePtr, bodyFormatPtr, bodyURIPtr, targetTitlePtr, selectorJSONPtr, tagsJSONPtr, colorPtr *string 517 + if bodyText != "" { 518 + bodyValuePtr = &bodyText 519 + } 520 + if record.Body.Format != "" { 521 + bodyFormatPtr = &record.Body.Format 522 + } 523 + if record.Body.URI != "" { 524 + bodyURIPtr = &record.Body.URI 525 + } 526 + if record.Target.Title != "" { 527 + targetTitlePtr = &record.Target.Title 528 + } 529 + if len(record.Target.Selector) > 0 && string(record.Target.Selector) != "null" { 530 + selectorStr := string(record.Target.Selector) 531 + selectorJSONPtr = &selectorStr 532 + } 533 + if len(record.Tags) > 0 { 534 + tagsBytes, _ := json.Marshal(record.Tags) 535 + tagsStr := string(tagsBytes) 536 + tagsJSONPtr = &tagsStr 537 + } 538 + if record.Color != "" { 539 + colorPtr = &record.Color 540 + } 541 + 542 + note := &db.Note{ 543 + URI: uri, 544 + AuthorDID: event.Repo, 545 + Motivation: motivation, 546 + Color: colorPtr, 547 + BodyValue: bodyValuePtr, 548 + BodyFormat: bodyFormatPtr, 549 + BodyURI: bodyURIPtr, 550 + TargetSource: targetSource, 551 + TargetHash: targetHash, 552 + TargetTitle: targetTitlePtr, 553 + SelectorJSON: selectorJSONPtr, 554 + TagsJSON: tagsJSONPtr, 555 + CreatedAt: createdAt, 556 + IndexedAt: time.Now(), 557 + } 558 + 559 + if err := i.db.CreateNote(note); err != nil { 560 + logger.Error("Failed to index note: %v", err) 561 + } else { 562 + logger.Info("Indexed note from %s on %s", event.Repo, targetSource) 563 + } 564 + } 565 + 566 + func (i *Ingester) handleCommunityBookmark(event *FirehoseEvent) { 567 + var record struct { 568 + Subject string `json:"subject"` 569 + Tags []string `json:"tags"` 570 + CreatedAt string `json:"createdAt"` 571 + } 572 + 573 + if err := json.Unmarshal(event.Record, &record); err != nil { 574 + return 575 + } 576 + 577 + if record.Subject == "" { 578 + return 579 + } 580 + 581 + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) 582 + 583 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 584 + if err != nil { 585 + createdAt = time.Now() 586 + } 587 + 588 + targetHash := db.HashURL(record.Subject) 589 + 590 + if exists, err := i.db.MarginNoteBookmarkExists(event.Repo, targetHash); err == nil && exists { 591 + return 592 + } 593 + 594 + var tagsJSONPtr *string 595 + if len(record.Tags) > 0 { 596 + tagsBytes, _ := json.Marshal(record.Tags) 597 + tagsStr := string(tagsBytes) 598 + tagsJSONPtr = &tagsStr 599 + } 600 + 601 + note := &db.Note{ 602 + URI: uri, 603 + AuthorDID: event.Repo, 604 + Motivation: "bookmarking", 605 + TargetSource: record.Subject, 606 + TargetHash: targetHash, 607 + TagsJSON: tagsJSONPtr, 608 + CreatedAt: createdAt, 609 + IndexedAt: time.Now(), 610 + } 611 + 612 + if err := i.db.CreateNote(note); err != nil { 613 + logger.Error("Failed to index community bookmark: %v", err) 614 + } else { 615 + logger.Info("Indexed community bookmark from %s on %s", event.Repo, record.Subject) 616 + } 617 + } 618 + 463 619 func (i *Ingester) handleReply(event *FirehoseEvent) { 464 620 var record struct { 465 621 Parent struct { ··· 842 998 SubscribedLabelers json.RawMessage `json:"subscribedLabelers"` 843 999 LabelPreferences json.RawMessage `json:"labelPreferences"` 844 1000 DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` 1001 + EnableCommunityBookmarks *bool `json:"enableCommunityBookmarks,omitempty"` 845 1002 CreatedAt string `json:"createdAt"` 846 1003 } 847 1004 ··· 886 1043 ExternalLinkSkippedHostnames: skippedHostnamesPtr, 887 1044 SubscribedLabelers: subscribedLabelersPtr, 888 1045 DisableExternalLinkWarning: record.DisableExternalLinkWarning, 1046 + EnableCommunityBookmarks: record.EnableCommunityBookmarks, 889 1047 LabelPreferences: labelPrefsPtr, 890 1048 CreatedAt: createdAt, 891 1049 IndexedAt: time.Now(),
+1 -25
backend/internal/middleware/logger.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 - "net/url" 6 - "strings" 7 5 "time" 8 6 9 7 "github.com/go-chi/chi/v5/middleware" ··· 16 14 t1 := time.Now() 17 15 18 16 defer func() { 19 - safeURL := redactURL(r.URL) 20 - 21 17 logger.Info("[%d] %s %s %s", 22 18 ww.Status(), 23 19 r.Method, 24 - safeURL, 20 + r.URL.String(), 25 21 time.Since(t1), 26 22 ) 27 23 }() ··· 29 25 next.ServeHTTP(ww, r) 30 26 }) 31 27 } 32 - 33 - func redactURL(u *url.URL) string { 34 - redacted := *u 35 - q := redacted.Query() 36 - 37 - sensitiveKeys := []string{"source", "url", "target", "parent", "root", "uri"} 38 - 39 - for _, key := range sensitiveKeys { 40 - if q.Has(key) { 41 - val := q.Get(key) 42 - if strings.Contains(val, "margin.at") { 43 - continue 44 - } 45 - q.Set(key, "[REDACTED]") 46 - } 47 - } 48 - 49 - redacted.RawQuery = q.Encode() 50 - return redacted.String() 51 - }
+27 -7
backend/internal/oauth/handler.go
··· 16 16 "sync" 17 17 "time" 18 18 19 + "margin.at/internal/analytics" 19 20 "margin.at/internal/db" 20 21 "margin.at/internal/logger" 21 22 internal_sync "margin.at/internal/sync" ··· 29 30 pending map[string]*PendingAuth 30 31 pendingMu sync.RWMutex 31 32 syncService *internal_sync.Service 33 + analytics *analytics.Client 32 34 } 33 35 34 - func NewHandler(database *db.DB, syncService *internal_sync.Service) (*Handler, error) { 36 + func NewHandler(database *db.DB, syncService *internal_sync.Service, ac *analytics.Client) (*Handler, error) { 35 37 36 38 configuredBaseURL := os.Getenv("BASE_URL") 37 39 ··· 46 48 privateKey: privateKey, 47 49 pending: make(map[string]*PendingAuth), 48 50 syncService: syncService, 51 + analytics: ac, 49 52 }, nil 50 53 } 51 54 ··· 105 108 baseURL = baseURL[:len(baseURL)-1] 106 109 } 107 110 108 - clientID := baseURL + "/client-metadata.json" 111 + clientID := baseURL + "/oauth-client-metadata.json" 109 112 redirectURI := baseURL + "/auth/callback" 110 113 111 114 return NewClient(clientID, redirectURI, h.privateKey) ··· 148 151 149 152 pkceVerifier, pkceChallenge := client.GeneratePKCE() 150 153 151 - scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" 154 + scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark" 152 155 153 156 parResp, state, dpopNonce, err := client.SendPAR(meta, handle, scope, dpopKey, pkceChallenge) 154 157 if err != nil { ··· 236 239 } 237 240 238 241 pkceVerifier, pkceChallenge := client.GeneratePKCE() 239 - scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" 242 + scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark" 240 243 241 244 parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) 242 245 if err != nil { ··· 316 319 } 317 320 318 321 pkceVerifier, pkceChallenge := client.GeneratePKCE() 319 - scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" 322 + scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark" 320 323 321 324 parResp, state, dpopNonce, err := client.SendPARWithPrompt(meta, "", scope, dpopKey, pkceChallenge, "create") 322 325 if err != nil { ··· 482 485 }() 483 486 484 487 http.Redirect(w, r, "/home?logged_in=true", http.StatusFound) 488 + 489 + go func() { 490 + if h.analytics == nil { 491 + return 492 + } 493 + existingCount, _ := h.db.CountSessionsByDID(tokenResp.Sub) 494 + if existingCount <= 1 { 495 + h.analytics.Capture(tokenResp.Sub, "account_created", map[string]interface{}{ 496 + "pds": pending.PDS, 497 + }) 498 + } else { 499 + h.analytics.Capture(tokenResp.Sub, "login_success", map[string]interface{}{ 500 + "handle": pending.Handle, 501 + "pds": pending.PDS, 502 + }) 503 + } 504 + }() 485 505 } 486 506 487 507 func (h *Handler) cleanupOrphanedReplies(did, accessToken, dpopKeyPEM, pds string) { ··· 604 624 605 625 func (h *Handler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { 606 626 client := h.getDynamicClient(r) 607 - baseURL := client.ClientID[:len(client.ClientID)-len("/client-metadata.json")] 627 + baseURL := client.ClientID[:len(client.ClientID)-len("/oauth-client-metadata.json")] 608 628 609 629 w.Header().Set("Content-Type", "application/json") 610 630 json.NewEncoder(w).Encode(map[string]interface{}{ ··· 617 637 "redirect_uris": []string{client.RedirectURI}, 618 638 "grant_types": []string{"authorization_code", "refresh_token"}, 619 639 "response_types": []string{"code"}, 620 - "scope": "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull", 640 + "scope": "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark", 621 641 "token_endpoint_auth_method": "private_key_jwt", 622 642 "token_endpoint_auth_signing_alg": "ES256", 623 643 "dpop_bound_access_tokens": true,
+200
backend/internal/repository/postgres/pg_engagement_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "database/sql/driver" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "margin.at/internal/domain" 11 + ) 12 + 13 + type EngagementRepository struct { 14 + db DB 15 + } 16 + 17 + func NewEngagementRepository(db DB) *EngagementRepository { 18 + return &EngagementRepository{db: db} 19 + } 20 + 21 + func (r *EngagementRepository) GetLikeCount(ctx context.Context, uri string) (int, error) { 22 + var count int 23 + err := r.db.QueryRowContext(ctx, 24 + `SELECT COUNT(*) FROM likes WHERE subject_uri = $1`, uri, 25 + ).Scan(&count) 26 + return count, err 27 + } 28 + 29 + func (r *EngagementRepository) GetLikeCounts(ctx context.Context, uris []string) (map[string]int, error) { 30 + if len(uris) == 0 { 31 + return map[string]int{}, nil 32 + } 33 + rows, err := r.db.QueryContext(ctx, 34 + `SELECT subject_uri, COUNT(*) FROM likes WHERE subject_uri = ANY($1) GROUP BY subject_uri`, 35 + pqArray(uris), 36 + ) 37 + if err != nil { 38 + return nil, err 39 + } 40 + defer rows.Close() 41 + 42 + counts := make(map[string]int, len(uris)) 43 + for rows.Next() { 44 + var uri string 45 + var count int 46 + if err := rows.Scan(&uri, &count); err != nil { 47 + return nil, err 48 + } 49 + counts[uri] = count 50 + } 51 + return counts, rows.Err() 52 + } 53 + 54 + func (r *EngagementRepository) GetReplyCounts(ctx context.Context, uris []string) (map[string]int, error) { 55 + if len(uris) == 0 { 56 + return map[string]int{}, nil 57 + } 58 + rows, err := r.db.QueryContext(ctx, 59 + `SELECT root_uri, COUNT(*) FROM replies WHERE root_uri = ANY($1) GROUP BY root_uri`, 60 + pqArray(uris), 61 + ) 62 + if err != nil { 63 + return nil, err 64 + } 65 + defer rows.Close() 66 + 67 + counts := make(map[string]int, len(uris)) 68 + for rows.Next() { 69 + var uri string 70 + var count int 71 + if err := rows.Scan(&uri, &count); err != nil { 72 + return nil, err 73 + } 74 + counts[uri] = count 75 + } 76 + return counts, rows.Err() 77 + } 78 + 79 + func (r *EngagementRepository) GetViewerLikes(ctx context.Context, viewerDID string, uris []string) (map[string]bool, error) { 80 + if len(uris) == 0 { 81 + return map[string]bool{}, nil 82 + } 83 + 84 + placeholders := make([]string, len(uris)) 85 + args := make([]interface{}, len(uris)+1) 86 + args[0] = viewerDID 87 + for i, uri := range uris { 88 + placeholders[i] = fmt.Sprintf("$%d", i+2) 89 + args[i+1] = uri 90 + } 91 + 92 + rows, err := r.db.QueryContext(ctx, 93 + `SELECT subject_uri FROM likes WHERE author_did = $1 AND subject_uri IN (`+ 94 + strings.Join(placeholders, ", ")+`)`, 95 + args..., 96 + ) 97 + if err != nil { 98 + return nil, err 99 + } 100 + defer rows.Close() 101 + 102 + likes := make(map[string]bool, len(uris)) 103 + for rows.Next() { 104 + var uri string 105 + if err := rows.Scan(&uri); err != nil { 106 + return nil, err 107 + } 108 + likes[uri] = true 109 + } 110 + return likes, rows.Err() 111 + } 112 + 113 + func (r *EngagementRepository) GetLabelsForURIs(ctx context.Context, uris []string, labelerDIDs []string) (map[string][]domain.ContentLabel, error) { 114 + return r.queryLabels(ctx, uris, labelerDIDs) 115 + } 116 + 117 + func (r *EngagementRepository) GetLabelsForDIDs(ctx context.Context, dids []string, labelerDIDs []string) (map[string][]domain.ContentLabel, error) { 118 + return r.queryLabels(ctx, dids, labelerDIDs) 119 + } 120 + 121 + func (r *EngagementRepository) queryLabels(ctx context.Context, subjects []string, labelerDIDs []string) (map[string][]domain.ContentLabel, error) { 122 + result := make(map[string][]domain.ContentLabel) 123 + if len(subjects) == 0 { 124 + return result, nil 125 + } 126 + 127 + query := `SELECT id, src, uri, val, neg, created_by, created_at 128 + FROM content_labels WHERE uri = ANY($1) AND neg = 0` 129 + args := []interface{}{pqArray(subjects)} 130 + 131 + if len(labelerDIDs) > 0 { 132 + query += ` AND src = ANY($2)` 133 + args = append(args, pqArray(labelerDIDs)) 134 + } 135 + query += ` ORDER BY created_at DESC` 136 + 137 + rows, err := r.db.QueryContext(ctx, query, args...) 138 + if err != nil { 139 + return result, err 140 + } 141 + defer rows.Close() 142 + 143 + for rows.Next() { 144 + var l domain.ContentLabel 145 + if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil { 146 + continue 147 + } 148 + result[l.URI] = append(result[l.URI], l) 149 + } 150 + return result, rows.Err() 151 + } 152 + 153 + func (r *EngagementRepository) GetLatestEditTimes(ctx context.Context, uris []string) (map[string]time.Time, error) { 154 + if len(uris) == 0 { 155 + return nil, nil 156 + } 157 + 158 + placeholders := make([]string, len(uris)) 159 + args := make([]interface{}, len(uris)) 160 + for i, uri := range uris { 161 + placeholders[i] = fmt.Sprintf("$%d", i+1) 162 + args[i] = uri 163 + } 164 + 165 + rows, err := r.db.QueryContext(ctx, 166 + `SELECT uri, MAX(edited_at) FROM edit_history WHERE uri IN (`+ 167 + strings.Join(placeholders, ",")+`) GROUP BY uri`, 168 + args..., 169 + ) 170 + if err != nil { 171 + return nil, err 172 + } 173 + defer rows.Close() 174 + 175 + result := make(map[string]time.Time, len(uris)) 176 + for rows.Next() { 177 + var uri string 178 + var editedAt time.Time 179 + if err := rows.Scan(&uri, &editedAt); err != nil { 180 + continue 181 + } 182 + result[uri] = editedAt 183 + } 184 + return result, rows.Err() 185 + } 186 + 187 + type pqArray []string 188 + 189 + func (a pqArray) Value() (driver.Value, error) { 190 + if a == nil { 191 + return "{}", nil 192 + } 193 + parts := make([]string, len(a)) 194 + for i, s := range a { 195 + escaped := strings.ReplaceAll(s, `\`, `\\`) 196 + escaped = strings.ReplaceAll(escaped, `"`, `\"`) 197 + parts[i] = fmt.Sprintf(`"%s"`, escaped) 198 + } 199 + return "{" + strings.Join(parts, ",") + "}", nil 200 + }
+342
backend/internal/repository/postgres/pg_note_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + "time" 9 + 10 + "margin.at/internal/domain" 11 + ) 12 + 13 + type DB interface { 14 + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) 15 + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) 16 + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row 17 + } 18 + 19 + type NoteRepository struct { 20 + db DB 21 + } 22 + 23 + func NewNoteRepository(db DB) *NoteRepository { 24 + return &NoteRepository{db: db} 25 + } 26 + 27 + func (r *NoteRepository) List(ctx context.Context, f domain.NoteFilter) ([]domain.Note, error) { 28 + var where []string 29 + var args []interface{} 30 + n := 1 31 + 32 + if len(f.Motivations) == 1 { 33 + where = append(where, fmt.Sprintf("motivation = $%d", n)) 34 + args = append(args, f.Motivations[0]) 35 + n++ 36 + } else if len(f.Motivations) > 1 { 37 + placeholders := make([]string, len(f.Motivations)) 38 + for i, m := range f.Motivations { 39 + placeholders[i] = fmt.Sprintf("$%d", n) 40 + args = append(args, m) 41 + n++ 42 + } 43 + where = append(where, fmt.Sprintf("motivation IN (%s)", strings.Join(placeholders, ", "))) 44 + } 45 + 46 + if f.AuthorDID != "" { 47 + where = append(where, fmt.Sprintf("author_did = $%d", n)) 48 + args = append(args, f.AuthorDID) 49 + n++ 50 + } 51 + 52 + if f.TargetHash != "" { 53 + where = append(where, fmt.Sprintf("target_hash = $%d", n)) 54 + args = append(args, f.TargetHash) 55 + n++ 56 + } 57 + 58 + if f.Tag != "" { 59 + where = append(where, fmt.Sprintf( 60 + "tags_json IS NOT NULL AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = lower($%d))", 61 + n, 62 + )) 63 + args = append(args, f.Tag) 64 + n++ 65 + } 66 + 67 + if f.Query != "" { 68 + pattern := "%" + escapeLike(f.Query) + "%" 69 + where = append(where, fmt.Sprintf( 70 + "(body_value ILIKE $%d OR target_source ILIKE $%d OR target_title ILIKE $%d OR tags_json::text ILIKE $%d)", 71 + n, n+1, n+2, n+3, 72 + )) 73 + args = append(args, pattern, pattern, pattern, pattern) 74 + n += 4 75 + } 76 + 77 + switch f.FeedType { 78 + case domain.FeedTypeMargin: 79 + where = append(where, "uri NOT LIKE '%network.cosmik%'") 80 + case domain.FeedTypeSemble: 81 + where = append(where, "uri LIKE '%network.cosmik%'") 82 + case domain.FeedTypePopular: 83 + since := time.Now().AddDate(0, 0, -14) 84 + where = append(where, fmt.Sprintf("created_at > $%d", n)) 85 + args = append(args, since) 86 + n++ 87 + case domain.FeedTypeShelved: 88 + olderThan := time.Now().AddDate(0, 0, -1) 89 + since := time.Now().AddDate(0, 0, -14) 90 + where = append(where, fmt.Sprintf("created_at < $%d AND created_at > $%d", n, n+1)) 91 + where = append(where, "NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = uri)") 92 + where = append(where, "NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = uri)") 93 + args = append(args, olderThan, since) 94 + n += 2 95 + } 96 + 97 + whereClause := "" 98 + if len(where) > 0 { 99 + whereClause = "WHERE " + strings.Join(where, " AND ") 100 + } 101 + 102 + orderClause := "ORDER BY created_at DESC" 103 + switch f.FeedType { 104 + case domain.FeedTypePopular: 105 + orderClause = `ORDER BY ( 106 + SELECT COUNT(*) FROM likes WHERE subject_uri = uri 107 + ) + ( 108 + SELECT COUNT(*) FROM replies WHERE root_uri = uri 109 + ) DESC, created_at DESC` 110 + case domain.FeedTypeShelved: 111 + orderClause = "ORDER BY RANDOM()" 112 + } 113 + 114 + limit := f.Limit 115 + if limit <= 0 { 116 + limit = 50 117 + } 118 + 119 + query := fmt.Sprintf(` 120 + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, 121 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 122 + FROM unified_notes 123 + %s 124 + %s 125 + LIMIT $%d OFFSET $%d 126 + `, whereClause, orderClause, n, n+1) 127 + 128 + args = append(args, limit, f.Offset) 129 + 130 + rows, err := r.db.QueryContext(ctx, query, args...) 131 + if err != nil { 132 + return nil, err 133 + } 134 + defer rows.Close() 135 + return scanNotes(rows) 136 + } 137 + 138 + func (r *NoteRepository) GetByURI(ctx context.Context, uri string) (*domain.Note, error) { 139 + query := ` 140 + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, 141 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 142 + FROM unified_notes 143 + WHERE uri = $1 144 + ` 145 + var note domain.Note 146 + err := r.db.QueryRowContext(ctx, query, uri).Scan( 147 + &note.URI, &note.AuthorDID, &note.Motivation, &note.Color, &note.Description, 148 + &note.BodyValue, &note.BodyFormat, &note.BodyURI, 149 + &note.TargetSource, &note.TargetHash, &note.TargetTitle, 150 + &note.SelectorJSON, &note.TagsJSON, &note.CreatedAt, &note.IndexedAt, &note.CID, 151 + ) 152 + if err == sql.ErrNoRows { 153 + return nil, nil 154 + } 155 + if err != nil { 156 + return nil, err 157 + } 158 + return &note, nil 159 + } 160 + 161 + func (r *NoteRepository) CreateNote(ctx context.Context, n *domain.Note) error { 162 + query := ` 163 + INSERT INTO notes ( 164 + uri, author_did, motivation, color, description, body_value, body_format, body_uri, 165 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 166 + ) VALUES ( 167 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 168 + ) ON CONFLICT (uri) DO UPDATE SET 169 + motivation = EXCLUDED.motivation, 170 + color = EXCLUDED.color, 171 + description = EXCLUDED.description, 172 + body_value = EXCLUDED.body_value, 173 + body_format = EXCLUDED.body_format, 174 + body_uri = EXCLUDED.body_uri, 175 + target_source = EXCLUDED.target_source, 176 + target_hash = EXCLUDED.target_hash, 177 + target_title = EXCLUDED.target_title, 178 + selector_json = EXCLUDED.selector_json, 179 + tags_json = EXCLUDED.tags_json, 180 + indexed_at = EXCLUDED.indexed_at, 181 + cid = EXCLUDED.cid 182 + ` 183 + _, err := r.db.ExecContext(ctx, query, 184 + n.URI, n.AuthorDID, n.Motivation, n.Color, n.Description, n.BodyValue, n.BodyFormat, n.BodyURI, 185 + n.TargetSource, n.TargetHash, n.TargetTitle, n.SelectorJSON, n.TagsJSON, n.CreatedAt, n.IndexedAt, n.CID, 186 + ) 187 + return err 188 + } 189 + 190 + func (r *NoteRepository) DeleteNote(ctx context.Context, uri string) error { 191 + _, err := r.db.ExecContext(ctx, "DELETE FROM notes WHERE uri = $1", uri) 192 + return err 193 + } 194 + 195 + func (r *NoteRepository) UpdateNoteAnnotation(ctx context.Context, uri, bodyValue, tagsJSON string, cid *string) error { 196 + _, err := r.db.ExecContext(ctx, ` 197 + UPDATE notes 198 + SET body_value = $1, tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 199 + WHERE uri = $5 200 + `, bodyValue, tagsJSON, cid, time.Now(), uri) 201 + return err 202 + } 203 + 204 + func (r *NoteRepository) GetLikeByUserAndSubject(ctx context.Context, did, subjectURI string) (*domain.Like, error) { 205 + query := "SELECT uri, author_did, subject_uri, created_at, indexed_at FROM likes WHERE author_did = $1 AND subject_uri = $2" 206 + var l domain.Like 207 + err := r.db.QueryRowContext(ctx, query, did, subjectURI).Scan( 208 + &l.URI, &l.AuthorDID, &l.SubjectURI, &l.CreatedAt, &l.IndexedAt, 209 + ) 210 + if err == sql.ErrNoRows { 211 + return nil, nil 212 + } 213 + if err != nil { 214 + return nil, err 215 + } 216 + return &l, nil 217 + } 218 + 219 + func (r *NoteRepository) CreateLike(ctx context.Context, l *domain.Like) error { 220 + _, err := r.db.ExecContext(ctx, ` 221 + INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) 222 + VALUES ($1, $2, $3, $4, $5) 223 + ON CONFLICT(uri) DO NOTHING 224 + `, l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) 225 + return err 226 + } 227 + 228 + func (r *NoteRepository) DeleteLike(ctx context.Context, uri string) error { 229 + _, err := r.db.ExecContext(ctx, "DELETE FROM likes WHERE uri = $1", uri) 230 + return err 231 + } 232 + 233 + func (r *NoteRepository) CreateReply(ctx context.Context, rep *domain.Reply) error { 234 + _, err := r.db.ExecContext(ctx, ` 235 + INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) 236 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 237 + ON CONFLICT(uri) DO NOTHING 238 + `, rep.URI, rep.AuthorDID, rep.ParentURI, rep.RootURI, rep.Text, rep.Format, rep.CreatedAt, rep.IndexedAt, rep.CID) 239 + return err 240 + } 241 + 242 + func (r *NoteRepository) GetReplyByURI(ctx context.Context, uri string) (*domain.Reply, error) { 243 + query := "SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid FROM replies WHERE uri = $1" 244 + var rep domain.Reply 245 + err := r.db.QueryRowContext(ctx, query, uri).Scan( 246 + &rep.URI, &rep.AuthorDID, &rep.ParentURI, &rep.RootURI, &rep.Text, &rep.Format, &rep.CreatedAt, &rep.IndexedAt, &rep.CID, 247 + ) 248 + if err == sql.ErrNoRows { 249 + return nil, nil 250 + } 251 + return &rep, err 252 + } 253 + 254 + func (r *NoteRepository) DeleteReply(ctx context.Context, uri string) error { 255 + _, err := r.db.ExecContext(ctx, "DELETE FROM replies WHERE uri = $1", uri) 256 + return err 257 + } 258 + 259 + func (r *NoteRepository) DeleteAnnotation(ctx context.Context, uri string) error { 260 + _, err := r.db.ExecContext(ctx, "DELETE FROM annotations WHERE uri = $1", uri) 261 + return err 262 + } 263 + 264 + func (r *NoteRepository) DeleteHighlight(ctx context.Context, uri string) error { 265 + _, err := r.db.ExecContext(ctx, "DELETE FROM highlights WHERE uri = $1", uri) 266 + return err 267 + } 268 + 269 + func (r *NoteRepository) DeleteBookmark(ctx context.Context, uri string) error { 270 + _, err := r.db.ExecContext(ctx, "DELETE FROM bookmarks WHERE uri = $1", uri) 271 + return err 272 + } 273 + 274 + func (r *NoteRepository) UpdateAnnotation(ctx context.Context, uri, bodyValue, tagsJSON string, cid *string) error { 275 + _, err := r.db.ExecContext(ctx, ` 276 + UPDATE annotations 277 + SET body_value = $1, tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 278 + WHERE uri = $5 279 + `, bodyValue, tagsJSON, cid, time.Now(), uri) 280 + return err 281 + } 282 + 283 + func (r *NoteRepository) GetAnnotationByURI(ctx context.Context, uri string) (*domain.Annotation, error) { 284 + query := ` 285 + SELECT uri, author_did, motivation, body_value, body_format, body_uri, 286 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 287 + FROM annotations WHERE uri = $1 288 + ` 289 + var a domain.Annotation 290 + err := r.db.QueryRowContext(ctx, query, uri).Scan( 291 + &a.URI, &a.AuthorDID, &a.Motivation, &a.BodyValue, &a.BodyFormat, &a.BodyURI, 292 + &a.TargetSource, &a.TargetHash, &a.TargetTitle, &a.SelectorJSON, &a.TagsJSON, &a.CreatedAt, &a.IndexedAt, &a.CID, 293 + ) 294 + if err == sql.ErrNoRows { 295 + return nil, nil 296 + } 297 + return &a, err 298 + } 299 + 300 + func (r *NoteRepository) CheckDuplicateAnnotation(ctx context.Context, did, url, text string) (*domain.Annotation, error) { 301 + query := "SELECT uri, cid FROM annotations WHERE author_did = $1 AND target_source = $2 AND body_value = $3 LIMIT 1" 302 + var a domain.Annotation 303 + err := r.db.QueryRowContext(ctx, query, did, url, text).Scan(&a.URI, &a.CID) 304 + if err == sql.ErrNoRows { 305 + return nil, nil 306 + } 307 + return &a, err 308 + } 309 + 310 + func (r *NoteRepository) CheckDuplicateHighlight(ctx context.Context, did, url string, selector []byte) (*domain.Highlight, error) { 311 + query := "SELECT uri, cid FROM highlights WHERE author_did = $1 AND target_source = $2 AND selector_json = $3 LIMIT 1" 312 + var h domain.Highlight 313 + err := r.db.QueryRowContext(ctx, query, did, url, selector).Scan(&h.URI, &h.CID) 314 + if err == sql.ErrNoRows { 315 + return nil, nil 316 + } 317 + return &h, err 318 + } 319 + 320 + func scanNotes(rows *sql.Rows) ([]domain.Note, error) { 321 + var notes []domain.Note 322 + for rows.Next() { 323 + var note domain.Note 324 + if err := rows.Scan( 325 + &note.URI, &note.AuthorDID, &note.Motivation, &note.Color, &note.Description, 326 + &note.BodyValue, &note.BodyFormat, &note.BodyURI, 327 + &note.TargetSource, &note.TargetHash, &note.TargetTitle, 328 + &note.SelectorJSON, &note.TagsJSON, &note.CreatedAt, &note.IndexedAt, &note.CID, 329 + ); err != nil { 330 + return nil, err 331 + } 332 + notes = append(notes, note) 333 + } 334 + return notes, rows.Err() 335 + } 336 + 337 + func escapeLike(s string) string { 338 + s = strings.ReplaceAll(s, `\`, `\\`) 339 + s = strings.ReplaceAll(s, `%`, `\%`) 340 + s = strings.ReplaceAll(s, `_`, `\_`) 341 + return s 342 + }
+63
backend/internal/repository/postgres/pg_notification_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "margin.at/internal/domain" 8 + ) 9 + 10 + type NotificationRepository struct { 11 + db DB 12 + } 13 + 14 + func NewNotificationRepository(db DB) *NotificationRepository { 15 + return &NotificationRepository{db: db} 16 + } 17 + 18 + func (r *NotificationRepository) CreateNotification(ctx context.Context, n *domain.Notification) error { 19 + _, err := r.db.ExecContext(ctx, ` 20 + INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) 21 + VALUES ($1, $2, $3, $4, $5) 22 + `, n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) 23 + return err 24 + } 25 + 26 + func (r *NotificationRepository) GetNotifications(ctx context.Context, recipientDID string, limit, offset int) ([]domain.Notification, error) { 27 + rows, err := r.db.QueryContext(ctx, ` 28 + SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at 29 + FROM notifications 30 + WHERE recipient_did = $1 31 + ORDER BY created_at DESC 32 + LIMIT $2 OFFSET $3 33 + `, recipientDID, limit, offset) 34 + if err != nil { 35 + return nil, err 36 + } 37 + defer rows.Close() 38 + 39 + var out []domain.Notification 40 + for rows.Next() { 41 + var n domain.Notification 42 + if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { 43 + continue 44 + } 45 + out = append(out, n) 46 + } 47 + return out, rows.Err() 48 + } 49 + 50 + func (r *NotificationRepository) GetUnreadNotificationCount(ctx context.Context, recipientDID string) (int, error) { 51 + var count int 52 + err := r.db.QueryRowContext(ctx, ` 53 + SELECT COUNT(*) FROM notifications WHERE recipient_did = $1 AND read_at IS NULL 54 + `, recipientDID).Scan(&count) 55 + return count, err 56 + } 57 + 58 + func (r *NotificationRepository) MarkNotificationsRead(ctx context.Context, recipientDID string) error { 59 + _, err := r.db.ExecContext(ctx, ` 60 + UPDATE notifications SET read_at = $1 WHERE recipient_did = $2 AND read_at IS NULL 61 + `, time.Now(), recipientDID) 62 + return err 63 + }
+98
backend/internal/repository/postgres/pg_profile_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "fmt" 7 + "strings" 8 + 9 + "margin.at/internal/domain" 10 + ) 11 + 12 + type ProfileRepository struct { 13 + db DB 14 + } 15 + 16 + func NewProfileRepository(db DB) *ProfileRepository { 17 + return &ProfileRepository{db: db} 18 + } 19 + 20 + func (r *ProfileRepository) GetProfiles(ctx context.Context, dids []string) (map[string]domain.Author, error) { 21 + if len(dids) == 0 { 22 + return map[string]domain.Author{}, nil 23 + } 24 + 25 + placeholders := make([]string, len(dids)) 26 + args := make([]interface{}, len(dids)) 27 + for i, did := range dids { 28 + placeholders[i] = fmt.Sprintf("$%d", i+1) 29 + args[i] = did 30 + } 31 + 32 + query := `SELECT author_did, display_name, avatar FROM profiles WHERE author_did IN (` + 33 + strings.Join(placeholders, ",") + `)` 34 + 35 + rows, err := r.db.QueryContext(ctx, query, args...) 36 + if err != nil { 37 + return nil, err 38 + } 39 + defer rows.Close() 40 + 41 + profiles := make(map[string]domain.Author, len(dids)) 42 + for rows.Next() { 43 + var did string 44 + var displayName, avatar *string 45 + if err := rows.Scan(&did, &displayName, &avatar); err != nil { 46 + continue 47 + } 48 + a := domain.Author{DID: did} 49 + if displayName != nil { 50 + a.DisplayName = *displayName 51 + } 52 + if avatar != nil { 53 + a.Avatar = *avatar 54 + } 55 + profiles[did] = a 56 + } 57 + 58 + result := make(map[string]domain.Author, len(dids)) 59 + for _, did := range dids { 60 + if a, ok := profiles[did]; ok { 61 + result[did] = a 62 + } else { 63 + result[did] = domain.Author{DID: did} 64 + } 65 + } 66 + return result, rows.Err() 67 + } 68 + 69 + func (r *ProfileRepository) GetProfile(ctx context.Context, did string) (*domain.Profile, error) { 70 + query := `SELECT uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at 71 + FROM profiles WHERE author_did = $1` 72 + var p domain.Profile 73 + err := r.db.QueryRowContext(ctx, query, did).Scan( 74 + &p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, 75 + ) 76 + if err == sql.ErrNoRows { 77 + return nil, nil 78 + } 79 + if err != nil { 80 + return nil, err 81 + } 82 + return &p, nil 83 + } 84 + 85 + func (r *ProfileRepository) UpsertProfile(ctx context.Context, p *domain.Profile) error { 86 + _, err := r.db.ExecContext(ctx, ` 87 + INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) 88 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) 89 + ON CONFLICT(uri) DO UPDATE SET 90 + display_name = EXCLUDED.display_name, 91 + avatar = EXCLUDED.avatar, 92 + bio = EXCLUDED.bio, 93 + website = EXCLUDED.website, 94 + links_json = EXCLUDED.links_json, 95 + indexed_at = EXCLUDED.indexed_at 96 + `, p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) 97 + return err 98 + }
+23
backend/internal/repository/postgres/pg_session_repo.go
··· 1 + package postgres 2 + 3 + import ( 4 + "context" 5 + "time" 6 + ) 7 + 8 + type SessionRepository struct { 9 + db DB 10 + } 11 + 12 + func NewSessionRepository(db DB) *SessionRepository { 13 + return &SessionRepository{db: db} 14 + } 15 + 16 + func (r *SessionRepository) GetSession(ctx context.Context, id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { 17 + err = r.db.QueryRowContext(ctx, ` 18 + SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') 19 + FROM sessions 20 + WHERE id = $1 AND expires_at > $2 21 + `, id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) 22 + return 23 + }
+128
backend/internal/service/feed.go
··· 1 + package service 2 + 3 + import ( 4 + "context" 5 + "sort" 6 + 7 + "margin.at/internal/domain" 8 + ) 9 + 10 + type FeedRequest struct { 11 + ViewerDID string 12 + Motivations []string 13 + AuthorDID string 14 + Tag string 15 + FeedType domain.FeedType 16 + Limit int 17 + Offset int 18 + } 19 + 20 + type FeedResponse struct { 21 + Items []APINote 22 + TotalItems int 23 + } 24 + 25 + type FeedService struct { 26 + notes domain.NoteRepository 27 + hydration *HydrationService 28 + database interface { 29 + GetAllHiddenDIDs(actorDID string) (map[string]bool, error) 30 + } 31 + } 32 + 33 + func NewFeedService( 34 + notes domain.NoteRepository, 35 + hydration *HydrationService, 36 + db interface { 37 + GetAllHiddenDIDs(actorDID string) (map[string]bool, error) 38 + }, 39 + ) *FeedService { 40 + return &FeedService{ 41 + notes: notes, 42 + hydration: hydration, 43 + database: db, 44 + } 45 + } 46 + 47 + func (s *FeedService) GetFeed(ctx context.Context, req FeedRequest) (*FeedResponse, error) { 48 + fetchLimit := req.Limit + req.Offset 49 + if fetchLimit <= 0 { 50 + fetchLimit = req.Limit 51 + } 52 + 53 + filter := domain.NoteFilter{ 54 + Motivations: req.Motivations, 55 + AuthorDID: req.AuthorDID, 56 + Tag: req.Tag, 57 + FeedType: req.FeedType, 58 + Limit: fetchLimit, 59 + Offset: 0, 60 + } 61 + 62 + notes, err := s.notes.List(ctx, filter) 63 + if err != nil { 64 + return nil, err 65 + } 66 + 67 + if req.ViewerDID != "" { 68 + hidden, _ := s.database.GetAllHiddenDIDs(req.ViewerDID) 69 + if len(hidden) > 0 { 70 + filtered := notes[:0] 71 + for _, n := range notes { 72 + if !hidden[n.AuthorDID] { 73 + filtered = append(filtered, n) 74 + } 75 + } 76 + notes = filtered 77 + } 78 + } 79 + 80 + lc, err := s.hydration.Load(ctx, notes, req.ViewerDID) 81 + if err != nil { 82 + return nil, err 83 + } 84 + 85 + items := make([]APINote, len(notes)) 86 + for i, n := range notes { 87 + items[i] = s.hydration.ToAPINote(n, lc) 88 + } 89 + 90 + if len(req.Motivations) != 1 { 91 + if req.FeedType == domain.FeedTypePopular { 92 + sortByEngagement(items) 93 + } else { 94 + sortByTime(items) 95 + } 96 + } 97 + 98 + if req.Offset < len(items) { 99 + items = items[req.Offset:] 100 + } else { 101 + items = nil 102 + } 103 + if len(items) > req.Limit { 104 + items = items[:req.Limit] 105 + } 106 + if items == nil { 107 + items = []APINote{} 108 + } 109 + 110 + return &FeedResponse{Items: items, TotalItems: len(items)}, nil 111 + } 112 + 113 + func sortByTime(items []APINote) { 114 + sort.Slice(items, func(i, j int) bool { 115 + return items[i].CreatedAt.After(items[j].CreatedAt) 116 + }) 117 + } 118 + 119 + func sortByEngagement(items []APINote) { 120 + sort.Slice(items, func(i, j int) bool { 121 + si := items[i].LikeCount + items[i].ReplyCount 122 + sj := items[j].LikeCount + items[j].ReplyCount 123 + if si != sj { 124 + return si > sj 125 + } 126 + return items[i].CreatedAt.After(items[j].CreatedAt) 127 + }) 128 + }
+298
backend/internal/service/hydration.go
··· 1 + package service 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "sync" 7 + "time" 8 + 9 + "margin.at/internal/domain" 10 + ) 11 + 12 + type APISelector struct { 13 + Type string `json:"type"` 14 + Exact string `json:"exact,omitempty"` 15 + Prefix string `json:"prefix,omitempty"` 16 + Suffix string `json:"suffix,omitempty"` 17 + Start *int `json:"start,omitempty"` 18 + End *int `json:"end,omitempty"` 19 + Value string `json:"value,omitempty"` 20 + ConformsTo string `json:"conformsTo,omitempty"` 21 + } 22 + 23 + type APIBody struct { 24 + Value string `json:"value,omitempty"` 25 + Format string `json:"format,omitempty"` 26 + URI string `json:"uri,omitempty"` 27 + } 28 + 29 + type APITarget struct { 30 + Source string `json:"source"` 31 + Title string `json:"title,omitempty"` 32 + Selector *APISelector `json:"selector,omitempty"` 33 + } 34 + 35 + type APILabel struct { 36 + Val string `json:"val"` 37 + Src string `json:"src"` 38 + Scope string `json:"scope"` 39 + } 40 + 41 + type APIGenerator struct { 42 + ID string `json:"id"` 43 + Type string `json:"type"` 44 + Name string `json:"name"` 45 + } 46 + 47 + type APINote struct { 48 + ID string `json:"id"` 49 + CID string `json:"cid,omitempty"` 50 + Type string `json:"type"` 51 + Motivation string `json:"motivation,omitempty"` 52 + Author domain.Author `json:"creator"` 53 + Body *APIBody `json:"body,omitempty"` 54 + Target APITarget `json:"target"` 55 + Color string `json:"color,omitempty"` 56 + Description string `json:"description,omitempty"` 57 + Tags []string `json:"tags,omitempty"` 58 + Generator *APIGenerator `json:"generator,omitempty"` 59 + CreatedAt time.Time `json:"created"` 60 + IndexedAt time.Time `json:"indexed"` 61 + LikeCount int `json:"likeCount"` 62 + ReplyCount int `json:"replyCount"` 63 + ViewerHasLiked bool `json:"viewerHasLiked"` 64 + Labels []APILabel `json:"labels,omitempty"` 65 + EditedAt *time.Time `json:"editedAt,omitempty"` 66 + } 67 + 68 + type LoadContext struct { 69 + Profiles map[string]domain.Author 70 + LikeCounts map[string]int 71 + ReplyCounts map[string]int 72 + ViewerLikes map[string]bool 73 + URILabels map[string][]domain.ContentLabel 74 + DIDLabels map[string][]domain.ContentLabel 75 + EditTimes map[string]time.Time 76 + } 77 + 78 + type HydrationService struct { 79 + engagement domain.EngagementRepository 80 + profiles domain.ProfileRepository 81 + } 82 + 83 + func NewHydrationService( 84 + engagement domain.EngagementRepository, 85 + profiles domain.ProfileRepository, 86 + ) *HydrationService { 87 + return &HydrationService{ 88 + engagement: engagement, 89 + profiles: profiles, 90 + } 91 + } 92 + 93 + func (h *HydrationService) Load(ctx context.Context, notes []domain.Note, viewerDID string) (*LoadContext, error) { 94 + lc := &LoadContext{ 95 + Profiles: make(map[string]domain.Author), 96 + LikeCounts: make(map[string]int), 97 + ReplyCounts: make(map[string]int), 98 + ViewerLikes: make(map[string]bool), 99 + URILabels: make(map[string][]domain.ContentLabel), 100 + DIDLabels: make(map[string][]domain.ContentLabel), 101 + EditTimes: make(map[string]time.Time), 102 + } 103 + if len(notes) == 0 { 104 + return lc, nil 105 + } 106 + 107 + uris := make([]string, len(notes)) 108 + didSet := make(map[string]struct{}, len(notes)) 109 + for i, n := range notes { 110 + uris[i] = n.URI 111 + didSet[n.AuthorDID] = struct{}{} 112 + } 113 + dids := make([]string, 0, len(didSet)) 114 + for did := range didSet { 115 + dids = append(dids, did) 116 + } 117 + 118 + var wg sync.WaitGroup 119 + var mu sync.Mutex 120 + 121 + run := func(fn func() error) { 122 + wg.Add(1) 123 + go func() { 124 + defer wg.Done() 125 + fn() 126 + }() 127 + } 128 + 129 + run(func() error { 130 + p, err := h.profiles.GetProfiles(ctx, dids) 131 + if err == nil { 132 + mu.Lock() 133 + lc.Profiles = p 134 + mu.Unlock() 135 + } 136 + return err 137 + }) 138 + 139 + run(func() error { 140 + counts, err := h.engagement.GetLikeCounts(ctx, uris) 141 + if err == nil { 142 + mu.Lock() 143 + lc.LikeCounts = counts 144 + mu.Unlock() 145 + } 146 + return err 147 + }) 148 + 149 + run(func() error { 150 + counts, err := h.engagement.GetReplyCounts(ctx, uris) 151 + if err == nil { 152 + mu.Lock() 153 + lc.ReplyCounts = counts 154 + mu.Unlock() 155 + } 156 + return err 157 + }) 158 + 159 + if viewerDID != "" { 160 + run(func() error { 161 + vl, err := h.engagement.GetViewerLikes(ctx, viewerDID, uris) 162 + if err == nil { 163 + mu.Lock() 164 + lc.ViewerLikes = vl 165 + mu.Unlock() 166 + } 167 + return err 168 + }) 169 + } 170 + 171 + run(func() error { 172 + ul, err := h.engagement.GetLabelsForURIs(ctx, uris, dids) 173 + if err == nil { 174 + mu.Lock() 175 + lc.URILabels = ul 176 + mu.Unlock() 177 + } 178 + return err 179 + }) 180 + 181 + run(func() error { 182 + dl, err := h.engagement.GetLabelsForDIDs(ctx, dids, dids) 183 + if err == nil { 184 + mu.Lock() 185 + lc.DIDLabels = dl 186 + mu.Unlock() 187 + } 188 + return err 189 + }) 190 + 191 + run(func() error { 192 + et, err := h.engagement.GetLatestEditTimes(ctx, uris) 193 + if err == nil { 194 + mu.Lock() 195 + lc.EditTimes = et 196 + mu.Unlock() 197 + } 198 + return err 199 + }) 200 + 201 + wg.Wait() 202 + return lc, nil 203 + } 204 + 205 + func (h *HydrationService) ToAPINote(n domain.Note, lc *LoadContext) APINote { 206 + noteType := "Annotation" 207 + switch n.Motivation { 208 + case "highlighting": 209 + noteType = "Highlight" 210 + case "bookmarking": 211 + noteType = "Bookmark" 212 + } 213 + 214 + note := APINote{ 215 + ID: n.URI, 216 + Type: noteType, 217 + Motivation: n.Motivation, 218 + Author: lc.Profiles[n.AuthorDID], 219 + Target: APITarget{ 220 + Source: n.TargetSource, 221 + }, 222 + CreatedAt: n.CreatedAt, 223 + IndexedAt: n.IndexedAt, 224 + LikeCount: lc.LikeCounts[n.URI], 225 + ReplyCount: lc.ReplyCounts[n.URI], 226 + ViewerHasLiked: lc.ViewerLikes[n.URI], 227 + Labels: mergeLabels(lc.URILabels[n.URI], lc.DIDLabels[n.AuthorDID]), 228 + Generator: &APIGenerator{ 229 + ID: "https://margin.at", 230 + Type: "Software", 231 + Name: "Margin", 232 + }, 233 + } 234 + 235 + if n.CID != nil { 236 + note.CID = *n.CID 237 + } 238 + 239 + if n.TargetTitle != nil { 240 + note.Target.Title = *n.TargetTitle 241 + } 242 + 243 + if n.SelectorJSON != nil && *n.SelectorJSON != "" { 244 + sel := &APISelector{} 245 + if json.Unmarshal([]byte(*n.SelectorJSON), sel) == nil { 246 + note.Target.Selector = sel 247 + } 248 + } 249 + 250 + if n.BodyValue != nil || n.BodyURI != nil { 251 + body := &APIBody{} 252 + if n.BodyValue != nil { 253 + body.Value = *n.BodyValue 254 + } 255 + if n.BodyFormat != nil { 256 + body.Format = *n.BodyFormat 257 + } 258 + if n.BodyURI != nil { 259 + body.URI = *n.BodyURI 260 + } 261 + note.Body = body 262 + } 263 + 264 + if n.Color != nil { 265 + note.Color = *n.Color 266 + } 267 + 268 + if n.Description != nil { 269 + note.Description = *n.Description 270 + } 271 + 272 + if n.TagsJSON != nil && *n.TagsJSON != "" { 273 + var tags []string 274 + if json.Unmarshal([]byte(*n.TagsJSON), &tags) == nil { 275 + note.Tags = tags 276 + } 277 + } 278 + 279 + if t, ok := lc.EditTimes[n.URI]; ok { 280 + note.EditedAt = &t 281 + } 282 + 283 + return note 284 + } 285 + 286 + func mergeLabels(uriLabels, didLabels []domain.ContentLabel) []APILabel { 287 + if len(uriLabels) == 0 && len(didLabels) == 0 { 288 + return nil 289 + } 290 + result := make([]APILabel, 0, len(uriLabels)+len(didLabels)) 291 + for _, l := range uriLabels { 292 + result = append(result, APILabel{Val: l.Val, Src: l.Src, Scope: "content"}) 293 + } 294 + for _, l := range didLabels { 295 + result = append(result, APILabel{Val: l.Val, Src: l.Src, Scope: "author"}) 296 + } 297 + return result 298 + }
+80
backend/internal/service/profile.go
··· 1 + package service 2 + 3 + import ( 4 + "context" 5 + "sync" 6 + "time" 7 + 8 + "margin.at/internal/domain" 9 + ) 10 + 11 + const defaultProfileTTL = 5 * time.Minute 12 + 13 + type profileCacheEntry struct { 14 + author domain.Author 15 + expiresAt time.Time 16 + } 17 + 18 + type ProfileService struct { 19 + repo domain.ProfileRepository 20 + ttl time.Duration 21 + mu sync.RWMutex 22 + cache map[string]profileCacheEntry 23 + } 24 + 25 + func NewProfileService(repo domain.ProfileRepository) *ProfileService { 26 + return &ProfileService{ 27 + repo: repo, 28 + ttl: defaultProfileTTL, 29 + cache: make(map[string]profileCacheEntry), 30 + } 31 + } 32 + 33 + func (s *ProfileService) GetProfiles(ctx context.Context, dids []string) (map[string]domain.Author, error) { 34 + now := time.Now() 35 + result := make(map[string]domain.Author, len(dids)) 36 + var missing []string 37 + 38 + s.mu.RLock() 39 + for _, did := range dids { 40 + if e, ok := s.cache[did]; ok && now.Before(e.expiresAt) { 41 + result[did] = e.author 42 + } else { 43 + missing = append(missing, did) 44 + } 45 + } 46 + s.mu.RUnlock() 47 + 48 + if len(missing) == 0 { 49 + return result, nil 50 + } 51 + 52 + fetched, err := s.repo.GetProfiles(ctx, missing) 53 + if err != nil { 54 + return result, err 55 + } 56 + 57 + expiry := now.Add(s.ttl) 58 + s.mu.Lock() 59 + for did, author := range fetched { 60 + s.cache[did] = profileCacheEntry{author: author, expiresAt: expiry} 61 + result[did] = author 62 + } 63 + s.mu.Unlock() 64 + 65 + return result, nil 66 + } 67 + 68 + func (s *ProfileService) GetProfile(ctx context.Context, did string) (*domain.Profile, error) { 69 + return s.repo.GetProfile(ctx, did) 70 + } 71 + 72 + func (s *ProfileService) UpsertProfile(ctx context.Context, p *domain.Profile) error { 73 + if err := s.repo.UpsertProfile(ctx, p); err != nil { 74 + return err 75 + } 76 + s.mu.Lock() 77 + delete(s.cache, p.AuthorDID) 78 + s.mu.Unlock() 79 + return nil 80 + }
+2 -2
backend/internal/verification/verify.go
··· 253 253 select { 254 254 case verifyQueue <- verifyTask{url: pubURL, uri: uri, onVerified: onVerified, isDoc: false}: 255 255 default: 256 - // Queue full — drop silently to protect network 256 + // Queue full 257 257 } 258 258 } 259 259 ··· 264 264 select { 265 265 case verifyQueue <- verifyTask{url: docURL, uri: uri, onVerified: onVerified, isDoc: true}: 266 266 default: 267 - // Queue full — drop silently to protect network 267 + // Queue full 268 268 } 269 269 }
+47
backend/internal/xrpc/client.go
··· 275 275 return &output, nil 276 276 } 277 277 278 + type ListRecordsRecord struct { 279 + URI string `json:"uri"` 280 + CID string `json:"cid"` 281 + Value json.RawMessage `json:"value"` 282 + } 283 + 284 + type ListRecordsOutput struct { 285 + Cursor string `json:"cursor"` 286 + Records []ListRecordsRecord `json:"records"` 287 + } 288 + 289 + func (c *Client) ListRecords(ctx context.Context, repo, collection string, limit int) (*ListRecordsOutput, error) { 290 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", 291 + c.PDS, repo, collection, limit) 292 + 293 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 294 + if err != nil { 295 + return nil, err 296 + } 297 + 298 + dpopProof, err := c.createDPoPProof("GET", url) 299 + if err != nil { 300 + return nil, err 301 + } 302 + 303 + req.Header.Set("Authorization", "DPoP "+c.AccessToken) 304 + req.Header.Set("DPoP", dpopProof) 305 + 306 + resp, err := http.DefaultClient.Do(req) 307 + if err != nil { 308 + return nil, err 309 + } 310 + defer resp.Body.Close() 311 + 312 + if resp.StatusCode >= 400 { 313 + bodyBytes, _ := io.ReadAll(resp.Body) 314 + return nil, fmt.Errorf("XRPC error %d: %s", resp.StatusCode, string(bodyBytes)) 315 + } 316 + 317 + var output ListRecordsOutput 318 + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { 319 + return nil, err 320 + } 321 + 322 + return &output, nil 323 + } 324 + 278 325 type ResolveHandleOutput struct { 279 326 Did string `json:"did"` 280 327 }
+115 -113
backend/internal/xrpc/records.go
··· 8 8 ) 9 9 10 10 const ( 11 - CollectionAnnotation = "at.margin.annotation" 12 - CollectionHighlight = "at.margin.highlight" 13 - CollectionBookmark = "at.margin.bookmark" 14 - CollectionReply = "at.margin.reply" 15 - CollectionLike = "at.margin.like" 16 - CollectionCollection = "at.margin.collection" 17 - CollectionCollectionItem = "at.margin.collectionItem" 18 - CollectionProfile = "at.margin.profile" 19 - CollectionPreferences = "at.margin.preferences" 20 - CollectionAPIKey = "at.margin.apikey" 21 - CollectionDocument = "site.standard.document" 22 - CollectionPublication = "site.standard.publication" 11 + CollectionNote = "at.margin.note" 12 + CollectionCommunityBookmark = "community.lexicon.bookmarks.bookmark" 13 + CollectionAnnotation = "at.margin.annotation" 14 + CollectionHighlight = "at.margin.highlight" 15 + CollectionBookmark = "at.margin.bookmark" 16 + CollectionReply = "at.margin.reply" 17 + CollectionLike = "at.margin.like" 18 + CollectionCollection = "at.margin.collection" 19 + CollectionCollectionItem = "at.margin.collectionItem" 20 + CollectionProfile = "at.margin.profile" 21 + CollectionPreferences = "at.margin.preferences" 22 + CollectionAPIKey = "at.margin.apikey" 23 + CollectionDocument = "site.standard.document" 24 + CollectionPublication = "site.standard.publication" 23 25 ) 24 26 25 27 const ( ··· 102 104 Homepage string `json:"homepage,omitempty"` 103 105 } 104 106 105 - type AnnotationRecord struct { 107 + type NoteRecord struct { 106 108 Type string `json:"$type"` 107 - Motivation string `json:"motivation,omitempty"` 109 + Motivation string `json:"motivation"` 110 + Color string `json:"color,omitempty"` 108 111 Body *AnnotationBody `json:"body,omitempty"` 109 112 Target AnnotationTarget `json:"target"` 110 113 Tags []string `json:"tags,omitempty"` ··· 113 116 Rights string `json:"rights,omitempty"` 114 117 Labels *SelfLabels `json:"labels,omitempty"` 115 118 CreatedAt string `json:"createdAt"` 116 - } 117 - 118 - type Facet struct { 119 - Index FacetIndex `json:"index"` 120 - Features []FacetFeature `json:"features"` 121 - } 122 - 123 - type FacetIndex struct { 124 - ByteStart int `json:"byteStart"` 125 - ByteEnd int `json:"byteEnd"` 126 - } 127 - 128 - type FacetFeature struct { 129 - Type string `json:"$type"` 130 - Did string `json:"did,omitempty"` 131 - Uri string `json:"uri,omitempty"` 132 - } 133 - 134 - type AnnotationBody struct { 135 - Value string `json:"value,omitempty"` 136 - Format string `json:"format,omitempty"` 137 - } 138 - 139 - type AnnotationTarget struct { 140 - Source string `json:"source"` 141 - SourceHash string `json:"sourceHash"` 142 - Title string `json:"title,omitempty"` 143 - Selector json.RawMessage `json:"selector,omitempty"` 119 + ModifiedAt string `json:"modifiedAt,omitempty"` 144 120 } 145 121 146 - func (r *AnnotationRecord) Validate() error { 122 + func (r *NoteRecord) Validate() error { 123 + if r.Motivation == "" { 124 + return fmt.Errorf("motivation is required") 125 + } 147 126 if r.Target.Source == "" { 148 127 return fmt.Errorf("target source is required") 149 128 } ··· 163 142 return fmt.Errorf("tag too long: %s", tag) 164 143 } 165 144 } 166 - 167 145 if len(r.Target.Selector) > 0 { 168 146 var typeCheck Selector 169 147 if err := json.Unmarshal(r.Target.Selector, &typeCheck); err != nil { 170 148 return fmt.Errorf("invalid selector format") 171 149 } 172 - 173 150 switch typeCheck.Type { 174 151 case SelectorTypeQuote: 175 152 var s TextQuoteSelector ··· 185 162 return s.Validate() 186 163 } 187 164 } 188 - 189 165 return nil 190 166 } 191 167 192 - func NewAnnotationRecord(url, urlHash, text string, selector interface{}, title string) *AnnotationRecord { 193 - return NewAnnotationRecordWithMotivation(url, urlHash, text, selector, title, "commenting") 194 - } 195 - 196 - func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector interface{}, title string, motivation string) *AnnotationRecord { 168 + func NewNoteRecord(url, urlHash, text string, selector interface{}, title, color, description, motivation string) *NoteRecord { 197 169 var selectorJSON json.RawMessage 198 170 if selector != nil { 199 171 b, _ := json.Marshal(selector) 200 172 selectorJSON = b 201 173 } 202 174 203 - record := &AnnotationRecord{ 204 - Type: CollectionAnnotation, 175 + record := &NoteRecord{ 176 + Type: CollectionNote, 205 177 Motivation: motivation, 178 + Color: color, 206 179 Target: AnnotationTarget{ 207 180 Source: url, 208 181 SourceHash: urlHash, ··· 212 185 CreatedAt: time.Now().UTC().Format(time.RFC3339), 213 186 } 214 187 215 - if text != "" { 188 + bodyText := text 189 + if bodyText == "" { 190 + bodyText = description 191 + } 192 + if bodyText != "" { 216 193 record.Body = &AnnotationBody{ 217 - Value: text, 194 + Value: bodyText, 218 195 Format: "text/plain", 219 196 } 220 197 } ··· 222 199 return record 223 200 } 224 201 202 + type AnnotationRecord struct { 203 + Type string `json:"$type"` 204 + Motivation string `json:"motivation,omitempty"` 205 + Body *AnnotationBody `json:"body,omitempty"` 206 + Target AnnotationTarget `json:"target"` 207 + Tags []string `json:"tags,omitempty"` 208 + Facets []Facet `json:"facets,omitempty"` 209 + Generator *Generator `json:"generator,omitempty"` 210 + Rights string `json:"rights,omitempty"` 211 + Labels *SelfLabels `json:"labels,omitempty"` 212 + CreatedAt string `json:"createdAt"` 213 + } 214 + 215 + type Facet struct { 216 + Index FacetIndex `json:"index"` 217 + Features []FacetFeature `json:"features"` 218 + } 219 + 220 + type FacetIndex struct { 221 + ByteStart int `json:"byteStart"` 222 + ByteEnd int `json:"byteEnd"` 223 + } 224 + 225 + type FacetFeature struct { 226 + Type string `json:"$type"` 227 + Did string `json:"did,omitempty"` 228 + Uri string `json:"uri,omitempty"` 229 + } 230 + 231 + type AnnotationBody struct { 232 + Value string `json:"value,omitempty"` 233 + Format string `json:"format,omitempty"` 234 + } 235 + 236 + type AnnotationTarget struct { 237 + Source string `json:"source"` 238 + SourceHash string `json:"sourceHash"` 239 + Title string `json:"title,omitempty"` 240 + Selector json.RawMessage `json:"selector,omitempty"` 241 + } 242 + 243 + func (r *AnnotationRecord) Validate() error { 244 + if r.Target.Source == "" { 245 + return fmt.Errorf("target source is required") 246 + } 247 + if r.Body != nil && utf8.RuneCountInString(r.Body.Value) > 3000 { 248 + return fmt.Errorf("body too long") 249 + } 250 + if len(r.Tags) > 10 { 251 + return fmt.Errorf("too many tags") 252 + } 253 + return nil 254 + } 255 + 225 256 type HighlightRecord struct { 226 257 Type string `json:"$type"` 227 258 Target AnnotationTarget `json:"target"` ··· 238 269 return fmt.Errorf("target source is required") 239 270 } 240 271 if len(r.Tags) > 10 { 241 - return fmt.Errorf("too many tags: %d", len(r.Tags)) 272 + return fmt.Errorf("too many tags") 242 273 } 243 274 if len(r.Color) > 20 { 244 275 return fmt.Errorf("color too long") ··· 246 277 return nil 247 278 } 248 279 249 - func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { 250 - var selectorJSON json.RawMessage 251 - if selector != nil { 252 - b, _ := json.Marshal(selector) 253 - selectorJSON = b 254 - } 280 + type BookmarkRecord struct { 281 + Type string `json:"$type"` 282 + Source string `json:"source"` 283 + SourceHash string `json:"sourceHash"` 284 + Title string `json:"title,omitempty"` 285 + Description string `json:"description,omitempty"` 286 + Tags []string `json:"tags,omitempty"` 287 + Generator *Generator `json:"generator,omitempty"` 288 + Rights string `json:"rights,omitempty"` 289 + Labels *SelfLabels `json:"labels,omitempty"` 290 + CreatedAt string `json:"createdAt"` 291 + } 255 292 256 - return &HighlightRecord{ 257 - Type: CollectionHighlight, 258 - Target: AnnotationTarget{ 259 - Source: url, 260 - SourceHash: urlHash, 261 - Selector: selectorJSON, 262 - }, 263 - Color: color, 264 - Tags: tags, 265 - CreatedAt: time.Now().UTC().Format(time.RFC3339), 293 + func (r *BookmarkRecord) Validate() error { 294 + if r.Source == "" { 295 + return fmt.Errorf("source is required") 266 296 } 297 + if len(r.Title) > 500 { 298 + return fmt.Errorf("title too long") 299 + } 300 + if len(r.Description) > 1000 { 301 + return fmt.Errorf("description too long") 302 + } 303 + if len(r.Tags) > 10 { 304 + return fmt.Errorf("too many tags") 305 + } 306 + return nil 267 307 } 268 308 269 309 type ReplyRef struct { ··· 333 373 } 334 374 } 335 375 336 - type BookmarkRecord struct { 337 - Type string `json:"$type"` 338 - Source string `json:"source"` 339 - SourceHash string `json:"sourceHash"` 340 - Title string `json:"title,omitempty"` 341 - Description string `json:"description,omitempty"` 342 - Tags []string `json:"tags,omitempty"` 343 - Generator *Generator `json:"generator,omitempty"` 344 - Rights string `json:"rights,omitempty"` 345 - Labels *SelfLabels `json:"labels,omitempty"` 346 - CreatedAt string `json:"createdAt"` 347 - } 348 - 349 - func (r *BookmarkRecord) Validate() error { 350 - if r.Source == "" { 351 - return fmt.Errorf("source is required") 352 - } 353 - if len(r.Title) > 500 { 354 - return fmt.Errorf("title too long") 355 - } 356 - if len(r.Description) > 1000 { 357 - return fmt.Errorf("description too long") 358 - } 359 - if len(r.Tags) > 10 { 360 - return fmt.Errorf("too many tags") 361 - } 362 - return nil 363 - } 364 - 365 - func NewBookmarkRecord(url, urlHash, title, description string) *BookmarkRecord { 366 - return &BookmarkRecord{ 367 - Type: CollectionBookmark, 368 - Source: url, 369 - SourceHash: urlHash, 370 - Title: title, 371 - Description: description, 372 - CreatedAt: time.Now().UTC().Format(time.RFC3339), 373 - } 374 - } 375 - 376 376 type CollectionRecord struct { 377 377 Type string `json:"$type"` 378 378 Name string `json:"name"` ··· 481 481 SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers,omitempty"` 482 482 LabelPreferences []LabelPreference `json:"labelPreferences,omitempty"` 483 483 DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` 484 + EnableCommunityBookmarks *bool `json:"enableCommunityBookmarks,omitempty"` 484 485 CreatedAt string `json:"createdAt"` 485 486 } 486 487 ··· 502 503 return nil 503 504 } 504 505 505 - func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}, disableExternalLinkWarning *bool) *PreferencesRecord { 506 + func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}, disableExternalLinkWarning *bool, enableCommunityBookmarks *bool) *PreferencesRecord { 506 507 record := &PreferencesRecord{ 507 508 Type: CollectionPreferences, 508 509 ExternalLinkSkippedHostnames: skippedHostnames, 509 510 DisableExternalLinkWarning: disableExternalLinkWarning, 511 + EnableCommunityBookmarks: enableCommunityBookmarks, 510 512 CreatedAt: time.Now().UTC().Format(time.RFC3339), 511 513 } 512 514
+54 -31
extension/src/components/popup/App.tsx
··· 1 1 import { useState, useEffect } from 'react'; 2 + import { capture } from '@/utils/analytics'; 2 3 import { sendMessage } from '@/utils/messaging'; 3 4 import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage'; 4 5 import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; ··· 59 60 const [bookmarkTags, setBookmarkTags] = useState<string[]>([]); 60 61 const [showBookmarkTags, setShowBookmarkTags] = useState(false); 61 62 const [tagSuggestions, setTagSuggestions] = useState<string[]>([]); 62 - 63 63 useEffect(() => { 64 64 checkSession(); 65 65 loadCurrentTab(); 66 66 loadTheme(); 67 67 loadSettings(); 68 + 69 + sendMessage('checkSession', undefined) 70 + .then((s) => 71 + capture('popup_opened', { authenticated: s?.authenticated ?? false }, s?.did ?? undefined) 72 + ) 73 + .catch(() => {}); 68 74 }, []); 69 75 70 76 useEffect(() => { ··· 329 335 setText(''); 330 336 setTags([]); 331 337 loadAnnotations(); 338 + capture( 339 + 'annotation_created', 340 + { url: currentUrl, tag_count: tags.length, source: 'extension' }, 341 + session?.did ?? undefined 342 + ); 332 343 } else { 333 344 alert('Failed to post annotation'); 334 345 } ··· 352 363 setBookmarked(true); 353 364 setBookmarkTags([]); 354 365 setShowBookmarkTags(false); 366 + capture( 367 + 'bookmark_created', 368 + { url: currentUrl, tag_count: bookmarkTags.length, source: 'extension' }, 369 + session?.did ?? undefined 370 + ); 355 371 } else { 356 372 alert('Failed to bookmark page'); 357 373 } ··· 454 470 <img src="/icons/logo.svg" alt="Margin" className="w-8 h-8" /> 455 471 </div> 456 472 <h2 className="font-display text-xl font-bold tracking-tight mb-2">Welcome to Margin</h2> 457 - <p className="text-[var(--text-secondary)] text-sm leading-relaxed mb-8 max-w-[280px]"> 473 + <p className="text-[var(--text-secondary)] text-sm leading-relaxed mb-5 max-w-[280px]"> 458 474 Annotate, highlight, and bookmark the web with your AT Protocol identity. 459 475 </p> 460 476 <button 461 477 onClick={() => browser.tabs.create({ url: `${apiUrl}/login` })} 462 - className="w-full max-w-[240px] px-6 py-2.5 bg-[var(--accent)] text-white rounded-xl text-sm font-semibold hover:bg-[var(--accent-hover)] transition-colors" 478 + className="w-full max-w-[280px] px-6 py-2.5 bg-[var(--accent)] text-white rounded-xl text-sm font-semibold hover:bg-[var(--accent-hover)] transition-colors" 463 479 > 464 480 Sign In 465 481 </button> 482 + 466 483 <button 467 484 onClick={() => setShowSettings(true)} 468 485 className="mt-4 text-xs text-[var(--text-tertiary)] hover:text-[var(--text-primary)] flex items-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-[var(--bg-hover)] transition-colors" ··· 577 594 <button 578 595 onClick={() => browser.tabs.create({ url: apiUrl })} 579 596 className="text-[11px] text-[var(--text-tertiary)] hover:text-[var(--accent)] px-2 py-1 rounded-md hover:bg-[var(--bg-hover)] transition-colors" 597 + style={{ display: session.handle ? undefined : 'none' }} 580 598 > 581 599 @{session.handle} 582 600 </button> ··· 591 609 </header> 592 610 593 611 <div className="flex border-b border-[var(--border)] px-2 gap-0.5"> 594 - {(['page', 'bookmarks', 'highlights', 'collections'] as Tab[]).map((tab) => { 595 - const icons: Record<Tab, JSX.Element> = { 596 - page: <Globe size={13} />, 597 - bookmarks: <BookmarkIcon size={13} />, 598 - highlights: <Highlighter size={13} />, 599 - collections: <Folder size={13} />, 600 - }; 601 - const labels: Record<Tab, string> = { 602 - page: 'Page', 603 - bookmarks: 'Bookmarks', 604 - highlights: 'Highlights', 605 - collections: 'Collections', 606 - }; 607 - return ( 608 - <button 609 - key={tab} 610 - onClick={() => setActiveTab(tab)} 611 - className={`flex-1 py-2.5 text-[11px] font-medium flex items-center justify-center gap-1 border-b-2 transition-all ${ 612 - activeTab === tab 613 - ? 'border-[var(--accent)] text-[var(--accent)]' 614 - : 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]' 615 - }`} 616 - > 617 - {icons[tab]} 618 - {labels[tab]} 619 - </button> 620 - ); 621 - })} 612 + {(['page', 'bookmarks', 'highlights', 'collections'] as Tab[]) 613 + .filter((tab) => tab === 'page' || !!session.did) 614 + .map((tab) => { 615 + const icons: Record<Tab, JSX.Element> = { 616 + page: <Globe size={13} />, 617 + bookmarks: <BookmarkIcon size={13} />, 618 + highlights: <Highlighter size={13} />, 619 + collections: <Folder size={13} />, 620 + }; 621 + const labels: Record<Tab, string> = { 622 + page: 'Page', 623 + bookmarks: 'Bookmarks', 624 + highlights: 'Highlights', 625 + collections: 'Collections', 626 + }; 627 + return ( 628 + <button 629 + key={tab} 630 + onClick={() => { 631 + setActiveTab(tab); 632 + capture('extension_tab_switched', { tab }, session?.did ?? undefined); 633 + }} 634 + className={`flex-1 py-2.5 text-[11px] font-medium flex items-center justify-center gap-1 border-b-2 transition-all ${ 635 + activeTab === tab 636 + ? 'border-[var(--accent)] text-[var(--accent)]' 637 + : 'border-transparent text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]' 638 + }`} 639 + > 640 + {icons[tab]} 641 + {labels[tab]} 642 + </button> 643 + ); 644 + })} 622 645 </div> 623 646 624 647 <div className="flex-1 overflow-y-auto">
+6 -1
extension/src/components/sidepanel/App.tsx
··· 592 592 {showBookmarkTags && !bookmarked && ( 593 593 <div className="mt-2 pt-2 border-t border-[var(--border)]"> 594 594 <div className="mb-1.5"> 595 - <TagInput tags={bookmarkTags} onChange={setBookmarkTags} suggestions={tagSuggestions} placeholder="Add bookmark tags..." /> 595 + <TagInput 596 + tags={bookmarkTags} 597 + onChange={setBookmarkTags} 598 + suggestions={tagSuggestions} 599 + placeholder="Add bookmark tags..." 600 + /> 596 601 </div> 597 602 <button 598 603 onClick={handleBookmark}
+50 -3
extension/src/entrypoints/background.ts
··· 1 1 import { onMessage } from '@/utils/messaging'; 2 2 import type { Annotation } from '@/utils/types'; 3 + import * as analytics from '@/utils/analytics'; 3 4 import { 4 5 checkSession, 5 6 getAnnotations, ··· 244 245 }); 245 246 } 246 247 247 - browser.runtime.onInstalled.addListener(async () => { 248 + browser.runtime.onInstalled.addListener(async (details) => { 248 249 await ensureContextMenus(); 250 + if (details.reason === 'install') { 251 + const manifest = browser.runtime.getManifest(); 252 + analytics.capture('extension_installed', { 253 + version: manifest.version, 254 + browser: import.meta.env.BROWSER ?? 'unknown', 255 + }); 256 + } else if (details.reason === 'update' && details.previousVersion) { 257 + const manifest = browser.runtime.getManifest(); 258 + analytics.capture('extension_updated', { 259 + previous_version: details.previousVersion, 260 + version: manifest.version, 261 + }); 262 + } 249 263 }); 250 264 251 265 browser.runtime.onStartup.addListener(async () => { ··· 287 301 288 302 if (result.success) { 289 303 showNotification('Margin', 'Page bookmarked!'); 304 + const session = await checkSession(); 305 + analytics.capture( 306 + 'bookmark_created', 307 + { url: resolveTabUrl(tab.url), tag_count: 0, source: 'extension' }, 308 + session.did ?? undefined 309 + ); 290 310 } 291 311 return; 292 312 } ··· 352 372 353 373 if (result.success) { 354 374 showNotification('Margin', 'Text highlighted!'); 375 + const session = await checkSession(); 376 + analytics.capture( 377 + 'highlight_created', 378 + { url: highlightUrl, tag_count: 0, has_color: false, source: 'extension' }, 379 + session.did ?? undefined 380 + ); 355 381 try { 356 382 await browser.tabs.sendMessage(tab.id!, { type: 'REFRESH_ANNOTATIONS' }); 357 383 const uri = result.data?.uri || result.data?.id || ''; ··· 441 467 442 468 if (result.success) { 443 469 showNotification('Margin', 'Page bookmarked!'); 470 + const session = await checkSession(); 471 + analytics.capture( 472 + 'bookmark_created', 473 + { url: resolveTabUrl(tab.url), tag_count: 0, source: 'extension' }, 474 + session.did ?? undefined 475 + ); 444 476 } 445 477 return; 446 478 } ··· 448 480 if ((command === 'annotate-selection' || command === 'highlight-selection') && tab?.id) { 449 481 try { 450 482 const selection = (await browser.tabs.sendMessage(tab.id, { type: 'GET_SELECTION' })) as 451 - | { text?: string } 483 + | { text?: string; prefix?: string; suffix?: string } 452 484 | undefined; 453 485 if (!selection?.text) return; 454 486 ··· 462 494 if (command === 'annotate-selection') { 463 495 await browser.tabs.sendMessage(tab.id, { 464 496 type: 'SHOW_INLINE_ANNOTATE', 465 - data: { selector: { exact: selection.text } }, 497 + data: { 498 + selector: { 499 + type: 'TextQuoteSelector', 500 + exact: selection.text, 501 + prefix: selection.prefix, 502 + suffix: selection.suffix, 503 + }, 504 + }, 466 505 }); 467 506 } else if (command === 'highlight-selection') { 468 507 const result = await createHighlight({ ··· 471 510 selector: { 472 511 type: 'TextQuoteSelector', 473 512 exact: selection.text, 513 + prefix: selection.prefix, 514 + suffix: selection.suffix, 474 515 }, 475 516 }); 476 517 477 518 if (result.success) { 478 519 showNotification('Margin', 'Text highlighted!'); 520 + const session = await checkSession(); 521 + analytics.capture( 522 + 'highlight_created', 523 + { url: resolveTabUrl(tab.url!), tag_count: 0, has_color: false, source: 'extension' }, 524 + session.did ?? undefined 525 + ); 479 526 await browser.tabs.sendMessage(tab.id, { type: 'REFRESH_ANNOTATIONS' }); 480 527 } 481 528 }
+38
extension/src/utils/analytics.ts
··· 1 + import { apiUrlItem } from './storage'; 2 + 3 + export type ExtensionEvents = { 4 + extension_installed: { version: string; browser: string }; 5 + extension_updated: { previous_version: string; version: string }; 6 + popup_opened: { authenticated: boolean }; 7 + extension_tab_switched: { tab: string }; 8 + annotation_created: { url: string; tag_count: number; source: 'extension' }; 9 + highlight_created: { url: string; tag_count: number; has_color: boolean; source: 'extension' }; 10 + bookmark_created: { url: string; tag_count: number; source: 'extension' }; 11 + extension_connected: { did: string }; 12 + api_key_created: Record<string, never>; 13 + }; 14 + 15 + export async function capture<E extends keyof ExtensionEvents>( 16 + event: E, 17 + properties: ExtensionEvents[E], 18 + distinctId?: string 19 + ): Promise<void> { 20 + try { 21 + const apiUrl = await apiUrlItem.getValue(); 22 + await fetch(`${apiUrl}/api/analytics/capture`, { 23 + method: 'POST', 24 + headers: { 'Content-Type': 'application/json' }, 25 + body: JSON.stringify({ 26 + event, 27 + distinct_id: distinctId ?? 'anonymous_extension', 28 + properties: { 29 + ...properties, 30 + $lib: 'margin-extension', 31 + }, 32 + }), 33 + keepalive: true, 34 + }); 35 + } catch { 36 + // ignore 37 + } 38 + }
+44 -43
extension/src/utils/api.ts
··· 1 - import type { MarginSession, TextSelector } from './types'; 1 + import type { MarginSession, TextSelector, Annotation } from './types'; 2 2 import { apiUrlItem } from './storage'; 3 3 4 4 async function getApiUrl(): Promise<string> { ··· 24 24 const apiUrl = await getApiUrl(); 25 25 const cookie = await getSessionCookie(); 26 26 27 - if (!cookie) { 28 - return { authenticated: false }; 29 - } 27 + if (!cookie) return { authenticated: false }; 30 28 31 29 const res = await fetch(`${apiUrl}/auth/session`, { 32 - headers: { 33 - 'X-Session-Token': cookie, 34 - }, 30 + headers: { 'X-Session-Token': cookie }, 35 31 }); 36 32 37 - if (!res.ok) { 38 - return { authenticated: false }; 39 - } 33 + if (!res.ok) return { authenticated: false }; 40 34 41 35 const sessionData = await res.json(); 42 - 43 - if (!sessionData.did || !sessionData.handle) { 44 - return { authenticated: false }; 45 - } 36 + if (!sessionData.did || !sessionData.handle) return { authenticated: false }; 46 37 47 38 return { 48 39 authenticated: true, ··· 81 72 return response; 82 73 } 83 74 75 + async function hashUrl(rawUrl: string): Promise<string> { 76 + let toHash: string; 77 + try { 78 + const parsed = new URL(rawUrl); 79 + let host = parsed.host.toLowerCase(); 80 + if (host.startsWith('www.')) host = host.slice(4); 81 + let normalized = host + parsed.pathname; 82 + if (parsed.search) normalized += parsed.search; 83 + normalized = normalized.replace(/\/$/, ''); 84 + toHash = normalized; 85 + } catch { 86 + toHash = rawUrl; 87 + } 88 + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(toHash)); 89 + return Array.from(new Uint8Array(buf)) 90 + .map((b) => b.toString(16).padStart(2, '0')) 91 + .join(''); 92 + } 93 + 84 94 export async function getAnnotations( 85 95 url: string, 86 96 citedUrls: string[] = [], ··· 90 100 const apiUrl = await getApiUrl(); 91 101 const uniqueUrls = [...new Set([url, ...citedUrls])]; 92 102 93 - const fetchPromises = uniqueUrls.map(async (u) => { 94 - try { 95 - let requestUrl = `${apiUrl}/api/targets?source=${encodeURIComponent(u)}`; 96 - if (cacheBust) { 97 - requestUrl += `&t=${Date.now()}`; 98 - } 99 - const res = await fetch(requestUrl); 100 - if (!res.ok) return { annotations: [], highlights: [], bookmarks: [] }; 101 - return await res.json(); 102 - } catch { 103 - return { annotations: [], highlights: [], bookmarks: [] }; 104 - } 105 - }); 103 + const hashes = await Promise.all(uniqueUrls.map(hashUrl)); 104 + 105 + const params = new URLSearchParams(); 106 + hashes.forEach((h) => params.append('h', h)); 107 + if (cacheBust) params.append('t', Date.now().toString()); 108 + 109 + const res = await fetch(`${apiUrl}/api/targets/hash?${params}`); 110 + const data = res.ok ? await res.json() : { annotations: [], highlights: [], bookmarks: [] }; 106 111 107 - const results = await Promise.all(fetchPromises); 108 112 const allItems: any[] = []; 109 113 const seenIds = new Set<string>(); 110 - 111 - results.forEach((data) => { 112 - const items = [ 113 - ...(data.annotations || []), 114 - ...(data.highlights || []), 115 - ...(data.bookmarks || []), 116 - ]; 117 - items.forEach((item: any) => { 118 - const id = item.uri || item.id; 119 - if (id && !seenIds.has(id)) { 120 - seenIds.add(id); 121 - allItems.push(item); 122 - } 123 - }); 114 + const items = [ 115 + ...(data.annotations || []), 116 + ...(data.highlights || []), 117 + ...(data.bookmarks || []), 118 + ]; 119 + items.forEach((item: any) => { 120 + const id = item.uri || item.id; 121 + if (id && !seenIds.has(id)) { 122 + seenIds.add(id); 123 + allItems.push(item); 124 + } 124 125 }); 125 126 126 127 return allItems; ··· 136 137 title?: string; 137 138 selector?: TextSelector; 138 139 tags?: string[]; 139 - }) { 140 + }): Promise<{ success: boolean; data?: Annotation; error?: string }> { 140 141 try { 141 142 const res = await apiRequest('/annotations', { 142 143 method: 'POST',
+275 -122
extension/src/utils/overlay.ts
··· 36 36 .replace(/'/g, '&#039;'); 37 37 } 38 38 39 + function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T { 40 + let t: ReturnType<typeof setTimeout> | null = null; 41 + return ((...args: any[]) => { 42 + if (t) clearTimeout(t); 43 + t = setTimeout(() => fn(...args), ms); 44 + }) as T; 45 + } 46 + 39 47 export async function initContentScript(ctx: { onInvalidated: (cb: () => void) => void }) { 40 48 let overlayHost: HTMLElement | null = null; 41 49 let shadowRoot: ShadowRoot | null = null; ··· 44 52 let composeModal: HTMLElement | null = null; 45 53 let activeItems: Array<{ range: Range; item: Annotation }> = []; 46 54 let cachedMatcher: DOMTextMatcher | null = null; 55 + let matcherNeedsRebuild = false; 47 56 const injectedStyles = new Set<string>(); 48 57 let overlayEnabled = true; 49 58 let currentUserDid: string | null = null; ··· 114 123 115 124 function getCiteUrlForText(text: string): string | null { 116 125 if (!text) return null; 117 - if (!cachedMatcher) cachedMatcher = new DOMTextMatcher(); 126 + if (!cachedMatcher || matcherNeedsRebuild) { 127 + cachedMatcher = new DOMTextMatcher(); 128 + matcherNeedsRebuild = false; 129 + } 118 130 const range = cachedMatcher.findRange(text); 119 131 if (!range) return null; 120 132 ··· 276 288 } 277 289 } 278 290 279 - function showComposeModal(quoteText: string) { 291 + function getSelectionContext(exact: string): { prefix?: string; suffix?: string } { 292 + const sel = window.getSelection(); 293 + if (!sel || sel.rangeCount === 0) return {}; 294 + const range = sel.getRangeAt(0); 295 + if (sel.toString().trim() !== exact.trim()) return {}; 296 + 297 + try { 298 + const prefixRange = document.createRange(); 299 + prefixRange.setStart(range.startContainer, 0); 300 + prefixRange.setEnd(range.startContainer, range.startOffset); 301 + const rawPrefix = prefixRange.toString(); 302 + const prefix = rawPrefix.length > 0 ? rawPrefix.slice(-150) : undefined; 303 + 304 + const endNode = range.endContainer; 305 + const endLen = 306 + endNode.nodeType === Node.TEXT_NODE 307 + ? (endNode as Text).length 308 + : ((endNode as Element).textContent?.length ?? 0); 309 + const suffixRange = document.createRange(); 310 + suffixRange.setStart(range.endContainer, range.endOffset); 311 + suffixRange.setEnd(range.endContainer, endLen); 312 + const rawSuffix = suffixRange.toString(); 313 + const suffix = rawSuffix.length > 0 ? rawSuffix.slice(0, 150) : undefined; 314 + 315 + return { prefix: prefix || undefined, suffix: suffix || undefined }; 316 + } catch { 317 + return {}; 318 + } 319 + } 320 + 321 + function showComposeModal( 322 + quoteText: string, 323 + selectorContext?: { prefix?: string; suffix?: string } 324 + ) { 280 325 if (!shadowRoot) return; 281 326 282 327 const container = shadowRoot.getElementById('margin-overlay-container'); ··· 396 441 }); 397 442 } 398 443 399 - tagInput.addEventListener('input', showTagSuggestions); 444 + tagInput.addEventListener('input', debounce(showTagSuggestions, 120)); 400 445 tagInput.addEventListener('keydown', (e) => { 401 446 if (e.key === 'Enter' || e.key === ',') { 402 447 e.preventDefault(); ··· 450 495 451 496 try { 452 497 const citeUrl = getCiteUrlForText(quoteText); 498 + const selector = { 499 + type: 'TextQuoteSelector', 500 + exact: quoteText, 501 + prefix: selectorContext?.prefix, 502 + suffix: selectorContext?.suffix, 503 + }; 453 504 const res = await sendMessage('createAnnotation', { 454 505 url: citeUrl || getPageUrl(), 455 506 title: document.title, 456 507 text, 457 - selector: { type: 'TextQuoteSelector', exact: quoteText }, 508 + selector, 458 509 tags: composeTags.length > 0 ? composeTags : undefined, 459 510 }); 460 511 ··· 480 531 } 481 532 browser.runtime.onMessage.addListener((message: any) => { 482 533 if (message.type === 'SHOW_INLINE_ANNOTATE' && message.data?.selector?.exact) { 483 - showComposeModal(message.data.selector.exact); 534 + const exact = message.data.selector.exact as string; 535 + showComposeModal(exact, getSelectionContext(exact)); 484 536 } 485 537 if (message.type === 'REFRESH_ANNOTATIONS') { 486 538 fetchAnnotations(0, true); ··· 494 546 if (message.type === 'GET_SELECTION') { 495 547 const selection = window.getSelection(); 496 548 const text = selection?.toString().trim() || ''; 497 - return Promise.resolve({ text }); 549 + const context = text ? getSelectionContext(text) : {}; 550 + return Promise.resolve({ text, ...context }); 498 551 } 499 552 if (message.type === 'GET_DOI') { 500 553 return Promise.resolve({ doiUrl: getPageDOIUrl() }); ··· 507 560 function scrollToText(text: string) { 508 561 if (!text || text.length < 3) return; 509 562 510 - if (!cachedMatcher) { 563 + if (!cachedMatcher || matcherNeedsRebuild) { 511 564 cachedMatcher = new DOMTextMatcher(); 565 + matcherNeedsRebuild = false; 512 566 } 513 567 514 568 const range = cachedMatcher.findRange(text); ··· 668 722 } 669 723 }); 670 724 671 - input.addEventListener('input', () => { 672 - const val = input.value.toLowerCase().trim(); 673 - if (!val) { 674 - suggestionsEl.style.display = 'none'; 675 - return; 676 - } 677 - const matches = cachedUserTags.filter((t) => t.includes(val) && !tags.includes(t)); 678 - if (matches.length === 0) { 679 - suggestionsEl.style.display = 'none'; 680 - return; 681 - } 682 - suggestionsEl.innerHTML = matches 683 - .slice(0, 5) 684 - .map((t) => `<button class="tag-suggestion-btn">${escapeHtml(t)}</button>`) 685 - .join(''); 686 - suggestionsEl.style.display = 'flex'; 687 - suggestionsEl.querySelectorAll('.tag-suggestion-btn').forEach((btn) => { 688 - btn.addEventListener('click', () => addTag(btn.textContent || '')); 689 - }); 690 - }); 725 + input.addEventListener( 726 + 'input', 727 + debounce(() => { 728 + const val = input.value.toLowerCase().trim(); 729 + if (!val) { 730 + suggestionsEl.style.display = 'none'; 731 + return; 732 + } 733 + const matches = cachedUserTags.filter((t) => t.includes(val) && !tags.includes(t)); 734 + if (matches.length === 0) { 735 + suggestionsEl.style.display = 'none'; 736 + return; 737 + } 738 + suggestionsEl.innerHTML = matches 739 + .slice(0, 5) 740 + .map((t) => `<button class="tag-suggestion-btn">${escapeHtml(t)}</button>`) 741 + .join(''); 742 + suggestionsEl.style.display = 'flex'; 743 + suggestionsEl.querySelectorAll('.tag-suggestion-btn').forEach((btn) => { 744 + btn.addEventListener('click', () => addTag(btn.textContent || '')); 745 + }); 746 + }, 120) 747 + ); 691 748 692 749 function dismiss() { 693 750 toast.classList.add('toast-out'); ··· 714 771 715 772 setTimeout(() => input.focus(), 50); 716 773 } 774 + 775 + let isFetching = false; 717 776 718 777 async function fetchAnnotations(retryCount = 0, cacheBust = false) { 719 778 if (!overlayEnabled) { ··· 721 780 return; 722 781 } 723 782 783 + if (isFetching && !cacheBust) return; 784 + isFetching = true; 785 + 724 786 try { 725 787 const pageUrl = getPageUrl(); 726 788 const doiUrl = getPageDOIUrl(); ··· 749 811 if (retryCount < 3) { 750 812 setTimeout(() => fetchAnnotations(retryCount + 1, cacheBust), 1000 * (retryCount + 1)); 751 813 } 814 + } finally { 815 + isFetching = false; 752 816 } 753 817 } 754 818 ··· 758 822 activeItems = []; 759 823 const rangesByColor: Record<string, Range[]> = {}; 760 824 761 - if (!cachedMatcher) { 825 + if (matcherNeedsRebuild || !cachedMatcher) { 762 826 cachedMatcher = new DOMTextMatcher(); 827 + matcherNeedsRebuild = false; 763 828 } 764 829 const matcher = cachedMatcher; 765 830 766 - annotations.forEach((item) => { 767 - const selector = item.target?.selector || item.selector; 768 - if (!selector?.exact) return; 831 + const CHUNK_SIZE = 20; 832 + let index = 0; 769 833 770 - const range = matcher.findRange(selector.exact); 771 - if (range) { 772 - activeItems.push({ range, item }); 834 + function processChunk() { 835 + const end = Math.min(index + CHUNK_SIZE, annotations.length); 836 + for (let i = index; i < end; i++) { 837 + const item = annotations[i]; 838 + const selector = item.target?.selector || item.selector; 839 + if (!selector?.exact) continue; 840 + 841 + const range = matcher.findRange(selector.exact); 842 + if (range) { 843 + activeItems.push({ range, item }); 844 + 845 + const isHighlight = (item as any).type === 'Highlight'; 846 + const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; 847 + const color = item.color || defaultColor; 848 + if (!rangesByColor[color]) rangesByColor[color] = []; 849 + rangesByColor[color].push(range); 850 + } 851 + } 852 + index = end; 773 853 774 - const isHighlight = (item as any).type === 'Highlight'; 775 - const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; 776 - const color = item.color || defaultColor; 777 - if (!rangesByColor[color]) rangesByColor[color] = []; 778 - rangesByColor[color].push(range); 854 + if (index < annotations.length) { 855 + if ('requestIdleCallback' in window) { 856 + requestIdleCallback(processChunk, { timeout: 500 }); 857 + } else { 858 + setTimeout(processChunk, 0); 859 + } 860 + } else { 861 + commitHighlights(); 779 862 } 780 - }); 863 + } 781 864 782 - if (typeof CSS !== 'undefined' && CSS.highlights) { 783 - CSS.highlights.clear(); 784 - for (const [color, ranges] of Object.entries(rangesByColor)) { 785 - const highlight = new Highlight(...ranges); 786 - const safeColor = color.replace(/[^a-zA-Z0-9]/g, ''); 787 - const name = `margin-hl-${safeColor}`; 788 - CSS.highlights.set(name, highlight); 789 - injectHighlightStyle(name, color); 865 + function commitHighlights() { 866 + if (typeof CSS !== 'undefined' && CSS.highlights) { 867 + CSS.highlights.clear(); 868 + for (const [color, ranges] of Object.entries(rangesByColor)) { 869 + const highlight = new Highlight(...ranges); 870 + const safeColor = color.replace(/[^a-zA-Z0-9]/g, ''); 871 + const name = `margin-hl-${safeColor}`; 872 + CSS.highlights.set(name, highlight); 873 + injectHighlightStyle(name, color); 874 + } 790 875 } 791 876 } 877 + 878 + processChunk(); 792 879 } 793 880 794 881 function injectHighlightStyle(name: string, color: string) { 795 882 if (injectedStyles.has(name)) return; 796 883 const style = document.createElement('style'); 797 884 885 + const hex = color.replace('#', ''); 886 + const r = parseInt(hex.substring(0, 2), 16) || 99; 887 + const g = parseInt(hex.substring(2, 4), 16) || 102; 888 + const b = parseInt(hex.substring(4, 6), 16) || 241; 889 + 798 890 if (isPdfContext()) { 799 - const hex = color.replace('#', ''); 800 - const r = parseInt(hex.substring(0, 2), 16) || 99; 801 - const g = parseInt(hex.substring(2, 4), 16) || 102; 802 - const b = parseInt(hex.substring(4, 6), 16) || 241; 803 891 style.textContent = ` 804 892 ::highlight(${name}) { 805 893 background-color: rgba(${r}, ${g}, ${b}, 0.35); ··· 823 911 } 824 912 825 913 let hoverRafId: number | null = null; 914 + let hoverIntentTimer: ReturnType<typeof setTimeout> | null = null; 915 + let lastHoverX = -1; 916 + let lastHoverY = -1; 826 917 827 918 function handleMouseMove(e: MouseEvent) { 828 919 if (!overlayEnabled || !overlayHost) return; 829 920 830 - if (hoverRafId) cancelAnimationFrame(hoverRafId); 921 + lastHoverX = e.clientX; 922 + lastHoverY = e.clientY; 923 + 924 + if (hoverRafId !== null) { 925 + cancelAnimationFrame(hoverRafId); 926 + hoverRafId = null; 927 + } 928 + 831 929 hoverRafId = requestAnimationFrame(() => { 832 - processHover(e.clientX, e.clientY, e); 930 + hoverRafId = null; 931 + if (hoverIntentTimer) clearTimeout(hoverIntentTimer); 932 + hoverIntentTimer = setTimeout(() => { 933 + hoverIntentTimer = null; 934 + processHover(lastHoverX, lastHoverY, e); 935 + }, 150); 833 936 }); 834 937 } 938 + function getAnnotationsAtPoint( 939 + x: number, 940 + y: number 941 + ): Array<{ range: Range; item: Annotation; rect: DOMRect }> { 942 + const results: Array<{ range: Range; item: Annotation; rect: DOMRect }> = []; 835 943 836 - function processHover(x: number, y: number, e: MouseEvent) { 837 - const foundItems: Array<{ range: Range; item: Annotation; rect: DOMRect }> = []; 838 - let firstRange: Range | null = null; 944 + let caretRange: Range | null = null; 945 + try { 946 + if (typeof (document as any).caretPositionFromPoint === 'function') { 947 + const pos = (document as any).caretPositionFromPoint(x, y); 948 + if (pos) { 949 + caretRange = document.createRange(); 950 + caretRange.setStart(pos.offsetNode, pos.offset); 951 + caretRange.collapse(true); 952 + } 953 + } else if (typeof (document as any).caretRangeFromPoint === 'function') { 954 + caretRange = (document as any).caretRangeFromPoint(x, y); 955 + } 956 + } catch { 957 + /* ignore */ 958 + } 839 959 840 960 for (const { range, item } of activeItems) { 841 - const rects = range.getClientRects(); 842 - for (const rect of rects) { 843 - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 844 - let container: Node | null = range.commonAncestorContainer; 845 - if (container.nodeType === Node.TEXT_NODE) { 846 - container = container.parentNode; 961 + let hit = false; 962 + 963 + if (caretRange) { 964 + try { 965 + const afterStart = range.compareBoundaryPoints(Range.START_TO_START, caretRange) <= 0; 966 + const beforeEnd = range.compareBoundaryPoints(Range.END_TO_START, caretRange) >= 0; 967 + hit = afterStart && beforeEnd; 968 + } catch { 969 + /* ignore */ 970 + } 971 + } 972 + 973 + if (!hit) { 974 + for (const rect of range.getClientRects()) { 975 + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 976 + hit = true; 977 + break; 847 978 } 979 + } 980 + } 848 981 849 - if ( 850 - container && 851 - ((e.target as Node).contains(container) || container.contains(e.target as Node)) 852 - ) { 853 - if (!firstRange) firstRange = range; 854 - if (!foundItems.some((f) => f.item === item)) { 855 - foundItems.push({ range, item, rect }); 856 - } 857 - } 858 - break; 982 + if (hit) { 983 + const firstRect = range.getClientRects()[0]; 984 + if (firstRect && !results.some((r) => r.item === item)) { 985 + results.push({ range, item, rect: firstRect }); 859 986 } 860 987 } 861 988 } 989 + 990 + return results; 991 + } 992 + 993 + function processHover(x: number, y: number, e: MouseEvent) { 994 + const target = e.target as HTMLElement; 995 + if ( 996 + target.closest( 997 + 'a[href], button, input, select, textarea, [role="button"], [role="link"], [contenteditable]' 998 + ) 999 + ) { 1000 + document.body.style.cursor = ''; 1001 + if (hoverIndicator) hoverIndicator.classList.remove('visible'); 1002 + return; 1003 + } 1004 + 1005 + const foundItems = getAnnotationsAtPoint(x, y); 1006 + const firstRange = foundItems[0]?.range ?? null; 862 1007 863 1008 if (foundItems.length > 0 && shadowRoot) { 864 1009 document.body.style.cursor = 'pointer'; ··· 910 1055 const firstRect = firstRange.getClientRects()[0]; 911 1056 const totalWidth = 912 1057 Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 18 + 8; 913 - const leftPos = firstRect.left - totalWidth; 914 - const topPos = firstRect.top + firstRect.height / 2 - 12; 1058 + const indicatorHeight = 28; 1059 + const gap = 4; 1060 + 1061 + let leftPos = firstRect.left - totalWidth; 1062 + leftPos = Math.max(gap, Math.min(leftPos, window.innerWidth - totalWidth - gap)); 1063 + 1064 + const topPos = Math.max( 1065 + gap, 1066 + Math.min( 1067 + firstRect.top + firstRect.height / 2 - indicatorHeight / 2, 1068 + window.innerHeight - indicatorHeight - gap 1069 + ) 1070 + ); 915 1071 916 1072 hoverIndicator.style.left = `${leftPos}px`; 917 1073 hoverIndicator.style.top = `${topPos}px`; ··· 919 1075 } 920 1076 } else { 921 1077 document.body.style.cursor = ''; 922 - if (hoverIndicator) { 923 - hoverIndicator.classList.remove('visible'); 1078 + if (hoverIndicator && hoverIndicator.classList.contains('visible')) { 1079 + if (hoverIntentTimer) clearTimeout(hoverIntentTimer); 1080 + hoverIntentTimer = setTimeout(() => { 1081 + hoverIntentTimer = null; 1082 + if (hoverIndicator) hoverIndicator.classList.remove('visible'); 1083 + }, 80); 924 1084 } 925 1085 } 926 1086 } ··· 947 1107 composeModal = null; 948 1108 } 949 1109 950 - const clickedItems: Annotation[] = []; 951 - for (const { range, item } of activeItems) { 952 - const rects = range.getClientRects(); 953 - for (const rect of rects) { 954 - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { 955 - let container: Node | null = range.commonAncestorContainer; 956 - if (container.nodeType === Node.TEXT_NODE) { 957 - container = container.parentNode; 958 - } 959 - 960 - if ( 961 - container && 962 - ((e.target as Node).contains(container) || container.contains(e.target as Node)) 963 - ) { 964 - if (!clickedItems.includes(item)) { 965 - clickedItems.push(item); 966 - } 967 - } 968 - break; 969 - } 970 - } 971 - } 1110 + const clickedItems: Annotation[] = getAnnotationsAtPoint(x, y).map((r) => r.item); 972 1111 973 1112 if (clickedItems.length > 0) { 974 1113 const target = e.target as HTMLElement; 975 - if (target.closest('a[href]')) return; 1114 + if ( 1115 + target.closest( 1116 + 'a[href], button, input, select, textarea, [role="button"], [role="link"], [contenteditable]' 1117 + ) 1118 + ) 1119 + return; 976 1120 977 1121 e.preventDefault(); 978 1122 e.stopPropagation(); ··· 1219 1363 let lastPolledUrl = getPageUrl(); 1220 1364 1221 1365 function onUrlChange() { 1222 - lastPolledUrl = getPageUrl(); 1366 + const currentUrl = getPageUrl(); 1367 + if (currentUrl === lastPolledUrl) return; 1368 + lastPolledUrl = currentUrl; 1223 1369 if (typeof CSS !== 'undefined' && CSS.highlights) { 1224 1370 CSS.highlights.clear(); 1225 1371 } ··· 1236 1382 } 1237 1383 1238 1384 window.addEventListener('popstate', onUrlChange); 1385 + window.addEventListener('hashchange', onUrlChange); 1239 1386 1240 1387 const originalPushState = history.pushState; 1241 1388 const originalReplaceState = history.replaceState; ··· 1250 1397 onUrlChange(); 1251 1398 }; 1252 1399 1253 - // Only re-fetch when the URL actually changes (not every 500ms) 1254 - setInterval(() => { 1255 - const currentUrl = getPageUrl(); 1256 - if (currentUrl !== lastPolledUrl) { 1257 - onUrlChange(); 1400 + let domChangeTimeout: ReturnType<typeof setTimeout> | null = null; 1401 + let domChangeCount = 0; 1402 + 1403 + function isMeaningfulMutation(mutations: MutationRecord[]): boolean { 1404 + for (const m of mutations) { 1405 + if (m.type !== 'childList') continue; 1406 + for (const node of [...m.addedNodes, ...m.removedNodes]) { 1407 + if ((node as Element).id === 'margin-overlay-host') continue; 1408 + if (node.nodeType === Node.TEXT_NODE && (node.textContent?.trim().length ?? 0) > 20) 1409 + return true; 1410 + if (node.nodeType === Node.ELEMENT_NODE) { 1411 + const tag = (node as Element).tagName; 1412 + if (['STYLE', 'SCRIPT', 'SVG', 'IMG', 'VIDEO', 'CANVAS'].includes(tag)) continue; 1413 + if ((node as Element).textContent?.trim().length ?? 0 > 20) return true; 1414 + } 1415 + } 1258 1416 } 1259 - }, 500); 1417 + return false; 1418 + } 1260 1419 1261 - let domChangeTimeout: ReturnType<typeof setTimeout> | null = null; 1262 - let domChangeCount = 0; 1263 1420 const observer = new MutationObserver((mutations) => { 1264 - const hasSignificantChange = mutations.some( 1265 - (m) => m.type === 'childList' && (m.addedNodes.length > 3 || m.removedNodes.length > 3) 1266 - ); 1267 - if (hasSignificantChange && overlayEnabled) { 1268 - domChangeCount++; 1269 - if (domChangeTimeout) clearTimeout(domChangeTimeout); 1270 - const delay = Math.min(500 + domChangeCount * 100, 2000); 1271 - domChangeTimeout = setTimeout(() => { 1272 - cachedMatcher = null; 1273 - domChangeCount = 0; 1274 - fetchAnnotations(); 1275 - }, delay); 1276 - } 1421 + if (!overlayEnabled || !isMeaningfulMutation(mutations)) return; 1422 + domChangeCount++; 1423 + if (domChangeTimeout) clearTimeout(domChangeTimeout); 1424 + const delay = Math.min(800 + domChangeCount * 200, 3000); 1425 + domChangeTimeout = setTimeout(() => { 1426 + matcherNeedsRebuild = true; 1427 + domChangeCount = 0; 1428 + fetchAnnotations(); 1429 + }, delay); 1277 1430 }); 1278 1431 observer.observe(document.body || document.documentElement, { 1279 1432 childList: true,
-305
lexicons/at/margin/annotation.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "at.margin.annotation", 4 - "revision": 2, 5 - "description": "W3C Web Annotation Data Model compliant annotation record for ATProto", 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "description": "A W3C-compliant web annotation stored on the AT Protocol", 10 - "key": "tid", 11 - "record": { 12 - "type": "object", 13 - "required": ["target", "createdAt"], 14 - "properties": { 15 - "motivation": { 16 - "type": "string", 17 - "description": "W3C motivation for the annotation", 18 - "knownValues": [ 19 - "commenting", 20 - "highlighting", 21 - "bookmarking", 22 - "tagging", 23 - "describing", 24 - "linking", 25 - "replying", 26 - "editing", 27 - "questioning", 28 - "assessing" 29 - ] 30 - }, 31 - "body": { 32 - "type": "ref", 33 - "ref": "#body", 34 - "description": "The annotation content (text or reference)" 35 - }, 36 - "target": { 37 - "type": "ref", 38 - "ref": "#target", 39 - "description": "The resource being annotated with optional selector" 40 - }, 41 - "tags": { 42 - "type": "array", 43 - "description": "Tags for categorization", 44 - "items": { 45 - "type": "string", 46 - "maxLength": 64, 47 - "maxGraphemes": 32 48 - }, 49 - "maxLength": 10 50 - }, 51 - "generator": { 52 - "type": "ref", 53 - "ref": "#generator", 54 - "description": "The client/agent that created this record" 55 - }, 56 - "rights": { 57 - "type": "string", 58 - "format": "uri", 59 - "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" 60 - }, 61 - "labels": { 62 - "type": "ref", 63 - "ref": "com.atproto.label.defs#selfLabels", 64 - "description": "Self-applied content labels for this annotation" 65 - }, 66 - "createdAt": { 67 - "type": "string", 68 - "format": "datetime" 69 - } 70 - } 71 - } 72 - }, 73 - "generator": { 74 - "type": "object", 75 - "description": "The client/agent that created this record", 76 - "properties": { 77 - "id": { 78 - "type": "string", 79 - "format": "uri" 80 - }, 81 - "name": { 82 - "type": "string" 83 - }, 84 - "homepage": { 85 - "type": "string", 86 - "format": "uri" 87 - } 88 - } 89 - }, 90 - "body": { 91 - "type": "object", 92 - "description": "Annotation body - the content of the annotation", 93 - "properties": { 94 - "value": { 95 - "type": "string", 96 - "maxLength": 10000, 97 - "maxGraphemes": 3000, 98 - "description": "Text content of the annotation" 99 - }, 100 - "format": { 101 - "type": "string", 102 - "description": "MIME type of the body content", 103 - "default": "text/plain" 104 - }, 105 - "language": { 106 - "type": "string", 107 - "description": "BCP47 language tag" 108 - }, 109 - "uri": { 110 - "type": "string", 111 - "format": "uri", 112 - "description": "Reference to external body content" 113 - } 114 - } 115 - }, 116 - "target": { 117 - "type": "object", 118 - "description": "W3C SpecificResource - the target with optional selector", 119 - "required": ["source"], 120 - "properties": { 121 - "source": { 122 - "type": "string", 123 - "format": "uri", 124 - "description": "The URL being annotated" 125 - }, 126 - "sourceHash": { 127 - "type": "string", 128 - "description": "SHA256 hash of normalized URL for indexing" 129 - }, 130 - "title": { 131 - "type": "string", 132 - "maxLength": 500, 133 - "description": "Page title at time of annotation" 134 - }, 135 - "selector": { 136 - "type": "union", 137 - "description": "Selector to identify the specific segment", 138 - "refs": [ 139 - "#textQuoteSelector", 140 - "#textPositionSelector", 141 - "#cssSelector", 142 - "#xpathSelector", 143 - "#fragmentSelector", 144 - "#rangeSelector" 145 - ] 146 - }, 147 - "state": { 148 - "type": "ref", 149 - "ref": "#timeState", 150 - "description": "State of the resource at annotation time" 151 - } 152 - } 153 - }, 154 - "textQuoteSelector": { 155 - "type": "object", 156 - "description": "W3C TextQuoteSelector - select text by quoting it with context", 157 - "required": ["exact"], 158 - "properties": { 159 - "type": { 160 - "type": "string", 161 - "const": "TextQuoteSelector" 162 - }, 163 - "exact": { 164 - "type": "string", 165 - "maxLength": 5000, 166 - "maxGraphemes": 1500, 167 - "description": "The exact text to match" 168 - }, 169 - "prefix": { 170 - "type": "string", 171 - "maxLength": 500, 172 - "maxGraphemes": 150, 173 - "description": "Text immediately before the selection" 174 - }, 175 - "suffix": { 176 - "type": "string", 177 - "maxLength": 500, 178 - "maxGraphemes": 150, 179 - "description": "Text immediately after the selection" 180 - } 181 - } 182 - }, 183 - "textPositionSelector": { 184 - "type": "object", 185 - "description": "W3C TextPositionSelector - select by character offsets", 186 - "required": ["start", "end"], 187 - "properties": { 188 - "type": { 189 - "type": "string", 190 - "const": "TextPositionSelector" 191 - }, 192 - "start": { 193 - "type": "integer", 194 - "minimum": 0, 195 - "description": "Starting character position (0-indexed, inclusive)" 196 - }, 197 - "end": { 198 - "type": "integer", 199 - "minimum": 0, 200 - "description": "Ending character position (exclusive)" 201 - } 202 - } 203 - }, 204 - "cssSelector": { 205 - "type": "object", 206 - "description": "W3C CssSelector - select DOM elements by CSS selector", 207 - "required": ["value"], 208 - "properties": { 209 - "type": { 210 - "type": "string", 211 - "const": "CssSelector" 212 - }, 213 - "value": { 214 - "type": "string", 215 - "maxLength": 2000, 216 - "description": "CSS selector string" 217 - } 218 - } 219 - }, 220 - "xpathSelector": { 221 - "type": "object", 222 - "description": "W3C XPathSelector - select by XPath expression", 223 - "required": ["value"], 224 - "properties": { 225 - "type": { 226 - "type": "string", 227 - "const": "XPathSelector" 228 - }, 229 - "value": { 230 - "type": "string", 231 - "maxLength": 2000, 232 - "description": "XPath expression" 233 - } 234 - } 235 - }, 236 - "fragmentSelector": { 237 - "type": "object", 238 - "description": "W3C FragmentSelector - select by URI fragment", 239 - "required": ["value"], 240 - "properties": { 241 - "type": { 242 - "type": "string", 243 - "const": "FragmentSelector" 244 - }, 245 - "value": { 246 - "type": "string", 247 - "maxLength": 1000, 248 - "description": "Fragment identifier value" 249 - }, 250 - "conformsTo": { 251 - "type": "string", 252 - "format": "uri", 253 - "description": "Specification the fragment conforms to" 254 - } 255 - } 256 - }, 257 - "rangeSelector": { 258 - "type": "object", 259 - "description": "W3C RangeSelector - select range between two selectors", 260 - "required": ["startSelector", "endSelector"], 261 - "properties": { 262 - "type": { 263 - "type": "string", 264 - "const": "RangeSelector" 265 - }, 266 - "startSelector": { 267 - "type": "union", 268 - "description": "Selector for range start", 269 - "refs": [ 270 - "#textQuoteSelector", 271 - "#textPositionSelector", 272 - "#cssSelector", 273 - "#xpathSelector" 274 - ] 275 - }, 276 - "endSelector": { 277 - "type": "union", 278 - "description": "Selector for range end", 279 - "refs": [ 280 - "#textQuoteSelector", 281 - "#textPositionSelector", 282 - "#cssSelector", 283 - "#xpathSelector" 284 - ] 285 - } 286 - } 287 - }, 288 - "timeState": { 289 - "type": "object", 290 - "description": "W3C TimeState - record when content was captured", 291 - "properties": { 292 - "sourceDate": { 293 - "type": "string", 294 - "format": "datetime", 295 - "description": "When the source was accessed" 296 - }, 297 - "cached": { 298 - "type": "string", 299 - "format": "uri", 300 - "description": "URL to cached/archived version" 301 - } 302 - } 303 - } 304 - } 305 - }
+2 -4
lexicons/at/margin/authFull.json
··· 6 6 "type": "permission-set", 7 7 "title": "Margin", 8 8 "title:langs": {}, 9 - "detail": "Full access to Margin features including annotations, highlights, bookmarks, and collections.", 9 + "detail": "Full access to Margin features including notes, replies, likes, and collections.", 10 10 "detail:langs": {}, 11 11 "permissions": [ 12 12 { ··· 14 14 "resource": "repo", 15 15 "action": ["create", "update", "delete"], 16 16 "collection": [ 17 - "at.margin.annotation", 18 - "at.margin.highlight", 19 - "at.margin.bookmark", 17 + "at.margin.note", 20 18 "at.margin.reply", 21 19 "at.margin.like", 22 20 "at.margin.collection",
-79
lexicons/at/margin/bookmark.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "at.margin.bookmark", 4 - "description": "A bookmark record - save URL for later", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A bookmarked URL (motivation: bookmarking)", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": ["source", "createdAt"], 13 - "properties": { 14 - "source": { 15 - "type": "string", 16 - "format": "uri", 17 - "description": "The bookmarked URL" 18 - }, 19 - "sourceHash": { 20 - "type": "string", 21 - "description": "SHA256 hash of normalized URL for indexing" 22 - }, 23 - "title": { 24 - "type": "string", 25 - "maxLength": 500, 26 - "description": "Page title" 27 - }, 28 - "description": { 29 - "type": "string", 30 - "maxLength": 1000, 31 - "maxGraphemes": 300, 32 - "description": "Optional description/note" 33 - }, 34 - "tags": { 35 - "type": "array", 36 - "description": "Tags for categorization", 37 - "items": { 38 - "type": "string", 39 - "maxLength": 64, 40 - "maxGraphemes": 32 41 - }, 42 - "maxLength": 10 43 - }, 44 - "generator": { 45 - "type": "object", 46 - "description": "The client/agent that created this record", 47 - "properties": { 48 - "id": { 49 - "type": "string", 50 - "format": "uri" 51 - }, 52 - "name": { 53 - "type": "string" 54 - }, 55 - "homepage": { 56 - "type": "string", 57 - "format": "uri" 58 - } 59 - } 60 - }, 61 - "rights": { 62 - "type": "string", 63 - "format": "uri", 64 - "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" 65 - }, 66 - "labels": { 67 - "type": "ref", 68 - "ref": "com.atproto.label.defs#selfLabels", 69 - "description": "Self-applied content labels for this bookmark" 70 - }, 71 - "createdAt": { 72 - "type": "string", 73 - "format": "datetime" 74 - } 75 - } 76 - } 77 - } 78 - } 79 - }
-69
lexicons/at/margin/highlight.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "at.margin.highlight", 4 - "description": "A lightweight highlight record - annotation without body text", 5 - "defs": { 6 - "main": { 7 - "type": "record", 8 - "description": "A highlight on a web page (motivation: highlighting)", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": ["target", "createdAt"], 13 - "properties": { 14 - "target": { 15 - "type": "ref", 16 - "ref": "at.margin.annotation#target", 17 - "description": "The resource and segment being highlighted" 18 - }, 19 - "color": { 20 - "type": "string", 21 - "description": "Highlight color (hex or named)", 22 - "maxLength": 20 23 - }, 24 - "tags": { 25 - "type": "array", 26 - "description": "Tags for categorization", 27 - "items": { 28 - "type": "string", 29 - "maxLength": 64, 30 - "maxGraphemes": 32 31 - }, 32 - "maxLength": 10 33 - }, 34 - "generator": { 35 - "type": "object", 36 - "description": "The client/agent that created this record", 37 - "properties": { 38 - "id": { 39 - "type": "string", 40 - "format": "uri" 41 - }, 42 - "name": { 43 - "type": "string" 44 - }, 45 - "homepage": { 46 - "type": "string", 47 - "format": "uri" 48 - } 49 - } 50 - }, 51 - "rights": { 52 - "type": "string", 53 - "format": "uri", 54 - "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" 55 - }, 56 - "labels": { 57 - "type": "ref", 58 - "ref": "com.atproto.label.defs#selfLabels", 59 - "description": "Self-applied content labels for this highlight" 60 - }, 61 - "createdAt": { 62 - "type": "string", 63 - "format": "datetime" 64 - } 65 - } 66 - } 67 - } 68 - } 69 - }
+235
lexicons/at/margin/note.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "at.margin.note", 4 + "revision": 3, 5 + "description": "W3C Web Annotation Data Model compliant unified note record for ATProto", 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "description": "A W3C-compliant web annotation stored on the AT Protocol", 10 + "key": "tid", 11 + "record": { 12 + "type": "object", 13 + "required": ["motivation", "target", "createdAt"], 14 + "properties": { 15 + "motivation": { 16 + "type": "string", 17 + "description": "W3C motivation for the annotation", 18 + "knownValues": [ 19 + "commenting", 20 + "highlighting", 21 + "bookmarking", 22 + "tagging", 23 + "describing", 24 + "linking", 25 + "replying", 26 + "editing", 27 + "questioning", 28 + "assessing" 29 + ] 30 + }, 31 + "color": { 32 + "type": "string", 33 + "description": "Highlight color tint", 34 + "maxLength": 20 35 + }, 36 + "body": { 37 + "type": "ref", 38 + "ref": "#body", 39 + "description": "The annotation content (text or reference). For bookmarks, use body.value for the description." 40 + }, 41 + "target": { 42 + "type": "ref", 43 + "ref": "#target", 44 + "description": "The resource being annotated with optional selector" 45 + }, 46 + "tags": { 47 + "type": "array", 48 + "description": "Tags for categorization", 49 + "items": { 50 + "type": "string", 51 + "maxLength": 64, 52 + "maxGraphemes": 32 53 + }, 54 + "maxLength": 10 55 + }, 56 + "facets": { 57 + "type": "array", 58 + "description": "Rich text facets (e.g. mentions, links)", 59 + "items": { 60 + "type": "ref", 61 + "ref": "app.bsky.richtext.facet" 62 + } 63 + }, 64 + "generator": { 65 + "type": "ref", 66 + "ref": "#generator", 67 + "description": "The client/agent that created this record" 68 + }, 69 + "rights": { 70 + "type": "string", 71 + "format": "uri", 72 + "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" 73 + }, 74 + "labels": { 75 + "type": "ref", 76 + "ref": "com.atproto.label.defs#selfLabels", 77 + "description": "Self-applied content labels for this annotation" 78 + }, 79 + "createdAt": { 80 + "type": "string", 81 + "format": "datetime" 82 + }, 83 + "modifiedAt": { 84 + "type": "string", 85 + "format": "datetime", 86 + "description": "When this record was last modified" 87 + } 88 + } 89 + } 90 + }, 91 + "generator": { 92 + "type": "object", 93 + "description": "The client/agent that created this record", 94 + "properties": { 95 + "id": { 96 + "type": "string", 97 + "format": "uri" 98 + }, 99 + "name": { 100 + "type": "string" 101 + }, 102 + "homepage": { 103 + "type": "string", 104 + "format": "uri" 105 + } 106 + } 107 + }, 108 + "body": { 109 + "type": "object", 110 + "description": "Annotation body - the content of the annotation", 111 + "properties": { 112 + "value": { 113 + "type": "string", 114 + "maxLength": 10000, 115 + "maxGraphemes": 3000, 116 + "description": "Text content of the annotation. For bookmarks, this is the description." 117 + }, 118 + "format": { 119 + "type": "string", 120 + "description": "MIME type of the body content", 121 + "default": "text/plain" 122 + }, 123 + "uri": { 124 + "type": "string", 125 + "format": "uri", 126 + "description": "Reference to external body content" 127 + } 128 + } 129 + }, 130 + "target": { 131 + "type": "object", 132 + "description": "W3C SpecificResource - the target with optional selector", 133 + "required": ["source"], 134 + "properties": { 135 + "source": { 136 + "type": "string", 137 + "format": "uri", 138 + "description": "The URL being annotated" 139 + }, 140 + "sourceHash": { 141 + "type": "string", 142 + "description": "SHA256 hash of normalized URL for indexing" 143 + }, 144 + "title": { 145 + "type": "string", 146 + "maxLength": 500, 147 + "description": "Page title at time of annotation" 148 + }, 149 + "selector": { 150 + "type": "ref", 151 + "ref": "#selector", 152 + "description": "W3C Selector to identify the annotated segment. Uses W3C 'type' field (not ATProto $type) per the Web Annotation Data Model." 153 + }, 154 + "state": { 155 + "type": "ref", 156 + "ref": "#timeState", 157 + "description": "State of the resource at annotation time" 158 + } 159 + } 160 + }, 161 + "selector": { 162 + "type": "object", 163 + "description": "W3C Web Annotation Selector. The 'type' field discriminates the selector kind using W3C type names (e.g. TextQuoteSelector). This follows W3C conventions, not ATProto union $type.", 164 + "required": ["type"], 165 + "properties": { 166 + "type": { 167 + "type": "string", 168 + "description": "W3C selector type identifier", 169 + "knownValues": [ 170 + "TextQuoteSelector", 171 + "TextPositionSelector", 172 + "CssSelector", 173 + "XPathSelector", 174 + "FragmentSelector", 175 + "RangeSelector" 176 + ] 177 + }, 178 + "exact": { 179 + "type": "string", 180 + "maxLength": 5000, 181 + "maxGraphemes": 1500, 182 + "description": "TextQuoteSelector: the exact text being selected" 183 + }, 184 + "prefix": { 185 + "type": "string", 186 + "maxLength": 500, 187 + "maxGraphemes": 150, 188 + "description": "TextQuoteSelector: text immediately before the selection, for disambiguation" 189 + }, 190 + "suffix": { 191 + "type": "string", 192 + "maxLength": 500, 193 + "maxGraphemes": 150, 194 + "description": "TextQuoteSelector: text immediately after the selection, for disambiguation" 195 + }, 196 + "start": { 197 + "type": "integer", 198 + "minimum": 0, 199 + "description": "TextPositionSelector: start character offset (inclusive)" 200 + }, 201 + "end": { 202 + "type": "integer", 203 + "minimum": 0, 204 + "description": "TextPositionSelector: end character offset (exclusive)" 205 + }, 206 + "value": { 207 + "type": "string", 208 + "maxLength": 2000, 209 + "description": "CssSelector/XPathSelector/FragmentSelector: the selector expression or fragment value" 210 + }, 211 + "conformsTo": { 212 + "type": "string", 213 + "format": "uri", 214 + "description": "FragmentSelector: URI of the specification the fragment conforms to" 215 + } 216 + } 217 + }, 218 + "timeState": { 219 + "type": "object", 220 + "description": "W3C TimeState - record when content was captured", 221 + "properties": { 222 + "sourceDate": { 223 + "type": "string", 224 + "format": "datetime", 225 + "description": "When the source was accessed" 226 + }, 227 + "cached": { 228 + "type": "string", 229 + "format": "uri", 230 + "description": "URL to cached/archived version" 231 + } 232 + } 233 + } 234 + } 235 + }
+5
lexicons/at/margin/preferences.json
··· 44 44 "disableExternalLinkWarning": { 45 45 "type": "boolean", 46 46 "description": "If true, do not show the confirmation modal when opening external links." 47 + }, 48 + "enableCommunityBookmarks": { 49 + "type": "boolean", 50 + "description": "If true, dual-write bookmarks to the standard for ATProto interop.", 51 + "default": false 47 52 } 48 53 } 49 54 }
+9
web/astro.config.mjs
··· 3 3 import react from "@astrojs/react"; 4 4 import tailwind from "@astrojs/tailwind"; 5 5 import node from "@astrojs/node"; 6 + import { fileURLToPath } from "url"; 7 + import { dirname, resolve } from "path"; 8 + 9 + const __dirname = dirname(fileURLToPath(import.meta.url)); 6 10 7 11 const API_PORT = process.env.API_PORT || 8081; 8 12 ··· 17 21 defaultStrategy: "viewport", 18 22 }, 19 23 vite: { 24 + resolve: { 25 + alias: { 26 + "@": resolve(__dirname, "src"), 27 + }, 28 + }, 20 29 ssr: { 21 30 external: ["@resvg/resvg-js"], 22 31 },
+2
web/src/api/client.ts
··· 1101 1101 subscribedLabelers?: LabelerSubscription[]; 1102 1102 labelPreferences?: LabelPreference[]; 1103 1103 disableExternalLinkWarning?: boolean; 1104 + enableCommunityBookmarks?: boolean; 1104 1105 } 1105 1106 1106 1107 export async function getPreferences(): Promise<PreferencesResponse> { ··· 1121 1122 subscribedLabelers?: LabelerSubscription[]; 1122 1123 labelPreferences?: LabelPreference[]; 1123 1124 disableExternalLinkWarning?: boolean; 1125 + enableCommunityBookmarks?: boolean; 1124 1126 }): Promise<boolean> { 1125 1127 try { 1126 1128 const res = await apiRequest("/api/preferences", {
+34 -2
web/src/components/common/Card.tsx
··· 19 19 Tag, 20 20 Send, 21 21 X, 22 + Bookmark, 22 23 } from "lucide-react"; 23 24 import ShareMenu from "../modals/ShareMenu"; 24 25 import AddToCollectionModal from "../modals/AddToCollectionModal"; ··· 47 48 import { Avatar } from "../ui"; 48 49 import CollectionIcon from "./CollectionIcon"; 49 50 import ProfileHoverCard from "./ProfileHoverCard"; 51 + import { analytics } from "../../lib/analytics"; 50 52 51 53 const LABEL_DESCRIPTIONS: Record<string, string> = { 52 54 sexual: "Sexual Content", ··· 172 174 const isSemble = 173 175 item.uri?.includes("network.cosmik") || item.uri?.includes("semble"); 174 176 177 + const isCommunityBookmark = item.uri?.includes( 178 + "community.lexicon.bookmarks.bookmark", 179 + ); 180 + 175 181 const safeUrlHostname = (url: string | null | undefined) => { 176 182 if (!url) return null; 177 183 try { ··· 212 218 if (!success) { 213 219 setLiked(prev.liked); 214 220 setLikes(prev.likes); 221 + } else { 222 + analytics.capture("item_liked", { 223 + type, 224 + action: liked ? "unlike" : "like", 225 + }); 215 226 } 216 227 }; 217 228 218 229 const handleDelete = async () => { 219 230 if (window.confirm("Delete this item?")) { 220 231 const success = await deleteItem(item.uri, type); 221 - if (success && onDelete) onDelete(item.uri); 232 + if (success && onDelete) { 233 + analytics.capture("item_deleted", { type }); 234 + onDelete(item.uri); 235 + } 222 236 } 223 237 }; 224 238 ··· 479 493 </span> 480 494 ); 481 495 })()} 496 + 497 + {isCommunityBookmark && ( 498 + <span className="relative inline-flex items-center"> 499 + <span className="text-surface-300 dark:text-surface-600"> 500 + · 501 + </span> 502 + <span className="group/cb relative inline-flex items-center ml-1"> 503 + <Bookmark 504 + size={12} 505 + className="text-surface-400 dark:text-surface-500 fill-current" 506 + /> 507 + <span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2.5 py-1 rounded-lg bg-surface-800 dark:bg-surface-700 text-white text-[11px] font-medium whitespace-nowrap opacity-0 group-hover/cb:opacity-100 transition-opacity shadow-lg"> 508 + Community bookmark 509 + <span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-surface-800 dark:border-t-surface-700" /> 510 + </span> 511 + </span> 512 + </span> 513 + )} 482 514 </div> 483 515 484 516 {pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && ( ··· 638 670 const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`; 639 671 handleExternalClick(e, url); 640 672 }} 641 - className="block" 673 + className="block break-words" 642 674 > 643 675 "{item.target?.selector?.exact}" 644 676 </a>
+13
web/src/components/feed/Composer.tsx
··· 9 9 import type { Selector, ContentLabelValue } from "../../types"; 10 10 import { X, ShieldAlert } from "lucide-react"; 11 11 import TagInput from "../ui/TagInput"; 12 + import { analytics } from "../../lib/analytics"; 12 13 13 14 const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ 14 15 { value: "sexual", label: "Sexual" }, ··· 99 100 tags: tagList, 100 101 labels: selfLabels.length > 0 ? selfLabels : undefined, 101 102 }); 103 + analytics.capture("highlight_created", { 104 + url, 105 + tag_count: tagList.length, 106 + has_labels: selfLabels.length > 0, 107 + }); 102 108 } else { 103 109 await createAnnotation({ 104 110 url, ··· 107 113 tags: tagList, 108 114 labels: selfLabels.length > 0 ? selfLabels : undefined, 109 115 }); 116 + analytics.capture("annotation_created", { 117 + url, 118 + has_quote: !!finalSelector, 119 + tag_count: tagList.length, 120 + has_labels: selfLabels.length > 0, 121 + }); 110 122 } 111 123 112 124 setText(""); ··· 115 127 setSelector(null); 116 128 if (onSuccess) onSuccess(); 117 129 } catch (err) { 130 + analytics.captureException(err); 118 131 setError( 119 132 (err instanceof Error ? err.message : "Unknown error") || 120 133 "Failed to post",
+2 -2
web/src/components/feed/ReplyList.tsx
··· 108 108 </div> 109 109 <p 110 110 className={clsx( 111 - "text-surface-800 dark:text-surface-200 whitespace-pre-wrap leading-relaxed", 111 + "text-surface-800 dark:text-surface-200 whitespace-pre-wrap break-words leading-relaxed", 112 112 depth > 0 ? "text-sm" : "text-sm", 113 113 )} 114 114 > ··· 147 147 : ""} 148 148 </span> 149 149 </div> 150 - <p className="text-surface-800 dark:text-surface-200 text-sm pl-9 mb-2 whitespace-pre-wrap"> 150 + <p className="text-surface-800 dark:text-surface-200 text-sm pl-9 mb-2 whitespace-pre-wrap break-words"> 151 151 {reply.text || reply.body?.value} 152 152 </p> 153 153 <div className="flex items-center justify-end gap-2 pl-9">
+2
web/src/components/modals/AddToCollectionModal.tsx
··· 14 14 import { useStore } from "@nanostores/react"; 15 15 import { $user } from "../../store/auth"; 16 16 import { $theme } from "../../store/theme"; 17 + import { analytics } from "../../lib/analytics"; 17 18 import { 18 19 getCollections, 19 20 addCollectionItem, ··· 88 89 setAddingTo(collectionUri); 89 90 await addCollectionItem(collectionUri, annotationUri); 90 91 setAddedTo((prev) => new Set([...prev, collectionUri])); 92 + analytics.capture("item_added_to_collection"); 91 93 } catch (err) { 92 94 console.error(err); 93 95 setError("Failed to add to collection");
+11
web/src/components/modals/ShareMenu.tsx
··· 14 14 CatskyIcon, 15 15 DeerIcon, 16 16 } from "../common/Icons"; 17 + import { analytics } from "../../lib/analytics"; 17 18 18 19 const SembleLogo = () => ( 19 20 <img src="/semble-logo.svg" alt="Semble" className="w-4 h-4 opacity-90" /> ··· 84 85 try { 85 86 await navigator.clipboard.writeText(textToCopy); 86 87 setCopied(key); 88 + analytics.capture("item_shared", { 89 + method: "copy_link", 90 + destination: key, 91 + item_type: type, 92 + }); 87 93 setTimeout(() => { 88 94 setCopied(null); 89 95 setIsOpen(false); ··· 98 104 ? `${text.substring(0, 200)}...\n\n${shareUrl}` 99 105 : shareUrl; 100 106 const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; 107 + analytics.capture("item_shared", { 108 + method: "social_app", 109 + destination: domain, 110 + item_type: type, 111 + }); 101 112 window.open(composeUrl, "_blank"); 102 113 setIsOpen(false); 103 114 };
+5
web/src/components/modals/SignUpModal.tsx
··· 8 8 MarginIcon, 9 9 } from "../common/Icons"; 10 10 import { startSignup } from "../../api/client"; 11 + import { analytics } from "../../lib/analytics"; 11 12 12 13 interface Provider { 13 14 id: string; ··· 169 170 setError(null); 170 171 171 172 try { 173 + analytics.capture("signup_initiated", { provider: provider.id }); 172 174 const result = await startSignup(provider.service); 173 175 if (result.authorizationUrl) { 174 176 window.location.assign(result.authorizationUrl); 175 177 } 176 178 } catch (err) { 177 179 console.error(err); 180 + analytics.captureException(err); 178 181 setError("Could not connect to this provider. Please try again."); 179 182 setLoading(false); 180 183 } ··· 193 196 } 194 197 195 198 try { 199 + analytics.capture("signup_initiated", { provider: "custom" }); 196 200 const result = await startSignup(serviceUrl); 197 201 if (result.authorizationUrl) { 198 202 const url = new URL(result.authorizationUrl); ··· 202 206 } 203 207 } catch (err) { 204 208 console.error(err); 209 + analytics.captureException(err); 205 210 setError("Could not connect to this PDS. Please check the URL."); 206 211 setLoading(false); 207 212 }
+13
web/src/components/posthog.astro
··· 1 + <script is:inline define:vars={{ apiKey: import.meta.env.POSTHOG_PROJECT_TOKEN, apiHost: import.meta.env.POSTHOG_HOST, uiHost: import.meta.env.POSTHOG_UI_HOST }}> 2 + if (!window.__posthog_initialized) { 3 + window.__posthog_initialized = true; 4 + !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys getNextSurveyStep onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); 5 + posthog.init(apiKey || '', { 6 + api_host: apiHost, 7 + ui_host: uiHost, 8 + defaults: '2026-01-30', 9 + person_profiles: 'identified_only', 10 + capture_pageview: 'history_change' 11 + }) 12 + } 13 + </script>
+5
web/src/env.d.ts
··· 5 5 user: import("./types").UserProfile | null; 6 6 } 7 7 } 8 + 9 + interface Window { 10 + __posthog_initialized?: boolean; 11 + posthog?: import("posthog-js").PostHog; 12 + }
+2
web/src/layouts/BaseLayout.astro
··· 1 1 --- 2 2 import { ClientRouter } from 'astro:transitions'; 3 3 import '../styles/global.css'; 4 + import PostHog from '../components/posthog.astro'; 4 5 5 6 interface Props { 6 7 title?: string; ··· 39 40 <title>{title}</title> 40 41 41 42 <ClientRouter /> 43 + <PostHog /> 42 44 43 45 <script is:inline> 44 46 (function() {
+114
web/src/lib/analytics.ts
··· 1 + export type AnalyticsEvents = { 2 + login_initiated: { handle: string }; 3 + login_success: { handle: string; pds?: string }; 4 + signup_initiated: { provider: string }; 5 + user_logged_out: Record<string, never>; 6 + 7 + annotation_created: { 8 + url: string; 9 + has_quote: boolean; 10 + tag_count: number; 11 + has_labels: boolean; 12 + source?: "web" | "extension"; 13 + }; 14 + highlight_created: { 15 + url: string; 16 + tag_count: number; 17 + has_color: boolean; 18 + has_labels: boolean; 19 + source?: "web" | "extension"; 20 + }; 21 + bookmark_created: { 22 + url: string; 23 + tag_count: number; 24 + source?: "web" | "extension"; 25 + }; 26 + reply_created: { parent_uri: string; root_uri: string }; 27 + 28 + item_liked: { 29 + action: "like" | "unlike"; 30 + type: "annotation" | "highlight" | "bookmark"; 31 + }; 32 + item_deleted: { type: "annotation" | "highlight" | "bookmark" }; 33 + item_shared: { 34 + method: "copy" | "bluesky" | "twitter" | "mastodon" | "email"; 35 + item_type?: string; 36 + }; 37 + item_added_to_collection: Record<string, never>; 38 + 39 + collection_created: { name: string }; 40 + collection_deleted: Record<string, never>; 41 + 42 + extension_installed: { version: string; browser: string }; 43 + extension_connected: { did: string }; 44 + popup_opened: { authenticated: boolean }; 45 + extension_tab_switched: { tab: string }; 46 + 47 + highlights_imported: { total: number; completed: number; failed: number }; 48 + 49 + search_performed: { query: string }; 50 + 51 + api_key_created: Record<string, never>; 52 + theme_changed: { theme: string }; 53 + }; 54 + 55 + function getPostHog() { 56 + if (typeof window === "undefined") return null; 57 + return window.posthog ?? null; 58 + } 59 + 60 + export const analytics = { 61 + capture<E extends keyof AnalyticsEvents>( 62 + event: E, 63 + properties?: AnalyticsEvents[E], 64 + ): void { 65 + try { 66 + getPostHog()?.capture( 67 + event as string, 68 + properties as Record<string, unknown>, 69 + ); 70 + } catch { 71 + // ignore 72 + } 73 + }, 74 + 75 + identify( 76 + did: string, 77 + properties: { handle: string; displayName?: string }, 78 + ): void { 79 + try { 80 + getPostHog()?.identify(did, { 81 + handle: properties.handle, 82 + display_name: properties.displayName ?? undefined, 83 + $set_once: { first_seen_at: new Date().toISOString() }, 84 + }); 85 + } catch { 86 + // noop 87 + } 88 + }, 89 + 90 + reset(): void { 91 + try { 92 + getPostHog()?.reset(); 93 + } catch { 94 + // noop 95 + } 96 + }, 97 + 98 + captureException(error: unknown, properties?: Record<string, unknown>): void { 99 + try { 100 + const ph = getPostHog(); 101 + if (!ph) return; 102 + if (typeof ph.captureException === "function") { 103 + ph.captureException(error, properties); 104 + } else { 105 + ph.capture("$exception", { 106 + message: error instanceof Error ? error.message : String(error), 107 + ...properties, 108 + }); 109 + } 110 + } catch { 111 + // noop 112 + } 113 + }, 114 + };
+6 -1
web/src/middleware.ts
··· 6 6 const API_PORT = process.env.API_PORT || 8081; 7 7 const API_URL = process.env.API_URL || `http://localhost:${API_PORT}`; 8 8 9 - const PROXY_PATHS = ["/api/", "/auth/", "/client-metadata.json", "/jwks.json"]; 9 + const PROXY_PATHS = [ 10 + "/api/", 11 + "/auth/", 12 + "/oauth-client-metadata.json", 13 + "/jwks.json", 14 + ]; 10 15 11 16 const CORS_HEADERS = { 12 17 "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+63 -47
web/src/pages/privacy.astro
··· 11 11 12 12 <div class="prose prose-surface dark:prose-invert max-w-none"> 13 13 <h1 class="font-display font-bold text-3xl mb-2 text-surface-900 dark:text-white">Privacy Policy</h1> 14 - <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: March 4, 2026</p> 14 + <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: April 14, 2026</p> 15 15 16 16 <section class="mb-8"> 17 17 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Overview</h2> 18 18 <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 19 - Margin ("we", "our", or "us") is a web annotation tool that lets you highlight, annotate, and bookmark any webpage. Your data is stored on the decentralized AT Protocol network, giving you ownership and control over your content. 19 + Margin is a web annotation tool built on the AT Protocol, operated by <strong>Padding Labs LLC</strong> ("we", "our", or "us"). It lets you highlight, take notes, and bookmark any webpage. Because Margin is built on the AT Protocol, your notes are stored as records in your Personal Data Server (PDS) — you own your content and can take it with you. You must be at least 13 years old to use Margin. 20 20 </p> 21 21 </section> 22 22 23 23 <section class="mb-8"> 24 24 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Data We Collect</h2> 25 + 25 26 <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Account Information</h3> 26 27 <p class="text-surface-700 dark:text-surface-300 mb-4"> 27 - When you log in with your Bluesky/AT Protocol account, we access your: 28 + When you log in with your AT Protocol account (e.g. Bluesky), we access your: 28 29 </p> 29 30 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 30 31 <li>Decentralized Identifier (DID)</li> 31 32 <li>Handle (username)</li> 32 - <li>Display name and avatar (for showing your profile)</li> 33 + <li>Display name, avatar, bio, and website (for showing your profile)</li> 33 34 </ul> 34 35 35 - <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Annotations & Content</h3> 36 - <p class="text-surface-700 dark:text-surface-300 mb-4">When you use Margin, we store:</p> 36 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Notes & Content</h3> 37 + <p class="text-surface-700 dark:text-surface-300 mb-4">When you create a note, we store:</p> 37 38 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 38 - <li>URLs of pages you annotate</li> 39 - <li>Text you highlight or select</li> 40 - <li>Annotations and comments you create</li> 41 - <li>Bookmarks you save</li> 42 - <li>Collections you organize content into</li> 39 + <li>The full URL of the page you noted on</li> 40 + <li>The page title at the time of the note</li> 41 + <li>The text you selected (up to 5,000 characters)</li> 42 + <li>Surrounding context around your selection (up to 500 characters before and after), used to re-locate the note on the page</li> 43 + <li>Any text you write as part of the note</li> 44 + <li>Tags you apply</li> 45 + <li>Highlight color (if set)</li> 46 + <li>Replies and likes on notes</li> 47 + <li>Collections you create, and which notes belong to them</li> 48 + <li>An edit history of any changes you make to your notes</li> 43 49 </ul> 44 50 45 - <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Discover & Recommendations</h3> 51 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Overlay Lookups (Browser Extension)</h3> 52 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 53 + When you browse the web with the extension enabled, it checks each page for existing notes. For these lookups, the extension computes a SHA-256 hash of the page URL locally and sends only that hash to our server — <strong>the raw URL is not sent for lookups.</strong> A hash cannot be reversed to recover the original URL. You can disable overlay lookups entirely by turning off page overlays in the extension settings. 54 + </p> 46 55 <p class="text-surface-700 dark:text-surface-300 mb-4"> 47 - To power the Discover page and personalized recommendations, we generate mathematical representations (embeddings) of: 56 + Note: when you <em>create</em> a note, the full URL is included as part of the record stored on your PDS. 57 + </p> 58 + 59 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Recommendations</h3> 60 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 61 + To power the Discover page, we generate vector embeddings of your notes and public documents on the AT Protocol network. These embeddings are used to build an interest profile and surface relevant content. Your interest profile is stored on our server and is not shared with other users. 62 + </p> 63 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 64 + Embeddings are generated via the <strong>OpenAI</strong> API. The text content of your notes and public AT Protocol documents is sent to OpenAI for this purpose. OpenAI does not use API inputs to train their models — see their <a href="https://openai.com/policies/usage-policies/" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline" target="_blank" rel="noopener noreferrer">API data usage policy</a>. Recommendations are only generated if you have used Margin to annotate content. 48 65 </p> 49 - <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 50 - <li>Your annotations, highlights, and their associated tags</li> 51 - <li>Publicly published documents from the AT Protocol network</li> 52 - </ul> 66 + 67 + <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Analytics</h3> 53 68 <p class="text-surface-700 dark:text-surface-300 mb-4"> 54 - These embeddings are used to build an interest profile that helps us suggest relevant content. Your interest profile is stored on our server and is not shared with other users. 69 + We use <strong>PostHog</strong> for product analytics to understand how Margin is used and improve the product. Analytics events — such as page views, note creation, and feature interactions — are collected on the Margin website and server. These events may include the URL of the page being noted on, your browser type, and extension version. PostHog may set cookies for this purpose. We do not use analytics to track you across other websites or share analytics data with advertisers. See PostHog's <a href="https://posthog.com/privacy" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline" target="_blank" rel="noopener noreferrer">privacy policy</a>. 55 70 </p> 56 71 57 72 <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Authentication</h3> 58 73 <p class="text-surface-700 dark:text-surface-300 mb-4"> 59 - We store OAuth session tokens locally in your browser to keep you logged in. These tokens are used solely for authenticating API requests. 74 + We store your OAuth session tokens (access token, refresh token, and DPoP proof key) in our database to keep you logged in. These are used solely for authenticating requests to the AT Protocol on your behalf and expire when your session ends. 60 75 </p> 61 76 </section> 62 77 63 78 <section class="mb-8"> 64 79 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">How We Use Your Data</h2> 65 - <p class="text-surface-700 dark:text-surface-300 mb-4">Your data is used exclusively to:</p> 80 + <p class="text-surface-700 dark:text-surface-300 mb-4">Your data is used to:</p> 66 81 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 67 - <li>Display your annotations on webpages</li> 82 + <li>Display your notes on webpages via the browser extension</li> 68 83 <li>Sync your content across devices</li> 69 - <li>Show your public annotations to other users</li> 84 + <li>Show your public notes to other users on the same page</li> 70 85 <li>Enable social features like replies and likes</li> 71 86 <li>Generate personalized content recommendations on the Discover page</li> 87 + <li>Understand product usage and improve the service</li> 72 88 </ul> 73 - 74 - <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Third-Party Services</h3> 75 - <p class="text-surface-700 dark:text-surface-300 mb-4"> 76 - We use <strong>OpenAI</strong> to generate text embeddings for powering recommendations. When generating embeddings, the text content of your annotations and public documents is sent to OpenAI's API. OpenAI processes this data according to their <a href="https://openai.com/policies/api-data-usage-policies" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline" target="_blank" rel="noopener noreferrer">API data usage policy</a>, which states that API inputs are not used to train their models. No other third-party services receive your data. 89 + <p class="text-surface-700 dark:text-surface-300"> 90 + <strong>We do not sell your data.</strong> We do not share your data with third parties for advertising or marketing purposes. 77 91 </p> 78 92 </section> 79 93 80 94 <section class="mb-8"> 81 - <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Data Storage</h2> 95 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">The AT Protocol & Data Portability</h2> 82 96 <p class="text-surface-700 dark:text-surface-300 mb-4"> 83 - Your annotations are stored on the AT Protocol network through your Personal Data Server (PDS). This means: 97 + Margin is built on the AT Protocol. Your notes, collections, and profile are stored as records in your Personal Data Server (PDS). This means: 84 98 </p> 85 99 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 86 - <li>You own your data</li> 87 - <li>You can export or delete it at any time</li> 88 - <li>Your data is portable across AT Protocol services</li> 100 + <li>You own your content</li> 101 + <li>You can export your full data repository from your PDS at any time</li> 102 + <li>Your data is portable — you can use it with other AT Protocol-compatible services</li> 103 + <li>You can migrate your account to a different PDS without losing your content</li> 89 104 </ul> 90 105 <p class="text-surface-700 dark:text-surface-300 mb-4"> 91 - We also maintain a local index of annotations to provide faster search and discovery features. 106 + We also maintain a server-side index of public notes to power features like search, discovery, and the extension overlay. This index is derived from the public AT Protocol firehose and is used solely to operate the service. 92 107 </p> 93 108 94 109 <h3 class="text-lg font-semibold text-surface-900 dark:text-white mb-2">Margin PDS (margin.cafe)</h3> 95 110 <p class="text-surface-700 dark:text-surface-300 mb-4"> 96 - We operate a Personal Data Server at <strong>margin.cafe</strong>. If you create an account on this PDS, we additionally store: 111 + We operate an optional Personal Data Server at <strong>margin.cafe</strong>. If you create an account there, we additionally store: 97 112 </p> 98 113 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 99 - <li>Your account credentials (password hashed, never stored in plain text)</li> 114 + <li>Your account credentials (password is hashed and never stored in plain text)</li> 100 115 <li>Your email address (for account recovery)</li> 101 116 <li>All AT Protocol data repositories you create on this server</li> 102 117 </ul> 103 118 <p class="text-surface-700 dark:text-surface-300"> 104 - You can migrate your account and data to a different PDS at any time using standard AT Protocol account migration. Using the margin.cafe PDS is entirely optional as you can use Margin with any AT Protocol PDS. 119 + Using margin.cafe is entirely optional — Margin works with any AT Protocol PDS. You can migrate your account to a different PDS at any time using standard AT Protocol account migration. 105 120 </p> 106 121 </section> 107 122 108 123 <section class="mb-8"> 109 - <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Data Sharing</h2> 124 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Public Content</h2> 110 125 <p class="text-surface-700 dark:text-surface-300 mb-4"> 111 - <strong>We do not sell your data.</strong> We do not share your data with third parties for advertising or marketing purposes. 126 + Notes you create are public by default on the AT Protocol network. This means they may be visible to: 112 127 </p> 113 - <p class="text-surface-700 dark:text-surface-300 mb-4">Your public annotations may be visible to:</p> 114 128 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 115 129 <li>Other Margin users viewing the same webpage</li> 116 - <li>Anyone on the AT Protocol network (for public content)</li> 130 + <li>Anyone on the AT Protocol network who subscribes to public records</li> 117 131 </ul> 118 132 </section> 119 133 120 134 <section class="mb-8"> 121 135 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Browser Extension Permissions</h2> 122 - <p class="text-surface-700 dark:text-surface-300 mb-4">The Margin browser extension requires certain permissions:</p> 136 + <p class="text-surface-700 dark:text-surface-300 mb-4">The Margin browser extension requires the following permissions:</p> 123 137 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 124 - <li> 125 - <strong>All URLs:</strong> To display and create annotations on any webpage. When checking a page for annotations, the URL is sent to our server. <strong>We do not store these requests, the URLs are redacted, and we do not link your identity to the URLs you visit.</strong> You can disable sending URLs to Margin by turning off page overlays in the extension settings. 126 - </li> 138 + <li><strong>All URLs:</strong> To display and create notes on any webpage. Overlay lookups use a local SHA-256 hash of the URL — the raw URL is only sent when you actively create a note.</li> 127 139 <li><strong>Storage:</strong> To save your preferences and session locally</li> 128 140 <li><strong>Cookies:</strong> To maintain your logged-in session</li> 129 - <li><strong>Tabs:</strong> To know which page you're viewing</li> 141 + <li><strong>Tabs:</strong> To know which page you're currently viewing</li> 130 142 </ul> 131 143 </section> 132 144 ··· 134 146 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Your Rights</h2> 135 147 <p class="text-surface-700 dark:text-surface-300 mb-4">You can:</p> 136 148 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 137 - <li>Delete any annotation, highlight, or bookmark you've created</li> 149 + <li>Delete any note or reply you've created</li> 138 150 <li>Delete your collections</li> 139 - <li>Export your data from your PDS</li> 140 - <li>Revoke the extension's access at any time</li> 151 + <li>Export your full data repository from your PDS</li> 152 + <li>Revoke Margin's OAuth access to your account at any time</li> 153 + <li>Request deletion of your margin.cafe account by contacting us — deletion of AT Protocol data on your PDS is governed by your PDS provider</li> 141 154 </ul> 155 + <p class="text-surface-700 dark:text-surface-300"> 156 + To exercise any of these rights, email us at <a href="mailto:hello@margin.at" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">hello@margin.at</a>. 157 + </p> 142 158 </section> 143 159 144 160 <section class="mb-8">
+48 -10
web/src/pages/terms.astro
··· 11 11 12 12 <div class="prose prose-surface dark:prose-invert max-w-none"> 13 13 <h1 class="font-display font-bold text-3xl mb-2 text-surface-900 dark:text-white">Terms of Service</h1> 14 - <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: January 17, 2026</p> 14 + <p class="text-surface-500 dark:text-surface-400 mb-8">Last updated: April 14, 2026</p> 15 15 16 16 <section class="mb-8"> 17 17 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Overview</h2> 18 18 <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 19 - Margin is an open-source project. By using our service, you agree to these terms ("Terms"). If you do not agree to these Terms, please do not use the Service. 19 + Margin is a web annotation tool built on the AT Protocol, operated by Padding Labs LLC ("we", "our", or "us"). By using Margin (the "Service"), you agree to these Terms. If you do not agree, please do not use the Service. You must be at least 13 years old to use Margin. 20 20 </p> 21 21 </section> 22 22 23 23 <section class="mb-8"> 24 24 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Open Source</h2> 25 25 <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 26 - Margin is open source software. The code is available publicly and is provided "as is", without warranty of any kind, express or implied. 26 + Margin is open source software. The source code is publicly available and provided "as is", without warranty of any kind, express or implied. These Terms govern your use of the hosted service at margin.at, not the source code itself. 27 + </p> 28 + </section> 29 + 30 + <section class="mb-8"> 31 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Your Content</h2> 32 + <p class="text-surface-700 dark:text-surface-300 mb-4"> 33 + You retain full ownership of all notes and other content you create ("Your Content"). By using the Service, you grant Padding Labs LLC a limited, non-exclusive, royalty-free license to store, index, display, and sync Your Content solely as needed to operate and improve the Service. 34 + </p> 35 + <p class="text-surface-700 dark:text-surface-300"> 36 + Because Margin is built on the AT Protocol, Your Content is stored as records on your Personal Data Server (PDS). It is portable and exportable at any time — we do not lock you in. 37 + </p> 38 + </section> 39 + 40 + <section class="mb-8"> 41 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Public Notes</h2> 42 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 43 + Notes you create are public by default on the AT Protocol network and may be visible to other users and services that interact with the network. Do not include sensitive or private information in your notes if you do not want it to be publicly visible. 27 44 </p> 28 45 </section> 29 46 ··· 33 50 You are responsible for your use of the Service and for any content you provide, including compliance with applicable laws, rules, and regulations. 34 51 </p> 35 52 <p class="text-surface-700 dark:text-surface-300 mb-4"> 36 - We reserve the right to remove any content that violates these terms, including but not limited to: 53 + We reserve the right to remove content or suspend access for violations of these Terms, including but not limited to: 37 54 </p> 38 55 <ul class="list-disc pl-5 mb-4 text-surface-700 dark:text-surface-300 space-y-1"> 39 56 <li>Illegal content</li> ··· 43 60 </section> 44 61 45 62 <section class="mb-8"> 46 - <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Decentralized Nature</h2> 63 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">The AT Protocol</h2> 47 64 <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 48 - Margin interacts with the AT Protocol network. We do not control the network itself or the data stored on your Personal Data Server (PDS). Please refer to the terms of your PDS provider for data storage policies. 65 + Margin interacts with the AT Protocol network. We do not control the network itself, other services on the network, or the data stored on your Personal Data Server. Your PDS provider's own terms govern data stored there. Content published to the AT Protocol is public and may be indexed by other services beyond Margin's control. 49 66 </p> 50 67 </section> 51 68 52 69 <section class="mb-8"> 53 - <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Disclaimer</h2> 54 - <p class="text-surface-700 dark:text-surface-300 leading-relaxed uppercase"> 55 - THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE". WE DISCLAIM ALL CONDITIONS, REPRESENTATIONS AND WARRANTIES NOT EXPRESSLY SET OUT IN THESE TERMS. 70 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Disclaimer of Warranties</h2> 71 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 72 + The Service is provided "as is" and "as available" without warranties of any kind, either express or implied, including but not limited to implied warranties of merchantability, fitness for a particular purpose, or non-infringement. We do not warrant that the Service will be uninterrupted, error-free, or free of harmful components. 73 + </p> 74 + </section> 75 + 76 + <section class="mb-8"> 77 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Limitation of Liability</h2> 78 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 79 + To the fullest extent permitted by law, Padding Labs LLC shall not be liable for any indirect, incidental, special, consequential, or punitive damages arising out of or related to your use of the Service. Because Margin is provided free of charge, our total aggregate liability to you shall not exceed $0. 80 + </p> 81 + </section> 82 + 83 + <section class="mb-8"> 84 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Governing Law</h2> 85 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 86 + These Terms are governed by the laws of the State of Wyoming, without regard to its conflict of law provisions. 87 + </p> 88 + </section> 89 + 90 + <section class="mb-8"> 91 + <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Changes to These Terms</h2> 92 + <p class="text-surface-700 dark:text-surface-300 leading-relaxed"> 93 + We may update these Terms from time to time. We will note the date of the last update at the top of this page. Continued use of the Service after changes constitutes acceptance of the updated Terms. 56 94 </p> 57 95 </section> 58 96 59 97 <section class="mb-8"> 60 98 <h2 class="text-xl font-bold text-surface-900 dark:text-white mb-4">Contact</h2> 61 99 <p class="text-surface-700 dark:text-surface-300"> 62 - For questions about these Terms, please contact us at <a href="mailto:hello@margin.at" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">hello@margin.at</a> 100 + For questions about these Terms, contact us at <a href="mailto:hello@margin.at" class="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline">hello@margin.at</a> 63 101 </p> 64 102 </section> 65 103 </div>
+7
web/src/store/auth.ts
··· 1 1 import { atom } from "nanostores"; 2 2 import { loadPreferences } from "./preferences"; 3 3 import type { UserProfile } from "../types"; 4 + import { analytics } from "../lib/analytics"; 4 5 5 6 export const $user = atom<UserProfile | null>(null); 6 7 7 8 $user.subscribe((user) => { 8 9 if (user) { 9 10 loadPreferences(); 11 + analytics.identify(user.did, { 12 + handle: user.handle, 13 + displayName: user.displayName, 14 + }); 10 15 } 11 16 }); 12 17 13 18 export function logout() { 19 + analytics.capture("user_logged_out"); 20 + analytics.reset(); 14 21 $user.set(null); 15 22 fetch("/auth/logout", { method: "POST" }).finally(() => { 16 23 window.location.href = "/";
+15
web/src/store/preferences.ts
··· 11 11 subscribedLabelers: LabelerSubscription[]; 12 12 labelPreferences: LabelPreference[]; 13 13 disableExternalLinkWarning: boolean; 14 + enableCommunityBookmarks: boolean; 14 15 } 15 16 16 17 export const $preferences = atom<Preferences>({ ··· 18 19 subscribedLabelers: [], 19 20 labelPreferences: [], 20 21 disableExternalLinkWarning: false, 22 + enableCommunityBookmarks: true, 21 23 }); 22 24 23 25 export async function loadPreferences() { ··· 27 29 subscribedLabelers: prefs.subscribedLabelers || [], 28 30 labelPreferences: prefs.labelPreferences || [], 29 31 disableExternalLinkWarning: !!prefs.disableExternalLinkWarning, 32 + enableCommunityBookmarks: !!prefs.enableCommunityBookmarks, 30 33 }); 31 34 } 32 35 ··· 107 110 $preferences.set(updated); 108 111 await updatePreferences(updated); 109 112 } 113 + 114 + export async function setEnableCommunityBookmarks(enabled: boolean) { 115 + const current = $preferences.get(); 116 + if (current.enableCommunityBookmarks === enabled) return; 117 + 118 + const updated = { 119 + ...current, 120 + enableCommunityBookmarks: enabled, 121 + }; 122 + $preferences.set(updated); 123 + await updatePreferences(updated); 124 + }
+15
web/src/views/AppShell.tsx
··· 23 23 import RightSidebar from "../components/navigation/RightSidebar"; 24 24 import Sidebar from "../components/navigation/Sidebar"; 25 25 import { $user } from "../store/auth"; 26 + import { analytics } from "../lib/analytics"; 27 + 26 28 import AdminModeration from "./core/AdminModeration"; 27 29 import Discover from "./core/Discover"; 28 30 import Feed from "./core/Feed"; ··· 112 114 useEffect(() => { 113 115 document.title = PAGE_TITLES[location.pathname] ?? "Margin"; 114 116 }, [location.pathname]); 117 + 118 + useEffect(() => { 119 + if (searchParams.get("logged_in") !== "true") return; 120 + const user = $user.get(); 121 + analytics.capture("login_success", { 122 + handle: user?.handle ?? "", 123 + pds: undefined, 124 + }); 125 + const url = new URL(window.location.href); 126 + url.searchParams.delete("logged_in"); 127 + window.history.replaceState({}, "", url.toString()); 128 + // eslint-disable-next-line react-hooks/exhaustive-deps 129 + }, [location.search]); 115 130 116 131 useEffect(() => { 117 132 const SERVER_PATHS = [
+5
web/src/views/auth/Login.tsx
··· 9 9 import { Avatar } from "../../components/ui"; 10 10 import { useStore } from "@nanostores/react"; 11 11 import { $theme } from "../../store/theme"; 12 + import { analytics } from "../../lib/analytics"; 12 13 13 14 interface LoginProps { 14 15 initialError?: string; ··· 36 37 "AT Protocol", 37 38 "Margin", 38 39 "Bluesky", 40 + "Eurosky", 39 41 "Blacksky", 40 42 "Tangled", 41 43 "Northsky", 44 + "selfhosted.social", 42 45 "witchcraft.systems", 43 46 "tophhie.social", 44 47 "altq.net", ··· 135 138 setError(null); 136 139 137 140 try { 141 + analytics.capture("login_initiated", { handle: handle.trim() }); 138 142 const result = await startLogin(handle.trim()); 139 143 if (result.authorizationUrl) { 140 144 const url = new URL(result.authorizationUrl); ··· 144 148 } 145 149 } catch (err) { 146 150 const message = err instanceof Error ? err.message : "Unknown error"; 151 + analytics.captureException(err); 147 152 setError(message || "Failed to initiate login. Please try again."); 148 153 setLoading(false); 149 154 }
+5
web/src/views/collections/Collections.tsx
··· 16 16 import { formatDistanceToNow } from "date-fns"; 17 17 import { clsx } from "clsx"; 18 18 import { Button, Input, EmptyState, Skeleton } from "../../components/ui"; 19 + import { analytics } from "../../lib/analytics"; 19 20 20 21 const collectionsCache = { 21 22 data: null as Collection[] | null, ··· 97 98 setNewItemIcon("folder"); 98 99 setActiveTab("icon"); 99 100 fetchCollections(); 101 + analytics.capture("collection_created", { 102 + has_description: !!newItemDesc.trim(), 103 + }); 100 104 } 101 105 setCreating(false); 102 106 }; ··· 112 116 collectionsCache.timestamp = Date.now(); 113 117 return updated; 114 118 }); 119 + analytics.capture("collection_deleted"); 115 120 } 116 121 } 117 122 };
+5
web/src/views/content/AnnotationDetail.tsx
··· 20 20 AlertTriangle, 21 21 } from "lucide-react"; 22 22 import { getAvatarUrl } from "../../api/client"; 23 + import { analytics } from "../../lib/analytics"; 23 24 24 25 interface AnnotationDetailProps { 25 26 handle?: string; ··· 159 160 replyText, 160 161 ); 161 162 163 + analytics.capture("reply_created", { 164 + parent_uri: parentUri, 165 + root_uri: targetUri, 166 + }); 162 167 setReplyText(""); 163 168 setReplyingTo(null); 164 169 await refreshReplies();
+7
web/src/views/core/HighlightImporter.tsx
··· 9 9 import { useRef, useState } from "react"; 10 10 import { createHighlight } from "../../api/client"; 11 11 import type { Selector } from "../../types"; 12 + import { analytics } from "../../lib/analytics"; 12 13 13 14 interface Highlight { 14 15 url: string; ··· 214 215 await new Promise((resolve) => setTimeout(resolve, 500)); 215 216 } 216 217 218 + analytics.capture("highlights_imported", { 219 + total: importState.total, 220 + completed: importState.completed, 221 + failed: importState.failed, 222 + }); 217 223 setIsImporting(false); 218 224 } catch (error) { 225 + analytics.captureException(error); 219 226 alert( 220 227 `Error parsing CSV: ${error instanceof Error ? error.message : "Unknown error"}`, 221 228 );
+2
web/src/views/core/Search.tsx
··· 16 16 import LayoutToggle from "../../components/ui/LayoutToggle"; 17 17 import { $user } from "../../store/auth"; 18 18 import { $feedLayout } from "../../store/feedLayout"; 19 + import { analytics } from "../../lib/analytics"; 19 20 20 21 const searchCache = new Map< 21 22 string, ··· 165 166 const url = new URL(window.location.href); 166 167 url.searchParams.set("q", query.trim()); 167 168 window.history.replaceState({}, "", url.toString()); 169 + analytics.capture("search_performed", { query: query.trim() }); 168 170 doSearch(query.trim()); 169 171 } 170 172 };
+24 -1
web/src/views/core/Settings.tsx
··· 10 10 setLabelVisibility, 11 11 getLabelVisibility, 12 12 setDisableExternalLinkWarning, 13 + setEnableCommunityBookmarks, 13 14 } from "../../store/preferences"; 14 15 import { 15 16 getAPIKeys, ··· 61 62 import { AppleIcon } from "../../components/common/Icons"; 62 63 import { HighlightImporter } from "./HighlightImporter"; 63 64 import IOSShortcutModal from "../../components/modals/IOSShortcutModal"; 65 + import { analytics } from "../../lib/analytics"; 64 66 65 67 export default function Settings() { 66 68 const user = useStore($user); ··· 115 117 setKeys([res, ...keys]); 116 118 setCreatedKey(res.key || null); 117 119 setNewKeyName(""); 120 + analytics.capture("api_key_created"); 118 121 } 119 122 setCreating(false); 120 123 }; ··· 178 181 {themeOptions.map((opt) => ( 179 182 <button 180 183 key={opt.value} 181 - onClick={() => setTheme(opt.value)} 184 + onClick={() => { 185 + setTheme(opt.value); 186 + analytics.capture("theme_changed", { 187 + theme: opt.value, 188 + }); 189 + }} 182 190 className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all ${ 183 191 theme === opt.value 184 192 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20" ··· 214 222 <Switch 215 223 checked={preferences.disableExternalLinkWarning} 216 224 onCheckedChange={setDisableExternalLinkWarning} 225 + /> 226 + </div> 227 + 228 + <div className="mt-6 flex items-center justify-between"> 229 + <div> 230 + <h3 className="text-sm font-medium text-surface-900 dark:text-white"> 231 + Share bookmarks to community feed 232 + </h3> 233 + <p className="text-sm text-surface-500 dark:text-surface-400"> 234 + Your saved bookmarks will appear in the community bookmarks feed 235 + </p> 236 + </div> 237 + <Switch 238 + checked={preferences.enableCommunityBookmarks} 239 + onCheckedChange={setEnableCommunityBookmarks} 217 240 /> 218 241 </div> 219 242 </section>