From 9693fbf5aada582900ca13fa62b1e2713963e3df Mon Sep 17 00:00:00 2001 From: scanash00 Date: Tue, 14 Apr 2026 23:07:05 -0800 Subject: [PATCH] Analytics, new lexicon, improvements! --- .env.example | 12 +- Dockerfile | 2 +- backend/cmd/server/main.go | 16 +- backend/go.mod | 16 +- backend/go.sum | 52 +- backend/internal/analytics/posthog.go | 73 ++ backend/internal/api/annotations_helpers.go | 60 - backend/internal/api/apikey.go | 206 ++- backend/internal/api/handler.go | 1157 ++++++----------- .../internal/api/{annotations.go => notes.go} | 622 ++++++--- backend/internal/api/notes_helpers.go | 103 ++ backend/internal/api/preferences.go | 10 +- backend/internal/api/token_refresh.go | 2 +- backend/internal/db/db.go | 797 +----------- backend/internal/db/filter.go | 230 ++++ backend/internal/db/migrate.go | 46 + .../db/migrations/00001_core_tables.sql | 204 +++ .../db/migrations/00002_support_tables.sql | 131 ++ .../db/migrations/00003_alter_columns.sql | 43 + .../internal/db/migrations/00004_views.sql | 33 + .../db/migrations/00005_recommendations.sql | 29 + backend/internal/db/queries_annotations.go | 40 +- backend/internal/db/queries_bookmarks.go | 36 +- backend/internal/db/queries_highlights.go | 38 +- backend/internal/db/queries_notes.go | 130 ++ backend/internal/db/queries_profiles.go | 180 +++ .../internal/db/queries_recommendations.go | 40 - backend/internal/db/queries_sessions.go | 9 + backend/internal/domain/filters.go | 22 + backend/internal/domain/interfaces.go | 65 + backend/internal/domain/models.go | 212 +++ backend/internal/firehose/ingester.go | 158 +++ backend/internal/middleware/logger.go | 26 +- backend/internal/oauth/handler.go | 34 +- .../repository/postgres/pg_engagement_repo.go | 200 +++ .../repository/postgres/pg_note_repo.go | 342 +++++ .../postgres/pg_notification_repo.go | 63 + .../repository/postgres/pg_profile_repo.go | 98 ++ .../repository/postgres/pg_session_repo.go | 23 + backend/internal/service/feed.go | 128 ++ backend/internal/service/hydration.go | 298 +++++ backend/internal/service/profile.go | 80 ++ backend/internal/verification/verify.go | 4 +- backend/internal/xrpc/client.go | 47 + backend/internal/xrpc/records.go | 228 ++-- extension/src/components/popup/App.tsx | 85 +- extension/src/components/sidepanel/App.tsx | 7 +- extension/src/entrypoints/background.ts | 53 +- extension/src/utils/analytics.ts | 38 + extension/src/utils/api.ts | 87 +- extension/src/utils/overlay.ts | 399 ++++-- lexicons/at/margin/authFull.json | 6 +- lexicons/at/margin/bookmark.json | 79 -- lexicons/at/margin/highlight.json | 69 - .../at/margin/{annotation.json => note.json} | 162 +-- lexicons/at/margin/preferences.json | 5 + web/astro.config.mjs | 9 + web/src/api/client.ts | 2 + web/src/components/common/Card.tsx | 36 +- web/src/components/feed/Composer.tsx | 13 + web/src/components/feed/ReplyList.tsx | 4 +- .../modals/AddToCollectionModal.tsx | 2 + web/src/components/modals/ShareMenu.tsx | 11 + web/src/components/modals/SignUpModal.tsx | 5 + web/src/components/posthog.astro | 13 + web/src/env.d.ts | 5 + web/src/layouts/BaseLayout.astro | 2 + web/src/lib/analytics.ts | 114 ++ web/src/middleware.ts | 7 +- web/src/pages/privacy.astro | 110 +- web/src/pages/terms.astro | 58 +- web/src/store/auth.ts | 7 + web/src/store/preferences.ts | 15 + web/src/views/AppShell.tsx | 15 + web/src/views/auth/Login.tsx | 5 + web/src/views/collections/Collections.tsx | 5 + web/src/views/content/AnnotationDetail.tsx | 5 + web/src/views/core/HighlightImporter.tsx | 7 + web/src/views/core/Search.tsx | 2 + web/src/views/core/Settings.tsx | 25 +- 80 files changed, 5192 insertions(+), 2620 deletions(-) create mode 100644 backend/internal/analytics/posthog.go delete mode 100644 backend/internal/api/annotations_helpers.go rename backend/internal/api/{annotations.go => notes.go} (56%) create mode 100644 backend/internal/api/notes_helpers.go create mode 100644 backend/internal/db/filter.go create mode 100644 backend/internal/db/migrate.go create mode 100644 backend/internal/db/migrations/00001_core_tables.sql create mode 100644 backend/internal/db/migrations/00002_support_tables.sql create mode 100644 backend/internal/db/migrations/00003_alter_columns.sql create mode 100644 backend/internal/db/migrations/00004_views.sql create mode 100644 backend/internal/db/migrations/00005_recommendations.sql create mode 100644 backend/internal/db/queries_notes.go create mode 100644 backend/internal/db/queries_profiles.go create mode 100644 backend/internal/domain/filters.go create mode 100644 backend/internal/domain/interfaces.go create mode 100644 backend/internal/domain/models.go create mode 100644 backend/internal/repository/postgres/pg_engagement_repo.go create mode 100644 backend/internal/repository/postgres/pg_note_repo.go create mode 100644 backend/internal/repository/postgres/pg_notification_repo.go create mode 100644 backend/internal/repository/postgres/pg_profile_repo.go create mode 100644 backend/internal/repository/postgres/pg_session_repo.go create mode 100644 backend/internal/service/feed.go create mode 100644 backend/internal/service/hydration.go create mode 100644 backend/internal/service/profile.go create mode 100644 extension/src/utils/analytics.ts delete mode 100644 lexicons/at/margin/bookmark.json delete mode 100644 lexicons/at/margin/highlight.json rename lexicons/at/margin/{annotation.json => note.json} (58%) create mode 100644 web/src/components/posthog.astro create mode 100644 web/src/lib/analytics.ts diff --git a/.env.example b/.env.example index 901afc2..3043274 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# Margin Server Configuration +# Margin Configuration # Server PORT=8080 BASE_URL=https://example.com -# Database (SQLite file path or PostgreSQL connection string) -DATABASE_URL=margin.db +# Database (PostgreSQL connection string) +DATABASE_URL=postgres://user:pass@localhost:5432/margin # Static Files (path to built frontend) STATIC_DIR=../web/dist @@ -13,8 +13,12 @@ STATIC_DIR=../web/dist # OAuth private key for signing requests (auto-generated if missing) OAUTH_KEY_PATH=/data/oauth_private_key.pem - # Optional: Override default ATProto network URLs (you probably don't need these) # BSKY_PUBLIC_API=https://public.api.bsky.app # PLC_DIRECTORY_URL=https://plc.directory # BLOCK_RELAY_URL=wss://jetstream2.us-east.bsky.network/subscribe + +# Analytics (PostHog) — leave blank to disable +# POSTHOG_PROJECT_TOKEN=phc_ +# POSTHOG_HOST= +# POSTHOG_UI_HOST=https://us.posthog.com diff --git a/Dockerfile b/Dockerfile index b4e2902..d569d70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ RUN bun install COPY web/ ./ RUN bun run build -FROM golang:1.24-alpine AS backend-builder +FROM golang:1.25-alpine AS backend-builder RUN apk add --no-cache gcc musl-dev diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b9e4367..83ef349 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/cors" "github.com/joho/godotenv" + "margin.at/internal/analytics" "margin.at/internal/api" "margin.at/internal/db" "margin.at/internal/embeddings" @@ -41,6 +42,7 @@ func main() { if err := database.Migrate(); err != nil { logger.Fatal("Failed to run migrations: %v", err) } + database.MigrateUnifiedNotes() go func() { ticker := time.NewTicker(1 * time.Hour) @@ -58,15 +60,15 @@ func main() { }() embeddingClient := embeddings.NewClient() - if err := database.MigrateRecommendations(); err != nil { - logger.Fatal("Failed to run recommendation migrations: %v", err) - } recService := recommendations.NewService(database, embeddingClient) logger.Info("Recommendation engine initialized (embeddings enabled: %v)", embeddingClient.IsEnabled()) syncSvc := sync.NewService(database) - oauthHandler, err := oauth.NewHandler(database, syncSvc) + analyticsCl := analytics.New() + defer analyticsCl.Close() + + oauthHandler, err := oauth.NewHandler(database, syncSvc, analyticsCl) if err != nil { logger.Fatal("Failed to initialize OAuth: %v", err) } @@ -157,9 +159,9 @@ func main() { })) tokenRefresher := api.NewTokenRefresher(database, oauthHandler.GetPrivateKey()) - annotationSvc := api.NewAnnotationService(database, tokenRefresher) + noteWriteSvc := api.NewNoteWriteService(database, tokenRefresher) - handler := api.NewHandler(database, annotationSvc, tokenRefresher, syncSvc, recService) + handler := api.NewHandler(database, noteWriteSvc, tokenRefresher, syncSvc, recService, analyticsCl) handler.RegisterRoutes(r) r.Route("/auth", func(r chi.Router) { @@ -171,7 +173,7 @@ func main() { r.Post("/logout", oauthHandler.HandleLogout) r.Get("/session", oauthHandler.HandleSession) }) - r.Get("/client-metadata.json", oauthHandler.HandleClientMetadata) + r.Get("/oauth-client-metadata.json", oauthHandler.HandleClientMetadata) r.Get("/jwks.json", oauthHandler.HandleJWKS) port := getEnv("PORT", "8081") diff --git a/backend/go.mod b/backend/go.mod index 69778a7..1288888 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module margin.at -go 1.24.0 +go 1.25.0 require ( github.com/fxamacker/cbor/v2 v2.9.0 @@ -12,11 +12,17 @@ require ( github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/multiformats/go-multihash v0.2.3 + github.com/posthog/posthog-go v1.11.2 + github.com/pressly/goose/v3 v3.27.0 ) require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/minio/sha256-simd v1.0.0 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.0.3 // indirect @@ -24,10 +30,12 @@ require ( github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-varint v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/x448/float16 v0.8.4 // indirect - golang.org/x/crypto v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect lukechampine.com/blake3 v1.1.6 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 84dca23..72382c0 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= @@ -8,10 +10,16 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -21,6 +29,10 @@ github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBF github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -35,19 +47,43 @@ github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7B github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q= +github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= +github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= +github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c= lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/backend/internal/analytics/posthog.go b/backend/internal/analytics/posthog.go new file mode 100644 index 0000000..b24554b --- /dev/null +++ b/backend/internal/analytics/posthog.go @@ -0,0 +1,73 @@ +package analytics + +import ( + "os" + + "github.com/posthog/posthog-go" + "margin.at/internal/logger" +) + +type Client struct { + ph posthog.Client + enabled bool +} + +func New() *Client { + token := os.Getenv("POSTHOG_PROJECT_TOKEN") + if token == "" { + logger.Info("PostHog analytics disabled (POSTHOG_PROJECT_TOKEN not set)") + return &Client{} + } + + host := os.Getenv("POSTHOG_HOST") + if host == "" { + host = "https://us.i.posthog.com" + } + + ph, err := posthog.NewWithConfig(token, posthog.Config{ + Endpoint: host, + }) + if err != nil { + logger.Error("Failed to initialise PostHog client: %v", err) + return &Client{} + } + + logger.Info("PostHog analytics enabled (host: %s)", host) + return &Client{ph: ph, enabled: true} +} + +func (c *Client) Capture(distinctID, event string, properties map[string]interface{}) { + if !c.enabled || c.ph == nil { + return + } + props := posthog.NewProperties() + for k, v := range properties { + props.Set(k, v) + } + + _ = c.ph.Enqueue(posthog.Capture{ + DistinctId: distinctID, + Event: event, + Properties: props, + }) +} + +func (c *Client) Identify(distinctID string, properties map[string]interface{}) { + if !c.enabled || c.ph == nil { + return + } + traits := posthog.NewProperties() + for k, v := range properties { + traits.Set(k, v) + } + _ = c.ph.Enqueue(posthog.Identify{ + DistinctId: distinctID, + Properties: traits, + }) +} + +func (c *Client) Close() { + if c.enabled && c.ph != nil { + c.ph.Close() + } +} diff --git a/backend/internal/api/annotations_helpers.go b/backend/internal/api/annotations_helpers.go deleted file mode 100644 index 655b02c..0000000 --- a/backend/internal/api/annotations_helpers.go +++ /dev/null @@ -1,60 +0,0 @@ -package api - -import ( - "encoding/json" - "time" - - "margin.at/internal/db" -) - -func (s *AnnotationService) checkDuplicateAnnotation(did, url, text string) (*db.Annotation, error) { - recentAnnos, err := s.db.GetAnnotationsByAuthor(did, 5, 0) - if err != nil { - return nil, err - } - for _, a := range recentAnnos { - if a.TargetSource == url && - ((a.BodyValue == nil && text == "") || (a.BodyValue != nil && *a.BodyValue == text)) && - time.Since(a.CreatedAt) < 10*time.Second { - return &a, nil - } - } - return nil, nil -} - -func (s *AnnotationService) checkDuplicateHighlight(did, url string, selector json.RawMessage) (*db.Highlight, error) { - recentHighs, err := s.db.GetHighlightsByAuthor(did, 5, 0) - if err != nil { - return nil, err - } - for _, h := range recentHighs { - matchSelector := false - if h.SelectorJSON == nil && selector == nil { - matchSelector = true - } else if h.SelectorJSON != nil && selector != nil { - selectorBytes, _ := json.Marshal(selector) - if *h.SelectorJSON == string(selectorBytes) { - matchSelector = true - } - } - - if h.TargetSource == url && matchSelector && time.Since(h.CreatedAt) < 10*time.Second { - return &h, nil - } - } - return nil, nil -} - -func (s *AnnotationService) checkDuplicateBookmark(did, url string) (*db.Bookmark, error) { - urlHash := db.HashURL(url) - bookmarks, err := s.db.GetBookmarksByTargetHash(urlHash, 50, 0) - if err != nil { - return nil, err - } - for _, b := range bookmarks { - if b.AuthorDID == did && b.Source == url { - return &b, nil - } - } - return nil, nil -} diff --git a/backend/internal/api/apikey.go b/backend/internal/api/apikey.go index f871039..2c292de 100644 --- a/backend/internal/api/apikey.go +++ b/backend/internal/api/apikey.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/rand" "crypto/sha256" "crypto/x509" @@ -154,9 +155,10 @@ func (h *APIKeyHandler) DeleteKey(w http.ResponseWriter, r *http.Request) { } type QuickBookmarkRequest struct { - URL string `json:"url"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` + URL string `json:"url"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` } func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { @@ -179,52 +181,98 @@ func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { session, err := h.getSessionByDID(apiKey.OwnerDID) if err != nil { + logger.Error("[QuickBookmark] session lookup failed for DID %s: %v", apiKey.OwnerDID, err) WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") return } + for i, t := range req.Tags { + req.Tags[i] = strings.ToLower(t) + } + urlHash := db.HashURL(req.URL) - record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) + record := xrpc.NewNoteRecord(req.URL, urlHash, "", nil, req.Title, "", req.Description, "bookmarking") + if len(req.Tags) > 0 { + record.Tags = req.Tags + } if err := record.Validate(); err != nil { + logger.Error("[QuickBookmark] record validation failed for DID %s url=%s: %v", apiKey.OwnerDID, req.URL, err) WriteBadRequest(w, "Validation error: "+err.Error()) return } + logger.Info("[QuickBookmark] creating record for DID %s url=%s", apiKey.OwnerDID, req.URL) + var result *xrpc.CreateRecordOutput err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err != nil { + logger.Error("[QuickBookmark] PDS CreateRecord failed for DID %s url=%s: %v", apiKey.OwnerDID, req.URL, err) WriteInternalError(w, "Failed to create bookmark") return } + logger.Info("[QuickBookmark] created record URI=%s for DID %s", result.URI, apiKey.OwnerDID) + h.db.UpdateAPIKeyLastUsed(apiKey.ID) - var titlePtr, descPtr *string + capturedTags := append([]string(nil), req.Tags...) + go func(did, url string) { + prefs, dbErr := h.db.GetPreferences(did) + communityEnabled := dbErr == nil && prefs != nil && (prefs.EnableCommunityBookmarks == nil || *prefs.EnableCommunityBookmarks) + if !communityEnabled { + return + } + sess, err := h.getSessionByDID(did) + if err != nil { + return + } + client := h.refresher.CreateClientFromSession(sess) + communityRecord := map[string]interface{}{ + "$type": xrpc.CollectionCommunityBookmark, + "subject": url, + "createdAt": time.Now().UTC().Format(time.RFC3339), + } + if len(capturedTags) > 0 { + communityRecord["tags"] = capturedTags + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = client.CreateRecord(ctx, did, xrpc.CollectionCommunityBookmark, communityRecord) + }(apiKey.OwnerDID, req.URL) + + var titlePtr, bodyValuePtr, tagsJSONPtr *string if req.Title != "" { titlePtr = &req.Title } if req.Description != "" { - descPtr = &req.Description + bodyValuePtr = &req.Description + } + if len(req.Tags) > 0 { + b, _ := json.Marshal(req.Tags) + s := string(b) + tagsJSONPtr = &s } cid := result.CID - bookmark := &db.Bookmark{ - URI: result.URI, - AuthorDID: apiKey.OwnerDID, - Source: req.URL, - SourceHash: urlHash, - Title: titlePtr, - Description: descPtr, - CreatedAt: time.Now(), - IndexedAt: time.Now(), - CID: &cid, - } - h.db.CreateBookmark(bookmark) + note := &db.Note{ + URI: result.URI, + AuthorDID: apiKey.OwnerDID, + Motivation: "bookmarking", + TargetSource: req.URL, + TargetHash: urlHash, + TargetTitle: titlePtr, + BodyValue: bodyValuePtr, + TagsJSON: tagsJSONPtr, + CreatedAt: time.Now(), + IndexedAt: time.Now(), + CID: &cid, + } + h.db.CreateNote(note) WriteSuccess(w, map[string]string{ "uri": result.URI, @@ -236,8 +284,10 @@ func (h *APIKeyHandler) QuickBookmark(w http.ResponseWriter, r *http.Request) { type QuickSaveRequest struct { URL string `json:"url"` Text string `json:"text,omitempty"` + Title string `json:"title,omitempty"` Selector json.RawMessage `json:"selector,omitempty"` Color string `json:"color,omitempty"` + Tags []string `json:"tags,omitempty"` } func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { @@ -274,12 +324,26 @@ func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { var result *xrpc.CreateRecordOutput var createErr error + for i, t := range req.Tags { + req.Tags[i] = strings.ToLower(t) + } + + var tagsJSONPtr *string + if len(req.Tags) > 0 { + b, _ := json.Marshal(req.Tags) + s := string(b) + tagsJSONPtr = &s + } + if isHighlight { color := req.Color if color == "" { color = "yellow" } - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) + record := xrpc.NewNoteRecord(req.URL, urlHash, "", req.Selector, req.Title, color, "", "highlighting") + if len(req.Tags) > 0 { + record.Tags = req.Tags + } if err := record.Validate(); err != nil { WriteBadRequest(w, "Validation error: "+err.Error()) @@ -287,7 +351,7 @@ func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { } err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err == nil { @@ -296,26 +360,37 @@ func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { selectorStr := string(selectorJSON) colorPtr := &color - highlight := &db.Highlight{ + var titlePtr *string + if req.Title != "" { + titlePtr = &req.Title + } + + note := &db.Note{ URI: result.URI, AuthorDID: apiKey.OwnerDID, + Motivation: "highlighting", TargetSource: req.URL, TargetHash: urlHash, + TargetTitle: titlePtr, SelectorJSON: &selectorStr, Color: colorPtr, + TagsJSON: tagsJSONPtr, CreatedAt: time.Now(), IndexedAt: time.Now(), CID: &result.CID, } go func() { - if err := h.db.CreateHighlight(highlight); err != nil { - fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) + if err := h.db.CreateNote(note); err != nil { + logger.Error("Warning: failed to index highlight note in local DB: %v", err) } }() } } else { - record := xrpc.NewAnnotationRecord(req.URL, urlHash, req.Text, req.Selector, "") + record := xrpc.NewNoteRecord(req.URL, urlHash, req.Text, req.Selector, req.Title, "", "", "commenting") + if len(req.Tags) > 0 { + record.Tags = req.Tags + } if err := record.Validate(); err != nil { WriteBadRequest(w, "Validation error: "+err.Error()) @@ -323,7 +398,7 @@ func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { } err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err == nil { @@ -336,26 +411,30 @@ func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { selectorStrPtr = &s } - bodyValue := req.Text - var bodyValuePtr *string - if bodyValue != "" { - bodyValuePtr = &bodyValue + var bodyValuePtr, titlePtr *string + if req.Text != "" { + bodyValuePtr = &req.Text + } + if req.Title != "" { + titlePtr = &req.Title } - annotation := &db.Annotation{ + note := &db.Note{ URI: result.URI, AuthorDID: apiKey.OwnerDID, Motivation: "commenting", BodyValue: bodyValuePtr, TargetSource: req.URL, TargetHash: urlHash, + TargetTitle: titlePtr, SelectorJSON: selectorStrPtr, + TagsJSON: tagsJSONPtr, CreatedAt: time.Now(), IndexedAt: time.Now(), CID: &result.CID, } go func() { - h.db.CreateAnnotation(annotation) + h.db.CreateNote(note) }() } } @@ -374,8 +453,10 @@ func (h *APIKeyHandler) QuickSave(w http.ResponseWriter, r *http.Request) { type QuickHighlightRequest struct { URL string `json:"url"` + Title string `json:"title,omitempty"` Selector interface{} `json:"selector"` Color string `json:"color,omitempty"` + Tags []string `json:"tags,omitempty"` } func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { @@ -402,13 +483,20 @@ func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { return } + for i, t := range req.Tags { + req.Tags[i] = strings.ToLower(t) + } + urlHash := db.HashURL(req.URL) color := req.Color if color == "" { color = "yellow" } - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, color, nil) + record := xrpc.NewNoteRecord(req.URL, urlHash, "", req.Selector, req.Title, color, "", "highlighting") + if len(req.Tags) > 0 { + record.Tags = req.Tags + } if err := record.Validate(); err != nil { WriteBadRequest(w, "Validation error: "+err.Error()) @@ -418,7 +506,7 @@ func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { var result *xrpc.CreateRecordOutput err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err != nil { @@ -432,19 +520,32 @@ func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { selectorStr := string(selectorJSON) colorPtr := &color - highlight := &db.Highlight{ + var titlePtr, tagsJSONPtr *string + if req.Title != "" { + titlePtr = &req.Title + } + if len(req.Tags) > 0 { + b, _ := json.Marshal(req.Tags) + s := string(b) + tagsJSONPtr = &s + } + + note := &db.Note{ URI: result.URI, AuthorDID: apiKey.OwnerDID, + Motivation: "highlighting", TargetSource: req.URL, TargetHash: urlHash, + TargetTitle: titlePtr, SelectorJSON: &selectorStr, Color: colorPtr, + TagsJSON: tagsJSONPtr, CreatedAt: time.Now(), IndexedAt: time.Now(), CID: &result.CID, } - if err := h.db.CreateHighlight(highlight); err != nil { - fmt.Printf("Warning: failed to index highlight in local DB: %v\n", err) + if err := h.db.CreateNote(note); err != nil { + logger.Error("Warning: failed to index highlight note in local DB: %v", err) } WriteSuccess(w, map[string]string{ @@ -454,6 +555,37 @@ func (h *APIKeyHandler) QuickHighlight(w http.ResponseWriter, r *http.Request) { }) } +func (h *APIKeyHandler) GetMe(w http.ResponseWriter, r *http.Request) { + apiKey, err := h.authenticateAPIKey(r) + if err == nil { + session, err := h.getSessionByDID(apiKey.OwnerDID) + if err != nil { + WriteUnauthorized(w, "User session not found. Please log in to margin.at first.") + return + } + WriteSuccess(w, map[string]string{ + "did": session.DID, + "handle": session.Handle, + }) + return + } + + token := r.Header.Get("X-Session-Token") + if token == "" { + WriteUnauthorized(w, "Unauthorized") + return + } + did, handle, _, _, _, err := h.db.GetSession(token) + if err != nil { + WriteUnauthorized(w, "Invalid session") + return + } + WriteSuccess(w, map[string]string{ + "did": did, + "handle": handle, + }) +} + func (h *APIKeyHandler) authenticateAPIKey(r *http.Request) (*db.APIKey, error) { auth := r.Header.Get("Authorization") if auth == "" { diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 7a2e3c7..5bebd64 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "sort" "strconv" "strings" "sync" @@ -15,10 +14,14 @@ import ( "github.com/go-chi/chi/v5" + "margin.at/internal/analytics" "margin.at/internal/config" "margin.at/internal/db" + "margin.at/internal/domain" "margin.at/internal/logger" "margin.at/internal/recommendations" + "margin.at/internal/repository/postgres" + "margin.at/internal/service" internal_sync "margin.at/internal/sync" "margin.at/internal/xrpc" ) @@ -77,28 +80,51 @@ func (c *urlMetaCache) evictLoop() { } type Handler struct { - db *db.DB - annotationService *AnnotationService - refresher *TokenRefresher - apiKeys *APIKeyHandler - syncService *internal_sync.Service - moderation *ModerationHandler - recommendations *recommendations.Service - metaCache *urlMetaCache - metaSem chan struct{} + db *db.DB + noteRepo domain.NoteRepository + engagementRepo domain.EngagementRepository + notificationRepo domain.NotificationRepository + sessionRepo domain.SessionRepository + noteWriter *NoteWriteService + refresher *TokenRefresher + apiKeys *APIKeyHandler + syncService *internal_sync.Service + moderation *ModerationHandler + recommendations *recommendations.Service + feedSvc *service.FeedService + hydration *service.HydrationService + metaCache *urlMetaCache + metaSem chan struct{} + analytics *analytics.Client } -func NewHandler(database *db.DB, annotationService *AnnotationService, refresher *TokenRefresher, syncService *internal_sync.Service, recService *recommendations.Service) *Handler { +func NewHandler(database *db.DB, noteWriter *NoteWriteService, refresher *TokenRefresher, syncService *internal_sync.Service, recService *recommendations.Service, ac *analytics.Client) *Handler { + noteRepo := postgres.NewNoteRepository(database.DB) + engagementRepo := postgres.NewEngagementRepository(database.DB) + notificationRepo := postgres.NewNotificationRepository(database.DB) + sessionRepo := postgres.NewSessionRepository(database.DB) + profileRepo := &fullProfileRepository{db: database} // rich resolution: cache → DB → bsky.social + profileSvc := service.NewProfileService(profileRepo) // service-lifetime TTL cache on top + hydration := service.NewHydrationService(engagementRepo, profileSvc) + feedSvc := service.NewFeedService(noteRepo, hydration, database) + return &Handler{ - db: database, - annotationService: annotationService, - refresher: refresher, - apiKeys: NewAPIKeyHandler(database, refresher), - syncService: syncService, - moderation: NewModerationHandler(database, refresher), - recommendations: recService, - metaCache: newURLMetaCache(), - metaSem: make(chan struct{}, 5), + db: database, + noteRepo: noteRepo, + engagementRepo: engagementRepo, + notificationRepo: notificationRepo, + sessionRepo: sessionRepo, + noteWriter: noteWriter, + refresher: refresher, + apiKeys: NewAPIKeyHandler(database, refresher), + syncService: syncService, + moderation: NewModerationHandler(database, refresher), + recommendations: recService, + feedSvc: feedSvc, + hydration: hydration, + metaCache: newURLMetaCache(), + metaSem: make(chan struct{}, 5), + analytics: ac, } } @@ -113,27 +139,27 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/annotations/feed", h.GetFeed) r.Get("/annotation", h.GetAnnotation) r.Get("/annotations/history", h.GetEditHistory) - r.Post("/annotations", h.annotationService.CreateAnnotation) - r.Put("/annotations", h.annotationService.UpdateAnnotation) - r.Delete("/annotations", h.annotationService.DeleteAnnotation) - r.Post("/annotations/like", h.annotationService.LikeAnnotation) - r.Delete("/annotations/like", h.annotationService.UnlikeAnnotation) - r.Post("/annotations/reply", h.annotationService.CreateReply) - r.Delete("/annotations/reply", h.annotationService.DeleteReply) + r.Post("/annotations", h.noteWriter.CreateAnnotation) + r.Put("/annotations", h.noteWriter.UpdateAnnotation) + r.Delete("/annotations", h.noteWriter.DeleteAnnotation) + r.Post("/annotations/like", h.noteWriter.LikeAnnotation) + r.Delete("/annotations/like", h.noteWriter.UnlikeAnnotation) + r.Post("/annotations/reply", h.noteWriter.CreateReply) + r.Delete("/annotations/reply", h.noteWriter.DeleteReply) r.Get("/replies", h.GetReplies) r.Get("/likes", h.GetLikeCount) // Highlights r.Get("/highlights", h.GetHighlights) - r.Post("/highlights", h.annotationService.CreateHighlight) - r.Put("/highlights", h.annotationService.UpdateHighlight) - r.Delete("/highlights", h.annotationService.DeleteHighlight) + r.Post("/highlights", h.noteWriter.CreateHighlight) + r.Put("/highlights", h.noteWriter.UpdateHighlight) + r.Delete("/highlights", h.noteWriter.DeleteAnnotation) // Bookmarks r.Get("/bookmarks", h.GetBookmarks) - r.Post("/bookmarks", h.annotationService.CreateBookmark) - r.Put("/bookmarks", h.annotationService.UpdateBookmark) - r.Delete("/bookmarks", h.annotationService.DeleteBookmark) + r.Post("/bookmarks", h.noteWriter.CreateBookmark) + r.Put("/bookmarks", h.noteWriter.UpdateBookmark) + r.Delete("/bookmarks", h.noteWriter.DeleteAnnotation) // Collections r.Post("/collections", collectionService.CreateCollection) @@ -148,6 +174,7 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // Targets & discovery r.Get("/targets", h.GetByTarget) + r.Get("/targets/hash", h.GetByTargetHash) r.Get("/discover", h.DiscoverForURL) r.Get("/url-metadata", h.GetURLMetadata) @@ -182,11 +209,13 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/sync", h.SyncAll) // API keys + r.Get("/me", h.apiKeys.GetMe) r.Post("/keys", h.apiKeys.CreateKey) r.Get("/keys", h.apiKeys.ListKeys) r.Delete("/keys/{id}", h.apiKeys.DeleteKey) r.Post("/quick/bookmark", h.apiKeys.QuickBookmark) r.Post("/quick/save", h.apiKeys.QuickSave) + r.Post("/quick/highlight", h.apiKeys.QuickHighlight) // Moderation r.Post("/moderation/block", h.moderation.BlockUser) @@ -208,6 +237,9 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // Admin r.Post("/admin/backfill", h.AdminBackfill) + + // Analytics proxy + r.Post("/analytics/capture", h.CaptureEvent) }) } @@ -216,473 +248,160 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { WriteSuccess(w, map[string]string{"status": "ok", "version": "1.0"}) } +func (h *Handler) CaptureEvent(w http.ResponseWriter, r *http.Request) { + var body struct { + Event string `json:"event"` + DistinctID string `json:"distinct_id"` + Properties map[string]interface{} `json:"properties"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + if body.Event == "" { + http.Error(w, "event is required", http.StatusBadRequest) + return + } + if body.DistinctID == "" { + body.DistinctID = "anonymous_extension" + } + if h.analytics != nil { + if body.Properties == nil { + body.Properties = map[string]interface{}{} + } + body.Properties["$lib"] = "margin-extension" + h.analytics.Capture(body.DistinctID, body.Event, body.Properties) + } + w.WriteHeader(http.StatusNoContent) +} + func (h *Handler) GetAnnotations(w http.ResponseWriter, r *http.Request) { source := r.URL.Query().Get("source") if source == "" { source = r.URL.Query().Get("url") } - limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) motivation := r.URL.Query().Get("motivation") tag := r.URL.Query().Get("tag") - var annotations []db.Annotation - var err error - + filter := db.NoteFilter{Limit: limit, Offset: offset} if source != "" { - urlHash := db.HashURL(source) - annotations, err = h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) - } else if motivation != "" { - annotations, err = h.db.GetAnnotationsByMotivation(motivation, limit, offset) - } else if tag != "" { - annotations, err = h.db.GetAnnotationsByTag(tag, limit, offset) - } else { - annotations, err = h.db.GetRecentAnnotations(limit, offset) + filter.TargetHash = db.HashURL(source) + } + if motivation != "" { + filter.Motivations = []string{motivation} + } + if tag != "" { + filter.Tag = tag } + notes, err := h.noteRepo.List(r.Context(), filter) if err != nil { WriteInternalError(w, "Internal server error") return } - enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "AnnotationCollection", - "items": enriched, - "totalItems": len(enriched), + "items": items, + "totalItems": len(items), }) } -func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { - limit := parseIntParam(r, "limit", 50) - offset := parseIntParam(r, "offset", 0) - tag := strings.ToLower(r.URL.Query().Get("tag")) - creator := r.URL.Query().Get("creator") - feedType := r.URL.Query().Get("type") - - viewerDID := h.getViewerDID(r) - - var annotations []db.Annotation - var highlights []db.Highlight - var bookmarks []db.Bookmark - var collectionItems []db.CollectionItem - var err error - - motivation := r.URL.Query().Get("motivation") - - fetchLimit := limit + offset - - perTypeFetchLimit := fetchLimit - if motivation == "" { - perTypeFetchLimit = fetchLimit/2 + 10 - } - - if tag != "" { - if creator != "" { - if motivation == "" || motivation == "commenting" { - switch feedType { - case "margin": - annotations, _ = h.db.GetMarginAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) - case "semble": - annotations, _ = h.db.GetSembleAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) - default: - annotations, _ = h.db.GetAnnotationsByTagAndAuthor(tag, creator, fetchLimit, 0) - } - } - if motivation == "" || motivation == "highlighting" { - switch feedType { - case "margin": - highlights, _ = h.db.GetMarginHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) - case "semble": - highlights, _ = h.db.GetSembleHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) - default: - highlights, _ = h.db.GetHighlightsByTagAndAuthor(tag, creator, fetchLimit, 0) - } - } - if motivation == "" || motivation == "bookmarking" { - switch feedType { - case "margin": - bookmarks, _ = h.db.GetMarginBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) - case "semble": - bookmarks, _ = h.db.GetSembleBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) - default: - bookmarks, _ = h.db.GetBookmarksByTagAndAuthor(tag, creator, fetchLimit, 0) - } - } - collectionItems = []db.CollectionItem{} - } else { - if motivation == "" || motivation == "commenting" { - switch feedType { - case "margin": - annotations, _ = h.db.GetMarginAnnotationsByTag(tag, fetchLimit, 0) - case "semble": - annotations, _ = h.db.GetSembleAnnotationsByTag(tag, fetchLimit, 0) - default: - annotations, _ = h.db.GetAnnotationsByTag(tag, fetchLimit, 0) - } - } - if motivation == "" || motivation == "highlighting" { - switch feedType { - case "margin": - highlights, _ = h.db.GetMarginHighlightsByTag(tag, fetchLimit, 0) - case "semble": - highlights, _ = h.db.GetSembleHighlightsByTag(tag, fetchLimit, 0) - default: - highlights, _ = h.db.GetHighlightsByTag(tag, fetchLimit, 0) - } - } - if motivation == "" || motivation == "bookmarking" { - switch feedType { - case "margin": - bookmarks, _ = h.db.GetMarginBookmarksByTag(tag, fetchLimit, 0) - case "semble": - bookmarks, _ = h.db.GetSembleBookmarksByTag(tag, fetchLimit, 0) - default: - bookmarks, _ = h.db.GetBookmarksByTag(tag, fetchLimit, 0) - } - } - collectionItems = []db.CollectionItem{} - } - } else if creator != "" { - if motivation == "" || motivation == "commenting" { - switch feedType { - case "margin": - annotations, _ = h.db.GetMarginAnnotationsByAuthor(creator, fetchLimit, 0) - case "semble": - annotations, _ = h.db.GetSembleAnnotationsByAuthor(creator, fetchLimit, 0) - default: - annotations, _ = h.db.GetAnnotationsByAuthor(creator, fetchLimit, 0) - } - } - if motivation == "" || motivation == "highlighting" { - switch feedType { - case "margin": - highlights, _ = h.db.GetMarginHighlightsByAuthor(creator, fetchLimit, 0) - case "semble": - highlights, _ = h.db.GetSembleHighlightsByAuthor(creator, fetchLimit, 0) - default: - highlights, _ = h.db.GetHighlightsByAuthor(creator, fetchLimit, 0) - } - } - if motivation == "" || motivation == "bookmarking" { - switch feedType { - case "margin": - bookmarks, _ = h.db.GetMarginBookmarksByAuthor(creator, fetchLimit, 0) - case "semble": - bookmarks, _ = h.db.GetSembleBookmarksByAuthor(creator, fetchLimit, 0) - default: - bookmarks, _ = h.db.GetBookmarksByAuthor(creator, fetchLimit, 0) - } - } - collectionItems = []db.CollectionItem{} - } else { - typeLim := fetchLimit - if motivation == "" { - typeLim = perTypeFetchLimit - } - if motivation == "" || motivation == "commenting" { - switch feedType { - case "margin": - annotations, _ = h.db.GetMarginAnnotations(typeLim, 0) - case "semble": - annotations, _ = h.db.GetSembleAnnotations(typeLim, 0) - case "popular": - annotations, _ = h.db.GetPopularAnnotations(typeLim, 0) - case "shelved": - annotations, _ = h.db.GetShelvedAnnotations(typeLim, 0) - default: - annotations, _ = h.db.GetRecentAnnotations(typeLim, 0) - } - } - if motivation == "" || motivation == "highlighting" { - switch feedType { - case "margin": - highlights, _ = h.db.GetMarginHighlights(typeLim, 0) - case "semble": - highlights, _ = h.db.GetSembleHighlights(typeLim, 0) - case "popular": - highlights, _ = h.db.GetPopularHighlights(typeLim, 0) - case "shelved": - highlights, _ = h.db.GetShelvedHighlights(typeLim, 0) - default: - highlights, _ = h.db.GetRecentHighlights(typeLim, 0) - } - } - if motivation == "" || motivation == "bookmarking" { - switch feedType { - case "margin": - bookmarks, _ = h.db.GetMarginBookmarks(typeLim, 0) - case "semble": - bookmarks, _ = h.db.GetSembleBookmarks(typeLim, 0) - case "popular": - bookmarks, _ = h.db.GetPopularBookmarks(typeLim, 0) - case "shelved": - bookmarks, _ = h.db.GetShelvedBookmarks(typeLim, 0) - default: - bookmarks, _ = h.db.GetRecentBookmarks(typeLim, 0) - } - } - if motivation == "" { - switch feedType { - case "popular": - collectionItems, err = h.db.GetPopularCollectionItems(typeLim, 0) - case "shelved": - collectionItems, err = h.db.GetShelvedCollectionItems(typeLim, 0) - default: - collectionItems, err = h.db.GetRecentCollectionItems(typeLim, 0) - } - if err != nil { - logger.Error("Error fetching collection items: %v", err) - } - } - } - - allDIDs := make(map[string]bool) - for _, a := range annotations { - allDIDs[a.AuthorDID] = true - } - for _, h := range highlights { - allDIDs[h.AuthorDID] = true - } - for _, b := range bookmarks { - allDIDs[b.AuthorDID] = true - } - for _, ci := range collectionItems { - allDIDs[ci.AuthorDID] = true - } - didSlice := make([]string, 0, len(allDIDs)) - for did := range allDIDs { - didSlice = append(didSlice, did) - } - profiles := fetchProfilesForDIDs(h.db, didSlice) - shared := &hydrationData{profiles: profiles} - - var ( - authAnnos []APIAnnotation - authHighs []APIHighlight - authBooks []APIBookmark - authCollectionItems []APICollectionItem - wg sync.WaitGroup - ) - - wg.Add(3) - go func() { - defer wg.Done() - authAnnos, _ = hydrateAnnotationsWithData(h.db, annotations, viewerDID, shared) - }() - go func() { - defer wg.Done() - authHighs, _ = hydrateHighlightsWithData(h.db, highlights, viewerDID, shared) - }() - go func() { - defer wg.Done() - authBooks, _ = hydrateBookmarksWithData(h.db, bookmarks, viewerDID, shared) - }() - - if len(collectionItems) > 0 { - var sembleURIs []string - for _, item := range collectionItems { - if strings.Contains(item.AnnotationURI, "network.cosmik.card") { - sembleURIs = append(sembleURIs, item.AnnotationURI) - } - } - if len(sembleURIs) > 0 { - ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) - defer cancel() - ensureSembleCardsIndexed(ctx, h.db, sembleURIs) - } - wg.Add(1) - go func() { - defer wg.Done() - authCollectionItems, _ = hydrateCollectionItemsWithData(h.db, collectionItems, viewerDID, shared) - }() +func parseFeedType(s string) db.FeedType { + switch s { + case "popular": + return db.FeedTypePopular + case "shelved": + return db.FeedTypeShelved + case "margin": + return db.FeedTypeMargin + case "semble": + return db.FeedTypeSemble + default: + return db.FeedTypeRecent } +} - wg.Wait() - - collectionItemURIs := make(map[string]string) - for _, ci := range authCollectionItems { - var annotationURI string - if ci.Annotation != nil { - annotationURI = ci.Annotation.ID - } else if ci.Highlight != nil { - annotationURI = ci.Highlight.ID - } else if ci.Bookmark != nil { - annotationURI = ci.Bookmark.ID - } - if annotationURI != "" { - collectionItemURIs[annotationURI] = ci.Author.DID +func notesByMotivation(notes []service.APINote) (annotations, highlights, bookmarks []service.APINote) { + annotations = []service.APINote{} + highlights = []service.APINote{} + bookmarks = []service.APINote{} + for _, n := range notes { + switch n.Motivation { + case "highlighting": + highlights = append(highlights, n) + case "bookmarking": + bookmarks = append(bookmarks, n) + default: + annotations = append(annotations, n) } } + return +} - var feed []interface{} - for _, a := range authAnnos { - if addedBy, exists := collectionItemURIs[a.ID]; exists && addedBy == a.Author.DID { - continue +func mergeNotes(a, b []db.Note) []db.Note { + seen := make(map[string]bool, len(a)) + result := make([]db.Note, 0, len(a)+len(b)) + for _, n := range a { + if !seen[n.URI] { + seen[n.URI] = true + result = append(result, n) } - feed = append(feed, a) } - for _, h := range authHighs { - if addedBy, exists := collectionItemURIs[h.ID]; exists && addedBy == h.Author.DID { - continue + for _, n := range b { + if !seen[n.URI] { + seen[n.URI] = true + result = append(result, n) } - feed = append(feed, h) } - for _, b := range authBooks { - if addedBy, exists := collectionItemURIs[b.ID]; exists && addedBy == b.Author.DID { - continue - } - feed = append(feed, b) - } - for _, ci := range authCollectionItems { - feed = append(feed, ci) - } - - if feedType != "" && feedType != "all" && feedType != "my-feed" { - var filtered []interface{} - for _, item := range feed { - isSemble := false - var uri string - switch v := item.(type) { - case APIAnnotation: - uri = v.ID - case APIHighlight: - uri = v.ID - case APIBookmark: - uri = v.ID - case APICollectionItem: - if v.Annotation != nil { - uri = v.Annotation.ID - } else if v.Highlight != nil { - uri = v.Highlight.ID - } else if v.Bookmark != nil { - uri = v.Bookmark.ID - } else { - uri = v.ID - } - } - if strings.Contains(uri, "network.cosmik") { - isSemble = true - } - - switch feedType { - case "semble": - if isSemble { - filtered = append(filtered, item) - } - case "margin": - if !isSemble { - filtered = append(filtered, item) - } - case "popular", "shelved": - filtered = append(filtered, item) - } - } - feed = filtered - } - - feed = h.filterFeedByModeration(feed, viewerDID) + return result +} - switch feedType { - case "popular": - sortFeedByPopularity(feed) - default: - sortFeed(feed) - } +func (h *Handler) GetFeed(w http.ResponseWriter, r *http.Request) { + limit := parseIntParam(r, "limit", 50) + offset := parseIntParam(r, "offset", 0) + tag := strings.ToLower(r.URL.Query().Get("tag")) + creator := r.URL.Query().Get("creator") + motivation := r.URL.Query().Get("motivation") + feedTypeStr := r.URL.Query().Get("type") - logger.Info("[DEBUG] FeedType: %s, Total Items before slice: %d", feedType, len(feed)) - if len(feed) > 0 { - first := feed[0] - switch v := first.(type) { - case APIAnnotation: - logger.Info("[DEBUG] First Item (Annotation): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) - case APIHighlight: - logger.Info("[DEBUG] First Item (Highlight): %s, Likes: %d, Replies: %d", v.ID, v.LikeCount, v.ReplyCount) - } + var motivations []string + if motivation != "" { + motivations = []string{motivation} } - if offset < len(feed) { - feed = feed[offset:] - } else { - feed = []interface{}{} + req := service.FeedRequest{ + ViewerDID: h.getViewerDID(r), + Motivations: motivations, + AuthorDID: creator, + Tag: tag, + FeedType: parseFeedType(feedTypeStr), + Limit: limit, + Offset: offset, } - if len(feed) > limit { - feed = feed[:limit] + resp, err := h.feedSvc.GetFeed(r.Context(), req) + if err != nil { + WriteInternalError(w, "Internal server error") + return } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "Collection", - "items": feed, - "totalItems": len(feed), - }) -} - -func sortFeed(feed []interface{}) { - sort.Slice(feed, func(i, j int) bool { - t1 := getCreatedAt(feed[i]) - t2 := getCreatedAt(feed[j]) - return t1.After(t2) + "items": resp.Items, + "totalItems": resp.TotalItems, }) } - -func getCreatedAt(item interface{}) time.Time { - switch v := item.(type) { - case APIAnnotation: - return v.CreatedAt - case APIHighlight: - return v.CreatedAt - case APIBookmark: - return v.CreatedAt - case APICollectionItem: - return v.CreatedAt - default: - return time.Time{} - } -} - -func sortFeedByPopularity(feed []interface{}) { - sort.Slice(feed, func(i, j int) bool { - p1 := getPopularity(feed[i]) - p2 := getPopularity(feed[j]) - if p1 != p2 { - return p1 > p2 - } - t1 := getCreatedAt(feed[i]) - t2 := getCreatedAt(feed[j]) - return t1.After(t2) - }) -} - -func getPopularity(item interface{}) int { - switch v := item.(type) { - case APIAnnotation: - return v.LikeCount + v.ReplyCount - case APIHighlight: - return v.LikeCount + v.ReplyCount - case APIBookmark: - return v.LikeCount + v.ReplyCount - case APICollectionItem: - pop := 0 - if v.Annotation != nil { - pop += v.Annotation.LikeCount + v.Annotation.ReplyCount - } - if v.Highlight != nil { - pop += v.Highlight.LikeCount + v.Highlight.ReplyCount - } - if v.Bookmark != nil { - pop += v.Bookmark.LikeCount + v.Bookmark.ReplyCount - } - return pop - default: - return 0 - } -} - func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { @@ -690,172 +409,44 @@ func (h *Handler) GetAnnotation(w http.ResponseWriter, r *http.Request) { return } - serveResponse := func(data interface{}, context string) { - w.Header().Set("Content-Type", "application/json") - response := map[string]interface{}{ - "@context": context, - } - jsonData, _ := json.Marshal(data) - json.Unmarshal(jsonData, &response) - json.NewEncoder(w).Encode(response) - } - - if annotation, err := h.db.GetAnnotationByURI(uri); err == nil { - if annotation.CID == nil || *annotation.CID == "" { - parts := parseATURI(uri) - if len(parts) >= 3 { - did := parts[0] - collection := parts[1] - rkey := parts[2] - - session, err := h.refresher.GetSessionWithAutoRefresh(r) - if err == nil { - _ = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { - record, getErr := client.GetRecord(r.Context(), did, collection, rkey) - if getErr == nil { - h.db.UpdateAnnotation(uri, *annotation.BodyValue, *annotation.TagsJSON, record.CID) - cid := record.CID - annotation.CID = &cid - } - return nil - }) - } - } - } - - if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } - } - - if highlight, err := h.db.GetHighlightByURI(uri); err == nil { - if highlight.CID == nil || *highlight.CID == "" { - parts := parseATURI(uri) - if len(parts) >= 3 { - did := parts[0] - collection := parts[1] - rkey := parts[2] - - session, err := h.refresher.GetSessionWithAutoRefresh(r) - if err == nil { - _ = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { - record, getErr := client.GetRecord(r.Context(), did, collection, rkey) - if getErr == nil { - tagsJSON := "" - if highlight.TagsJSON != nil { - tagsJSON = *highlight.TagsJSON - } - color := "" - if highlight.Color != nil { - color = *highlight.Color - } - h.db.UpdateHighlight(uri, color, tagsJSON, record.CID) - cid := record.CID - highlight.CID = &cid - } - return nil - }) - } - } - } - - if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } + note, err := h.noteRepo.GetByURI(r.Context(), uri) + if err != nil { + WriteInternalError(w, "Internal server error") + return } - if strings.Contains(uri, "at.margin.annotation") { - highlightURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) - if highlight, err := h.db.GetHighlightByURI(highlightURI); err == nil { - if enriched, _ := hydrateHighlights(h.db, []db.Highlight{*highlight}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } - } + if note == nil && strings.Contains(uri, "at.margin.annotation") { + altURI := strings.Replace(uri, "at.margin.annotation", "at.margin.highlight", 1) + note, _ = h.noteRepo.GetByURI(r.Context(), altURI) } - if strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark") { - if strings.HasPrefix(uri, "at://") { - uriWithoutScheme := strings.TrimPrefix(uri, "at://") - parts := strings.Split(uriWithoutScheme, "/") - if len(parts) >= 3 { - did := parts[0] - rkey := parts[len(parts)-1] - - sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", did, rkey) - - if annotation, err := h.db.GetAnnotationByURI(sembleURI); err == nil { - if enriched, _ := hydrateAnnotations(h.db, []db.Annotation{*annotation}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } - } - - if bookmark, err := h.db.GetBookmarkByURI(sembleURI); err == nil { - if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } - } - } + if note == nil && strings.HasPrefix(uri, "at://") && + (strings.Contains(uri, "at.margin.annotation") || strings.Contains(uri, "at.margin.bookmark")) { + parts := strings.Split(strings.TrimPrefix(uri, "at://"), "/") + if len(parts) >= 3 { + sembleURI := fmt.Sprintf("at://%s/network.cosmik.card/%s", parts[0], parts[len(parts)-1]) + note, _ = h.noteRepo.GetByURI(r.Context(), sembleURI) } } - if bookmark, err := h.db.GetBookmarkByURI(uri); err == nil { - if bookmark.CID == nil || *bookmark.CID == "" { - parts := parseATURI(uri) - if len(parts) >= 3 { - did := parts[0] - collection := parts[1] - rkey := parts[2] - - session, err := h.refresher.GetSessionWithAutoRefresh(r) - if err == nil { - _ = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, _ string) error { - record, getErr := client.GetRecord(r.Context(), did, collection, rkey) - if getErr == nil { - tagsJSON := "" - if bookmark.TagsJSON != nil { - tagsJSON = *bookmark.TagsJSON - } - title := "" - if bookmark.Title != nil { - title = *bookmark.Title - } - desc := "" - if bookmark.Description != nil { - desc = *bookmark.Description - } - h.db.UpdateBookmark(uri, title, desc, tagsJSON, record.CID) - cid := record.CID - bookmark.CID = &cid - } - return nil - }) - } - } - } - - if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } + if note == nil && strings.Contains(uri, "at.margin.annotation") { + altURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) + note, _ = h.noteRepo.GetByURI(r.Context(), altURI) } - if strings.Contains(uri, "at.margin.annotation") { - bookmarkURI := strings.Replace(uri, "at.margin.annotation", "at.margin.bookmark", 1) - if bookmark, err := h.db.GetBookmarkByURI(bookmarkURI); err == nil { - if enriched, _ := hydrateBookmarks(h.db, []db.Bookmark{*bookmark}, h.getViewerDID(r)); len(enriched) > 0 { - serveResponse(enriched[0], "http://www.w3.org/ns/anno.jsonld") - return - } - } + if note == nil { + WriteNotFound(w, "Note not found") + return } - WriteNotFound(w, "Annotation, Highlight, or Bookmark not found") + lc, _ := h.hydration.Load(r.Context(), []db.Note{*note}, h.getViewerDID(r)) + apiNote := h.hydration.ToAPINote(*note, lc) + w.Header().Set("Content-Type", "application/json") + response := map[string]interface{}{"@context": "http://www.w3.org/ns/anno.jsonld"} + jsonData, _ := json.Marshal(apiNote) + json.Unmarshal(jsonData, &response) + json.NewEncoder(w).Encode(response) } func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { @@ -874,27 +465,24 @@ func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { urlHash := db.HashURL(source) rawHash := db.HashString(source) - annotations, _ := h.db.GetAnnotationsByTargetHash(urlHash, limit, offset) - highlights, _ := h.db.GetHighlightsByTargetHash(urlHash, limit, offset) - bookmarks, _ := h.db.GetBookmarksByTargetHash(urlHash, limit, offset) - + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: urlHash, Limit: limit, Offset: offset}) + if err != nil { + WriteInternalError(w, "Internal server error") + return + } if rawHash != urlHash { - rawAnnotations, _ := h.db.GetAnnotationsByTargetHash(rawHash, limit, offset) - rawHighlights, _ := h.db.GetHighlightsByTargetHash(rawHash, limit, offset) - rawBookmarks, _ := h.db.GetBookmarksByTargetHash(rawHash, limit, offset) - - annotations = mergeAnnotations(annotations, rawAnnotations) - highlights = mergeHighlights(highlights, rawHighlights) - bookmarks = mergeBookmarks(bookmarks, rawBookmarks) + rawNotes, _ := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: rawHash, Limit: limit, Offset: offset}) + notes = mergeNotes(notes, rawNotes) } - enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) - enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) - enrichedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) - - totalItems := len(enrichedAnnotations) + len(enrichedHighlights) + len(enrichedBookmarks) + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } + annotations, highlights, bookmarks := notesByMotivation(items) - if totalItems == 0 { + if len(items) == 0 { w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300") } else { w.Header().Set("Cache-Control", "private, max-age=0, no-store") @@ -904,9 +492,49 @@ func (h *Handler) GetByTarget(w http.ResponseWriter, r *http.Request) { "@context": "http://www.w3.org/ns/anno.jsonld", "source": source, "sourceHash": urlHash, - "annotations": enrichedAnnotations, - "highlights": enrichedHighlights, - "bookmarks": enrichedBookmarks, + "annotations": annotations, + "highlights": highlights, + "bookmarks": bookmarks, + }) +} + +func (h *Handler) GetByTargetHash(w http.ResponseWriter, r *http.Request) { + hashes := r.URL.Query()["h"] + if len(hashes) == 0 { + WriteBadRequest(w, "at least one hash parameter (h) required") + return + } + + limit := parseIntParam(r, "limit", 50) + offset := parseIntParam(r, "offset", 0) + + var notes []db.Note + for _, hash := range hashes { + if len(hash) != 64 { + continue + } + hashNotes, _ := h.noteRepo.List(r.Context(), db.NoteFilter{TargetHash: hash, Limit: limit, Offset: offset}) + notes = mergeNotes(notes, hashNotes) + } + + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } + annotations, highlights, bookmarks := notesByMotivation(items) + + if len(items) == 0 { + w.Header().Set("Cache-Control", "public, max-age=60, s-maxage=300") + } else { + w.Header().Set("Cache-Control", "private, max-age=0, no-store") + } + + WriteSuccess(w, map[string]interface{}{ + "@context": "http://www.w3.org/ns/anno.jsonld", + "annotations": annotations, + "highlights": highlights, + "bookmarks": bookmarks, }) } @@ -1020,29 +648,31 @@ func (h *Handler) GetHighlights(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - var highlights []db.Highlight - var err error - + filter := db.NoteFilter{Motivations: []string{"highlighting"}, Limit: limit, Offset: offset} if did != "" { - highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) - } else if tag != "" { - highlights, err = h.db.GetHighlightsByTag(tag, limit, offset) - } else { - highlights, err = h.db.GetRecentHighlights(limit, offset) + filter.AuthorDID = did + } + if tag != "" { + filter.Tag = tag } + notes, err := h.noteRepo.List(r.Context(), filter) if err != nil { WriteInternalError(w, "Internal server error") return } - enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "HighlightCollection", - "items": enriched, - "totalItems": len(enriched), + "items": items, + "totalItems": len(items), }) } @@ -1056,19 +686,28 @@ func (h *Handler) GetBookmarks(w http.ResponseWriter, r *http.Request) { return } - bookmarks, err := h.db.GetBookmarksByAuthor(did, limit, offset) + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ + Motivations: []string{"bookmarking"}, + AuthorDID: did, + Limit: limit, + Offset: offset, + }) if err != nil { WriteInternalError(w, "Internal server error") return } - enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "BookmarkCollection", - "items": enriched, - "totalItems": len(enriched), + "items": items, + "totalItems": len(items), }) } @@ -1079,10 +718,6 @@ func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) { } limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - - var annotations []db.Annotation - var err error - viewerDID := h.getViewerDID(r) if offset == 0 && viewerDID != "" && did == viewerDID { @@ -1093,21 +728,29 @@ func (h *Handler) GetUserAnnotations(w http.ResponseWriter, r *http.Request) { }() } - annotations, err = h.db.GetAnnotationsByAuthor(did, limit, offset) - + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ + AuthorDID: did, + Motivations: []string{"commenting"}, + Limit: limit, + Offset: offset, + }) if err != nil { WriteInternalError(w, "Internal server error") return } - enriched, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "AnnotationCollection", "creator": did, - "items": enriched, - "totalItems": len(enriched), + "items": items, + "totalItems": len(items), }) } @@ -1118,10 +761,6 @@ func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) { } limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - - var highlights []db.Highlight - var err error - viewerDID := h.getViewerDID(r) if offset == 0 && viewerDID != "" && did == viewerDID { @@ -1132,21 +771,29 @@ func (h *Handler) GetUserHighlights(w http.ResponseWriter, r *http.Request) { }() } - highlights, err = h.db.GetHighlightsByAuthor(did, limit, offset) - + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ + AuthorDID: did, + Motivations: []string{"highlighting"}, + Limit: limit, + Offset: offset, + }) if err != nil { WriteInternalError(w, "Internal server error") return } - enriched, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "HighlightCollection", "creator": did, - "items": enriched, - "totalItems": len(enriched), + "items": items, + "totalItems": len(items), }) } @@ -1157,10 +804,6 @@ func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { } limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - - var bookmarks []db.Bookmark - var err error - viewerDID := h.getViewerDID(r) if offset == 0 && viewerDID != "" && did == viewerDID { @@ -1171,21 +814,29 @@ func (h *Handler) GetUserBookmarks(w http.ResponseWriter, r *http.Request) { }() } - bookmarks, err = h.db.GetBookmarksByAuthor(did, limit, offset) - + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ + AuthorDID: did, + Motivations: []string{"bookmarking"}, + Limit: limit, + Offset: offset, + }) if err != nil { WriteInternalError(w, "Internal server error") return } - enriched, _ := hydrateBookmarks(h.db, bookmarks, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, viewerDID) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "type": "BookmarkCollection", "creator": did, - "items": enriched, - "totalItems": len(enriched), + "items": items, + "totalItems": len(items), }) } @@ -1209,19 +860,32 @@ func (h *Handler) GetUserTargetItems(w http.ResponseWriter, r *http.Request) { urlHash := db.HashURL(source) - annotations, _ := h.db.GetAnnotationsByAuthorAndTargetHash(did, urlHash, limit, offset) - highlights, _ := h.db.GetHighlightsByAuthorAndTargetHash(did, urlHash, limit, offset) + notes, err := h.noteRepo.List(r.Context(), db.NoteFilter{ + AuthorDID: did, + TargetHash: urlHash, + Motivations: []string{"commenting", "highlighting"}, + Limit: limit, + Offset: offset, + }) + if err != nil { + WriteInternalError(w, "Internal server error") + return + } - enrichedAnnotations, _ := hydrateAnnotations(h.db, annotations, h.getViewerDID(r)) - enrichedHighlights, _ := hydrateHighlights(h.db, highlights, h.getViewerDID(r)) + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } + annotations, highlights, _ := notesByMotivation(items) WriteSuccess(w, map[string]interface{}{ "@context": "http://www.w3.org/ns/anno.jsonld", "creator": did, "source": source, "sourceHash": urlHash, - "annotations": enrichedAnnotations, - "highlights": enrichedHighlights, + "annotations": annotations, + "highlights": highlights, }) } @@ -1256,7 +920,7 @@ func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) { return } - count, err := h.db.GetLikeCount(uri) + count, err := h.engagementRepo.GetLikeCount(r.Context(), uri) if err != nil { WriteInternalError(w, "Internal server error") return @@ -1267,7 +931,7 @@ func (h *Handler) GetLikeCount(w http.ResponseWriter, r *http.Request) { if err == nil && cookie != nil { session, err := h.refresher.GetSessionWithAutoRefresh(r) if err == nil { - userLike, err := h.db.GetLikeByUserAndSubject(session.DID, uri) + userLike, err := h.noteRepo.GetLikeByUserAndSubject(r.Context(), session.DID, uri) if err == nil && userLike != nil { liked = true } @@ -1523,7 +1187,7 @@ func (h *Handler) GetNotifications(w http.ResponseWriter, r *http.Request) { limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - notifications, err := h.db.GetNotifications(session.DID, limit, offset) + notifications, err := h.notificationRepo.GetNotifications(r.Context(), session.DID, limit, offset) if err != nil { WriteInternalError(w, "Failed to get notifications") return @@ -1549,7 +1213,7 @@ func (h *Handler) GetUnreadNotificationCount(w http.ResponseWriter, r *http.Requ return } - count, err := h.db.GetUnreadNotificationCount(session.DID) + count, err := h.notificationRepo.GetUnreadNotificationCount(r.Context(), session.DID) if err != nil { WriteInternalError(w, "Failed to get count") return @@ -1565,7 +1229,7 @@ func (h *Handler) MarkNotificationsRead(w http.ResponseWriter, r *http.Request) return } - if err := h.db.MarkNotificationsRead(session.DID); err != nil { + if err := h.notificationRepo.MarkNotificationsRead(r.Context(), session.DID); err != nil { WriteInternalError(w, "Failed to mark as read") return } @@ -1577,101 +1241,37 @@ func (h *Handler) getViewerDID(r *http.Request) string { if err != nil { return "" } - did, _, _, _, _, err := h.db.GetSession(cookie.Value) + did, _, _, _, _, err := h.sessionRepo.GetSession(r.Context(), cookie.Value) if err != nil { return "" } return did } -func getItemAuthorDID(item interface{}) string { - switch v := item.(type) { - case APIAnnotation: - return v.Author.DID - case APIHighlight: - return v.Author.DID - case APIBookmark: - return v.Author.DID - case APICollectionItem: - return v.Author.DID - default: - return "" - } +type fullProfileRepository struct { + db *db.DB } -func (h *Handler) filterFeedByModeration(feed []interface{}, viewerDID string) []interface{} { - if viewerDID == "" { - return feed - } - - hiddenDIDs, err := h.db.GetAllHiddenDIDs(viewerDID) - if err != nil || len(hiddenDIDs) == 0 { - return feed - } - - var filtered []interface{} - for _, item := range feed { - authorDID := getItemAuthorDID(item) - if authorDID != "" && hiddenDIDs[authorDID] { - continue +func (r *fullProfileRepository) GetProfiles(_ context.Context, dids []string) (map[string]domain.Author, error) { + raw := fetchProfilesForDIDs(r.db, dids) + result := make(map[string]domain.Author, len(raw)) + for did, a := range raw { + result[did] = domain.Author{ + DID: a.DID, + Handle: a.Handle, + DisplayName: a.DisplayName, + Avatar: a.Avatar, } - filtered = append(filtered, item) } - return filtered + return result, nil } -func mergeAnnotations(a, b []db.Annotation) []db.Annotation { - seen := make(map[string]bool) - var result []db.Annotation - for _, item := range a { - if !seen[item.URI] { - seen[item.URI] = true - result = append(result, item) - } - } - for _, item := range b { - if !seen[item.URI] { - seen[item.URI] = true - result = append(result, item) - } - } - return result +func (r *fullProfileRepository) GetProfile(_ context.Context, did string) (*domain.Profile, error) { + return r.db.GetProfile(did) } -func mergeHighlights(a, b []db.Highlight) []db.Highlight { - seen := make(map[string]bool) - var result []db.Highlight - for _, item := range a { - if !seen[item.URI] { - seen[item.URI] = true - result = append(result, item) - } - } - for _, item := range b { - if !seen[item.URI] { - seen[item.URI] = true - result = append(result, item) - } - } - return result -} - -func mergeBookmarks(a, b []db.Bookmark) []db.Bookmark { - seen := make(map[string]bool) - var result []db.Bookmark - for _, item := range a { - if !seen[item.URI] { - seen[item.URI] = true - result = append(result, item) - } - } - for _, item := range b { - if !seen[item.URI] { - seen[item.URI] = true - result = append(result, item) - } - } - return result +func (r *fullProfileRepository) UpsertProfile(_ context.Context, p *domain.Profile) error { + return r.db.UpsertProfile(p) } func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { @@ -1684,32 +1284,27 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { creator := r.URL.Query().Get("creator") limit := parseIntParam(r, "limit", 50) offset := parseIntParam(r, "offset", 0) - viewerDID := h.getViewerDID(r) - - annotations, _ := h.db.SearchAnnotations(query, creator, limit, offset) - highlights, _ := h.db.SearchHighlights(query, creator, limit, offset) - bookmarks, _ := h.db.SearchBookmarks(query, creator, limit, offset) - - hydratedAnnotations, _ := hydrateAnnotations(h.db, annotations, viewerDID) - hydratedHighlights, _ := hydrateHighlights(h.db, highlights, viewerDID) - hydratedBookmarks, _ := hydrateBookmarks(h.db, bookmarks, viewerDID) - var feed []interface{} - for _, a := range hydratedAnnotations { - feed = append(feed, a) + filter := db.NoteFilter{Query: query, Limit: limit, Offset: offset} + if creator != "" { + filter.AuthorDID = creator } - for _, hl := range hydratedHighlights { - feed = append(feed, hl) - } - for _, b := range hydratedBookmarks { - feed = append(feed, b) + + notes, err := h.noteRepo.List(r.Context(), filter) + if err != nil { + WriteInternalError(w, "Internal server error") + return } - sortFeed(feed) + lc, _ := h.hydration.Load(r.Context(), notes, h.getViewerDID(r)) + items := make([]service.APINote, len(notes)) + for i, n := range notes { + items[i] = h.hydration.ToAPINote(n, lc) + } WriteSuccess(w, map[string]interface{}{ - "items": feed, - "fetchedCount": len(feed), + "items": items, + "fetchedCount": len(items), }) } diff --git a/backend/internal/api/annotations.go b/backend/internal/api/notes.go similarity index 56% rename from backend/internal/api/annotations.go rename to backend/internal/api/notes.go index 2f1b5ff..a8bd72b 100644 --- a/backend/internal/api/annotations.go +++ b/backend/internal/api/notes.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -9,17 +10,134 @@ import ( "time" "margin.at/internal/db" + "margin.at/internal/domain" "margin.at/internal/logger" "margin.at/internal/xrpc" ) -type AnnotationService struct { - db *db.DB +type NoteIndexDB interface { + CreateNote(n *domain.Note) error + GetNoteByURI(uri string) (*domain.Note, error) + DeleteNote(uri string) error + UpdateNoteAnnotation(uri, bodyValue, tagsJSON, cid string) error + UpdateNoteHighlight(uri, color, tagsJSON, cid string) error + UpdateNoteBookmark(uri, title, description, tagsJSON, cid string) error + CreateAnnotation(a *domain.Annotation) error + GetAnnotationByURI(uri string) (*domain.Annotation, error) + GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]domain.Annotation, error) + UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error + DeleteAnnotation(uri string) error + CreateHighlight(h *domain.Highlight) error + GetHighlightByURI(uri string) (*domain.Highlight, error) + GetHighlightsByAuthor(authorDID string, limit, offset int) ([]domain.Highlight, error) + UpdateHighlight(uri, color, tagsJSON, cid string) error + DeleteHighlight(uri string) error + CreateBookmark(b *domain.Bookmark) error + GetBookmarkByURI(uri string) (*domain.Bookmark, error) + GetBookmarksByTargetHash(targetHash string, limit, offset int) ([]domain.Bookmark, error) + UpdateBookmark(uri, title, description, tagsJSON, cid string) error + DeleteBookmark(uri string) error + CreateLike(l *domain.Like) error + GetLikeByUserAndSubject(userDID, subjectURI string) (*domain.Like, error) + DeleteLike(uri string) error + CreateReply(rep *domain.Reply) error + GetReplyByURI(uri string) (*domain.Reply, error) + DeleteReply(uri string) error + CreateNotification(n *domain.Notification) error + GetAuthorByURI(uri string) (string, error) + GetPreferences(did string) (*domain.Preferences, error) + SyncSelfLabels(authorDID, uri string, labels []string) error + CreateContentLabel(src, uri, val, createdBy string) error + SaveEditHistory(uri, recordType, previousContent string, previousCID *string) error + HashURL(rawURL string) string + CommunityBookmarkExists(authorDID, targetHash, tagsJSON string) (bool, error) +} + +type dbAdapter struct{ d *db.DB } + +func (a *dbAdapter) CreateNote(n *domain.Note) error { return a.d.CreateNote(n) } +func (a *dbAdapter) GetNoteByURI(uri string) (*domain.Note, error) { return a.d.GetNoteByURI(uri) } +func (a *dbAdapter) DeleteNote(uri string) error { return a.d.DeleteNote(uri) } +func (a *dbAdapter) UpdateNoteAnnotation(uri, body, tags, cid string) error { + return a.d.UpdateNoteAnnotation(uri, body, tags, cid) +} +func (a *dbAdapter) UpdateNoteHighlight(uri, color, tags, cid string) error { + return a.d.UpdateNoteHighlight(uri, color, tags, cid) +} +func (a *dbAdapter) UpdateNoteBookmark(uri, title, desc, tags, cid string) error { + return a.d.UpdateNoteBookmark(uri, title, desc, tags, cid) +} +func (a *dbAdapter) CreateAnnotation(ann *domain.Annotation) error { return a.d.CreateAnnotation(ann) } +func (a *dbAdapter) GetAnnotationByURI(uri string) (*domain.Annotation, error) { + return a.d.GetAnnotationByURI(uri) +} +func (a *dbAdapter) GetAnnotationsByAuthor(did string, limit, offset int) ([]domain.Annotation, error) { + return a.d.GetAnnotationsByAuthor(did, limit, offset) +} +func (a *dbAdapter) UpdateAnnotation(uri, body, tags, cid string) error { + return a.d.UpdateAnnotation(uri, body, tags, cid) +} +func (a *dbAdapter) DeleteAnnotation(uri string) error { return a.d.DeleteAnnotation(uri) } +func (a *dbAdapter) CreateHighlight(h *domain.Highlight) error { return a.d.CreateHighlight(h) } +func (a *dbAdapter) GetHighlightByURI(uri string) (*domain.Highlight, error) { + return a.d.GetHighlightByURI(uri) +} +func (a *dbAdapter) GetHighlightsByAuthor(did string, limit, offset int) ([]domain.Highlight, error) { + return a.d.GetHighlightsByAuthor(did, limit, offset) +} +func (a *dbAdapter) UpdateHighlight(uri, color, tags, cid string) error { + return a.d.UpdateHighlight(uri, color, tags, cid) +} +func (a *dbAdapter) DeleteHighlight(uri string) error { return a.d.DeleteHighlight(uri) } +func (a *dbAdapter) CreateBookmark(b *domain.Bookmark) error { return a.d.CreateBookmark(b) } +func (a *dbAdapter) GetBookmarkByURI(uri string) (*domain.Bookmark, error) { + return a.d.GetBookmarkByURI(uri) +} +func (a *dbAdapter) GetBookmarksByTargetHash(hash string, limit, offset int) ([]domain.Bookmark, error) { + return a.d.GetBookmarksByTargetHash(hash, limit, offset) +} +func (a *dbAdapter) UpdateBookmark(uri, title, desc, tags, cid string) error { + return a.d.UpdateBookmark(uri, title, desc, tags, cid) +} +func (a *dbAdapter) DeleteBookmark(uri string) error { return a.d.DeleteBookmark(uri) } +func (a *dbAdapter) CreateLike(l *domain.Like) error { return a.d.CreateLike(l) } +func (a *dbAdapter) GetLikeByUserAndSubject(did, sub string) (*domain.Like, error) { + return a.d.GetLikeByUserAndSubject(did, sub) +} +func (a *dbAdapter) DeleteLike(uri string) error { return a.d.DeleteLike(uri) } +func (a *dbAdapter) CreateReply(rep *domain.Reply) error { return a.d.CreateReply(rep) } +func (a *dbAdapter) GetReplyByURI(uri string) (*domain.Reply, error) { + return a.d.GetReplyByURI(uri) +} +func (a *dbAdapter) DeleteReply(uri string) error { return a.d.DeleteReply(uri) } +func (a *dbAdapter) CreateNotification(n *domain.Notification) error { + return a.d.CreateNotification(n) +} +func (a *dbAdapter) GetAuthorByURI(uri string) (string, error) { return a.d.GetAuthorByURI(uri) } +func (a *dbAdapter) GetPreferences(did string) (*domain.Preferences, error) { + return a.d.GetPreferences(did) +} +func (a *dbAdapter) SyncSelfLabels(author, uri string, labels []string) error { + return a.d.SyncSelfLabels(author, uri, labels) +} +func (a *dbAdapter) CreateContentLabel(src, uri, val, by string) error { + return a.d.CreateContentLabel(src, uri, val, by) +} +func (a *dbAdapter) SaveEditHistory(uri, rt, prev string, cid *string) error { + return a.d.SaveEditHistory(uri, rt, prev, cid) +} +func (a *dbAdapter) HashURL(rawURL string) string { return db.HashURL(rawURL) } +func (a *dbAdapter) CommunityBookmarkExists(did, hash, tags string) (bool, error) { + return a.d.CommunityBookmarkExists(did, hash, tags) +} + +type NoteWriteService struct { + db NoteIndexDB refresher *TokenRefresher } -func NewAnnotationService(database *db.DB, refresher *TokenRefresher) *AnnotationService { - return &AnnotationService{db: database, refresher: refresher} +func NewNoteWriteService(database *db.DB, refresher *TokenRefresher) *NoteWriteService { + return &NoteWriteService{db: &dbAdapter{d: database}, refresher: refresher} } type CreateAnnotationRequest struct { @@ -36,7 +154,7 @@ type CreateAnnotationResponse struct { CID string `json:"cid"` } -func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) CreateAnnotation(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -135,7 +253,7 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ }) } - record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation) + record := xrpc.NewNoteRecord(req.URL, urlHash, req.Text, req.Selector, req.Title, "", "", motivation) if len(req.Tags) > 0 { record.Tags = req.Tags } @@ -143,14 +261,7 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ record.Facets = facets } - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var validLabels []string - for _, l := range req.Labels { - if validSelfLabels[l] { - validLabels = append(validLabels, l) - } - } - record.Labels = xrpc.NewSelfLabels(validLabels) + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) var result *xrpc.CreateRecordOutput @@ -165,7 +276,7 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err != nil { @@ -208,7 +319,7 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ cid := result.CID did := session.DID - annotation := &db.Annotation{ + note := &db.Note{ URI: result.URI, CID: &cid, AuthorDID: did, @@ -223,11 +334,11 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ IndexedAt: time.Now(), } - if err := s.db.CreateAnnotation(annotation); err != nil { - logger.Error("Warning: failed to index annotation in local DB: %v", err) + if err := s.db.CreateNote(note); err != nil { + logger.Error("Warning: failed to index note in local DB: %v", err) } - for _, label := range validLabels { + for _, label := range filterSelfLabels(req.Labels) { if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { logger.Error("Warning: failed to create self-label %s: %v", label, err) } @@ -239,7 +350,7 @@ func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Requ }) } -func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -260,10 +371,13 @@ func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Requ if collectionType == "reply" { collection = xrpc.CollectionReply } else { - candidateCollections := []string{xrpc.CollectionAnnotation, "network.cosmik.card"} + candidateCollections := []string{xrpc.CollectionNote, xrpc.CollectionAnnotation, xrpc.CollectionHighlight, xrpc.CollectionBookmark, xrpc.CollectionCommunityBookmark, "network.cosmik.card"} for _, col := range candidateCollections { uri := "at://" + did + "/" + col + "/" + rkey - if _, dbErr := s.db.GetAnnotationByURI(uri); dbErr == nil { + if note, dbErr := s.db.GetNoteByURI(uri); dbErr == nil && note != nil { + collection = col + break + } else if _, dbErr := s.db.GetAnnotationByURI(uri); dbErr == nil { collection = col break } @@ -277,13 +391,15 @@ func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Requ logger.Error("PDS delete failed (will still clean local DB): %v", pdsErr) } - // Always clean up local DB regardless of PDS result if collectionType == "reply" { uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey s.db.DeleteReply(uri) } else { uri := "at://" + did + "/" + collection + "/" + rkey s.db.DeleteAnnotation(uri) + s.db.DeleteHighlight(uri) + s.db.DeleteBookmark(uri) + s.db.DeleteNote(uri) } WriteSuccess(w, map[string]bool{"success": true}) @@ -295,7 +411,7 @@ type UpdateAnnotationRequest struct { Labels []string `json:"labels,omitempty"` } -func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { WriteBadRequest(w, "uri query parameter required") @@ -356,45 +472,63 @@ func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Requ var result *xrpc.PutRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) + collection := parts[1] + existing, getErr := client.GetRecord(r.Context(), did, collection, rkey) if getErr != nil { return fmt.Errorf("failed to fetch existing record: %w", getErr) } - var record xrpc.AnnotationRecord - if err := json.Unmarshal(existing.Value, &record); err != nil { - return fmt.Errorf("failed to parse existing record: %w", err) - } + var updateErr error + if collection == xrpc.CollectionNote { + var record xrpc.NoteRecord + if err := json.Unmarshal(existing.Value, &record); err != nil { + return fmt.Errorf("failed to parse existing record: %w", err) + } + record.Body = &xrpc.AnnotationBody{ + Value: req.Text, + Format: "text/plain", + } + if len(req.Tags) > 0 { + record.Tags = req.Tags + } else { + record.Tags = nil + } - record.Body = &xrpc.AnnotationBody{ - Value: req.Text, - Format: "text/plain", - } - if len(req.Tags) > 0 { - record.Tags = req.Tags - } else { - record.Tags = nil - } + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) - updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var updateLabels []string - for _, l := range req.Labels { - if updateValidLabels[l] { - updateLabels = append(updateLabels, l) + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + if updateErr != nil { + _ = client.DeleteRecord(r.Context(), did, collection, rkey) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + } + } else { + var record xrpc.AnnotationRecord + if err := json.Unmarshal(existing.Value, &record); err != nil { + return fmt.Errorf("failed to parse existing record: %w", err) + } + record.Body = &xrpc.AnnotationBody{ + Value: req.Text, + Format: "text/plain", + } + if len(req.Tags) > 0 { + record.Tags = req.Tags + } else { + record.Tags = nil } - } - record.Labels = xrpc.NewSelfLabels(updateLabels) - if err := record.Validate(); err != nil { - return fmt.Errorf("validation failed: %w", err) - } + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) - var updateErr error - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) - if updateErr != nil { - logger.Error("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr) - _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey) - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record) + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + if updateErr != nil { + _ = client.DeleteRecord(r.Context(), did, collection, rkey) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + } } return updateErr }) @@ -405,16 +539,13 @@ func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Requ return } - s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID) - - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var validLabels []string - for _, l := range req.Labels { - if validSelfLabels[l] { - validLabels = append(validLabels, l) - } + if parts[1] == xrpc.CollectionNote { + s.db.UpdateNoteAnnotation(uri, req.Text, tagsJSON, result.CID) + } else { + s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID) } - if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { + + if err := s.db.SyncSelfLabels(session.DID, uri, filterSelfLabels(req.Labels)); err != nil { logger.Error("Warning: failed to sync self-labels: %v", err) } @@ -438,7 +569,7 @@ type CreateLikeRequest struct { SubjectCID string `json:"subjectCid"` } -func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) LikeAnnotation(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -504,7 +635,7 @@ func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Reques WriteSuccess(w, map[string]string{"uri": result.URI}) } -func (s *AnnotationService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -547,7 +678,7 @@ type CreateReplyRequest struct { Text string `json:"text"` } -func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) CreateReply(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -631,7 +762,7 @@ func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) WriteSuccess(w, map[string]string{"uri": result.URI}) } -func (s *AnnotationService) DeleteReply(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) DeleteReply(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { WriteBadRequest(w, "uri query parameter required") @@ -677,7 +808,7 @@ type CreateHighlightRequest struct { Labels []string `json:"labels,omitempty"` } -func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) CreateHighlight(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -700,16 +831,12 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque } urlHash := db.HashURL(req.URL) - record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags) - - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var validLabels []string - for _, l := range req.Labels { - if validSelfLabels[l] { - validLabels = append(validLabels, l) - } + record := xrpc.NewNoteRecord(req.URL, urlHash, "", req.Selector, req.Title, req.Color, "", "highlighting") + if len(req.Tags) > 0 { + record.Tags = req.Tags } - record.Labels = xrpc.NewSelfLabels(validLabels) + + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) if err := record.Validate(); err != nil { WriteBadRequest(w, "Validation error: "+err.Error()) @@ -726,7 +853,7 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err != nil { @@ -758,9 +885,10 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque } cid := result.CID - highlight := &db.Highlight{ + note := &db.Note{ URI: result.URI, AuthorDID: session.DID, + Motivation: "highlighting", TargetSource: req.URL, TargetHash: urlHash, TargetTitle: titlePtr, @@ -771,12 +899,12 @@ func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Reque IndexedAt: time.Now(), CID: &cid, } - if err := s.db.CreateHighlight(highlight); err != nil { - WriteInternalError(w, "Failed to index highlight") + if err := s.db.CreateNote(note); err != nil { + WriteInternalError(w, "Failed to index highlight node") return } - for _, label := range validLabels { + for _, label := range filterSelfLabels(req.Labels) { if err := s.db.CreateContentLabel(session.DID, result.URI, label, session.DID); err != nil { logger.Error("Warning: failed to create self-label %s: %v", label, err) } @@ -792,7 +920,7 @@ type CreateBookmarkRequest struct { Tags []string `json:"tags,omitempty"` } -func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) CreateBookmark(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -815,7 +943,7 @@ func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Reques } urlHash := db.HashURL(req.URL) - record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description) + record := xrpc.NewNoteRecord(req.URL, urlHash, "", nil, req.Title, "", req.Description, "bookmarking") if len(req.Tags) > 0 { record.Tags = req.Tags } @@ -834,7 +962,7 @@ func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Reques err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { var createErr error - result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record) + result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionNote, record) return createErr }) if err != nil { @@ -842,6 +970,41 @@ func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Reques return } + capturedSession := session + capturedTags := append([]string(nil), req.Tags...) + capturedURL := req.URL + capturedURLHash := urlHash + go func() { + prefs, dbErr := s.db.GetPreferences(capturedSession.DID) + communityEnabled := dbErr == nil && prefs != nil && (prefs.EnableCommunityBookmarks == nil || *prefs.EnableCommunityBookmarks) + if !communityEnabled { + return + } + + tagsJSON := "" + if len(capturedTags) > 0 { + if b, err := json.Marshal(capturedTags); err == nil { + tagsJSON = string(b) + } + } + if exists, err := s.db.CommunityBookmarkExists(capturedSession.DID, capturedURLHash, tagsJSON); err == nil && exists { + return + } + + client := s.refresher.CreateClientFromSession(capturedSession) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + communityRecord := map[string]interface{}{ + "$type": xrpc.CollectionCommunityBookmark, + "subject": capturedURL, + "createdAt": time.Now().UTC().Format(time.RFC3339), + } + if len(capturedTags) > 0 { + communityRecord["tags"] = capturedTags + } + _, _ = client.CreateRecord(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, communityRecord) + }() + var titlePtr *string if req.Title != "" { titlePtr = &req.Title @@ -859,24 +1022,27 @@ func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Reques } cid := result.CID - bookmark := &db.Bookmark{ - URI: result.URI, - AuthorDID: session.DID, - Source: req.URL, - SourceHash: urlHash, - Title: titlePtr, - Description: descPtr, - TagsJSON: tagsJSONPtr, - CreatedAt: time.Now(), - IndexedAt: time.Now(), - CID: &cid, - } - s.db.CreateBookmark(bookmark) + note := &db.Note{ + URI: result.URI, + AuthorDID: session.DID, + Motivation: "bookmarking", + TargetSource: req.URL, + TargetHash: urlHash, + TargetTitle: titlePtr, + BodyValue: descPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: time.Now(), + IndexedAt: time.Now(), + CID: &cid, + } + if err := s.db.CreateNote(note); err != nil { + logger.Error("Warning: failed to index bookmark in local DB: %v", err) + } WriteSuccess(w, map[string]string{"uri": result.URI, "cid": result.CID}) } -func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) DeleteHighlight(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -889,20 +1055,34 @@ func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Reque return } + did := session.DID + collection := xrpc.CollectionNote + for _, col := range []string{xrpc.CollectionNote, xrpc.CollectionHighlight} { + uri := "at://" + did + "/" + col + "/" + rkey + if note, dbErr := s.db.GetNoteByURI(uri); dbErr == nil && note != nil { + collection = col + break + } else if _, dbErr := s.db.GetHighlightByURI(uri); dbErr == nil { + collection = col + break + } + } + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) + return client.DeleteRecord(r.Context(), did, collection, rkey) }) if pdsErr != nil { logger.Error("PDS delete highlight failed (will still clean local DB): %v", pdsErr) } - uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey + uri := "at://" + did + "/" + collection + "/" + rkey s.db.DeleteHighlight(uri) + s.db.DeleteNote(uri) WriteSuccess(w, map[string]bool{"success": true}) } -func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) DeleteBookmark(w http.ResponseWriter, r *http.Request) { session, err := s.refresher.GetSessionWithAutoRefresh(r) if err != nil { WriteUnauthorized(w, err.Error()) @@ -915,15 +1095,29 @@ func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Reques return } + did := session.DID + collection := xrpc.CollectionNote + for _, col := range []string{xrpc.CollectionNote, xrpc.CollectionBookmark, xrpc.CollectionCommunityBookmark} { + uri := "at://" + did + "/" + col + "/" + rkey + if note, dbErr := s.db.GetNoteByURI(uri); dbErr == nil && note != nil { + collection = col + break + } else if _, dbErr := s.db.GetBookmarkByURI(uri); dbErr == nil { + collection = col + break + } + } + pdsErr := s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) + return client.DeleteRecord(r.Context(), did, collection, rkey) }) if pdsErr != nil { logger.Error("PDS delete bookmark failed (will still clean local DB): %v", pdsErr) } - uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey + uri := "at://" + did + "/" + collection + "/" + rkey s.db.DeleteBookmark(uri) + s.db.DeleteNote(uri) WriteSuccess(w, map[string]bool{"success": true}) } @@ -934,7 +1128,7 @@ type UpdateHighlightRequest struct { Labels []string `json:"labels,omitempty"` } -func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) UpdateHighlight(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { WriteBadRequest(w, "uri query parameter required") @@ -971,40 +1165,55 @@ func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Reque var result *xrpc.PutRecordOutput err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) + collection := parts[1] + existing, getErr := client.GetRecord(r.Context(), did, collection, rkey) if getErr != nil { return fmt.Errorf("failed to fetch record: %w", getErr) } - var record xrpc.HighlightRecord - json.Unmarshal(existing.Value, &record) + var updateErr error + if collection == xrpc.CollectionNote { + var record xrpc.NoteRecord + json.Unmarshal(existing.Value, &record) - if req.Color != "" { - record.Color = req.Color - } - if req.Tags != nil { - record.Tags = req.Tags - } + if req.Color != "" { + record.Color = req.Color + } + if req.Tags != nil { + record.Tags = req.Tags + } - updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var updateLabels []string - for _, l := range req.Labels { - if updateValidLabels[l] { - updateLabels = append(updateLabels, l) + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) + + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) } - } - record.Labels = xrpc.NewSelfLabels(updateLabels) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + if updateErr != nil { + _ = client.DeleteRecord(r.Context(), did, collection, rkey) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + } + } else { + var record xrpc.HighlightRecord + json.Unmarshal(existing.Value, &record) - if err := record.Validate(); err != nil { - return fmt.Errorf("validation failed: %w", err) - } + if req.Color != "" { + record.Color = req.Color + } + if req.Tags != nil { + record.Tags = req.Tags + } - var updateErr error - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) - if updateErr != nil { - logger.Error("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr) - _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey) - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record) + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) + + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + if updateErr != nil { + _ = client.DeleteRecord(r.Context(), did, collection, rkey) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + } } return updateErr }) @@ -1019,16 +1228,13 @@ func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Reque b, _ := json.Marshal(req.Tags) tagsJSON = string(b) } - s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) - - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var validLabels []string - for _, l := range req.Labels { - if validSelfLabels[l] { - validLabels = append(validLabels, l) - } + if parts[1] == xrpc.CollectionNote { + s.db.UpdateNoteHighlight(uri, req.Color, tagsJSON, result.CID) + } else { + s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID) } - if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { + + if err := s.db.SyncSelfLabels(session.DID, uri, filterSelfLabels(req.Labels)); err != nil { logger.Error("Warning: failed to sync self-labels: %v", err) } @@ -1042,7 +1248,7 @@ type UpdateBookmarkRequest struct { Labels []string `json:"labels,omitempty"` } -func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { +func (s *NoteWriteService) UpdateBookmark(w http.ResponseWriter, r *http.Request) { uri := r.URL.Query().Get("uri") if uri == "" { WriteBadRequest(w, "uri query parameter required") @@ -1079,43 +1285,64 @@ func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Reques } err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { - existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) + collection := parts[1] + existing, getErr := client.GetRecord(r.Context(), did, collection, rkey) if getErr != nil { return fmt.Errorf("failed to fetch record: %w", getErr) } - var record xrpc.BookmarkRecord - json.Unmarshal(existing.Value, &record) + var updateErr error + if collection == xrpc.CollectionNote { + var record xrpc.NoteRecord + json.Unmarshal(existing.Value, &record) - if req.Title != "" { - record.Title = req.Title - } - if req.Description != "" { - record.Description = req.Description - } - if req.Tags != nil { - record.Tags = req.Tags - } + if req.Title != "" { + record.Target.Title = req.Title + } + if req.Description != "" { + if record.Body == nil { + record.Body = &xrpc.AnnotationBody{Format: "text/plain"} + } + record.Body.Value = req.Description + } + if req.Tags != nil { + record.Tags = req.Tags + } + + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) - updateValidLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var updateLabels []string - for _, l := range req.Labels { - if updateValidLabels[l] { - updateLabels = append(updateLabels, l) + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) } - } - record.Labels = xrpc.NewSelfLabels(updateLabels) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + if updateErr != nil { + _ = client.DeleteRecord(r.Context(), did, collection, rkey) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + } + } else { + var record xrpc.BookmarkRecord + json.Unmarshal(existing.Value, &record) - if err := record.Validate(); err != nil { - return fmt.Errorf("validation failed: %w", err) - } + if req.Title != "" { + record.Title = req.Title + } + if req.Description != "" { + record.Description = req.Description + } + if req.Tags != nil { + record.Tags = req.Tags + } - var updateErr error - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) - if updateErr != nil { - logger.Error("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr) - _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey) - result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record) + record.Labels = xrpc.NewSelfLabels(filterSelfLabels(req.Labels)) + + if err := record.Validate(); err != nil { + return fmt.Errorf("validation failed: %w", err) + } + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + if updateErr != nil { + _ = client.DeleteRecord(r.Context(), did, collection, rkey) + result, updateErr = client.PutRecord(r.Context(), did, collection, rkey, record) + } } return updateErr }) @@ -1130,18 +1357,59 @@ func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Reques b, _ := json.Marshal(req.Tags) tagsJSON = string(b) } - s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) - - validSelfLabels := map[string]bool{"sexual": true, "nudity": true, "violence": true, "gore": true, "spam": true, "misleading": true} - var validLabels []string - for _, l := range req.Labels { - if validSelfLabels[l] { - validLabels = append(validLabels, l) - } + if parts[1] == xrpc.CollectionNote { + s.db.UpdateNoteBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) + } else { + s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID) } - if err := s.db.SyncSelfLabels(session.DID, uri, validLabels); err != nil { + + if err := s.db.SyncSelfLabels(session.DID, uri, filterSelfLabels(req.Labels)); err != nil { logger.Error("Warning: failed to sync self-labels: %v", err) } + if req.Tags != nil { + capturedSession := session + capturedTags := append([]string(nil), req.Tags...) + capturedURI := uri + go func() { + note, err := s.db.GetNoteByURI(capturedURI) + if err != nil || note == nil || note.TargetSource == "" { + return + } + targetURL := note.TargetSource + client := s.refresher.CreateClientFromSession(capturedSession) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + records, err := client.ListRecords(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, 100) + if err != nil { + return + } + for _, rec := range records.Records { + var cb struct { + Subject string `json:"subject"` + CreatedAt string `json:"createdAt"` + Tags []string `json:"tags"` + } + if err := json.Unmarshal(rec.Value, &cb); err != nil { + continue + } + if cb.Subject != targetURL { + continue + } + parts := parseATURI(rec.URI) + if len(parts) < 3 { + continue + } + _, _ = client.PutRecord(ctx, capturedSession.DID, xrpc.CollectionCommunityBookmark, parts[2], map[string]interface{}{ + "$type": xrpc.CollectionCommunityBookmark, + "subject": cb.Subject, + "createdAt": cb.CreatedAt, + "tags": capturedTags, + }) + return + } + }() + } + WriteSuccess(w, map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID}) } diff --git a/backend/internal/api/notes_helpers.go b/backend/internal/api/notes_helpers.go new file mode 100644 index 0000000..75bff87 --- /dev/null +++ b/backend/internal/api/notes_helpers.go @@ -0,0 +1,103 @@ +package api + +import ( + "context" + "encoding/json" + "time" + + "margin.at/internal/domain" + "margin.at/internal/xrpc" +) + +var validSelfLabelSet = map[string]bool{ + "sexual": true, + "nudity": true, + "violence": true, + "gore": true, + "spam": true, + "misleading": true, +} + +func filterSelfLabels(labels []string) []string { + if len(labels) == 0 { + return nil + } + out := make([]string, 0, len(labels)) + for _, l := range labels { + if validSelfLabelSet[l] { + out = append(out, l) + } + } + return out +} + +func putRecordWithRetry( + ctx context.Context, + client *xrpc.Client, + did, collection, rkey string, + record interface{ Validate() error }, +) (*xrpc.PutRecordOutput, error) { + if err := record.Validate(); err != nil { + return nil, err + } + result, err := client.PutRecord(ctx, did, collection, rkey, record) + if err != nil { + _ = client.DeleteRecord(ctx, did, collection, rkey) + result, err = client.PutRecord(ctx, did, collection, rkey, record) + } + return result, err +} + +func (s *NoteWriteService) checkDuplicateAnnotation(did, url, text string) (*domain.Annotation, error) { + recent, err := s.db.GetAnnotationsByAuthor(did, 5, 0) + if err != nil { + return nil, err + } + for i := range recent { + a := &recent[i] + if a.TargetSource == url && + ((a.BodyValue == nil && text == "") || (a.BodyValue != nil && *a.BodyValue == text)) && + time.Since(a.CreatedAt) < 10*time.Second { + return a, nil + } + } + return nil, nil +} + +func (s *NoteWriteService) checkDuplicateHighlight(did, url string, selector json.RawMessage) (*domain.Highlight, error) { + recent, err := s.db.GetHighlightsByAuthor(did, 5, 0) + if err != nil { + return nil, err + } + for i := range recent { + h := &recent[i] + if h.TargetSource != url || time.Since(h.CreatedAt) >= 10*time.Second { + continue + } + if selector == nil && h.SelectorJSON == nil { + return h, nil + } + if selector != nil && h.SelectorJSON != nil { + b, _ := json.Marshal(selector) + if *h.SelectorJSON == string(b) { + return h, nil + } + } + } + return nil, nil +} + +func (s *NoteWriteService) checkDuplicateBookmark(did, url string) (*domain.Bookmark, error) { + urlHash := s.db.HashURL(url) + bookmarks, err := s.db.GetBookmarksByTargetHash(urlHash, 50, 0) + if err != nil { + return nil, err + } + for i := range bookmarks { + b := &bookmarks[i] + if b.AuthorDID == did && b.Source == url { + return b, nil + } + } + return nil, nil +} diff --git a/backend/internal/api/preferences.go b/backend/internal/api/preferences.go index 4df9606..0f7c02f 100644 --- a/backend/internal/api/preferences.go +++ b/backend/internal/api/preferences.go @@ -27,6 +27,7 @@ type PreferencesResponse struct { SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers"` LabelPreferences []LabelPreference `json:"labelPreferences"` DisableExternalLinkWarning bool `json:"disableExternalLinkWarning"` + EnableCommunityBookmarks bool `json:"enableCommunityBookmarks"` } func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { @@ -72,11 +73,17 @@ func (h *Handler) GetPreferences(w http.ResponseWriter, r *http.Request) { disableWarning = *prefs.DisableExternalLinkWarning } + enableCommunityBookmarks := true + if prefs != nil && prefs.EnableCommunityBookmarks != nil { + enableCommunityBookmarks = *prefs.EnableCommunityBookmarks + } + WriteSuccess(w, PreferencesResponse{ ExternalLinkSkippedHostnames: hostnames, SubscribedLabelers: labelers, LabelPreferences: labelPrefs, DisableExternalLinkWarning: disableWarning, + EnableCommunityBookmarks: enableCommunityBookmarks, }) } @@ -110,7 +117,7 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { }) } - record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs, &input.DisableExternalLinkWarning) + record := xrpc.NewPreferencesRecord(input.ExternalLinkSkippedHostnames, xrpcLabelers, xrpcLabelPrefs, &input.DisableExternalLinkWarning, &input.EnableCommunityBookmarks) if err := record.Validate(); err != nil { WriteBadRequest(w, fmt.Sprintf("Invalid record: %v", err)) return @@ -152,6 +159,7 @@ func (h *Handler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { SubscribedLabelers: subscribedLabelersPtr, LabelPreferences: labelPrefsPtr, DisableExternalLinkWarning: &input.DisableExternalLinkWarning, + EnableCommunityBookmarks: &input.EnableCommunityBookmarks, CreatedAt: createdAt, IndexedAt: time.Now(), }) diff --git a/backend/internal/api/token_refresh.go b/backend/internal/api/token_refresh.go index 02da9b2..48abcba 100644 --- a/backend/internal/api/token_refresh.go +++ b/backend/internal/api/token_refresh.go @@ -48,7 +48,7 @@ func (tr *TokenRefresher) getOAuthClient(r *http.Request) *oauth.Client { baseURL = baseURL[:len(baseURL)-1] } - clientID := baseURL + "/client-metadata.json" + clientID := baseURL + "/oauth-client-metadata.json" redirectURI := baseURL + "/auth/callback" return oauth.NewClient(clientID, redirectURI, tr.privateKey) diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index c3a1635..b4d8552 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -8,792 +8,64 @@ import ( "time" _ "github.com/lib/pq" + "margin.at/internal/domain" ) type DB struct { *sql.DB } -type Annotation struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - Motivation string `json:"motivation,omitempty"` - BodyValue *string `json:"bodyValue,omitempty"` - BodyFormat *string `json:"bodyFormat,omitempty"` - BodyURI *string `json:"bodyUri,omitempty"` - TargetSource string `json:"targetSource"` - TargetHash string `json:"targetHash"` - TargetTitle *string `json:"targetTitle,omitempty"` - SelectorJSON *string `json:"selector,omitempty"` - TagsJSON *string `json:"tags,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` - CID *string `json:"cid,omitempty"` -} - -type Selector struct { - Type string `json:"type"` - Exact string `json:"exact,omitempty"` - Prefix string `json:"prefix,omitempty"` - Suffix string `json:"suffix,omitempty"` - Start *int `json:"start,omitempty"` - End *int `json:"end,omitempty"` - Value string `json:"value,omitempty"` -} - -type Highlight struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - TargetSource string `json:"targetSource"` - TargetHash string `json:"targetHash"` - TargetTitle *string `json:"targetTitle,omitempty"` - SelectorJSON *string `json:"selector,omitempty"` - Color *string `json:"color,omitempty"` - TagsJSON *string `json:"tags,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` - CID *string `json:"cid,omitempty"` -} - -type Bookmark struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - Source string `json:"source"` - SourceHash string `json:"sourceHash"` - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - TagsJSON *string `json:"tags,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` - CID *string `json:"cid,omitempty"` -} - -type Reply struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - ParentURI string `json:"parentUri"` - RootURI string `json:"rootUri"` - Text string `json:"text"` - Format *string `json:"format,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` - CID *string `json:"cid,omitempty"` -} - -type Like struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - SubjectURI string `json:"subjectUri"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` -} - -type Collection struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` -} - -type CollectionItem struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - CollectionURI string `json:"collectionUri"` - AnnotationURI string `json:"annotationUri"` - Position int `json:"position"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` -} - -type Notification struct { - ID int `json:"id"` - RecipientDID string `json:"recipientDid"` - ActorDID string `json:"actorDid"` - Type string `json:"type"` - SubjectURI string `json:"subjectUri"` - CreatedAt time.Time `json:"createdAt"` - ReadAt *time.Time `json:"readAt,omitempty"` -} - -type APIKey struct { - ID string `json:"id"` - OwnerDID string `json:"ownerDid"` - Name string `json:"name"` - KeyHash string `json:"-"` - CreatedAt time.Time `json:"createdAt"` - LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` - URI string `json:"uri"` - CID *string `json:"cid,omitempty"` - IndexedAt time.Time `json:"indexedAt"` -} - -type Profile struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - DisplayName *string `json:"displayName,omitempty"` - Avatar *string `json:"avatar,omitempty"` - Bio *string `json:"bio,omitempty"` - Website *string `json:"website,omitempty"` - LinksJSON *string `json:"links,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` - CID *string `json:"cid,omitempty"` -} - -type Preferences struct { - URI string `json:"uri"` - AuthorDID string `json:"authorDid"` - ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` - SubscribedLabelers *string `json:"subscribedLabelers,omitempty"` - LabelPreferences *string `json:"labelPreferences,omitempty"` - DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` - CreatedAt time.Time `json:"createdAt"` - IndexedAt time.Time `json:"indexedAt"` - CID *string `json:"cid,omitempty"` -} - -type Block struct { - ID int `json:"id"` - ActorDID string `json:"actorDid"` - SubjectDID string `json:"subjectDid"` - CreatedAt time.Time `json:"createdAt"` -} - -type Mute struct { - ID int `json:"id"` - ActorDID string `json:"actorDid"` - SubjectDID string `json:"subjectDid"` - CreatedAt time.Time `json:"createdAt"` -} - -type ModerationReport struct { - ID int `json:"id"` - ReporterDID string `json:"reporterDid"` - SubjectDID string `json:"subjectDid"` - SubjectURI *string `json:"subjectUri,omitempty"` - ReasonType string `json:"reasonType"` - ReasonText *string `json:"reasonText,omitempty"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - ResolvedAt *time.Time `json:"resolvedAt,omitempty"` - ResolvedBy *string `json:"resolvedBy,omitempty"` -} - -type ModerationAction struct { - ID int `json:"id"` - ReportID int `json:"reportId"` - ActorDID string `json:"actorDid"` - Action string `json:"action"` - Comment *string `json:"comment,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -type ContentLabel struct { - ID int `json:"id"` - Src string `json:"src"` - URI string `json:"uri"` - Val string `json:"val"` - Neg bool `json:"neg"` - CreatedBy string `json:"createdBy"` - CreatedAt time.Time `json:"createdAt"` -} +type ( + Note = domain.Note + Annotation = domain.Annotation + Selector = domain.Selector + Highlight = domain.Highlight + Bookmark = domain.Bookmark + Reply = domain.Reply + Like = domain.Like + Collection = domain.Collection + CollectionItem = domain.CollectionItem + Notification = domain.Notification + APIKey = domain.APIKey + Profile = domain.Profile + Preferences = domain.Preferences + Block = domain.Block + Mute = domain.Mute + ModerationReport = domain.ModerationReport + ModerationAction = domain.ModerationAction + ContentLabel = domain.ContentLabel +) func New(dsn string) (*DB, error) { if !strings.HasPrefix(dsn, "postgres://") && !strings.HasPrefix(dsn, "postgresql://") { - return nil, fmt.Errorf("only PostgreSQL is supported, DSN must start with postgres:// or postgresql://") + return nil, fmt.Errorf("only PostgreSQL is supported; DSN must start with postgres:// or postgresql://") } - db, err := sql.Open("postgres", dsn) + sqlDB, err := sql.Open("postgres", dsn) if err != nil { - return nil, fmt.Errorf("failed to open database connection: %w", err) - } - - db.SetMaxOpenConns(25) - db.SetMaxIdleConns(10) - db.SetConnMaxLifetime(5 * time.Minute) - db.SetConnMaxIdleTime(2 * time.Minute) - - if err := db.Ping(); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) + return nil, fmt.Errorf("open database: %w", err) } - return &DB{DB: db}, nil -} + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(5 * time.Minute) + sqlDB.SetConnMaxIdleTime(2 * time.Minute) -func (db *DB) Migrate() error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS annotations ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - motivation TEXT, - body_value TEXT, - body_format TEXT DEFAULT 'text/plain', - body_uri TEXT, - target_source TEXT NOT NULL, - target_hash TEXT NOT NULL, - target_title TEXT, - selector_json TEXT, - tags_json TEXT, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL, - cid TEXT - )`) - if err != nil { - return err + if err := sqlDB.Ping(); err != nil { + return nil, fmt.Errorf("ping database: %w", err) } - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_target_hash ON annotations(target_hash)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_target_source ON annotations(target_source)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_author_did ON annotations(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_motivation ON annotations(motivation)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_author_created ON annotations(author_did, created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_annotations_uri_pattern ON annotations(uri text_pattern_ops)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS highlights ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - target_source TEXT NOT NULL, - target_hash TEXT NOT NULL, - target_title TEXT, - selector_json TEXT, - color TEXT, - tags_json TEXT, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL, - cid TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_target_hash ON highlights(target_hash)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_author_did ON highlights(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_created_at ON highlights(created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_author_created ON highlights(author_did, created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_highlights_uri_pattern ON highlights(uri text_pattern_ops)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS bookmarks ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - source TEXT NOT NULL, - source_hash TEXT NOT NULL, - title TEXT, - description TEXT, - tags_json TEXT, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL, - cid TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_source_hash ON bookmarks(source_hash)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_author_did ON bookmarks(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_author_created ON bookmarks(author_did, created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_uri_pattern ON bookmarks(uri text_pattern_ops)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS replies ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - parent_uri TEXT NOT NULL, - root_uri TEXT NOT NULL, - text TEXT NOT NULL, - format TEXT DEFAULT 'text/plain', - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL, - cid TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_parent_uri ON replies(parent_uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_root_uri ON replies(root_uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_created_at ON replies(created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_replies_author_did ON replies(author_did)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS likes ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - subject_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_likes_author_subject ON likes(author_did, subject_uri)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS collections ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - icon TEXT, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collections_author_did ON collections(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collections_created_at ON collections(created_at DESC)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS collection_items ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - collection_uri TEXT NOT NULL, - annotation_uri TEXT NOT NULL, - position INTEGER DEFAULT 0, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_annotation ON collection_items(annotation_uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_collection_items_created_at ON collection_items(created_at DESC)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - did TEXT NOT NULL, - handle TEXT NOT NULL, - access_token TEXT NOT NULL, - refresh_token TEXT NOT NULL, - dpop_key TEXT, - created_at TIMESTAMP NOT NULL, - expires_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_sessions_did ON sessions(did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS edit_history ( - id SERIAL PRIMARY KEY, - uri TEXT NOT NULL, - record_type TEXT NOT NULL, - previous_content TEXT NOT NULL, - previous_cid TEXT, - edited_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_edit_history_uri ON edit_history(uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_edit_history_edited_at ON edit_history(edited_at DESC)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS notifications ( - id SERIAL PRIMARY KEY, - recipient_did TEXT NOT NULL, - actor_did TEXT NOT NULL, - type TEXT NOT NULL, - subject_uri TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - read_at TIMESTAMP - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(recipient_did) WHERE read_at IS NULL`) - - db.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - owner_did TEXT NOT NULL, - name TEXT NOT NULL, - key_hash TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - last_used_at TIMESTAMP, - uri TEXT, - cid TEXT, - indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS profiles ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - display_name TEXT, - avatar TEXT, - bio TEXT, - website TEXT, - links_json TEXT, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL, - cid TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS preferences ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - external_link_skipped_hostnames TEXT, - subscribed_labelers TEXT, - label_preferences TEXT, - disable_external_link_warning BOOLEAN, - created_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL, - cid TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_preferences_author_did ON preferences(author_did)`) - - db.runMigrations() - - db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( - id TEXT PRIMARY KEY, - last_cursor BIGINT NOT NULL, - updated_at TIMESTAMP NOT NULL - )`) - - db.Exec(`CREATE TABLE IF NOT EXISTS blocks ( - id SERIAL PRIMARY KEY, - actor_did TEXT NOT NULL, - subject_did TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - UNIQUE(actor_did, subject_did) - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_actor ON blocks(actor_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_blocks_subject ON blocks(subject_did)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS mutes ( - id SERIAL PRIMARY KEY, - actor_did TEXT NOT NULL, - subject_did TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - UNIQUE(actor_did, subject_did) - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_actor ON mutes(actor_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mutes_subject ON mutes(subject_did)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports ( - id SERIAL PRIMARY KEY, - reporter_did TEXT NOT NULL, - subject_did TEXT NOT NULL, - subject_uri TEXT, - reason_type TEXT NOT NULL, - reason_text TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMP NOT NULL, - resolved_at TIMESTAMP, - resolved_by TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions ( - id SERIAL PRIMARY KEY, - report_id INTEGER NOT NULL, - actor_did TEXT NOT NULL, - action TEXT NOT NULL, - comment TEXT, - created_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS content_labels ( - id SERIAL PRIMARY KEY, - src TEXT NOT NULL, - uri TEXT NOT NULL, - val TEXT NOT NULL, - neg INTEGER NOT NULL DEFAULT 0, - created_by TEXT NOT NULL, - created_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS publications ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - url TEXT NOT NULL, - name TEXT NOT NULL, - description TEXT, - show_in_discover BOOLEAN NOT NULL DEFAULT true, - indexed_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_publications_author ON publications(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_publications_url ON publications(url)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS documents ( - uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - site TEXT NOT NULL, - path TEXT, - title TEXT NOT NULL, - description TEXT, - text_content TEXT, - tags_json TEXT, - canonical_url TEXT, - published_at TIMESTAMP NOT NULL, - indexed_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_author ON documents(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_site ON documents(site)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_canonical ON documents(canonical_url)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_documents_published ON documents(published_at DESC)`) - - db.runMigrations() - - return nil + return &DB{DB: sqlDB}, nil } -func (db *DB) GetProfilesByDIDs(dids []string) (map[string]*Profile, error) { - if len(dids) == 0 { - return nil, nil - } - - placeholders := make([]string, len(dids)) - args := make([]interface{}, len(dids)) - for i, did := range dids { - placeholders[i] = fmt.Sprintf("$%d", i+1) - args[i] = did - } - - 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, ",") + ")" - - rows, err := db.Query(query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - profiles := make(map[string]*Profile) - for rows.Next() { - var p Profile - if err := rows.Scan(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Bio, &p.Avatar, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt); err != nil { - continue - } - profiles[p.AuthorDID] = &p - } - - return profiles, nil -} - -func (db *DB) GetCursor(id string) (int64, error) { - var cursor int64 - err := db.QueryRow("SELECT last_cursor FROM cursors WHERE id = $1", id).Scan(&cursor) - if err == sql.ErrNoRows { - return 0, nil - } - if err != nil { - return 0, err - } - return cursor, nil -} - -func (db *DB) SetCursor(id string, cursor int64) error { - query := ` - INSERT INTO cursors (id, last_cursor, updated_at) - VALUES ($1, $2, $3) - ON CONFLICT(id) DO UPDATE SET - last_cursor = EXCLUDED.last_cursor, - updated_at = EXCLUDED.updated_at - ` - _, err := db.Exec(query, id, cursor, time.Now()) - return err -} - -func (db *DB) GetProfile(did string) (*Profile, error) { - var p Profile - 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( - &p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, - ) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &p, nil -} - -func (db *DB) UpsertProfile(p *Profile) error { - query := ` - INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT(uri) DO UPDATE SET - display_name = EXCLUDED.display_name, - avatar = EXCLUDED.avatar, - bio = EXCLUDED.bio, - website = EXCLUDED.website, - links_json = EXCLUDED.links_json, - indexed_at = EXCLUDED.indexed_at - ` - _, err := db.Exec(query, p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) - return err -} - -func (db *DB) DeleteProfile(uri string) error { - _, err := db.Exec("DELETE FROM profiles WHERE uri = $1", uri) - return err -} - -func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { - var uri string - err := db.QueryRow("SELECT uri FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID).Scan(&uri) - if err != nil { - if err == sql.ErrNoRows { - return "", nil - } - return "", err - } - - _, err = db.Exec("DELETE FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID) - return uri, err -} - -func (db *DB) GetPreferences(did string) (*Preferences, error) { - var p Preferences - 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( - &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, &p.LabelPreferences, &p.DisableExternalLinkWarning, &p.CreatedAt, &p.IndexedAt, &p.CID, - ) - if err == sql.ErrNoRows { - return nil, nil - } - if err != nil { - return nil, err - } - return &p, nil -} - -func (db *DB) UpsertPreferences(p *Preferences) error { - query := ` - INSERT INTO preferences (uri, author_did, external_link_skipped_hostnames, subscribed_labelers, label_preferences, disable_external_link_warning, created_at, indexed_at, cid) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) - ON CONFLICT(uri) DO UPDATE SET - external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, - subscribed_labelers = EXCLUDED.subscribed_labelers, - label_preferences = EXCLUDED.label_preferences, - disable_external_link_warning = EXCLUDED.disable_external_link_warning, - indexed_at = EXCLUDED.indexed_at, - cid = EXCLUDED.cid - ` - _, err := db.Exec(query, p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, p.LabelPreferences, p.DisableExternalLinkWarning, p.CreatedAt, p.IndexedAt, p.CID) - return err -} - -func (db *DB) DeleteAPIKeyByURI(uri string) error { - _, err := db.Exec("DELETE FROM api_keys WHERE uri = $1", uri) - return err -} - -func (db *DB) DeletePreferences(uri string) error { - _, err := db.Exec("DELETE FROM preferences WHERE uri = $1", uri) - return err -} - -func (db *DB) GetAPIKeyURIs(ownerDID string) ([]string, error) { - rows, err := db.Query("SELECT uri FROM api_keys WHERE owner_did = $1 AND uri IS NOT NULL AND uri != ''", ownerDID) - if err != nil { - return nil, err - } - defer rows.Close() - var uris []string - for rows.Next() { - var uri string - if err := rows.Scan(&uri); err != nil { - return nil, err - } - uris = append(uris, uri) - } - return uris, nil -} - -func (db *DB) GetPreferenceURIs(did string) ([]string, error) { - rows, err := db.Query("SELECT uri FROM preferences WHERE author_did = $1 AND uri IS NOT NULL AND uri != ''", did) - if err != nil { - return nil, err - } - defer rows.Close() - var uris []string - for rows.Next() { - var uri string - if err := rows.Scan(&uri); err != nil { - return nil, err - } - uris = append(uris, uri) - } - return uris, nil -} - -func (db *DB) runMigrations() { - db.Exec(`ALTER TABLE sessions ADD COLUMN IF NOT EXISTS dpop_key TEXT`) - - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS motivation TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_value TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_format TEXT DEFAULT 'text/plain'`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_uri TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_source TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_hash TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_title TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS selector_json TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS tags_json TEXT`) - db.Exec(`ALTER TABLE annotations ADD COLUMN IF NOT EXISTS cid TEXT`) - - db.Exec(`UPDATE annotations SET target_source = url WHERE target_source IS NULL AND url IS NOT NULL`) - db.Exec(`UPDATE annotations SET target_hash = url_hash WHERE target_hash IS NULL AND url_hash IS NOT NULL`) - db.Exec(`UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL`) - db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) - db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) - - db.Exec(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS website TEXT`) - db.Exec(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_name TEXT`) - db.Exec(`ALTER TABLE profiles ADD COLUMN IF NOT EXISTS avatar TEXT`) - - db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`) - - db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS uri TEXT`) - db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS cid TEXT`) - db.Exec(`ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP`) - - db.migrateModeration() - - db.Exec(`ALTER TABLE preferences ADD COLUMN IF NOT EXISTS subscribed_labelers TEXT`) - db.Exec(`ALTER TABLE preferences ADD COLUMN IF NOT EXISTS label_preferences TEXT`) - db.Exec(`ALTER TABLE preferences ADD COLUMN IF NOT EXISTS disable_external_link_warning BOOLEAN`) -} - -func (db *DB) migrateModeration() { - _, err := db.Exec(`SELECT subject_did FROM moderation_reports LIMIT 0`) - if err != nil { - db.Exec(`DROP TABLE IF EXISTS moderation_reports`) - db.Exec(`DROP TABLE IF EXISTS moderation_actions`) - - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_reports ( - id SERIAL PRIMARY KEY, - reporter_did TEXT NOT NULL, - subject_did TEXT NOT NULL, - subject_uri TEXT, - reason_type TEXT NOT NULL, - reason_text TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMP NOT NULL, - resolved_at TIMESTAMP, - resolved_by TEXT - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did)`) - - db.Exec(`CREATE TABLE IF NOT EXISTS moderation_actions ( - id SERIAL PRIMARY KEY, - report_id INTEGER NOT NULL, - actor_did TEXT NOT NULL, - action TEXT NOT NULL, - comment TEXT, - created_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id)`) - } - - db.Exec(`CREATE TABLE IF NOT EXISTS content_labels ( - id SERIAL PRIMARY KEY, - src TEXT NOT NULL, - uri TEXT NOT NULL, - val TEXT NOT NULL, - neg INTEGER NOT NULL DEFAULT 0, - created_by TEXT NOT NULL, - created_at TIMESTAMP NOT NULL - )`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src)`) -} - -func (db *DB) Close() error { - return db.DB.Close() -} +func (db *DB) Close() error { return db.DB.Close() } func ParseSelector(selectorJSON *string) (*Selector, error) { if selectorJSON == nil || *selectorJSON == "" { return nil, nil } var s Selector - err := json.Unmarshal([]byte(*selectorJSON), &s) - if err != nil { + if err := json.Unmarshal([]byte(*selectorJSON), &s); err != nil { return nil, err } return &s, nil @@ -804,8 +76,7 @@ func ParseTags(tagsJSON *string) ([]string, error) { return nil, nil } var tags []string - err := json.Unmarshal([]byte(*tagsJSON), &tags) - if err != nil { + if err := json.Unmarshal([]byte(*tagsJSON), &tags); err != nil { return nil, err } return tags, nil diff --git a/backend/internal/db/filter.go b/backend/internal/db/filter.go new file mode 100644 index 0000000..4ffa78a --- /dev/null +++ b/backend/internal/db/filter.go @@ -0,0 +1,230 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "margin.at/internal/domain" +) + +type FeedType = domain.FeedType +type NoteFilter = domain.NoteFilter + +const ( + FeedTypeRecent = domain.FeedTypeRecent + FeedTypePopular = domain.FeedTypePopular + FeedTypeShelved = domain.FeedTypeShelved + FeedTypeMargin = domain.FeedTypeMargin + FeedTypeSemble = domain.FeedTypeSemble +) + +func (db *DB) ListNotes(f NoteFilter) ([]Note, error) { + var where []string + var args []interface{} + n := 1 + + if len(f.Motivations) == 1 { + where = append(where, fmt.Sprintf("motivation = $%d", n)) + args = append(args, f.Motivations[0]) + n++ + } else if len(f.Motivations) > 1 { + placeholders := make([]string, len(f.Motivations)) + for i, m := range f.Motivations { + placeholders[i] = fmt.Sprintf("$%d", n) + args = append(args, m) + n++ + } + where = append(where, fmt.Sprintf("motivation IN (%s)", strings.Join(placeholders, ", "))) + } + + if f.AuthorDID != "" { + where = append(where, fmt.Sprintf("author_did = $%d", n)) + args = append(args, f.AuthorDID) + n++ + } + + if f.TargetHash != "" { + where = append(where, fmt.Sprintf("target_hash = $%d", n)) + args = append(args, f.TargetHash) + n++ + } + + if f.Tag != "" { + where = append(where, fmt.Sprintf( + "tags_json IS NOT NULL AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = lower($%d))", + n, + )) + args = append(args, f.Tag) + n++ + } + + if f.Query != "" { + pattern := "%" + escapeLike(f.Query) + "%" + where = append(where, fmt.Sprintf( + "(body_value ILIKE $%d OR target_source ILIKE $%d OR target_title ILIKE $%d OR tags_json::text ILIKE $%d)", + n, n+1, n+2, n+3, + )) + args = append(args, pattern, pattern, pattern, pattern) + n += 4 + } + + switch f.FeedType { + case FeedTypeMargin: + where = append(where, "uri NOT LIKE '%network.cosmik%'") + case FeedTypeSemble: + where = append(where, "uri LIKE '%network.cosmik%'") + case FeedTypePopular: + since := time.Now().AddDate(0, 0, -14) + where = append(where, fmt.Sprintf("created_at > $%d", n)) + args = append(args, since) + n++ + case FeedTypeShelved: + olderThan := time.Now().AddDate(0, 0, -1) + since := time.Now().AddDate(0, 0, -14) + where = append(where, fmt.Sprintf("created_at < $%d AND created_at > $%d", n, n+1)) + where = append(where, "NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = uri)") + where = append(where, "NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = uri)") + args = append(args, olderThan, since) + n += 2 + } + + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + + orderClause := "ORDER BY created_at DESC" + switch f.FeedType { + case FeedTypePopular: + orderClause = `ORDER BY ( + SELECT COUNT(*) FROM likes WHERE subject_uri = uri + ) + ( + SELECT COUNT(*) FROM replies WHERE root_uri = uri + ) DESC, created_at DESC` + case FeedTypeShelved: + orderClause = "ORDER BY RANDOM()" + } + + limit := f.Limit + if limit <= 0 { + limit = 50 + } + + query := fmt.Sprintf(` + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM unified_notes + %s + %s + LIMIT $%d OFFSET $%d + `, whereClause, orderClause, n, n+1) + + args = append(args, limit, f.Offset) + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNotes(rows) +} + +func (db *DB) GetNoteByURIFromUnified(uri string) (*Note, error) { + query := ` + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM unified_notes + WHERE uri = $1 + ` + var note Note + err := db.QueryRow(query, uri).Scan( + ¬e.URI, ¬e.AuthorDID, ¬e.Motivation, ¬e.Color, ¬e.Description, + ¬e.BodyValue, ¬e.BodyFormat, ¬e.BodyURI, + ¬e.TargetSource, ¬e.TargetHash, ¬e.TargetTitle, + ¬e.SelectorJSON, ¬e.TagsJSON, ¬e.CreatedAt, ¬e.IndexedAt, ¬e.CID, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return ¬e, nil +} + +func scanNotes(rows interface { + Next() bool + Scan(...interface{}) error +}) ([]Note, error) { + var notes []Note + for rows.Next() { + var note Note + if err := rows.Scan( + ¬e.URI, ¬e.AuthorDID, ¬e.Motivation, ¬e.Color, ¬e.Description, + ¬e.BodyValue, ¬e.BodyFormat, ¬e.BodyURI, + ¬e.TargetSource, ¬e.TargetHash, ¬e.TargetTitle, + ¬e.SelectorJSON, ¬e.TagsJSON, ¬e.CreatedAt, ¬e.IndexedAt, ¬e.CID, + ); err != nil { + return nil, err + } + notes = append(notes, note) + } + return notes, nil +} + +func (db *DB) MigrateUnifiedNotes() { + db.Exec(` + CREATE OR REPLACE VIEW unified_notes AS + -- New notes table (primary path) + SELECT + uri, author_did, + COALESCE(motivation, 'commenting') AS motivation, + color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, + created_at, indexed_at, cid + FROM notes + UNION ALL + -- Legacy annotations (motivation is 'commenting' or similar, never 'highlighting'/'bookmarking') + SELECT + uri, author_did, + COALESCE(motivation, 'commenting') AS motivation, + NULL::TEXT AS color, + NULL::TEXT AS description, + body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, + created_at, indexed_at, cid + FROM annotations + UNION ALL + -- Legacy highlights + SELECT + uri, author_did, + 'highlighting' AS motivation, + color, + NULL::TEXT AS description, + NULL::TEXT AS body_value, + 'text/plain' AS body_format, + NULL::TEXT AS body_uri, + target_source, target_hash, target_title, selector_json, tags_json, + created_at, indexed_at, cid + FROM highlights + UNION ALL + -- Legacy bookmarks (column mapping to Note layout) + SELECT + uri, author_did, + 'bookmarking' AS motivation, + NULL::TEXT AS color, + description, + NULL::TEXT AS body_value, + 'text/plain' AS body_format, + NULL::TEXT AS body_uri, + source AS target_source, + source_hash AS target_hash, + title AS target_title, + NULL::TEXT AS selector_json, + tags_json, + created_at, indexed_at, cid + FROM bookmarks + `) +} diff --git a/backend/internal/db/migrate.go b/backend/internal/db/migrate.go new file mode 100644 index 0000000..3542dab --- /dev/null +++ b/backend/internal/db/migrate.go @@ -0,0 +1,46 @@ +package db + +import ( + "database/sql" + "embed" + "time" + + "github.com/pressly/goose/v3" +) + +//go:embed migrations +var migrationsFS embed.FS + +func (db *DB) Migrate() error { + goose.SetBaseFS(migrationsFS) + + if err := goose.SetDialect("postgres"); err != nil { + return err + } + + return goose.Up(db.DB, "migrations") +} + +func (db *DB) GetCursor(id string) (int64, error) { + var cursor int64 + err := db.QueryRow("SELECT last_cursor FROM cursors WHERE id = $1", id).Scan(&cursor) + switch err { + case nil: + return cursor, nil + case sql.ErrNoRows: + return 0, nil + default: + return 0, err + } +} + +func (db *DB) SetCursor(id string, cursor int64) error { + _, err := db.Exec(` + INSERT INTO cursors (id, last_cursor, updated_at) + VALUES ($1, $2, $3) + ON CONFLICT(id) DO UPDATE SET + last_cursor = EXCLUDED.last_cursor, + updated_at = EXCLUDED.updated_at + `, id, cursor, time.Now()) + return err +} diff --git a/backend/internal/db/migrations/00001_core_tables.sql b/backend/internal/db/migrations/00001_core_tables.sql new file mode 100644 index 0000000..f5d785a --- /dev/null +++ b/backend/internal/db/migrations/00001_core_tables.sql @@ -0,0 +1,204 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS notes ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + motivation TEXT, + color TEXT, + description TEXT, + body_value TEXT, + body_format TEXT DEFAULT 'text/plain', + body_uri TEXT, + target_source TEXT NOT NULL, + target_hash TEXT NOT NULL, + target_title TEXT, + selector_json TEXT, + tags_json TEXT, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_notes_target_hash ON notes(target_hash); +CREATE INDEX IF NOT EXISTS idx_notes_target_source ON notes(target_source); +CREATE INDEX IF NOT EXISTS idx_notes_author_did ON notes(author_did); +CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at DESC); + +CREATE TABLE IF NOT EXISTS annotations ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + motivation TEXT, + body_value TEXT, + body_format TEXT DEFAULT 'text/plain', + body_uri TEXT, + target_source TEXT, + target_hash TEXT, + target_title TEXT, + selector_json TEXT, + tags_json TEXT, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_annotations_target_hash ON annotations(target_hash); +CREATE INDEX IF NOT EXISTS idx_annotations_target_source ON annotations(target_source); +CREATE INDEX IF NOT EXISTS idx_annotations_author_did ON annotations(author_did); +CREATE INDEX IF NOT EXISTS idx_annotations_motivation ON annotations(motivation); +CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_annotations_author_created ON annotations(author_did, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_annotations_uri_pattern ON annotations(uri text_pattern_ops); + +CREATE TABLE IF NOT EXISTS highlights ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + target_source TEXT NOT NULL, + target_hash TEXT NOT NULL, + target_title TEXT, + selector_json TEXT, + color TEXT, + tags_json TEXT, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_highlights_target_hash ON highlights(target_hash); +CREATE INDEX IF NOT EXISTS idx_highlights_author_did ON highlights(author_did); +CREATE INDEX IF NOT EXISTS idx_highlights_created_at ON highlights(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_highlights_author_created ON highlights(author_did, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_highlights_uri_pattern ON highlights(uri text_pattern_ops); + +CREATE TABLE IF NOT EXISTS bookmarks ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + source TEXT NOT NULL, + source_hash TEXT NOT NULL, + title TEXT, + description TEXT, + tags_json TEXT, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_bookmarks_source_hash ON bookmarks(source_hash); +CREATE INDEX IF NOT EXISTS idx_bookmarks_author_did ON bookmarks(author_did); +CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_bookmarks_author_created ON bookmarks(author_did, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_bookmarks_uri_pattern ON bookmarks(uri text_pattern_ops); + +CREATE TABLE IF NOT EXISTS replies ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + parent_uri TEXT NOT NULL, + root_uri TEXT NOT NULL, + text TEXT NOT NULL, + format TEXT DEFAULT 'text/plain', + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_replies_parent_uri ON replies(parent_uri); +CREATE INDEX IF NOT EXISTS idx_replies_root_uri ON replies(root_uri); +CREATE INDEX IF NOT EXISTS idx_replies_created_at ON replies(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_replies_author_did ON replies(author_did); + +CREATE TABLE IF NOT EXISTS likes ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + subject_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_likes_subject_uri ON likes(subject_uri); +CREATE INDEX IF NOT EXISTS idx_likes_author_did ON likes(author_did); +CREATE INDEX IF NOT EXISTS idx_likes_author_subject ON likes(author_did, subject_uri); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + handle TEXT NOT NULL, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + dpop_key TEXT, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_did ON sessions(did); +CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at); + +CREATE TABLE IF NOT EXISTS edit_history ( + id SERIAL PRIMARY KEY, + uri TEXT NOT NULL, + record_type TEXT NOT NULL, + previous_content TEXT NOT NULL, + previous_cid TEXT, + edited_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_edit_history_uri ON edit_history(uri); +CREATE INDEX IF NOT EXISTS idx_edit_history_edited_at ON edit_history(edited_at DESC); + +CREATE TABLE IF NOT EXISTS notifications ( + id SERIAL PRIMARY KEY, + recipient_did TEXT NOT NULL, + actor_did TEXT NOT NULL, + type TEXT NOT NULL, + subject_uri TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + read_at TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_did); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(recipient_did) WHERE read_at IS NULL; + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + name TEXT NOT NULL, + key_hash TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP, + uri TEXT, + cid TEXT, + indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did); +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); + +CREATE TABLE IF NOT EXISTS profiles ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + display_name TEXT, + avatar TEXT, + bio TEXT, + website TEXT, + links_json TEXT, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did); + +CREATE TABLE IF NOT EXISTS preferences ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + external_link_skipped_hostnames TEXT, + subscribed_labelers TEXT, + label_preferences TEXT, + disable_external_link_warning BOOLEAN, + enable_community_bookmarks BOOLEAN DEFAULT false, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL, + cid TEXT +); +CREATE INDEX IF NOT EXISTS idx_preferences_author_did ON preferences(author_did); + +-- +goose Down +DROP TABLE IF EXISTS preferences; +DROP TABLE IF EXISTS profiles; +DROP TABLE IF EXISTS api_keys; +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS edit_history; +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS likes; +DROP TABLE IF EXISTS replies; +DROP TABLE IF EXISTS bookmarks; +DROP TABLE IF EXISTS highlights; +DROP TABLE IF EXISTS annotations; +DROP TABLE IF EXISTS notes; diff --git a/backend/internal/db/migrations/00002_support_tables.sql b/backend/internal/db/migrations/00002_support_tables.sql new file mode 100644 index 0000000..1edf478 --- /dev/null +++ b/backend/internal/db/migrations/00002_support_tables.sql @@ -0,0 +1,131 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS cursors ( + id TEXT PRIMARY KEY, + last_cursor BIGINT NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS collections ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_collections_author_did ON collections(author_did); +CREATE INDEX IF NOT EXISTS idx_collections_created_at ON collections(created_at DESC); + +CREATE TABLE IF NOT EXISTS collection_items ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + collection_uri TEXT NOT NULL, + annotation_uri TEXT NOT NULL, + position INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_uri); +CREATE INDEX IF NOT EXISTS idx_collection_items_annotation ON collection_items(annotation_uri); +CREATE INDEX IF NOT EXISTS idx_collection_items_created_at ON collection_items(created_at DESC); + +CREATE TABLE IF NOT EXISTS blocks ( + id SERIAL PRIMARY KEY, + actor_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE(actor_did, subject_did) +); +CREATE INDEX IF NOT EXISTS idx_blocks_actor ON blocks(actor_did); +CREATE INDEX IF NOT EXISTS idx_blocks_subject ON blocks(subject_did); + +CREATE TABLE IF NOT EXISTS mutes ( + id SERIAL PRIMARY KEY, + actor_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + UNIQUE(actor_did, subject_did) +); +CREATE INDEX IF NOT EXISTS idx_mutes_actor ON mutes(actor_did); +CREATE INDEX IF NOT EXISTS idx_mutes_subject ON mutes(subject_did); + +CREATE TABLE IF NOT EXISTS content_labels ( + id SERIAL PRIMARY KEY, + src TEXT NOT NULL, + uri TEXT NOT NULL, + val TEXT NOT NULL, + neg INTEGER NOT NULL DEFAULT 0, + created_by TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_content_labels_uri ON content_labels(uri); +CREATE INDEX IF NOT EXISTS idx_content_labels_src ON content_labels(src); + +CREATE TABLE IF NOT EXISTS moderation_reports ( + id SERIAL PRIMARY KEY, + reporter_did TEXT NOT NULL, + subject_did TEXT NOT NULL, + subject_uri TEXT, + reason_type TEXT NOT NULL, + reason_text TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMP NOT NULL, + resolved_at TIMESTAMP, + resolved_by TEXT +); +CREATE INDEX IF NOT EXISTS idx_mod_reports_status ON moderation_reports(status); +CREATE INDEX IF NOT EXISTS idx_mod_reports_subject ON moderation_reports(subject_did); +CREATE INDEX IF NOT EXISTS idx_mod_reports_reporter ON moderation_reports(reporter_did); + +CREATE TABLE IF NOT EXISTS moderation_actions ( + id SERIAL PRIMARY KEY, + report_id INTEGER NOT NULL, + actor_did TEXT NOT NULL, + action TEXT NOT NULL, + comment TEXT, + created_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_mod_actions_report ON moderation_actions(report_id); + +CREATE TABLE IF NOT EXISTS publications ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + url TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT, + show_in_discover BOOLEAN NOT NULL DEFAULT true, + indexed_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_publications_author ON publications(author_did); +CREATE INDEX IF NOT EXISTS idx_publications_url ON publications(url); + +CREATE TABLE IF NOT EXISTS documents ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + site TEXT NOT NULL, + path TEXT, + title TEXT NOT NULL, + description TEXT, + text_content TEXT, + tags_json TEXT, + canonical_url TEXT, + published_at TIMESTAMP NOT NULL, + indexed_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_documents_author ON documents(author_did); +CREATE INDEX IF NOT EXISTS idx_documents_site ON documents(site); +CREATE INDEX IF NOT EXISTS idx_documents_canonical ON documents(canonical_url); +CREATE INDEX IF NOT EXISTS idx_documents_published ON documents(published_at DESC); + +-- +goose Down +DROP TABLE IF EXISTS documents; +DROP TABLE IF EXISTS publications; +DROP TABLE IF EXISTS moderation_actions; +DROP TABLE IF EXISTS moderation_reports; +DROP TABLE IF EXISTS content_labels; +DROP TABLE IF EXISTS mutes; +DROP TABLE IF EXISTS blocks; +DROP TABLE IF EXISTS collection_items; +DROP TABLE IF EXISTS collections; +DROP TABLE IF EXISTS cursors; diff --git a/backend/internal/db/migrations/00003_alter_columns.sql b/backend/internal/db/migrations/00003_alter_columns.sql new file mode 100644 index 0000000..a37dc4d --- /dev/null +++ b/backend/internal/db/migrations/00003_alter_columns.sql @@ -0,0 +1,43 @@ +-- +goose Up +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS dpop_key TEXT; + +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS motivation TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_value TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_format TEXT DEFAULT 'text/plain'; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS body_uri TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_source TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_hash TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS target_title TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS selector_json TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS tags_json TEXT; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS cid TEXT; + +-- +goose StatementBegin +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'annotations' AND column_name = 'url') THEN + UPDATE annotations SET target_source = url WHERE target_source IS NULL AND url IS NOT NULL; + UPDATE annotations SET target_hash = url_hash WHERE target_hash IS NULL AND url_hash IS NOT NULL; + UPDATE annotations SET body_value = text WHERE body_value IS NULL AND text IS NOT NULL; + UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL; + END IF; + UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL; +END $$; +-- +goose StatementEnd + +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS website TEXT; +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_name TEXT; +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS avatar TEXT; + +ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT; + +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS uri TEXT; +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS cid TEXT; +ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; + +ALTER TABLE preferences ADD COLUMN IF NOT EXISTS subscribed_labelers TEXT; +ALTER TABLE preferences ADD COLUMN IF NOT EXISTS label_preferences TEXT; +ALTER TABLE preferences ADD COLUMN IF NOT EXISTS disable_external_link_warning BOOLEAN; +ALTER TABLE preferences ADD COLUMN IF NOT EXISTS enable_community_bookmarks BOOLEAN DEFAULT false; + +-- +goose Down \ No newline at end of file diff --git a/backend/internal/db/migrations/00004_views.sql b/backend/internal/db/migrations/00004_views.sql new file mode 100644 index 0000000..ff61f58 --- /dev/null +++ b/backend/internal/db/migrations/00004_views.sql @@ -0,0 +1,33 @@ +-- +goose Up +-- +goose StatementBegin +CREATE OR REPLACE VIEW all_highlights AS +SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid +FROM highlights +UNION ALL +SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid +FROM notes WHERE motivation = 'highlighting'; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE OR REPLACE VIEW all_annotations AS +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 +FROM annotations +UNION ALL +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 +FROM notes WHERE motivation NOT IN ('highlighting', 'bookmarking'); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE OR REPLACE VIEW all_bookmarks AS +SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid +FROM bookmarks +UNION ALL +SELECT uri, author_did, target_source AS source, target_hash AS source_hash, target_title AS title, + COALESCE(body_value, description) AS description, tags_json, created_at, indexed_at, cid +FROM notes WHERE motivation = 'bookmarking'; +-- +goose StatementEnd + +-- +goose Down +DROP VIEW IF EXISTS all_bookmarks; +DROP VIEW IF EXISTS all_annotations; +DROP VIEW IF EXISTS all_highlights; diff --git a/backend/internal/db/migrations/00005_recommendations.sql b/backend/internal/db/migrations/00005_recommendations.sql new file mode 100644 index 0000000..a86a168 --- /dev/null +++ b/backend/internal/db/migrations/00005_recommendations.sql @@ -0,0 +1,29 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS document_embeddings ( + document_uri TEXT PRIMARY KEY, + embedding TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS annotation_embeddings ( + annotation_uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + document_uri TEXT, + embedding TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ann_emb_author ON annotation_embeddings(author_did); +CREATE INDEX IF NOT EXISTS idx_ann_emb_document ON annotation_embeddings(document_uri); + +CREATE TABLE IF NOT EXISTS user_profiles ( + author_did TEXT PRIMARY KEY, + embedding TEXT NOT NULL, + tag_affinities TEXT DEFAULT '{}', + annotation_count INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS user_profiles; +DROP TABLE IF EXISTS annotation_embeddings; +DROP TABLE IF EXISTS document_embeddings; diff --git a/backend/internal/db/queries_annotations.go b/backend/internal/db/queries_annotations.go index cc0cc8c..7d1a7a7 100644 --- a/backend/internal/db/queries_annotations.go +++ b/backend/internal/db/queries_annotations.go @@ -28,7 +28,7 @@ func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { var a Annotation err := db.QueryRow(` 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 - FROM annotations + FROM all_annotations WHERE uri = $1 `, 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) if err != nil { @@ -40,7 +40,7 @@ func (db *DB) GetAnnotationByURI(uri string) (*Annotation, error) { func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE target_hash = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -56,7 +56,7 @@ func (db *DB) GetAnnotationsByTargetHash(targetHash string, limit, offset int) ( func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE author_did = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -72,7 +72,7 @@ func (db *DB) GetAnnotationsByAuthor(authorDID string, limit, offset int) ([]Ann func (db *DB) GetMarginAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE author_did = $1 AND uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -88,7 +88,7 @@ func (db *DB) GetMarginAnnotationsByAuthor(authorDID string, limit, offset int) func (db *DB) GetSembleAnnotationsByAuthor(authorDID string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE author_did = $1 AND uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -104,7 +104,7 @@ func (db *DB) GetSembleAnnotationsByAuthor(authorDID string, limit, offset int) func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE motivation = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -120,7 +120,7 @@ func (db *DB) GetAnnotationsByMotivation(motivation string, limit, offset int) ( func (db *DB) GetRecentAnnotations(limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, limit, offset) @@ -139,7 +139,7 @@ func (db *DB) GetPopularAnnotations(limit, offset int) ([]Annotation, error) { a.uri, a.author_did, a.motivation, a.body_value, a.body_format, a.body_uri, a.target_source, a.target_hash, a.target_title, a.selector_json, a.tags_json, a.created_at, a.indexed_at, a.cid - FROM annotations a + FROM all_annotations a LEFT JOIN LATERAL ( SELECT COUNT(*) as cnt FROM likes WHERE subject_uri = a.uri ) l ON true @@ -166,7 +166,7 @@ func (db *DB) GetShelvedAnnotations(limit, offset int) ([]Annotation, error) { a.uri, a.author_did, a.motivation, a.body_value, a.body_format, a.body_uri, a.target_source, a.target_hash, a.target_title, a.selector_json, a.tags_json, a.created_at, a.indexed_at, a.cid - FROM annotations a + FROM all_annotations a WHERE a.created_at < $1 AND a.created_at > $2 AND NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = a.uri) AND NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = a.uri) @@ -184,7 +184,7 @@ func (db *DB) GetShelvedAnnotations(limit, offset int) ([]Annotation, error) { func (db *DB) GetMarginAnnotations(limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -200,7 +200,7 @@ func (db *DB) GetMarginAnnotations(limit, offset int) ([]Annotation, error) { func (db *DB) GetSembleAnnotations(limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -216,7 +216,7 @@ func (db *DB) GetSembleAnnotations(limit, offset int) ([]Annotation, error) { func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -232,7 +232,7 @@ func (db *DB) GetAnnotationsByTag(tag string, limit, offset int) ([]Annotation, func (db *DB) GetMarginAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -248,7 +248,7 @@ func (db *DB) GetMarginAnnotationsByTag(tag string, limit, offset int) ([]Annota func (db *DB) GetSembleAnnotationsByTag(tag string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -278,7 +278,7 @@ func (db *DB) UpdateAnnotation(uri, bodyValue, tagsJSON, cid string) error { func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -294,7 +294,7 @@ func (db *DB) GetAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset func (db *DB) GetMarginAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations 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%' ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -310,7 +310,7 @@ func (db *DB) GetMarginAnnotationsByTagAndAuthor(tag, authorDID string, limit, o func (db *DB) GetSembleAnnotationsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations 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%' ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -326,7 +326,7 @@ func (db *DB) GetSembleAnnotationsByTagAndAuthor(tag, authorDID string, limit, o func (db *DB) GetAnnotationsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Annotation, error) { rows, err := db.Query(` 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 - FROM annotations + FROM all_annotations WHERE author_did = $1 AND target_hash = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -346,7 +346,7 @@ func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { query := ` 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 - FROM annotations + FROM all_annotations WHERE uri = ANY($1) ` @@ -361,7 +361,7 @@ func (db *DB) GetAnnotationsByURIs(uris []string) ([]Annotation, error) { func (db *DB) GetAnnotationURIs(authorDID string) ([]string, error) { rows, err := db.Query(` - SELECT uri FROM annotations WHERE author_did = $1 + SELECT uri FROM all_annotations WHERE author_did = $1 `, authorDID) if err != nil { return nil, err diff --git a/backend/internal/db/queries_bookmarks.go b/backend/internal/db/queries_bookmarks.go index 8cf1f4d..3c3bcf3 100644 --- a/backend/internal/db/queries_bookmarks.go +++ b/backend/internal/db/queries_bookmarks.go @@ -24,7 +24,7 @@ func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { var b Bookmark err := db.QueryRow(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE uri = $1 `, uri).Scan(&b.URI, &b.AuthorDID, &b.Source, &b.SourceHash, &b.Title, &b.Description, &b.TagsJSON, &b.CreatedAt, &b.IndexedAt, &b.CID) if err != nil { @@ -36,7 +36,7 @@ func (db *DB) GetBookmarkByURI(uri string) (*Bookmark, error) { func (db *DB) GetRecentBookmarks(limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, limit, offset) @@ -54,7 +54,7 @@ func (db *DB) GetPopularBookmarks(limit, offset int) ([]Bookmark, error) { SELECT b.uri, b.author_did, b.source, b.source_hash, b.title, b.description, b.tags_json, b.created_at, b.indexed_at, b.cid - FROM bookmarks b + FROM all_bookmarks b LEFT JOIN LATERAL ( SELECT COUNT(*) as cnt FROM likes WHERE subject_uri = b.uri ) l ON true @@ -80,7 +80,7 @@ func (db *DB) GetShelvedBookmarks(limit, offset int) ([]Bookmark, error) { SELECT b.uri, b.author_did, b.source, b.source_hash, b.title, b.description, b.tags_json, b.created_at, b.indexed_at, b.cid - FROM bookmarks b + FROM all_bookmarks b WHERE b.created_at < $1 AND b.created_at > $2 AND NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = b.uri) AND NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = b.uri) @@ -98,7 +98,7 @@ func (db *DB) GetShelvedBookmarks(limit, offset int) ([]Bookmark, error) { func (db *DB) GetMarginBookmarks(limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -114,7 +114,7 @@ func (db *DB) GetMarginBookmarks(limit, offset int) ([]Bookmark, error) { func (db *DB) GetSembleBookmarks(limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -130,7 +130,7 @@ func (db *DB) GetSembleBookmarks(limit, offset int) ([]Bookmark, error) { func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -146,7 +146,7 @@ func (db *DB) GetBookmarksByTag(tag string, limit, offset int) ([]Bookmark, erro func (db *DB) GetMarginBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -162,7 +162,7 @@ func (db *DB) GetMarginBookmarksByTag(tag string, limit, offset int) ([]Bookmark func (db *DB) GetSembleBookmarksByTag(tag string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -178,7 +178,7 @@ func (db *DB) GetSembleBookmarksByTag(tag string, limit, offset int) ([]Bookmark func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -194,7 +194,7 @@ func (db *DB) GetBookmarksByTagAndAuthor(tag, authorDID string, limit, offset in func (db *DB) GetMarginBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks 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%' ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -210,7 +210,7 @@ func (db *DB) GetMarginBookmarksByTagAndAuthor(tag, authorDID string, limit, off func (db *DB) GetSembleBookmarksByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks 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%' ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -226,7 +226,7 @@ func (db *DB) GetSembleBookmarksByTagAndAuthor(tag, authorDID string, limit, off func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE author_did = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -242,7 +242,7 @@ func (db *DB) GetBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookm func (db *DB) GetMarginBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE author_did = $1 AND uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -258,7 +258,7 @@ func (db *DB) GetMarginBookmarksByAuthor(authorDID string, limit, offset int) ([ func (db *DB) GetSembleBookmarksByAuthor(authorDID string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE author_did = $1 AND uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -292,7 +292,7 @@ func (db *DB) GetBookmarksByURIs(uris []string) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE uri = ANY($1) `, pqStringArray(uris)) if err != nil { @@ -305,7 +305,7 @@ func (db *DB) GetBookmarksByURIs(uris []string) ([]Bookmark, error) { func (db *DB) GetBookmarkURIs(authorDID string) ([]string, error) { rows, err := db.Query(` - SELECT uri FROM bookmarks WHERE author_did = $1 + SELECT uri FROM all_bookmarks WHERE author_did = $1 `, authorDID) if err != nil { return nil, err @@ -326,7 +326,7 @@ func (db *DB) GetBookmarkURIs(authorDID string) ([]string, error) { func (db *DB) GetBookmarksByTargetHash(targetHash string, limit, offset int) ([]Bookmark, error) { rows, err := db.Query(` SELECT uri, author_did, source, source_hash, title, description, tags_json, created_at, indexed_at, cid - FROM bookmarks + FROM all_bookmarks WHERE source_hash = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 diff --git a/backend/internal/db/queries_highlights.go b/backend/internal/db/queries_highlights.go index d95b038..fc87b83 100644 --- a/backend/internal/db/queries_highlights.go +++ b/backend/internal/db/queries_highlights.go @@ -25,7 +25,7 @@ func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { var h Highlight err := db.QueryRow(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE uri = $1 `, uri).Scan(&h.URI, &h.AuthorDID, &h.TargetSource, &h.TargetHash, &h.TargetTitle, &h.SelectorJSON, &h.Color, &h.TagsJSON, &h.CreatedAt, &h.IndexedAt, &h.CID) if err != nil { @@ -37,7 +37,7 @@ func (db *DB) GetHighlightByURI(uri string) (*Highlight, error) { func (db *DB) GetRecentHighlights(limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights ORDER BY created_at DESC LIMIT $1 OFFSET $2 `, limit, offset) @@ -55,7 +55,7 @@ func (db *DB) GetPopularHighlights(limit, offset int) ([]Highlight, error) { SELECT h.uri, h.author_did, h.target_source, h.target_hash, h.target_title, h.selector_json, h.color, h.tags_json, h.created_at, h.indexed_at, h.cid - FROM highlights h + FROM all_highlights h LEFT JOIN LATERAL ( SELECT COUNT(*) as cnt FROM likes WHERE subject_uri = h.uri ) l ON true @@ -81,7 +81,7 @@ func (db *DB) GetShelvedHighlights(limit, offset int) ([]Highlight, error) { SELECT h.uri, h.author_did, h.target_source, h.target_hash, h.target_title, h.selector_json, h.color, h.tags_json, h.created_at, h.indexed_at, h.cid - FROM highlights h + FROM all_highlights h WHERE h.created_at < $1 AND h.created_at > $2 AND NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = h.uri) AND NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = h.uri) @@ -99,7 +99,7 @@ func (db *DB) GetShelvedHighlights(limit, offset int) ([]Highlight, error) { func (db *DB) GetMarginHighlights(limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -115,7 +115,7 @@ func (db *DB) GetMarginHighlights(limit, offset int) ([]Highlight, error) { func (db *DB) GetSembleHighlights(limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -131,7 +131,7 @@ func (db *DB) GetSembleHighlights(limit, offset int) ([]Highlight, error) { func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -147,7 +147,7 @@ func (db *DB) GetHighlightsByTag(tag string, limit, offset int) ([]Highlight, er func (db *DB) GetMarginHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -163,7 +163,7 @@ func (db *DB) GetMarginHighlightsByTag(tag string, limit, offset int) ([]Highlig func (db *DB) GetSembleHighlightsByTag(tag string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $1) AND uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -179,7 +179,7 @@ func (db *DB) GetSembleHighlightsByTag(tag string, limit, offset int) ([]Highlig func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE author_did = $1 AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = $2) ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -195,7 +195,7 @@ func (db *DB) GetHighlightsByTagAndAuthor(tag, authorDID string, limit, offset i func (db *DB) GetMarginHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights 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%' ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -211,7 +211,7 @@ func (db *DB) GetMarginHighlightsByTagAndAuthor(tag, authorDID string, limit, of func (db *DB) GetSembleHighlightsByTagAndAuthor(tag, authorDID string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights 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%' ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -227,7 +227,7 @@ func (db *DB) GetSembleHighlightsByTagAndAuthor(tag, authorDID string, limit, of func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE target_hash = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -243,7 +243,7 @@ func (db *DB) GetHighlightsByTargetHash(targetHash string, limit, offset int) ([ func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE author_did = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -259,7 +259,7 @@ func (db *DB) GetHighlightsByAuthor(authorDID string, limit, offset int) ([]High func (db *DB) GetMarginHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE author_did = $1 AND uri NOT LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -275,7 +275,7 @@ func (db *DB) GetMarginHighlightsByAuthor(authorDID string, limit, offset int) ( func (db *DB) GetSembleHighlightsByAuthor(authorDID string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE author_did = $1 AND uri LIKE '%network.cosmik%' ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -291,7 +291,7 @@ func (db *DB) GetSembleHighlightsByAuthor(authorDID string, limit, offset int) ( func (db *DB) GetHighlightsByAuthorAndTargetHash(authorDID, targetHash string, limit, offset int) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE author_did = $1 AND target_hash = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4 @@ -325,7 +325,7 @@ func (db *DB) GetHighlightsByURIs(uris []string) ([]Highlight, error) { rows, err := db.Query(` SELECT uri, author_did, target_source, target_hash, target_title, selector_json, color, tags_json, created_at, indexed_at, cid - FROM highlights + FROM all_highlights WHERE uri = ANY($1) `, pqStringArray(uris)) if err != nil { @@ -338,7 +338,7 @@ func (db *DB) GetHighlightsByURIs(uris []string) ([]Highlight, error) { func (db *DB) GetHighlightURIs(authorDID string) ([]string, error) { rows, err := db.Query(` - SELECT uri FROM highlights WHERE author_did = $1 + SELECT uri FROM all_highlights WHERE author_did = $1 `, authorDID) if err != nil { return nil, err diff --git a/backend/internal/db/queries_notes.go b/backend/internal/db/queries_notes.go new file mode 100644 index 0000000..a004410 --- /dev/null +++ b/backend/internal/db/queries_notes.go @@ -0,0 +1,130 @@ +package db + +import ( + "database/sql" + "time" +) + +func (db *DB) CreateNote(n *Note) error { + query := ` + INSERT INTO notes ( + uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + ) ON CONFLICT (uri) DO UPDATE SET + motivation = EXCLUDED.motivation, + color = EXCLUDED.color, + description = EXCLUDED.description, + body_value = EXCLUDED.body_value, + body_format = EXCLUDED.body_format, + body_uri = EXCLUDED.body_uri, + target_source = EXCLUDED.target_source, + target_hash = EXCLUDED.target_hash, + target_title = EXCLUDED.target_title, + selector_json = EXCLUDED.selector_json, + tags_json = EXCLUDED.tags_json, + indexed_at = EXCLUDED.indexed_at, + cid = EXCLUDED.cid + ` + _, err := db.Exec(query, + n.URI, n.AuthorDID, n.Motivation, n.Color, n.Description, n.BodyValue, n.BodyFormat, n.BodyURI, + n.TargetSource, n.TargetHash, n.TargetTitle, n.SelectorJSON, n.TagsJSON, n.CreatedAt, n.IndexedAt, n.CID, + ) + return err +} + +func (db *DB) GetNoteByURI(uri string) (*Note, error) { + query := ` + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM notes WHERE uri = $1 + ` + var n Note + err := db.QueryRow(query, uri).Scan( + &n.URI, &n.AuthorDID, &n.Motivation, &n.Color, &n.Description, &n.BodyValue, &n.BodyFormat, &n.BodyURI, + &n.TargetSource, &n.TargetHash, &n.TargetTitle, &n.SelectorJSON, &n.TagsJSON, &n.CreatedAt, &n.IndexedAt, &n.CID, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &n, nil +} + +func (db *DB) MarginNoteBookmarkExists(authorDID, targetHash string) (bool, error) { + var dummy int + err := db.QueryRow(` + SELECT 1 FROM notes + WHERE author_did = $1 + AND target_hash = $2 + AND motivation = 'bookmarking' + AND uri LIKE 'at://%/at.margin.note/%' + LIMIT 1 + `, authorDID, targetHash).Scan(&dummy) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (db *DB) CommunityBookmarkExists(authorDID, targetHash, tagsJSON string) (bool, error) { + query := ` + SELECT 1 FROM notes + WHERE author_did = $1 + AND target_hash = $2 + AND uri LIKE 'at://%/community.lexicon.bookmarks.bookmark/%' + AND COALESCE(tags_json, '[]') = COALESCE($3, '[]') + LIMIT 1 + ` + normalized := tagsJSON + if normalized == "" { + normalized = "[]" + } + var dummy int + err := db.QueryRow(query, authorDID, targetHash, normalized).Scan(&dummy) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (db *DB) DeleteNote(uri string) error { + _, err := db.Exec("DELETE FROM notes WHERE uri = $1", uri) + return err +} + +func (db *DB) UpdateNoteAnnotation(uri, bodyValue, tagsJSON, cid string) error { + _, err := db.Exec(` + UPDATE notes + SET body_value = $1, tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 + WHERE uri = $5 + `, bodyValue, tagsJSON, cid, time.Now(), uri) + return err +} + +func (db *DB) UpdateNoteHighlight(uri, color, tagsJSON, cid string) error { + _, err := db.Exec(` + UPDATE notes + SET color = NULLIF($1, ''), tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 + WHERE uri = $5 + `, color, tagsJSON, cid, time.Now(), uri) + return err +} + +func (db *DB) UpdateNoteBookmark(uri, title, description, tagsJSON, cid string) error { + _, err := db.Exec(` + UPDATE notes + SET target_title = NULLIF($1, ''), body_value = NULLIF($2, ''), tags_json = NULLIF($3, ''), cid = $4, indexed_at = $5 + WHERE uri = $6 + `, title, description, tagsJSON, cid, time.Now(), uri) + return err +} diff --git a/backend/internal/db/queries_profiles.go b/backend/internal/db/queries_profiles.go new file mode 100644 index 0000000..3772da5 --- /dev/null +++ b/backend/internal/db/queries_profiles.go @@ -0,0 +1,180 @@ +package db + +import ( + "database/sql" + "fmt" + "strings" +) + +func (db *DB) GetProfile(did string) (*Profile, error) { + var p Profile + 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(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt) + switch err { + case nil: + return &p, nil + case sql.ErrNoRows: + return nil, nil + default: + return nil, err + } +} + +func (db *DB) GetProfilesByDIDs(dids []string) (map[string]*Profile, error) { + if len(dids) == 0 { + return nil, nil + } + + placeholders := make([]string, len(dids)) + args := make([]interface{}, len(dids)) + for i, did := range dids { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = did + } + + rows, err := db.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, ",")+`)`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + profiles := make(map[string]*Profile) + for rows.Next() { + var p Profile + if err := rows.Scan(&p.URI, &p.AuthorDID, &p.DisplayName, &p.Bio, &p.Avatar, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt); err != nil { + continue + } + profiles[p.AuthorDID] = &p + } + return profiles, rows.Err() +} + +func (db *DB) UpsertProfile(p *Profile) error { + _, err := db.Exec(` + INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT(uri) DO UPDATE SET + display_name = EXCLUDED.display_name, + avatar = EXCLUDED.avatar, + bio = EXCLUDED.bio, + website = EXCLUDED.website, + links_json = EXCLUDED.links_json, + indexed_at = EXCLUDED.indexed_at + `, p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) + return err +} + +func (db *DB) DeleteProfile(uri string) error { + _, err := db.Exec("DELETE FROM profiles WHERE uri = $1", uri) + return err +} + +func (db *DB) GetPreferences(did string) (*Preferences, error) { + var p Preferences + err := db.QueryRow( + `SELECT uri, author_did, external_link_skipped_hostnames, subscribed_labelers, + label_preferences, disable_external_link_warning, enable_community_bookmarks, + created_at, indexed_at, cid + FROM preferences WHERE author_did = $1`, did, + ).Scan( + &p.URI, &p.AuthorDID, &p.ExternalLinkSkippedHostnames, &p.SubscribedLabelers, + &p.LabelPreferences, &p.DisableExternalLinkWarning, &p.EnableCommunityBookmarks, + &p.CreatedAt, &p.IndexedAt, &p.CID, + ) + switch err { + case nil: + return &p, nil + case sql.ErrNoRows: + return nil, nil + default: + return nil, err + } +} + +func (db *DB) UpsertPreferences(p *Preferences) error { + _, err := db.Exec(` + INSERT INTO preferences ( + uri, author_did, external_link_skipped_hostnames, subscribed_labelers, + label_preferences, disable_external_link_warning, enable_community_bookmarks, + created_at, indexed_at, cid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT(uri) DO UPDATE SET + external_link_skipped_hostnames = EXCLUDED.external_link_skipped_hostnames, + subscribed_labelers = EXCLUDED.subscribed_labelers, + label_preferences = EXCLUDED.label_preferences, + disable_external_link_warning = EXCLUDED.disable_external_link_warning, + enable_community_bookmarks = EXCLUDED.enable_community_bookmarks, + indexed_at = EXCLUDED.indexed_at, + cid = EXCLUDED.cid + `, p.URI, p.AuthorDID, p.ExternalLinkSkippedHostnames, p.SubscribedLabelers, + p.LabelPreferences, p.DisableExternalLinkWarning, p.EnableCommunityBookmarks, + p.CreatedAt, p.IndexedAt, p.CID) + return err +} + +func (db *DB) DeletePreferences(uri string) error { + _, err := db.Exec("DELETE FROM preferences WHERE uri = $1", uri) + return err +} + +func (db *DB) GetPreferenceURIs(did string) ([]string, error) { + rows, err := db.Query( + "SELECT uri FROM preferences WHERE author_did = $1 AND uri IS NOT NULL AND uri != ''", did, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, rows.Err() +} + +func (db *DB) DeleteAPIKey(id, ownerDID string) (string, error) { + var uri string + err := db.QueryRow("SELECT uri FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID).Scan(&uri) + if err != nil { + if err == sql.ErrNoRows { + return "", nil + } + return "", err + } + _, err = db.Exec("DELETE FROM api_keys WHERE id = $1 AND owner_did = $2", id, ownerDID) + return uri, err +} + +func (db *DB) DeleteAPIKeyByURI(uri string) error { + _, err := db.Exec("DELETE FROM api_keys WHERE uri = $1", uri) + return err +} + +func (db *DB) GetAPIKeyURIs(ownerDID string) ([]string, error) { + rows, err := db.Query( + "SELECT uri FROM api_keys WHERE owner_did = $1 AND uri IS NOT NULL AND uri != ''", ownerDID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var uris []string + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + uris = append(uris, uri) + } + return uris, rows.Err() +} diff --git a/backend/internal/db/queries_recommendations.go b/backend/internal/db/queries_recommendations.go index b5b4829..862ac14 100644 --- a/backend/internal/db/queries_recommendations.go +++ b/backend/internal/db/queries_recommendations.go @@ -54,46 +54,6 @@ type UserProfile struct { UpdatedAt time.Time `json:"updatedAt"` } -func (db *DB) MigrateRecommendations() error { - _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS document_embeddings ( - document_uri TEXT PRIMARY KEY, - embedding TEXT NOT NULL, - updated_at TIMESTAMP NOT NULL - )`) - if err != nil { - return fmt.Errorf("create document_embeddings table: %w", err) - } - - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS annotation_embeddings ( - annotation_uri TEXT PRIMARY KEY, - author_did TEXT NOT NULL, - document_uri TEXT, - embedding TEXT NOT NULL, - updated_at TIMESTAMP NOT NULL - )`) - if err != nil { - return fmt.Errorf("create annotation_embeddings table: %w", err) - } - db.Exec(`CREATE INDEX IF NOT EXISTS idx_ann_emb_author ON annotation_embeddings(author_did)`) - db.Exec(`CREATE INDEX IF NOT EXISTS idx_ann_emb_document ON annotation_embeddings(document_uri)`) - - _, err = db.Exec(` - CREATE TABLE IF NOT EXISTS user_profiles ( - author_did TEXT PRIMARY KEY, - embedding TEXT NOT NULL, - tag_affinities TEXT DEFAULT '{}', - annotation_count INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL - )`) - if err != nil { - return fmt.Errorf("create user_profiles table: %w", err) - } - - return nil -} - func (db *DB) UpsertPublication(p *Publication) error { query := ` INSERT INTO publications (uri, author_did, url, name, description, show_in_discover, indexed_at) diff --git a/backend/internal/db/queries_sessions.go b/backend/internal/db/queries_sessions.go index f5cd8f1..8e14470 100644 --- a/backend/internal/db/queries_sessions.go +++ b/backend/internal/db/queries_sessions.go @@ -35,3 +35,12 @@ func (db *DB) DeleteExpiredSessions() error { _, err := db.Exec(`DELETE FROM sessions WHERE expires_at <= $1`, time.Now()) return err } + +func (db *DB) CountSessionsByDID(did string) (int, error) { + var n int + err := db.QueryRow( + `SELECT COUNT(*) FROM sessions WHERE did = $1 AND expires_at > $2`, + did, time.Now(), + ).Scan(&n) + return n, err +} diff --git a/backend/internal/domain/filters.go b/backend/internal/domain/filters.go new file mode 100644 index 0000000..89c3fac --- /dev/null +++ b/backend/internal/domain/filters.go @@ -0,0 +1,22 @@ +package domain + +type FeedType string + +const ( + FeedTypeRecent FeedType = "recent" + FeedTypePopular FeedType = "popular" + FeedTypeShelved FeedType = "shelved" + FeedTypeMargin FeedType = "margin" + FeedTypeSemble FeedType = "semble" +) + +type NoteFilter struct { + Motivations []string + AuthorDID string + TargetHash string + Tag string + FeedType FeedType + Query string + Limit int + Offset int +} diff --git a/backend/internal/domain/interfaces.go b/backend/internal/domain/interfaces.go new file mode 100644 index 0000000..e6990a4 --- /dev/null +++ b/backend/internal/domain/interfaces.go @@ -0,0 +1,65 @@ +package domain + +import ( + "context" + "time" +) + +type Author struct { + DID string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +type NoteRepository interface { + List(ctx context.Context, f NoteFilter) ([]Note, error) + GetByURI(ctx context.Context, uri string) (*Note, error) + GetLikeByUserAndSubject(ctx context.Context, did, subjectURI string) (*Like, error) + CreateNote(ctx context.Context, note *Note) error + DeleteNote(ctx context.Context, uri string) error + UpdateNoteAnnotation(ctx context.Context, uri, text, tags string, cid *string) error + CreateLike(ctx context.Context, like *Like) error + DeleteLike(ctx context.Context, uri string) error + CreateReply(ctx context.Context, reply *Reply) error + GetReplyByURI(ctx context.Context, uri string) (*Reply, error) + DeleteReply(ctx context.Context, uri string) error + DeleteAnnotation(ctx context.Context, uri string) error + DeleteHighlight(ctx context.Context, uri string) error + DeleteBookmark(ctx context.Context, uri string) error + UpdateAnnotation(ctx context.Context, uri, text, tags string, cid *string) error + GetAnnotationByURI(ctx context.Context, uri string) (*Annotation, error) + CheckDuplicateAnnotation(ctx context.Context, did, url, text string) (*Annotation, error) + CheckDuplicateHighlight(ctx context.Context, did, url string, selector []byte) (*Highlight, error) +} + +type EngagementRepository interface { + GetLikeCount(ctx context.Context, uri string) (int, error) + GetLikeCounts(ctx context.Context, uris []string) (map[string]int, error) + GetReplyCounts(ctx context.Context, uris []string) (map[string]int, error) + GetViewerLikes(ctx context.Context, viewerDID string, uris []string) (map[string]bool, error) + GetLabelsForURIs(ctx context.Context, uris []string, labelerDIDs []string) (map[string][]ContentLabel, error) + GetLabelsForDIDs(ctx context.Context, dids []string, labelerDIDs []string) (map[string][]ContentLabel, error) + GetLatestEditTimes(ctx context.Context, uris []string) (map[string]time.Time, error) +} + +type ProfileRepository interface { + GetProfiles(ctx context.Context, dids []string) (map[string]Author, error) + GetProfile(ctx context.Context, did string) (*Profile, error) + UpsertProfile(ctx context.Context, p *Profile) error +} + +type NotificationRepository interface { + GetNotifications(ctx context.Context, recipientDID string, limit, offset int) ([]Notification, error) + GetUnreadNotificationCount(ctx context.Context, recipientDID string) (int, error) + MarkNotificationsRead(ctx context.Context, recipientDID string) error + CreateNotification(ctx context.Context, n *Notification) error +} + +type SessionRepository interface { + GetSession(ctx context.Context, id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) +} + +type NoteService interface{} + +type ProfileService interface{} diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go new file mode 100644 index 0000000..a5212e8 --- /dev/null +++ b/backend/internal/domain/models.go @@ -0,0 +1,212 @@ +package domain + +import ( + "time" +) + +type Note struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + Motivation string `json:"motivation,omitempty"` + Color *string `json:"color,omitempty"` + Description *string `json:"description,omitempty"` + BodyValue *string `json:"bodyValue,omitempty"` + BodyFormat *string `json:"bodyFormat,omitempty"` + BodyURI *string `json:"bodyUri,omitempty"` + TargetSource string `json:"targetSource"` + TargetHash string `json:"targetHash"` + TargetTitle *string `json:"targetTitle,omitempty"` + SelectorJSON *string `json:"selector,omitempty"` + TagsJSON *string `json:"tags,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Annotation struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + Motivation string `json:"motivation,omitempty"` + BodyValue *string `json:"bodyValue,omitempty"` + BodyFormat *string `json:"bodyFormat,omitempty"` + BodyURI *string `json:"bodyUri,omitempty"` + TargetSource string `json:"targetSource"` + TargetHash string `json:"targetHash"` + TargetTitle *string `json:"targetTitle,omitempty"` + SelectorJSON *string `json:"selector,omitempty"` + TagsJSON *string `json:"tags,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Selector struct { + Type string `json:"type"` + Exact string `json:"exact,omitempty"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + Start *int `json:"start,omitempty"` + End *int `json:"end,omitempty"` + Value string `json:"value,omitempty"` +} + +type Highlight struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + TargetSource string `json:"targetSource"` + TargetHash string `json:"targetHash"` + TargetTitle *string `json:"targetTitle,omitempty"` + SelectorJSON *string `json:"selector,omitempty"` + Color *string `json:"color,omitempty"` + TagsJSON *string `json:"tags,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Bookmark struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + Source string `json:"source"` + SourceHash string `json:"sourceHash"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + TagsJSON *string `json:"tags,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Reply struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + ParentURI string `json:"parentUri"` + RootURI string `json:"rootUri"` + Text string `json:"text"` + Format *string `json:"format,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Like struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + SubjectURI string `json:"subjectUri"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` +} + +type Collection struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` +} + +type CollectionItem struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + CollectionURI string `json:"collectionUri"` + AnnotationURI string `json:"annotationUri"` + Position int `json:"position"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` +} + +type Notification struct { + ID int `json:"id"` + RecipientDID string `json:"recipientDid"` + ActorDID string `json:"actorDid"` + Type string `json:"type"` + SubjectURI string `json:"subjectUri"` + CreatedAt time.Time `json:"createdAt"` + ReadAt *time.Time `json:"readAt,omitempty"` +} + +type APIKey struct { + ID string `json:"id"` + OwnerDID string `json:"ownerDid"` + Name string `json:"name"` + KeyHash string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + URI string `json:"uri"` + CID *string `json:"cid,omitempty"` + IndexedAt time.Time `json:"indexedAt"` +} + +type Profile struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + DisplayName *string `json:"displayName,omitempty"` + Avatar *string `json:"avatar,omitempty"` + Bio *string `json:"bio,omitempty"` + Website *string `json:"website,omitempty"` + LinksJSON *string `json:"links,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Preferences struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + ExternalLinkSkippedHostnames *string `json:"externalLinkSkippedHostnames,omitempty"` + SubscribedLabelers *string `json:"subscribedLabelers,omitempty"` + LabelPreferences *string `json:"labelPreferences,omitempty"` + DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` + EnableCommunityBookmarks *bool `json:"enableCommunityBookmarks,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + +type Block struct { + ID int `json:"id"` + ActorDID string `json:"actorDid"` + SubjectDID string `json:"subjectDid"` + CreatedAt time.Time `json:"createdAt"` +} + +type Mute struct { + ID int `json:"id"` + ActorDID string `json:"actorDid"` + SubjectDID string `json:"subjectDid"` + CreatedAt time.Time `json:"createdAt"` +} + +type ModerationReport struct { + ID int `json:"id"` + ReporterDID string `json:"reporterDid"` + SubjectDID string `json:"subjectDid"` + SubjectURI *string `json:"subjectUri,omitempty"` + ReasonType string `json:"reasonType"` + ReasonText *string `json:"reasonText,omitempty"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + ResolvedBy *string `json:"resolvedBy,omitempty"` +} + +type ModerationAction struct { + ID int `json:"id"` + ReportID int `json:"reportId"` + ActorDID string `json:"actorDid"` + Action string `json:"action"` + Comment *string `json:"comment,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type ContentLabel struct { + ID int `json:"id"` + Src string `json:"src"` + URI string `json:"uri"` + Val string `json:"val"` + Neg bool `json:"neg"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/internal/firehose/ingester.go b/backend/internal/firehose/ingester.go index 6904cd3..4faad7f 100644 --- a/backend/internal/firehose/ingester.go +++ b/backend/internal/firehose/ingester.go @@ -93,6 +93,8 @@ func NewIngester(database *db.DB, syncService *internal_sync.Service) *Ingester i.RegisterHandler(CollectionSembleCollection, i.handleSembleCollection) i.RegisterHandler(xrpc.CollectionSembleCollectionLink, i.handleSembleCollectionLink) i.RegisterHandler(CollectionDocument, i.handleDocument) + i.RegisterHandler(xrpc.CollectionNote, i.handleNote) + i.RegisterHandler(xrpc.CollectionCommunityBookmark, i.handleCommunityBookmark) return i } @@ -325,6 +327,10 @@ func (i *Ingester) handleDelete(collection, uri string) { i.db.RemoveFromCollection(uri) case CollectionDocument: i.db.DeleteDocument(uri) + case xrpc.CollectionNote: + i.db.DeleteNote(uri) + case xrpc.CollectionCommunityBookmark: + i.db.DeleteNote(uri) } } @@ -460,6 +466,156 @@ func (i *Ingester) handleAnnotation(event *FirehoseEvent) { } } +func (i *Ingester) handleNote(event *FirehoseEvent) { + var record struct { + Motivation string `json:"motivation"` + Color string `json:"color"` + Description string `json:"description"` + Body struct { + Value string `json:"value"` + Format string `json:"format"` + URI string `json:"uri"` + } `json:"body"` + Target struct { + Source string `json:"source"` + SourceHash string `json:"sourceHash"` + Title string `json:"title"` + Selector json.RawMessage `json:"selector"` + } `json:"target"` + Tags []string `json:"tags"` + CreatedAt string `json:"createdAt"` + } + + if err := json.Unmarshal(event.Record, &record); err != nil { + return + } + + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) + + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + createdAt = time.Now() + } + + targetSource := record.Target.Source + var targetHash string + if targetSource != "" { + targetHash = db.HashURL(targetSource) + } + + motivation := record.Motivation + if motivation == "" { + motivation = "commenting" + } + + bodyText := record.Body.Value + if bodyText == "" && record.Description != "" { + bodyText = record.Description + } + + var bodyValuePtr, bodyFormatPtr, bodyURIPtr, targetTitlePtr, selectorJSONPtr, tagsJSONPtr, colorPtr *string + if bodyText != "" { + bodyValuePtr = &bodyText + } + if record.Body.Format != "" { + bodyFormatPtr = &record.Body.Format + } + if record.Body.URI != "" { + bodyURIPtr = &record.Body.URI + } + if record.Target.Title != "" { + targetTitlePtr = &record.Target.Title + } + if len(record.Target.Selector) > 0 && string(record.Target.Selector) != "null" { + selectorStr := string(record.Target.Selector) + selectorJSONPtr = &selectorStr + } + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + if record.Color != "" { + colorPtr = &record.Color + } + + note := &db.Note{ + URI: uri, + AuthorDID: event.Repo, + Motivation: motivation, + Color: colorPtr, + BodyValue: bodyValuePtr, + BodyFormat: bodyFormatPtr, + BodyURI: bodyURIPtr, + TargetSource: targetSource, + TargetHash: targetHash, + TargetTitle: targetTitlePtr, + SelectorJSON: selectorJSONPtr, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + } + + if err := i.db.CreateNote(note); err != nil { + logger.Error("Failed to index note: %v", err) + } else { + logger.Info("Indexed note from %s on %s", event.Repo, targetSource) + } +} + +func (i *Ingester) handleCommunityBookmark(event *FirehoseEvent) { + var record struct { + Subject string `json:"subject"` + Tags []string `json:"tags"` + CreatedAt string `json:"createdAt"` + } + + if err := json.Unmarshal(event.Record, &record); err != nil { + return + } + + if record.Subject == "" { + return + } + + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) + + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + createdAt = time.Now() + } + + targetHash := db.HashURL(record.Subject) + + if exists, err := i.db.MarginNoteBookmarkExists(event.Repo, targetHash); err == nil && exists { + return + } + + var tagsJSONPtr *string + if len(record.Tags) > 0 { + tagsBytes, _ := json.Marshal(record.Tags) + tagsStr := string(tagsBytes) + tagsJSONPtr = &tagsStr + } + + note := &db.Note{ + URI: uri, + AuthorDID: event.Repo, + Motivation: "bookmarking", + TargetSource: record.Subject, + TargetHash: targetHash, + TagsJSON: tagsJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + } + + if err := i.db.CreateNote(note); err != nil { + logger.Error("Failed to index community bookmark: %v", err) + } else { + logger.Info("Indexed community bookmark from %s on %s", event.Repo, record.Subject) + } +} + func (i *Ingester) handleReply(event *FirehoseEvent) { var record struct { Parent struct { @@ -842,6 +998,7 @@ func (i *Ingester) handlePreferences(event *FirehoseEvent) { SubscribedLabelers json.RawMessage `json:"subscribedLabelers"` LabelPreferences json.RawMessage `json:"labelPreferences"` DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` + EnableCommunityBookmarks *bool `json:"enableCommunityBookmarks,omitempty"` CreatedAt string `json:"createdAt"` } @@ -886,6 +1043,7 @@ func (i *Ingester) handlePreferences(event *FirehoseEvent) { ExternalLinkSkippedHostnames: skippedHostnamesPtr, SubscribedLabelers: subscribedLabelersPtr, DisableExternalLinkWarning: record.DisableExternalLinkWarning, + EnableCommunityBookmarks: record.EnableCommunityBookmarks, LabelPreferences: labelPrefsPtr, CreatedAt: createdAt, IndexedAt: time.Now(), diff --git a/backend/internal/middleware/logger.go b/backend/internal/middleware/logger.go index 1680d27..f993ab5 100644 --- a/backend/internal/middleware/logger.go +++ b/backend/internal/middleware/logger.go @@ -2,8 +2,6 @@ package middleware import ( "net/http" - "net/url" - "strings" "time" "github.com/go-chi/chi/v5/middleware" @@ -16,12 +14,10 @@ func PrivacyLogger(next http.Handler) http.Handler { t1 := time.Now() defer func() { - safeURL := redactURL(r.URL) - logger.Info("[%d] %s %s %s", ww.Status(), r.Method, - safeURL, + r.URL.String(), time.Since(t1), ) }() @@ -29,23 +25,3 @@ func PrivacyLogger(next http.Handler) http.Handler { next.ServeHTTP(ww, r) }) } - -func redactURL(u *url.URL) string { - redacted := *u - q := redacted.Query() - - sensitiveKeys := []string{"source", "url", "target", "parent", "root", "uri"} - - for _, key := range sensitiveKeys { - if q.Has(key) { - val := q.Get(key) - if strings.Contains(val, "margin.at") { - continue - } - q.Set(key, "[REDACTED]") - } - } - - redacted.RawQuery = q.Encode() - return redacted.String() -} diff --git a/backend/internal/oauth/handler.go b/backend/internal/oauth/handler.go index 45d454d..23b183c 100644 --- a/backend/internal/oauth/handler.go +++ b/backend/internal/oauth/handler.go @@ -16,6 +16,7 @@ import ( "sync" "time" + "margin.at/internal/analytics" "margin.at/internal/db" "margin.at/internal/logger" internal_sync "margin.at/internal/sync" @@ -29,9 +30,10 @@ type Handler struct { pending map[string]*PendingAuth pendingMu sync.RWMutex syncService *internal_sync.Service + analytics *analytics.Client } -func NewHandler(database *db.DB, syncService *internal_sync.Service) (*Handler, error) { +func NewHandler(database *db.DB, syncService *internal_sync.Service, ac *analytics.Client) (*Handler, error) { configuredBaseURL := os.Getenv("BASE_URL") @@ -46,6 +48,7 @@ func NewHandler(database *db.DB, syncService *internal_sync.Service) (*Handler, privateKey: privateKey, pending: make(map[string]*PendingAuth), syncService: syncService, + analytics: ac, }, nil } @@ -105,7 +108,7 @@ func (h *Handler) getDynamicClient(r *http.Request) *Client { baseURL = baseURL[:len(baseURL)-1] } - clientID := baseURL + "/client-metadata.json" + clientID := baseURL + "/oauth-client-metadata.json" redirectURI := baseURL + "/auth/callback" return NewClient(clientID, redirectURI, h.privateKey) @@ -148,7 +151,7 @@ func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) { pkceVerifier, pkceChallenge := client.GeneratePKCE() - scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" + scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark" parResp, state, dpopNonce, err := client.SendPAR(meta, handle, scope, dpopKey, pkceChallenge) if err != nil { @@ -236,7 +239,7 @@ func (h *Handler) HandleStart(w http.ResponseWriter, r *http.Request) { } pkceVerifier, pkceChallenge := client.GeneratePKCE() - scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" + scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark" parResp, state, dpopNonce, err := client.SendPAR(meta, req.Handle, scope, dpopKey, pkceChallenge) if err != nil { @@ -316,7 +319,7 @@ func (h *Handler) HandleSignup(w http.ResponseWriter, r *http.Request) { } pkceVerifier, pkceChallenge := client.GeneratePKCE() - scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull" + scope := "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark" parResp, state, dpopNonce, err := client.SendPARWithPrompt(meta, "", scope, dpopKey, pkceChallenge, "create") if err != nil { @@ -482,6 +485,23 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { }() http.Redirect(w, r, "/home?logged_in=true", http.StatusFound) + + go func() { + if h.analytics == nil { + return + } + existingCount, _ := h.db.CountSessionsByDID(tokenResp.Sub) + if existingCount <= 1 { + h.analytics.Capture(tokenResp.Sub, "account_created", map[string]interface{}{ + "pds": pending.PDS, + }) + } else { + h.analytics.Capture(tokenResp.Sub, "login_success", map[string]interface{}{ + "handle": pending.Handle, + "pds": pending.PDS, + }) + } + }() } func (h *Handler) cleanupOrphanedReplies(did, accessToken, dpopKeyPEM, pds string) { @@ -604,7 +624,7 @@ func (h *Handler) HandleSession(w http.ResponseWriter, r *http.Request) { func (h *Handler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { client := h.getDynamicClient(r) - baseURL := client.ClientID[:len(client.ClientID)-len("/client-metadata.json")] + baseURL := client.ClientID[:len(client.ClientID)-len("/oauth-client-metadata.json")] w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -617,7 +637,7 @@ func (h *Handler) HandleClientMetadata(w http.ResponseWriter, r *http.Request) { "redirect_uris": []string{client.RedirectURI}, "grant_types": []string{"authorization_code", "refresh_token"}, "response_types": []string{"code"}, - "scope": "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull", + "scope": "atproto blob:* blob:image/jpeg blob:image/png include:at.margin.authFull repo:community.lexicon.bookmarks.bookmark", "token_endpoint_auth_method": "private_key_jwt", "token_endpoint_auth_signing_alg": "ES256", "dpop_bound_access_tokens": true, diff --git a/backend/internal/repository/postgres/pg_engagement_repo.go b/backend/internal/repository/postgres/pg_engagement_repo.go new file mode 100644 index 0000000..8e95759 --- /dev/null +++ b/backend/internal/repository/postgres/pg_engagement_repo.go @@ -0,0 +1,200 @@ +package postgres + +import ( + "context" + "database/sql/driver" + "fmt" + "strings" + "time" + + "margin.at/internal/domain" +) + +type EngagementRepository struct { + db DB +} + +func NewEngagementRepository(db DB) *EngagementRepository { + return &EngagementRepository{db: db} +} + +func (r *EngagementRepository) GetLikeCount(ctx context.Context, uri string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM likes WHERE subject_uri = $1`, uri, + ).Scan(&count) + return count, err +} + +func (r *EngagementRepository) GetLikeCounts(ctx context.Context, uris []string) (map[string]int, error) { + if len(uris) == 0 { + return map[string]int{}, nil + } + rows, err := r.db.QueryContext(ctx, + `SELECT subject_uri, COUNT(*) FROM likes WHERE subject_uri = ANY($1) GROUP BY subject_uri`, + pqArray(uris), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[string]int, len(uris)) + for rows.Next() { + var uri string + var count int + if err := rows.Scan(&uri, &count); err != nil { + return nil, err + } + counts[uri] = count + } + return counts, rows.Err() +} + +func (r *EngagementRepository) GetReplyCounts(ctx context.Context, uris []string) (map[string]int, error) { + if len(uris) == 0 { + return map[string]int{}, nil + } + rows, err := r.db.QueryContext(ctx, + `SELECT root_uri, COUNT(*) FROM replies WHERE root_uri = ANY($1) GROUP BY root_uri`, + pqArray(uris), + ) + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[string]int, len(uris)) + for rows.Next() { + var uri string + var count int + if err := rows.Scan(&uri, &count); err != nil { + return nil, err + } + counts[uri] = count + } + return counts, rows.Err() +} + +func (r *EngagementRepository) GetViewerLikes(ctx context.Context, viewerDID string, uris []string) (map[string]bool, error) { + if len(uris) == 0 { + return map[string]bool{}, nil + } + + placeholders := make([]string, len(uris)) + args := make([]interface{}, len(uris)+1) + args[0] = viewerDID + for i, uri := range uris { + placeholders[i] = fmt.Sprintf("$%d", i+2) + args[i+1] = uri + } + + rows, err := r.db.QueryContext(ctx, + `SELECT subject_uri FROM likes WHERE author_did = $1 AND subject_uri IN (`+ + strings.Join(placeholders, ", ")+`)`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + likes := make(map[string]bool, len(uris)) + for rows.Next() { + var uri string + if err := rows.Scan(&uri); err != nil { + return nil, err + } + likes[uri] = true + } + return likes, rows.Err() +} + +func (r *EngagementRepository) GetLabelsForURIs(ctx context.Context, uris []string, labelerDIDs []string) (map[string][]domain.ContentLabel, error) { + return r.queryLabels(ctx, uris, labelerDIDs) +} + +func (r *EngagementRepository) GetLabelsForDIDs(ctx context.Context, dids []string, labelerDIDs []string) (map[string][]domain.ContentLabel, error) { + return r.queryLabels(ctx, dids, labelerDIDs) +} + +func (r *EngagementRepository) queryLabels(ctx context.Context, subjects []string, labelerDIDs []string) (map[string][]domain.ContentLabel, error) { + result := make(map[string][]domain.ContentLabel) + if len(subjects) == 0 { + return result, nil + } + + query := `SELECT id, src, uri, val, neg, created_by, created_at + FROM content_labels WHERE uri = ANY($1) AND neg = 0` + args := []interface{}{pqArray(subjects)} + + if len(labelerDIDs) > 0 { + query += ` AND src = ANY($2)` + args = append(args, pqArray(labelerDIDs)) + } + query += ` ORDER BY created_at DESC` + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return result, err + } + defer rows.Close() + + for rows.Next() { + var l domain.ContentLabel + if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.Val, &l.Neg, &l.CreatedBy, &l.CreatedAt); err != nil { + continue + } + result[l.URI] = append(result[l.URI], l) + } + return result, rows.Err() +} + +func (r *EngagementRepository) GetLatestEditTimes(ctx context.Context, uris []string) (map[string]time.Time, error) { + if len(uris) == 0 { + return nil, nil + } + + placeholders := make([]string, len(uris)) + args := make([]interface{}, len(uris)) + for i, uri := range uris { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = uri + } + + rows, err := r.db.QueryContext(ctx, + `SELECT uri, MAX(edited_at) FROM edit_history WHERE uri IN (`+ + strings.Join(placeholders, ",")+`) GROUP BY uri`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]time.Time, len(uris)) + for rows.Next() { + var uri string + var editedAt time.Time + if err := rows.Scan(&uri, &editedAt); err != nil { + continue + } + result[uri] = editedAt + } + return result, rows.Err() +} + +type pqArray []string + +func (a pqArray) Value() (driver.Value, error) { + if a == nil { + return "{}", nil + } + parts := make([]string, len(a)) + for i, s := range a { + escaped := strings.ReplaceAll(s, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + parts[i] = fmt.Sprintf(`"%s"`, escaped) + } + return "{" + strings.Join(parts, ",") + "}", nil +} diff --git a/backend/internal/repository/postgres/pg_note_repo.go b/backend/internal/repository/postgres/pg_note_repo.go new file mode 100644 index 0000000..be8b2d2 --- /dev/null +++ b/backend/internal/repository/postgres/pg_note_repo.go @@ -0,0 +1,342 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "margin.at/internal/domain" +) + +type DB interface { + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) + QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) + QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row +} + +type NoteRepository struct { + db DB +} + +func NewNoteRepository(db DB) *NoteRepository { + return &NoteRepository{db: db} +} + +func (r *NoteRepository) List(ctx context.Context, f domain.NoteFilter) ([]domain.Note, error) { + var where []string + var args []interface{} + n := 1 + + if len(f.Motivations) == 1 { + where = append(where, fmt.Sprintf("motivation = $%d", n)) + args = append(args, f.Motivations[0]) + n++ + } else if len(f.Motivations) > 1 { + placeholders := make([]string, len(f.Motivations)) + for i, m := range f.Motivations { + placeholders[i] = fmt.Sprintf("$%d", n) + args = append(args, m) + n++ + } + where = append(where, fmt.Sprintf("motivation IN (%s)", strings.Join(placeholders, ", "))) + } + + if f.AuthorDID != "" { + where = append(where, fmt.Sprintf("author_did = $%d", n)) + args = append(args, f.AuthorDID) + n++ + } + + if f.TargetHash != "" { + where = append(where, fmt.Sprintf("target_hash = $%d", n)) + args = append(args, f.TargetHash) + n++ + } + + if f.Tag != "" { + where = append(where, fmt.Sprintf( + "tags_json IS NOT NULL AND EXISTS(SELECT 1 FROM jsonb_array_elements_text(tags_json::jsonb) elem WHERE lower(elem) = lower($%d))", + n, + )) + args = append(args, f.Tag) + n++ + } + + if f.Query != "" { + pattern := "%" + escapeLike(f.Query) + "%" + where = append(where, fmt.Sprintf( + "(body_value ILIKE $%d OR target_source ILIKE $%d OR target_title ILIKE $%d OR tags_json::text ILIKE $%d)", + n, n+1, n+2, n+3, + )) + args = append(args, pattern, pattern, pattern, pattern) + n += 4 + } + + switch f.FeedType { + case domain.FeedTypeMargin: + where = append(where, "uri NOT LIKE '%network.cosmik%'") + case domain.FeedTypeSemble: + where = append(where, "uri LIKE '%network.cosmik%'") + case domain.FeedTypePopular: + since := time.Now().AddDate(0, 0, -14) + where = append(where, fmt.Sprintf("created_at > $%d", n)) + args = append(args, since) + n++ + case domain.FeedTypeShelved: + olderThan := time.Now().AddDate(0, 0, -1) + since := time.Now().AddDate(0, 0, -14) + where = append(where, fmt.Sprintf("created_at < $%d AND created_at > $%d", n, n+1)) + where = append(where, "NOT EXISTS (SELECT 1 FROM likes WHERE subject_uri = uri)") + where = append(where, "NOT EXISTS (SELECT 1 FROM replies WHERE root_uri = uri)") + args = append(args, olderThan, since) + n += 2 + } + + whereClause := "" + if len(where) > 0 { + whereClause = "WHERE " + strings.Join(where, " AND ") + } + + orderClause := "ORDER BY created_at DESC" + switch f.FeedType { + case domain.FeedTypePopular: + orderClause = `ORDER BY ( + SELECT COUNT(*) FROM likes WHERE subject_uri = uri + ) + ( + SELECT COUNT(*) FROM replies WHERE root_uri = uri + ) DESC, created_at DESC` + case domain.FeedTypeShelved: + orderClause = "ORDER BY RANDOM()" + } + + limit := f.Limit + if limit <= 0 { + limit = 50 + } + + query := fmt.Sprintf(` + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM unified_notes + %s + %s + LIMIT $%d OFFSET $%d + `, whereClause, orderClause, n, n+1) + + args = append(args, limit, f.Offset) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNotes(rows) +} + +func (r *NoteRepository) GetByURI(ctx context.Context, uri string) (*domain.Note, error) { + query := ` + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM unified_notes + WHERE uri = $1 + ` + var note domain.Note + err := r.db.QueryRowContext(ctx, query, uri).Scan( + ¬e.URI, ¬e.AuthorDID, ¬e.Motivation, ¬e.Color, ¬e.Description, + ¬e.BodyValue, ¬e.BodyFormat, ¬e.BodyURI, + ¬e.TargetSource, ¬e.TargetHash, ¬e.TargetTitle, + ¬e.SelectorJSON, ¬e.TagsJSON, ¬e.CreatedAt, ¬e.IndexedAt, ¬e.CID, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return ¬e, nil +} + +func (r *NoteRepository) CreateNote(ctx context.Context, n *domain.Note) error { + query := ` + INSERT INTO notes ( + uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16 + ) ON CONFLICT (uri) DO UPDATE SET + motivation = EXCLUDED.motivation, + color = EXCLUDED.color, + description = EXCLUDED.description, + body_value = EXCLUDED.body_value, + body_format = EXCLUDED.body_format, + body_uri = EXCLUDED.body_uri, + target_source = EXCLUDED.target_source, + target_hash = EXCLUDED.target_hash, + target_title = EXCLUDED.target_title, + selector_json = EXCLUDED.selector_json, + tags_json = EXCLUDED.tags_json, + indexed_at = EXCLUDED.indexed_at, + cid = EXCLUDED.cid + ` + _, err := r.db.ExecContext(ctx, query, + n.URI, n.AuthorDID, n.Motivation, n.Color, n.Description, n.BodyValue, n.BodyFormat, n.BodyURI, + n.TargetSource, n.TargetHash, n.TargetTitle, n.SelectorJSON, n.TagsJSON, n.CreatedAt, n.IndexedAt, n.CID, + ) + return err +} + +func (r *NoteRepository) DeleteNote(ctx context.Context, uri string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM notes WHERE uri = $1", uri) + return err +} + +func (r *NoteRepository) UpdateNoteAnnotation(ctx context.Context, uri, bodyValue, tagsJSON string, cid *string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notes + SET body_value = $1, tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 + WHERE uri = $5 + `, bodyValue, tagsJSON, cid, time.Now(), uri) + return err +} + +func (r *NoteRepository) GetLikeByUserAndSubject(ctx context.Context, did, subjectURI string) (*domain.Like, error) { + query := "SELECT uri, author_did, subject_uri, created_at, indexed_at FROM likes WHERE author_did = $1 AND subject_uri = $2" + var l domain.Like + err := r.db.QueryRowContext(ctx, query, did, subjectURI).Scan( + &l.URI, &l.AuthorDID, &l.SubjectURI, &l.CreatedAt, &l.IndexedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &l, nil +} + +func (r *NoteRepository) CreateLike(ctx context.Context, l *domain.Like) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO likes (uri, author_did, subject_uri, created_at, indexed_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT(uri) DO NOTHING + `, l.URI, l.AuthorDID, l.SubjectURI, l.CreatedAt, l.IndexedAt) + return err +} + +func (r *NoteRepository) DeleteLike(ctx context.Context, uri string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM likes WHERE uri = $1", uri) + return err +} + +func (r *NoteRepository) CreateReply(ctx context.Context, rep *domain.Reply) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO replies (uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT(uri) DO NOTHING + `, rep.URI, rep.AuthorDID, rep.ParentURI, rep.RootURI, rep.Text, rep.Format, rep.CreatedAt, rep.IndexedAt, rep.CID) + return err +} + +func (r *NoteRepository) GetReplyByURI(ctx context.Context, uri string) (*domain.Reply, error) { + query := "SELECT uri, author_did, parent_uri, root_uri, text, format, created_at, indexed_at, cid FROM replies WHERE uri = $1" + var rep domain.Reply + err := r.db.QueryRowContext(ctx, query, uri).Scan( + &rep.URI, &rep.AuthorDID, &rep.ParentURI, &rep.RootURI, &rep.Text, &rep.Format, &rep.CreatedAt, &rep.IndexedAt, &rep.CID, + ) + if err == sql.ErrNoRows { + return nil, nil + } + return &rep, err +} + +func (r *NoteRepository) DeleteReply(ctx context.Context, uri string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM replies WHERE uri = $1", uri) + return err +} + +func (r *NoteRepository) DeleteAnnotation(ctx context.Context, uri string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM annotations WHERE uri = $1", uri) + return err +} + +func (r *NoteRepository) DeleteHighlight(ctx context.Context, uri string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM highlights WHERE uri = $1", uri) + return err +} + +func (r *NoteRepository) DeleteBookmark(ctx context.Context, uri string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM bookmarks WHERE uri = $1", uri) + return err +} + +func (r *NoteRepository) UpdateAnnotation(ctx context.Context, uri, bodyValue, tagsJSON string, cid *string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE annotations + SET body_value = $1, tags_json = NULLIF($2, ''), cid = $3, indexed_at = $4 + WHERE uri = $5 + `, bodyValue, tagsJSON, cid, time.Now(), uri) + return err +} + +func (r *NoteRepository) GetAnnotationByURI(ctx context.Context, uri string) (*domain.Annotation, error) { + query := ` + 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 + FROM annotations WHERE uri = $1 + ` + var a domain.Annotation + err := r.db.QueryRowContext(ctx, query, 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, + ) + if err == sql.ErrNoRows { + return nil, nil + } + return &a, err +} + +func (r *NoteRepository) CheckDuplicateAnnotation(ctx context.Context, did, url, text string) (*domain.Annotation, error) { + query := "SELECT uri, cid FROM annotations WHERE author_did = $1 AND target_source = $2 AND body_value = $3 LIMIT 1" + var a domain.Annotation + err := r.db.QueryRowContext(ctx, query, did, url, text).Scan(&a.URI, &a.CID) + if err == sql.ErrNoRows { + return nil, nil + } + return &a, err +} + +func (r *NoteRepository) CheckDuplicateHighlight(ctx context.Context, did, url string, selector []byte) (*domain.Highlight, error) { + query := "SELECT uri, cid FROM highlights WHERE author_did = $1 AND target_source = $2 AND selector_json = $3 LIMIT 1" + var h domain.Highlight + err := r.db.QueryRowContext(ctx, query, did, url, selector).Scan(&h.URI, &h.CID) + if err == sql.ErrNoRows { + return nil, nil + } + return &h, err +} + +func scanNotes(rows *sql.Rows) ([]domain.Note, error) { + var notes []domain.Note + for rows.Next() { + var note domain.Note + if err := rows.Scan( + ¬e.URI, ¬e.AuthorDID, ¬e.Motivation, ¬e.Color, ¬e.Description, + ¬e.BodyValue, ¬e.BodyFormat, ¬e.BodyURI, + ¬e.TargetSource, ¬e.TargetHash, ¬e.TargetTitle, + ¬e.SelectorJSON, ¬e.TagsJSON, ¬e.CreatedAt, ¬e.IndexedAt, ¬e.CID, + ); err != nil { + return nil, err + } + notes = append(notes, note) + } + return notes, rows.Err() +} + +func escapeLike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} diff --git a/backend/internal/repository/postgres/pg_notification_repo.go b/backend/internal/repository/postgres/pg_notification_repo.go new file mode 100644 index 0000000..c24cc00 --- /dev/null +++ b/backend/internal/repository/postgres/pg_notification_repo.go @@ -0,0 +1,63 @@ +package postgres + +import ( + "context" + "time" + + "margin.at/internal/domain" +) + +type NotificationRepository struct { + db DB +} + +func NewNotificationRepository(db DB) *NotificationRepository { + return &NotificationRepository{db: db} +} + +func (r *NotificationRepository) CreateNotification(ctx context.Context, n *domain.Notification) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO notifications (recipient_did, actor_did, type, subject_uri, created_at) + VALUES ($1, $2, $3, $4, $5) + `, n.RecipientDID, n.ActorDID, n.Type, n.SubjectURI, n.CreatedAt) + return err +} + +func (r *NotificationRepository) GetNotifications(ctx context.Context, recipientDID string, limit, offset int) ([]domain.Notification, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, recipient_did, actor_did, type, subject_uri, created_at, read_at + FROM notifications + WHERE recipient_did = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + `, recipientDID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []domain.Notification + for rows.Next() { + var n domain.Notification + if err := rows.Scan(&n.ID, &n.RecipientDID, &n.ActorDID, &n.Type, &n.SubjectURI, &n.CreatedAt, &n.ReadAt); err != nil { + continue + } + out = append(out, n) + } + return out, rows.Err() +} + +func (r *NotificationRepository) GetUnreadNotificationCount(ctx context.Context, recipientDID string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM notifications WHERE recipient_did = $1 AND read_at IS NULL + `, recipientDID).Scan(&count) + return count, err +} + +func (r *NotificationRepository) MarkNotificationsRead(ctx context.Context, recipientDID string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notifications SET read_at = $1 WHERE recipient_did = $2 AND read_at IS NULL + `, time.Now(), recipientDID) + return err +} diff --git a/backend/internal/repository/postgres/pg_profile_repo.go b/backend/internal/repository/postgres/pg_profile_repo.go new file mode 100644 index 0000000..9f67b42 --- /dev/null +++ b/backend/internal/repository/postgres/pg_profile_repo.go @@ -0,0 +1,98 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "margin.at/internal/domain" +) + +type ProfileRepository struct { + db DB +} + +func NewProfileRepository(db DB) *ProfileRepository { + return &ProfileRepository{db: db} +} + +func (r *ProfileRepository) GetProfiles(ctx context.Context, dids []string) (map[string]domain.Author, error) { + if len(dids) == 0 { + return map[string]domain.Author{}, nil + } + + placeholders := make([]string, len(dids)) + args := make([]interface{}, len(dids)) + for i, did := range dids { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = did + } + + query := `SELECT author_did, display_name, avatar FROM profiles WHERE author_did IN (` + + strings.Join(placeholders, ",") + `)` + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + profiles := make(map[string]domain.Author, len(dids)) + for rows.Next() { + var did string + var displayName, avatar *string + if err := rows.Scan(&did, &displayName, &avatar); err != nil { + continue + } + a := domain.Author{DID: did} + if displayName != nil { + a.DisplayName = *displayName + } + if avatar != nil { + a.Avatar = *avatar + } + profiles[did] = a + } + + result := make(map[string]domain.Author, len(dids)) + for _, did := range dids { + if a, ok := profiles[did]; ok { + result[did] = a + } else { + result[did] = domain.Author{DID: did} + } + } + return result, rows.Err() +} + +func (r *ProfileRepository) GetProfile(ctx context.Context, did string) (*domain.Profile, error) { + query := `SELECT uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at + FROM profiles WHERE author_did = $1` + var p domain.Profile + err := r.db.QueryRowContext(ctx, query, did).Scan( + &p.URI, &p.AuthorDID, &p.DisplayName, &p.Avatar, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (r *ProfileRepository) UpsertProfile(ctx context.Context, p *domain.Profile) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO profiles (uri, author_did, display_name, avatar, bio, website, links_json, created_at, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT(uri) DO UPDATE SET + display_name = EXCLUDED.display_name, + avatar = EXCLUDED.avatar, + bio = EXCLUDED.bio, + website = EXCLUDED.website, + links_json = EXCLUDED.links_json, + indexed_at = EXCLUDED.indexed_at + `, p.URI, p.AuthorDID, p.DisplayName, p.Avatar, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) + return err +} diff --git a/backend/internal/repository/postgres/pg_session_repo.go b/backend/internal/repository/postgres/pg_session_repo.go new file mode 100644 index 0000000..06a9fc5 --- /dev/null +++ b/backend/internal/repository/postgres/pg_session_repo.go @@ -0,0 +1,23 @@ +package postgres + +import ( + "context" + "time" +) + +type SessionRepository struct { + db DB +} + +func NewSessionRepository(db DB) *SessionRepository { + return &SessionRepository{db: db} +} + +func (r *SessionRepository) GetSession(ctx context.Context, id string) (did, handle, accessToken, refreshToken, dpopKey string, err error) { + err = r.db.QueryRowContext(ctx, ` + SELECT did, handle, access_token, refresh_token, COALESCE(dpop_key, '') + FROM sessions + WHERE id = $1 AND expires_at > $2 + `, id, time.Now()).Scan(&did, &handle, &accessToken, &refreshToken, &dpopKey) + return +} diff --git a/backend/internal/service/feed.go b/backend/internal/service/feed.go new file mode 100644 index 0000000..ba0eeb7 --- /dev/null +++ b/backend/internal/service/feed.go @@ -0,0 +1,128 @@ +package service + +import ( + "context" + "sort" + + "margin.at/internal/domain" +) + +type FeedRequest struct { + ViewerDID string + Motivations []string + AuthorDID string + Tag string + FeedType domain.FeedType + Limit int + Offset int +} + +type FeedResponse struct { + Items []APINote + TotalItems int +} + +type FeedService struct { + notes domain.NoteRepository + hydration *HydrationService + database interface { + GetAllHiddenDIDs(actorDID string) (map[string]bool, error) + } +} + +func NewFeedService( + notes domain.NoteRepository, + hydration *HydrationService, + db interface { + GetAllHiddenDIDs(actorDID string) (map[string]bool, error) + }, +) *FeedService { + return &FeedService{ + notes: notes, + hydration: hydration, + database: db, + } +} + +func (s *FeedService) GetFeed(ctx context.Context, req FeedRequest) (*FeedResponse, error) { + fetchLimit := req.Limit + req.Offset + if fetchLimit <= 0 { + fetchLimit = req.Limit + } + + filter := domain.NoteFilter{ + Motivations: req.Motivations, + AuthorDID: req.AuthorDID, + Tag: req.Tag, + FeedType: req.FeedType, + Limit: fetchLimit, + Offset: 0, + } + + notes, err := s.notes.List(ctx, filter) + if err != nil { + return nil, err + } + + if req.ViewerDID != "" { + hidden, _ := s.database.GetAllHiddenDIDs(req.ViewerDID) + if len(hidden) > 0 { + filtered := notes[:0] + for _, n := range notes { + if !hidden[n.AuthorDID] { + filtered = append(filtered, n) + } + } + notes = filtered + } + } + + lc, err := s.hydration.Load(ctx, notes, req.ViewerDID) + if err != nil { + return nil, err + } + + items := make([]APINote, len(notes)) + for i, n := range notes { + items[i] = s.hydration.ToAPINote(n, lc) + } + + if len(req.Motivations) != 1 { + if req.FeedType == domain.FeedTypePopular { + sortByEngagement(items) + } else { + sortByTime(items) + } + } + + if req.Offset < len(items) { + items = items[req.Offset:] + } else { + items = nil + } + if len(items) > req.Limit { + items = items[:req.Limit] + } + if items == nil { + items = []APINote{} + } + + return &FeedResponse{Items: items, TotalItems: len(items)}, nil +} + +func sortByTime(items []APINote) { + sort.Slice(items, func(i, j int) bool { + return items[i].CreatedAt.After(items[j].CreatedAt) + }) +} + +func sortByEngagement(items []APINote) { + sort.Slice(items, func(i, j int) bool { + si := items[i].LikeCount + items[i].ReplyCount + sj := items[j].LikeCount + items[j].ReplyCount + if si != sj { + return si > sj + } + return items[i].CreatedAt.After(items[j].CreatedAt) + }) +} diff --git a/backend/internal/service/hydration.go b/backend/internal/service/hydration.go new file mode 100644 index 0000000..23f9d7e --- /dev/null +++ b/backend/internal/service/hydration.go @@ -0,0 +1,298 @@ +package service + +import ( + "context" + "encoding/json" + "sync" + "time" + + "margin.at/internal/domain" +) + +type APISelector struct { + Type string `json:"type"` + Exact string `json:"exact,omitempty"` + Prefix string `json:"prefix,omitempty"` + Suffix string `json:"suffix,omitempty"` + Start *int `json:"start,omitempty"` + End *int `json:"end,omitempty"` + Value string `json:"value,omitempty"` + ConformsTo string `json:"conformsTo,omitempty"` +} + +type APIBody struct { + Value string `json:"value,omitempty"` + Format string `json:"format,omitempty"` + URI string `json:"uri,omitempty"` +} + +type APITarget struct { + Source string `json:"source"` + Title string `json:"title,omitempty"` + Selector *APISelector `json:"selector,omitempty"` +} + +type APILabel struct { + Val string `json:"val"` + Src string `json:"src"` + Scope string `json:"scope"` +} + +type APIGenerator struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` +} + +type APINote struct { + ID string `json:"id"` + CID string `json:"cid,omitempty"` + Type string `json:"type"` + Motivation string `json:"motivation,omitempty"` + Author domain.Author `json:"creator"` + Body *APIBody `json:"body,omitempty"` + Target APITarget `json:"target"` + Color string `json:"color,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + Generator *APIGenerator `json:"generator,omitempty"` + CreatedAt time.Time `json:"created"` + IndexedAt time.Time `json:"indexed"` + LikeCount int `json:"likeCount"` + ReplyCount int `json:"replyCount"` + ViewerHasLiked bool `json:"viewerHasLiked"` + Labels []APILabel `json:"labels,omitempty"` + EditedAt *time.Time `json:"editedAt,omitempty"` +} + +type LoadContext struct { + Profiles map[string]domain.Author + LikeCounts map[string]int + ReplyCounts map[string]int + ViewerLikes map[string]bool + URILabels map[string][]domain.ContentLabel + DIDLabels map[string][]domain.ContentLabel + EditTimes map[string]time.Time +} + +type HydrationService struct { + engagement domain.EngagementRepository + profiles domain.ProfileRepository +} + +func NewHydrationService( + engagement domain.EngagementRepository, + profiles domain.ProfileRepository, +) *HydrationService { + return &HydrationService{ + engagement: engagement, + profiles: profiles, + } +} + +func (h *HydrationService) Load(ctx context.Context, notes []domain.Note, viewerDID string) (*LoadContext, error) { + lc := &LoadContext{ + Profiles: make(map[string]domain.Author), + LikeCounts: make(map[string]int), + ReplyCounts: make(map[string]int), + ViewerLikes: make(map[string]bool), + URILabels: make(map[string][]domain.ContentLabel), + DIDLabels: make(map[string][]domain.ContentLabel), + EditTimes: make(map[string]time.Time), + } + if len(notes) == 0 { + return lc, nil + } + + uris := make([]string, len(notes)) + didSet := make(map[string]struct{}, len(notes)) + for i, n := range notes { + uris[i] = n.URI + didSet[n.AuthorDID] = struct{}{} + } + dids := make([]string, 0, len(didSet)) + for did := range didSet { + dids = append(dids, did) + } + + var wg sync.WaitGroup + var mu sync.Mutex + + run := func(fn func() error) { + wg.Add(1) + go func() { + defer wg.Done() + fn() + }() + } + + run(func() error { + p, err := h.profiles.GetProfiles(ctx, dids) + if err == nil { + mu.Lock() + lc.Profiles = p + mu.Unlock() + } + return err + }) + + run(func() error { + counts, err := h.engagement.GetLikeCounts(ctx, uris) + if err == nil { + mu.Lock() + lc.LikeCounts = counts + mu.Unlock() + } + return err + }) + + run(func() error { + counts, err := h.engagement.GetReplyCounts(ctx, uris) + if err == nil { + mu.Lock() + lc.ReplyCounts = counts + mu.Unlock() + } + return err + }) + + if viewerDID != "" { + run(func() error { + vl, err := h.engagement.GetViewerLikes(ctx, viewerDID, uris) + if err == nil { + mu.Lock() + lc.ViewerLikes = vl + mu.Unlock() + } + return err + }) + } + + run(func() error { + ul, err := h.engagement.GetLabelsForURIs(ctx, uris, dids) + if err == nil { + mu.Lock() + lc.URILabels = ul + mu.Unlock() + } + return err + }) + + run(func() error { + dl, err := h.engagement.GetLabelsForDIDs(ctx, dids, dids) + if err == nil { + mu.Lock() + lc.DIDLabels = dl + mu.Unlock() + } + return err + }) + + run(func() error { + et, err := h.engagement.GetLatestEditTimes(ctx, uris) + if err == nil { + mu.Lock() + lc.EditTimes = et + mu.Unlock() + } + return err + }) + + wg.Wait() + return lc, nil +} + +func (h *HydrationService) ToAPINote(n domain.Note, lc *LoadContext) APINote { + noteType := "Annotation" + switch n.Motivation { + case "highlighting": + noteType = "Highlight" + case "bookmarking": + noteType = "Bookmark" + } + + note := APINote{ + ID: n.URI, + Type: noteType, + Motivation: n.Motivation, + Author: lc.Profiles[n.AuthorDID], + Target: APITarget{ + Source: n.TargetSource, + }, + CreatedAt: n.CreatedAt, + IndexedAt: n.IndexedAt, + LikeCount: lc.LikeCounts[n.URI], + ReplyCount: lc.ReplyCounts[n.URI], + ViewerHasLiked: lc.ViewerLikes[n.URI], + Labels: mergeLabels(lc.URILabels[n.URI], lc.DIDLabels[n.AuthorDID]), + Generator: &APIGenerator{ + ID: "https://margin.at", + Type: "Software", + Name: "Margin", + }, + } + + if n.CID != nil { + note.CID = *n.CID + } + + if n.TargetTitle != nil { + note.Target.Title = *n.TargetTitle + } + + if n.SelectorJSON != nil && *n.SelectorJSON != "" { + sel := &APISelector{} + if json.Unmarshal([]byte(*n.SelectorJSON), sel) == nil { + note.Target.Selector = sel + } + } + + if n.BodyValue != nil || n.BodyURI != nil { + body := &APIBody{} + if n.BodyValue != nil { + body.Value = *n.BodyValue + } + if n.BodyFormat != nil { + body.Format = *n.BodyFormat + } + if n.BodyURI != nil { + body.URI = *n.BodyURI + } + note.Body = body + } + + if n.Color != nil { + note.Color = *n.Color + } + + if n.Description != nil { + note.Description = *n.Description + } + + if n.TagsJSON != nil && *n.TagsJSON != "" { + var tags []string + if json.Unmarshal([]byte(*n.TagsJSON), &tags) == nil { + note.Tags = tags + } + } + + if t, ok := lc.EditTimes[n.URI]; ok { + note.EditedAt = &t + } + + return note +} + +func mergeLabels(uriLabels, didLabels []domain.ContentLabel) []APILabel { + if len(uriLabels) == 0 && len(didLabels) == 0 { + return nil + } + result := make([]APILabel, 0, len(uriLabels)+len(didLabels)) + for _, l := range uriLabels { + result = append(result, APILabel{Val: l.Val, Src: l.Src, Scope: "content"}) + } + for _, l := range didLabels { + result = append(result, APILabel{Val: l.Val, Src: l.Src, Scope: "author"}) + } + return result +} diff --git a/backend/internal/service/profile.go b/backend/internal/service/profile.go new file mode 100644 index 0000000..2f4efa1 --- /dev/null +++ b/backend/internal/service/profile.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "sync" + "time" + + "margin.at/internal/domain" +) + +const defaultProfileTTL = 5 * time.Minute + +type profileCacheEntry struct { + author domain.Author + expiresAt time.Time +} + +type ProfileService struct { + repo domain.ProfileRepository + ttl time.Duration + mu sync.RWMutex + cache map[string]profileCacheEntry +} + +func NewProfileService(repo domain.ProfileRepository) *ProfileService { + return &ProfileService{ + repo: repo, + ttl: defaultProfileTTL, + cache: make(map[string]profileCacheEntry), + } +} + +func (s *ProfileService) GetProfiles(ctx context.Context, dids []string) (map[string]domain.Author, error) { + now := time.Now() + result := make(map[string]domain.Author, len(dids)) + var missing []string + + s.mu.RLock() + for _, did := range dids { + if e, ok := s.cache[did]; ok && now.Before(e.expiresAt) { + result[did] = e.author + } else { + missing = append(missing, did) + } + } + s.mu.RUnlock() + + if len(missing) == 0 { + return result, nil + } + + fetched, err := s.repo.GetProfiles(ctx, missing) + if err != nil { + return result, err + } + + expiry := now.Add(s.ttl) + s.mu.Lock() + for did, author := range fetched { + s.cache[did] = profileCacheEntry{author: author, expiresAt: expiry} + result[did] = author + } + s.mu.Unlock() + + return result, nil +} + +func (s *ProfileService) GetProfile(ctx context.Context, did string) (*domain.Profile, error) { + return s.repo.GetProfile(ctx, did) +} + +func (s *ProfileService) UpsertProfile(ctx context.Context, p *domain.Profile) error { + if err := s.repo.UpsertProfile(ctx, p); err != nil { + return err + } + s.mu.Lock() + delete(s.cache, p.AuthorDID) + s.mu.Unlock() + return nil +} diff --git a/backend/internal/verification/verify.go b/backend/internal/verification/verify.go index 3a4802b..8b12f0d 100644 --- a/backend/internal/verification/verify.go +++ b/backend/internal/verification/verify.go @@ -253,7 +253,7 @@ func VerifyPublicationAsync(pubURL, uri string, onVerified func(string)) { select { case verifyQueue <- verifyTask{url: pubURL, uri: uri, onVerified: onVerified, isDoc: false}: default: - // Queue full — drop silently to protect network + // Queue full } } @@ -264,6 +264,6 @@ func VerifyDocumentAsync(docURL, uri string, onVerified func(string)) { select { case verifyQueue <- verifyTask{url: docURL, uri: uri, onVerified: onVerified, isDoc: true}: default: - // Queue full — drop silently to protect network + // Queue full } } diff --git a/backend/internal/xrpc/client.go b/backend/internal/xrpc/client.go index a89a2fa..ce13326 100644 --- a/backend/internal/xrpc/client.go +++ b/backend/internal/xrpc/client.go @@ -275,6 +275,53 @@ func (c *Client) GetRecord(ctx context.Context, repo, collection, rkey string) ( return &output, nil } +type ListRecordsRecord struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value json.RawMessage `json:"value"` +} + +type ListRecordsOutput struct { + Cursor string `json:"cursor"` + Records []ListRecordsRecord `json:"records"` +} + +func (c *Client) ListRecords(ctx context.Context, repo, collection string, limit int) (*ListRecordsOutput, error) { + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s&limit=%d", + c.PDS, repo, collection, limit) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + dpopProof, err := c.createDPoPProof("GET", url) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "DPoP "+c.AccessToken) + req.Header.Set("DPoP", dpopProof) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("XRPC error %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var output ListRecordsOutput + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { + return nil, err + } + + return &output, nil +} + type ResolveHandleOutput struct { Did string `json:"did"` } diff --git a/backend/internal/xrpc/records.go b/backend/internal/xrpc/records.go index c34bf8f..96b0f92 100644 --- a/backend/internal/xrpc/records.go +++ b/backend/internal/xrpc/records.go @@ -8,18 +8,20 @@ import ( ) const ( - CollectionAnnotation = "at.margin.annotation" - CollectionHighlight = "at.margin.highlight" - CollectionBookmark = "at.margin.bookmark" - CollectionReply = "at.margin.reply" - CollectionLike = "at.margin.like" - CollectionCollection = "at.margin.collection" - CollectionCollectionItem = "at.margin.collectionItem" - CollectionProfile = "at.margin.profile" - CollectionPreferences = "at.margin.preferences" - CollectionAPIKey = "at.margin.apikey" - CollectionDocument = "site.standard.document" - CollectionPublication = "site.standard.publication" + CollectionNote = "at.margin.note" + CollectionCommunityBookmark = "community.lexicon.bookmarks.bookmark" + CollectionAnnotation = "at.margin.annotation" + CollectionHighlight = "at.margin.highlight" + CollectionBookmark = "at.margin.bookmark" + CollectionReply = "at.margin.reply" + CollectionLike = "at.margin.like" + CollectionCollection = "at.margin.collection" + CollectionCollectionItem = "at.margin.collectionItem" + CollectionProfile = "at.margin.profile" + CollectionPreferences = "at.margin.preferences" + CollectionAPIKey = "at.margin.apikey" + CollectionDocument = "site.standard.document" + CollectionPublication = "site.standard.publication" ) const ( @@ -102,9 +104,10 @@ type Generator struct { Homepage string `json:"homepage,omitempty"` } -type AnnotationRecord struct { +type NoteRecord struct { Type string `json:"$type"` - Motivation string `json:"motivation,omitempty"` + Motivation string `json:"motivation"` + Color string `json:"color,omitempty"` Body *AnnotationBody `json:"body,omitempty"` Target AnnotationTarget `json:"target"` Tags []string `json:"tags,omitempty"` @@ -113,37 +116,13 @@ type AnnotationRecord struct { Rights string `json:"rights,omitempty"` Labels *SelfLabels `json:"labels,omitempty"` CreatedAt string `json:"createdAt"` + ModifiedAt string `json:"modifiedAt,omitempty"` } -type Facet struct { - Index FacetIndex `json:"index"` - Features []FacetFeature `json:"features"` -} - -type FacetIndex struct { - ByteStart int `json:"byteStart"` - ByteEnd int `json:"byteEnd"` -} - -type FacetFeature struct { - Type string `json:"$type"` - Did string `json:"did,omitempty"` - Uri string `json:"uri,omitempty"` -} - -type AnnotationBody struct { - Value string `json:"value,omitempty"` - Format string `json:"format,omitempty"` -} - -type AnnotationTarget struct { - Source string `json:"source"` - SourceHash string `json:"sourceHash"` - Title string `json:"title,omitempty"` - Selector json.RawMessage `json:"selector,omitempty"` -} - -func (r *AnnotationRecord) Validate() error { +func (r *NoteRecord) Validate() error { + if r.Motivation == "" { + return fmt.Errorf("motivation is required") + } if r.Target.Source == "" { return fmt.Errorf("target source is required") } @@ -163,13 +142,11 @@ func (r *AnnotationRecord) Validate() error { return fmt.Errorf("tag too long: %s", tag) } } - if len(r.Target.Selector) > 0 { var typeCheck Selector if err := json.Unmarshal(r.Target.Selector, &typeCheck); err != nil { return fmt.Errorf("invalid selector format") } - switch typeCheck.Type { case SelectorTypeQuote: var s TextQuoteSelector @@ -185,24 +162,20 @@ func (r *AnnotationRecord) Validate() error { return s.Validate() } } - return nil } -func NewAnnotationRecord(url, urlHash, text string, selector interface{}, title string) *AnnotationRecord { - return NewAnnotationRecordWithMotivation(url, urlHash, text, selector, title, "commenting") -} - -func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector interface{}, title string, motivation string) *AnnotationRecord { +func NewNoteRecord(url, urlHash, text string, selector interface{}, title, color, description, motivation string) *NoteRecord { var selectorJSON json.RawMessage if selector != nil { b, _ := json.Marshal(selector) selectorJSON = b } - record := &AnnotationRecord{ - Type: CollectionAnnotation, + record := &NoteRecord{ + Type: CollectionNote, Motivation: motivation, + Color: color, Target: AnnotationTarget{ Source: url, SourceHash: urlHash, @@ -212,9 +185,13 @@ func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector inter CreatedAt: time.Now().UTC().Format(time.RFC3339), } - if text != "" { + bodyText := text + if bodyText == "" { + bodyText = description + } + if bodyText != "" { record.Body = &AnnotationBody{ - Value: text, + Value: bodyText, Format: "text/plain", } } @@ -222,6 +199,60 @@ func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector inter return record } +type AnnotationRecord struct { + Type string `json:"$type"` + Motivation string `json:"motivation,omitempty"` + Body *AnnotationBody `json:"body,omitempty"` + Target AnnotationTarget `json:"target"` + Tags []string `json:"tags,omitempty"` + Facets []Facet `json:"facets,omitempty"` + Generator *Generator `json:"generator,omitempty"` + Rights string `json:"rights,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type Facet struct { + Index FacetIndex `json:"index"` + Features []FacetFeature `json:"features"` +} + +type FacetIndex struct { + ByteStart int `json:"byteStart"` + ByteEnd int `json:"byteEnd"` +} + +type FacetFeature struct { + Type string `json:"$type"` + Did string `json:"did,omitempty"` + Uri string `json:"uri,omitempty"` +} + +type AnnotationBody struct { + Value string `json:"value,omitempty"` + Format string `json:"format,omitempty"` +} + +type AnnotationTarget struct { + Source string `json:"source"` + SourceHash string `json:"sourceHash"` + Title string `json:"title,omitempty"` + Selector json.RawMessage `json:"selector,omitempty"` +} + +func (r *AnnotationRecord) Validate() error { + if r.Target.Source == "" { + return fmt.Errorf("target source is required") + } + if r.Body != nil && utf8.RuneCountInString(r.Body.Value) > 3000 { + return fmt.Errorf("body too long") + } + if len(r.Tags) > 10 { + return fmt.Errorf("too many tags") + } + return nil +} + type HighlightRecord struct { Type string `json:"$type"` Target AnnotationTarget `json:"target"` @@ -238,7 +269,7 @@ func (r *HighlightRecord) Validate() error { return fmt.Errorf("target source is required") } if len(r.Tags) > 10 { - return fmt.Errorf("too many tags: %d", len(r.Tags)) + return fmt.Errorf("too many tags") } if len(r.Color) > 20 { return fmt.Errorf("color too long") @@ -246,24 +277,33 @@ func (r *HighlightRecord) Validate() error { return nil } -func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { - var selectorJSON json.RawMessage - if selector != nil { - b, _ := json.Marshal(selector) - selectorJSON = b - } +type BookmarkRecord struct { + Type string `json:"$type"` + Source string `json:"source"` + SourceHash string `json:"sourceHash"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Tags []string `json:"tags,omitempty"` + Generator *Generator `json:"generator,omitempty"` + Rights string `json:"rights,omitempty"` + Labels *SelfLabels `json:"labels,omitempty"` + CreatedAt string `json:"createdAt"` +} - return &HighlightRecord{ - Type: CollectionHighlight, - Target: AnnotationTarget{ - Source: url, - SourceHash: urlHash, - Selector: selectorJSON, - }, - Color: color, - Tags: tags, - CreatedAt: time.Now().UTC().Format(time.RFC3339), +func (r *BookmarkRecord) Validate() error { + if r.Source == "" { + return fmt.Errorf("source is required") + } + if len(r.Title) > 500 { + return fmt.Errorf("title too long") + } + if len(r.Description) > 1000 { + return fmt.Errorf("description too long") + } + if len(r.Tags) > 10 { + return fmt.Errorf("too many tags") } + return nil } type ReplyRef struct { @@ -333,46 +373,6 @@ func NewLikeRecord(subjectURI, subjectCID string) *LikeRecord { } } -type BookmarkRecord struct { - Type string `json:"$type"` - Source string `json:"source"` - SourceHash string `json:"sourceHash"` - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Tags []string `json:"tags,omitempty"` - Generator *Generator `json:"generator,omitempty"` - Rights string `json:"rights,omitempty"` - Labels *SelfLabels `json:"labels,omitempty"` - CreatedAt string `json:"createdAt"` -} - -func (r *BookmarkRecord) Validate() error { - if r.Source == "" { - return fmt.Errorf("source is required") - } - if len(r.Title) > 500 { - return fmt.Errorf("title too long") - } - if len(r.Description) > 1000 { - return fmt.Errorf("description too long") - } - if len(r.Tags) > 10 { - return fmt.Errorf("too many tags") - } - return nil -} - -func NewBookmarkRecord(url, urlHash, title, description string) *BookmarkRecord { - return &BookmarkRecord{ - Type: CollectionBookmark, - Source: url, - SourceHash: urlHash, - Title: title, - Description: description, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - } -} - type CollectionRecord struct { Type string `json:"$type"` Name string `json:"name"` @@ -481,6 +481,7 @@ type PreferencesRecord struct { SubscribedLabelers []LabelerSubscription `json:"subscribedLabelers,omitempty"` LabelPreferences []LabelPreference `json:"labelPreferences,omitempty"` DisableExternalLinkWarning *bool `json:"disableExternalLinkWarning,omitempty"` + EnableCommunityBookmarks *bool `json:"enableCommunityBookmarks,omitempty"` CreatedAt string `json:"createdAt"` } @@ -502,11 +503,12 @@ func (r *PreferencesRecord) Validate() error { return nil } -func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}, disableExternalLinkWarning *bool) *PreferencesRecord { +func NewPreferencesRecord(skippedHostnames []string, labelers interface{}, labelPrefs interface{}, disableExternalLinkWarning *bool, enableCommunityBookmarks *bool) *PreferencesRecord { record := &PreferencesRecord{ Type: CollectionPreferences, ExternalLinkSkippedHostnames: skippedHostnames, DisableExternalLinkWarning: disableExternalLinkWarning, + EnableCommunityBookmarks: enableCommunityBookmarks, CreatedAt: time.Now().UTC().Format(time.RFC3339), } diff --git a/extension/src/components/popup/App.tsx b/extension/src/components/popup/App.tsx index 9eb7ebd..1c29120 100644 --- a/extension/src/components/popup/App.tsx +++ b/extension/src/components/popup/App.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { capture } from '@/utils/analytics'; import { sendMessage } from '@/utils/messaging'; import { themeItem, apiUrlItem, overlayEnabledItem } from '@/utils/storage'; import type { MarginSession, Annotation, Bookmark, Highlight, Collection } from '@/utils/types'; @@ -59,12 +60,17 @@ export function App() { const [bookmarkTags, setBookmarkTags] = useState([]); const [showBookmarkTags, setShowBookmarkTags] = useState(false); const [tagSuggestions, setTagSuggestions] = useState([]); - useEffect(() => { checkSession(); loadCurrentTab(); loadTheme(); loadSettings(); + + sendMessage('checkSession', undefined) + .then((s) => + capture('popup_opened', { authenticated: s?.authenticated ?? false }, s?.did ?? undefined) + ) + .catch(() => {}); }, []); useEffect(() => { @@ -329,6 +335,11 @@ export function App() { setText(''); setTags([]); loadAnnotations(); + capture( + 'annotation_created', + { url: currentUrl, tag_count: tags.length, source: 'extension' }, + session?.did ?? undefined + ); } else { alert('Failed to post annotation'); } @@ -352,6 +363,11 @@ export function App() { setBookmarked(true); setBookmarkTags([]); setShowBookmarkTags(false); + capture( + 'bookmark_created', + { url: currentUrl, tag_count: bookmarkTags.length, source: 'extension' }, + session?.did ?? undefined + ); } else { alert('Failed to bookmark page'); } @@ -454,15 +470,16 @@ export function App() { Margin

Welcome to Margin

-

+

Annotate, highlight, and bookmark the web with your AT Protocol identity.

+ @@ -591,34 +609,39 @@ export function App() {
- {(['page', 'bookmarks', 'highlights', 'collections'] as Tab[]).map((tab) => { - const icons: Record = { - page: , - bookmarks: , - highlights: , - collections: , - }; - const labels: Record = { - page: 'Page', - bookmarks: 'Bookmarks', - highlights: 'Highlights', - collections: 'Collections', - }; - return ( - - ); - })} + {(['page', 'bookmarks', 'highlights', 'collections'] as Tab[]) + .filter((tab) => tab === 'page' || !!session.did) + .map((tab) => { + const icons: Record = { + page: , + bookmarks: , + highlights: , + collections: , + }; + const labels: Record = { + page: 'Page', + bookmarks: 'Bookmarks', + highlights: 'Highlights', + collections: 'Collections', + }; + return ( + + ); + })}
diff --git a/extension/src/components/sidepanel/App.tsx b/extension/src/components/sidepanel/App.tsx index 7215314..5b501d4 100644 --- a/extension/src/components/sidepanel/App.tsx +++ b/extension/src/components/sidepanel/App.tsx @@ -592,7 +592,12 @@ export function App() { {showBookmarkTags && !bookmarked && (
- +
`) - .join(''); - suggestionsEl.style.display = 'flex'; - suggestionsEl.querySelectorAll('.tag-suggestion-btn').forEach((btn) => { - btn.addEventListener('click', () => addTag(btn.textContent || '')); - }); - }); + input.addEventListener( + 'input', + debounce(() => { + const val = input.value.toLowerCase().trim(); + if (!val) { + suggestionsEl.style.display = 'none'; + return; + } + const matches = cachedUserTags.filter((t) => t.includes(val) && !tags.includes(t)); + if (matches.length === 0) { + suggestionsEl.style.display = 'none'; + return; + } + suggestionsEl.innerHTML = matches + .slice(0, 5) + .map((t) => ``) + .join(''); + suggestionsEl.style.display = 'flex'; + suggestionsEl.querySelectorAll('.tag-suggestion-btn').forEach((btn) => { + btn.addEventListener('click', () => addTag(btn.textContent || '')); + }); + }, 120) + ); function dismiss() { toast.classList.add('toast-out'); @@ -715,12 +772,17 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = setTimeout(() => input.focus(), 50); } + let isFetching = false; + async function fetchAnnotations(retryCount = 0, cacheBust = false) { if (!overlayEnabled) { sendMessage('updateBadge', { count: 0 }); return; } + if (isFetching && !cacheBust) return; + isFetching = true; + try { const pageUrl = getPageUrl(); const doiUrl = getPageDOIUrl(); @@ -749,6 +811,8 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = if (retryCount < 3) { setTimeout(() => fetchAnnotations(retryCount + 1, cacheBust), 1000 * (retryCount + 1)); } + } finally { + isFetching = false; } } @@ -758,48 +822,72 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = activeItems = []; const rangesByColor: Record = {}; - if (!cachedMatcher) { + if (matcherNeedsRebuild || !cachedMatcher) { cachedMatcher = new DOMTextMatcher(); + matcherNeedsRebuild = false; } const matcher = cachedMatcher; - annotations.forEach((item) => { - const selector = item.target?.selector || item.selector; - if (!selector?.exact) return; - - const range = matcher.findRange(selector.exact); - if (range) { - activeItems.push({ range, item }); + const CHUNK_SIZE = 20; + let index = 0; + + function processChunk() { + const end = Math.min(index + CHUNK_SIZE, annotations.length); + for (let i = index; i < end; i++) { + const item = annotations[i]; + const selector = item.target?.selector || item.selector; + if (!selector?.exact) continue; + + const range = matcher.findRange(selector.exact); + if (range) { + activeItems.push({ range, item }); + + const isHighlight = (item as any).type === 'Highlight'; + const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; + const color = item.color || defaultColor; + if (!rangesByColor[color]) rangesByColor[color] = []; + rangesByColor[color].push(range); + } + } + index = end; - const isHighlight = (item as any).type === 'Highlight'; - const defaultColor = isHighlight ? '#f59e0b' : '#3b82f6'; - const color = item.color || defaultColor; - if (!rangesByColor[color]) rangesByColor[color] = []; - rangesByColor[color].push(range); + if (index < annotations.length) { + if ('requestIdleCallback' in window) { + requestIdleCallback(processChunk, { timeout: 500 }); + } else { + setTimeout(processChunk, 0); + } + } else { + commitHighlights(); } - }); + } - if (typeof CSS !== 'undefined' && CSS.highlights) { - CSS.highlights.clear(); - for (const [color, ranges] of Object.entries(rangesByColor)) { - const highlight = new Highlight(...ranges); - const safeColor = color.replace(/[^a-zA-Z0-9]/g, ''); - const name = `margin-hl-${safeColor}`; - CSS.highlights.set(name, highlight); - injectHighlightStyle(name, color); + function commitHighlights() { + if (typeof CSS !== 'undefined' && CSS.highlights) { + CSS.highlights.clear(); + for (const [color, ranges] of Object.entries(rangesByColor)) { + const highlight = new Highlight(...ranges); + const safeColor = color.replace(/[^a-zA-Z0-9]/g, ''); + const name = `margin-hl-${safeColor}`; + CSS.highlights.set(name, highlight); + injectHighlightStyle(name, color); + } } } + + processChunk(); } function injectHighlightStyle(name: string, color: string) { if (injectedStyles.has(name)) return; const style = document.createElement('style'); + const hex = color.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16) || 99; + const g = parseInt(hex.substring(2, 4), 16) || 102; + const b = parseInt(hex.substring(4, 6), 16) || 241; + if (isPdfContext()) { - const hex = color.replace('#', ''); - const r = parseInt(hex.substring(0, 2), 16) || 99; - const g = parseInt(hex.substring(2, 4), 16) || 102; - const b = parseInt(hex.substring(4, 6), 16) || 241; style.textContent = ` ::highlight(${name}) { background-color: rgba(${r}, ${g}, ${b}, 0.35); @@ -823,43 +911,100 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = } let hoverRafId: number | null = null; + let hoverIntentTimer: ReturnType | null = null; + let lastHoverX = -1; + let lastHoverY = -1; function handleMouseMove(e: MouseEvent) { if (!overlayEnabled || !overlayHost) return; - if (hoverRafId) cancelAnimationFrame(hoverRafId); + lastHoverX = e.clientX; + lastHoverY = e.clientY; + + if (hoverRafId !== null) { + cancelAnimationFrame(hoverRafId); + hoverRafId = null; + } + hoverRafId = requestAnimationFrame(() => { - processHover(e.clientX, e.clientY, e); + hoverRafId = null; + if (hoverIntentTimer) clearTimeout(hoverIntentTimer); + hoverIntentTimer = setTimeout(() => { + hoverIntentTimer = null; + processHover(lastHoverX, lastHoverY, e); + }, 150); }); } + function getAnnotationsAtPoint( + x: number, + y: number + ): Array<{ range: Range; item: Annotation; rect: DOMRect }> { + const results: Array<{ range: Range; item: Annotation; rect: DOMRect }> = []; - function processHover(x: number, y: number, e: MouseEvent) { - const foundItems: Array<{ range: Range; item: Annotation; rect: DOMRect }> = []; - let firstRange: Range | null = null; + let caretRange: Range | null = null; + try { + if (typeof (document as any).caretPositionFromPoint === 'function') { + const pos = (document as any).caretPositionFromPoint(x, y); + if (pos) { + caretRange = document.createRange(); + caretRange.setStart(pos.offsetNode, pos.offset); + caretRange.collapse(true); + } + } else if (typeof (document as any).caretRangeFromPoint === 'function') { + caretRange = (document as any).caretRangeFromPoint(x, y); + } + } catch { + /* ignore */ + } for (const { range, item } of activeItems) { - const rects = range.getClientRects(); - for (const rect of rects) { - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { - let container: Node | null = range.commonAncestorContainer; - if (container.nodeType === Node.TEXT_NODE) { - container = container.parentNode; - } + let hit = false; - if ( - container && - ((e.target as Node).contains(container) || container.contains(e.target as Node)) - ) { - if (!firstRange) firstRange = range; - if (!foundItems.some((f) => f.item === item)) { - foundItems.push({ range, item, rect }); - } + if (caretRange) { + try { + const afterStart = range.compareBoundaryPoints(Range.START_TO_START, caretRange) <= 0; + const beforeEnd = range.compareBoundaryPoints(Range.END_TO_START, caretRange) >= 0; + hit = afterStart && beforeEnd; + } catch { + /* ignore */ + } + } + + if (!hit) { + for (const rect of range.getClientRects()) { + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + hit = true; + break; } - break; + } + } + + if (hit) { + const firstRect = range.getClientRects()[0]; + if (firstRect && !results.some((r) => r.item === item)) { + results.push({ range, item, rect: firstRect }); } } } + return results; + } + + function processHover(x: number, y: number, e: MouseEvent) { + const target = e.target as HTMLElement; + if ( + target.closest( + 'a[href], button, input, select, textarea, [role="button"], [role="link"], [contenteditable]' + ) + ) { + document.body.style.cursor = ''; + if (hoverIndicator) hoverIndicator.classList.remove('visible'); + return; + } + + const foundItems = getAnnotationsAtPoint(x, y); + const firstRange = foundItems[0]?.range ?? null; + if (foundItems.length > 0 && shadowRoot) { document.body.style.cursor = 'pointer'; @@ -910,8 +1055,19 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = const firstRect = firstRange.getClientRects()[0]; const totalWidth = Math.min(uniqueAuthors.length, maxShow + (overflow > 0 ? 1 : 0)) * 18 + 8; - const leftPos = firstRect.left - totalWidth; - const topPos = firstRect.top + firstRect.height / 2 - 12; + const indicatorHeight = 28; + const gap = 4; + + let leftPos = firstRect.left - totalWidth; + leftPos = Math.max(gap, Math.min(leftPos, window.innerWidth - totalWidth - gap)); + + const topPos = Math.max( + gap, + Math.min( + firstRect.top + firstRect.height / 2 - indicatorHeight / 2, + window.innerHeight - indicatorHeight - gap + ) + ); hoverIndicator.style.left = `${leftPos}px`; hoverIndicator.style.top = `${topPos}px`; @@ -919,8 +1075,12 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = } } else { document.body.style.cursor = ''; - if (hoverIndicator) { - hoverIndicator.classList.remove('visible'); + if (hoverIndicator && hoverIndicator.classList.contains('visible')) { + if (hoverIntentTimer) clearTimeout(hoverIntentTimer); + hoverIntentTimer = setTimeout(() => { + hoverIntentTimer = null; + if (hoverIndicator) hoverIndicator.classList.remove('visible'); + }, 80); } } } @@ -947,32 +1107,16 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = composeModal = null; } - const clickedItems: Annotation[] = []; - for (const { range, item } of activeItems) { - const rects = range.getClientRects(); - for (const rect of rects) { - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { - let container: Node | null = range.commonAncestorContainer; - if (container.nodeType === Node.TEXT_NODE) { - container = container.parentNode; - } - - if ( - container && - ((e.target as Node).contains(container) || container.contains(e.target as Node)) - ) { - if (!clickedItems.includes(item)) { - clickedItems.push(item); - } - } - break; - } - } - } + const clickedItems: Annotation[] = getAnnotationsAtPoint(x, y).map((r) => r.item); if (clickedItems.length > 0) { const target = e.target as HTMLElement; - if (target.closest('a[href]')) return; + if ( + target.closest( + 'a[href], button, input, select, textarea, [role="button"], [role="link"], [contenteditable]' + ) + ) + return; e.preventDefault(); e.stopPropagation(); @@ -1219,7 +1363,9 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = let lastPolledUrl = getPageUrl(); function onUrlChange() { - lastPolledUrl = getPageUrl(); + const currentUrl = getPageUrl(); + if (currentUrl === lastPolledUrl) return; + lastPolledUrl = currentUrl; if (typeof CSS !== 'undefined' && CSS.highlights) { CSS.highlights.clear(); } @@ -1236,6 +1382,7 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = } window.addEventListener('popstate', onUrlChange); + window.addEventListener('hashchange', onUrlChange); const originalPushState = history.pushState; const originalReplaceState = history.replaceState; @@ -1250,30 +1397,36 @@ export async function initContentScript(ctx: { onInvalidated: (cb: () => void) = onUrlChange(); }; - // Only re-fetch when the URL actually changes (not every 500ms) - setInterval(() => { - const currentUrl = getPageUrl(); - if (currentUrl !== lastPolledUrl) { - onUrlChange(); - } - }, 500); - let domChangeTimeout: ReturnType | null = null; let domChangeCount = 0; - const observer = new MutationObserver((mutations) => { - const hasSignificantChange = mutations.some( - (m) => m.type === 'childList' && (m.addedNodes.length > 3 || m.removedNodes.length > 3) - ); - if (hasSignificantChange && overlayEnabled) { - domChangeCount++; - if (domChangeTimeout) clearTimeout(domChangeTimeout); - const delay = Math.min(500 + domChangeCount * 100, 2000); - domChangeTimeout = setTimeout(() => { - cachedMatcher = null; - domChangeCount = 0; - fetchAnnotations(); - }, delay); + + function isMeaningfulMutation(mutations: MutationRecord[]): boolean { + for (const m of mutations) { + if (m.type !== 'childList') continue; + for (const node of [...m.addedNodes, ...m.removedNodes]) { + if ((node as Element).id === 'margin-overlay-host') continue; + if (node.nodeType === Node.TEXT_NODE && (node.textContent?.trim().length ?? 0) > 20) + return true; + if (node.nodeType === Node.ELEMENT_NODE) { + const tag = (node as Element).tagName; + if (['STYLE', 'SCRIPT', 'SVG', 'IMG', 'VIDEO', 'CANVAS'].includes(tag)) continue; + if ((node as Element).textContent?.trim().length ?? 0 > 20) return true; + } + } } + return false; + } + + const observer = new MutationObserver((mutations) => { + if (!overlayEnabled || !isMeaningfulMutation(mutations)) return; + domChangeCount++; + if (domChangeTimeout) clearTimeout(domChangeTimeout); + const delay = Math.min(800 + domChangeCount * 200, 3000); + domChangeTimeout = setTimeout(() => { + matcherNeedsRebuild = true; + domChangeCount = 0; + fetchAnnotations(); + }, delay); }); observer.observe(document.body || document.documentElement, { childList: true, diff --git a/lexicons/at/margin/authFull.json b/lexicons/at/margin/authFull.json index 2467979..32843ef 100644 --- a/lexicons/at/margin/authFull.json +++ b/lexicons/at/margin/authFull.json @@ -6,7 +6,7 @@ "type": "permission-set", "title": "Margin", "title:langs": {}, - "detail": "Full access to Margin features including annotations, highlights, bookmarks, and collections.", + "detail": "Full access to Margin features including notes, replies, likes, and collections.", "detail:langs": {}, "permissions": [ { @@ -14,9 +14,7 @@ "resource": "repo", "action": ["create", "update", "delete"], "collection": [ - "at.margin.annotation", - "at.margin.highlight", - "at.margin.bookmark", + "at.margin.note", "at.margin.reply", "at.margin.like", "at.margin.collection", diff --git a/lexicons/at/margin/bookmark.json b/lexicons/at/margin/bookmark.json deleted file mode 100644 index 5ce24e3..0000000 --- a/lexicons/at/margin/bookmark.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "lexicon": 1, - "id": "at.margin.bookmark", - "description": "A bookmark record - save URL for later", - "defs": { - "main": { - "type": "record", - "description": "A bookmarked URL (motivation: bookmarking)", - "key": "tid", - "record": { - "type": "object", - "required": ["source", "createdAt"], - "properties": { - "source": { - "type": "string", - "format": "uri", - "description": "The bookmarked URL" - }, - "sourceHash": { - "type": "string", - "description": "SHA256 hash of normalized URL for indexing" - }, - "title": { - "type": "string", - "maxLength": 500, - "description": "Page title" - }, - "description": { - "type": "string", - "maxLength": 1000, - "maxGraphemes": 300, - "description": "Optional description/note" - }, - "tags": { - "type": "array", - "description": "Tags for categorization", - "items": { - "type": "string", - "maxLength": 64, - "maxGraphemes": 32 - }, - "maxLength": 10 - }, - "generator": { - "type": "object", - "description": "The client/agent that created this record", - "properties": { - "id": { - "type": "string", - "format": "uri" - }, - "name": { - "type": "string" - }, - "homepage": { - "type": "string", - "format": "uri" - } - } - }, - "rights": { - "type": "string", - "format": "uri", - "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" - }, - "labels": { - "type": "ref", - "ref": "com.atproto.label.defs#selfLabels", - "description": "Self-applied content labels for this bookmark" - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } -} diff --git a/lexicons/at/margin/highlight.json b/lexicons/at/margin/highlight.json deleted file mode 100644 index e0ad190..0000000 --- a/lexicons/at/margin/highlight.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "lexicon": 1, - "id": "at.margin.highlight", - "description": "A lightweight highlight record - annotation without body text", - "defs": { - "main": { - "type": "record", - "description": "A highlight on a web page (motivation: highlighting)", - "key": "tid", - "record": { - "type": "object", - "required": ["target", "createdAt"], - "properties": { - "target": { - "type": "ref", - "ref": "at.margin.annotation#target", - "description": "The resource and segment being highlighted" - }, - "color": { - "type": "string", - "description": "Highlight color (hex or named)", - "maxLength": 20 - }, - "tags": { - "type": "array", - "description": "Tags for categorization", - "items": { - "type": "string", - "maxLength": 64, - "maxGraphemes": 32 - }, - "maxLength": 10 - }, - "generator": { - "type": "object", - "description": "The client/agent that created this record", - "properties": { - "id": { - "type": "string", - "format": "uri" - }, - "name": { - "type": "string" - }, - "homepage": { - "type": "string", - "format": "uri" - } - } - }, - "rights": { - "type": "string", - "format": "uri", - "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)" - }, - "labels": { - "type": "ref", - "ref": "com.atproto.label.defs#selfLabels", - "description": "Self-applied content labels for this highlight" - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } -} diff --git a/lexicons/at/margin/annotation.json b/lexicons/at/margin/note.json similarity index 58% rename from lexicons/at/margin/annotation.json rename to lexicons/at/margin/note.json index 9cc3e31..527d3c0 100644 --- a/lexicons/at/margin/annotation.json +++ b/lexicons/at/margin/note.json @@ -1,8 +1,8 @@ { "lexicon": 1, - "id": "at.margin.annotation", - "revision": 2, - "description": "W3C Web Annotation Data Model compliant annotation record for ATProto", + "id": "at.margin.note", + "revision": 3, + "description": "W3C Web Annotation Data Model compliant unified note record for ATProto", "defs": { "main": { "type": "record", @@ -10,7 +10,7 @@ "key": "tid", "record": { "type": "object", - "required": ["target", "createdAt"], + "required": ["motivation", "target", "createdAt"], "properties": { "motivation": { "type": "string", @@ -28,10 +28,15 @@ "assessing" ] }, + "color": { + "type": "string", + "description": "Highlight color tint", + "maxLength": 20 + }, "body": { "type": "ref", "ref": "#body", - "description": "The annotation content (text or reference)" + "description": "The annotation content (text or reference). For bookmarks, use body.value for the description." }, "target": { "type": "ref", @@ -48,6 +53,14 @@ }, "maxLength": 10 }, + "facets": { + "type": "array", + "description": "Rich text facets (e.g. mentions, links)", + "items": { + "type": "ref", + "ref": "app.bsky.richtext.facet" + } + }, "generator": { "type": "ref", "ref": "#generator", @@ -66,6 +79,11 @@ "createdAt": { "type": "string", "format": "datetime" + }, + "modifiedAt": { + "type": "string", + "format": "datetime", + "description": "When this record was last modified" } } } @@ -95,17 +113,13 @@ "type": "string", "maxLength": 10000, "maxGraphemes": 3000, - "description": "Text content of the annotation" + "description": "Text content of the annotation. For bookmarks, this is the description." }, "format": { "type": "string", "description": "MIME type of the body content", "default": "text/plain" }, - "language": { - "type": "string", - "description": "BCP47 language tag" - }, "uri": { "type": "string", "format": "uri", @@ -133,16 +147,9 @@ "description": "Page title at time of annotation" }, "selector": { - "type": "union", - "description": "Selector to identify the specific segment", - "refs": [ - "#textQuoteSelector", - "#textPositionSelector", - "#cssSelector", - "#xpathSelector", - "#fragmentSelector", - "#rangeSelector" - ] + "type": "ref", + "ref": "#selector", + "description": "W3C Selector to identify the annotated segment. Uses W3C 'type' field (not ATProto $type) per the Web Annotation Data Model." }, "state": { "type": "ref", @@ -151,137 +158,60 @@ } } }, - "textQuoteSelector": { + "selector": { "type": "object", - "description": "W3C TextQuoteSelector - select text by quoting it with context", - "required": ["exact"], + "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.", + "required": ["type"], "properties": { "type": { "type": "string", - "const": "TextQuoteSelector" + "description": "W3C selector type identifier", + "knownValues": [ + "TextQuoteSelector", + "TextPositionSelector", + "CssSelector", + "XPathSelector", + "FragmentSelector", + "RangeSelector" + ] }, "exact": { "type": "string", "maxLength": 5000, "maxGraphemes": 1500, - "description": "The exact text to match" + "description": "TextQuoteSelector: the exact text being selected" }, "prefix": { "type": "string", "maxLength": 500, "maxGraphemes": 150, - "description": "Text immediately before the selection" + "description": "TextQuoteSelector: text immediately before the selection, for disambiguation" }, "suffix": { "type": "string", "maxLength": 500, "maxGraphemes": 150, - "description": "Text immediately after the selection" - } - } - }, - "textPositionSelector": { - "type": "object", - "description": "W3C TextPositionSelector - select by character offsets", - "required": ["start", "end"], - "properties": { - "type": { - "type": "string", - "const": "TextPositionSelector" + "description": "TextQuoteSelector: text immediately after the selection, for disambiguation" }, "start": { "type": "integer", "minimum": 0, - "description": "Starting character position (0-indexed, inclusive)" + "description": "TextPositionSelector: start character offset (inclusive)" }, "end": { "type": "integer", "minimum": 0, - "description": "Ending character position (exclusive)" - } - } - }, - "cssSelector": { - "type": "object", - "description": "W3C CssSelector - select DOM elements by CSS selector", - "required": ["value"], - "properties": { - "type": { - "type": "string", - "const": "CssSelector" - }, - "value": { - "type": "string", - "maxLength": 2000, - "description": "CSS selector string" - } - } - }, - "xpathSelector": { - "type": "object", - "description": "W3C XPathSelector - select by XPath expression", - "required": ["value"], - "properties": { - "type": { - "type": "string", - "const": "XPathSelector" + "description": "TextPositionSelector: end character offset (exclusive)" }, "value": { "type": "string", "maxLength": 2000, - "description": "XPath expression" - } - } - }, - "fragmentSelector": { - "type": "object", - "description": "W3C FragmentSelector - select by URI fragment", - "required": ["value"], - "properties": { - "type": { - "type": "string", - "const": "FragmentSelector" - }, - "value": { - "type": "string", - "maxLength": 1000, - "description": "Fragment identifier value" + "description": "CssSelector/XPathSelector/FragmentSelector: the selector expression or fragment value" }, "conformsTo": { "type": "string", "format": "uri", - "description": "Specification the fragment conforms to" - } - } - }, - "rangeSelector": { - "type": "object", - "description": "W3C RangeSelector - select range between two selectors", - "required": ["startSelector", "endSelector"], - "properties": { - "type": { - "type": "string", - "const": "RangeSelector" - }, - "startSelector": { - "type": "union", - "description": "Selector for range start", - "refs": [ - "#textQuoteSelector", - "#textPositionSelector", - "#cssSelector", - "#xpathSelector" - ] - }, - "endSelector": { - "type": "union", - "description": "Selector for range end", - "refs": [ - "#textQuoteSelector", - "#textPositionSelector", - "#cssSelector", - "#xpathSelector" - ] + "description": "FragmentSelector: URI of the specification the fragment conforms to" } } }, diff --git a/lexicons/at/margin/preferences.json b/lexicons/at/margin/preferences.json index 728ac76..c1ecb51 100644 --- a/lexicons/at/margin/preferences.json +++ b/lexicons/at/margin/preferences.json @@ -44,6 +44,11 @@ "disableExternalLinkWarning": { "type": "boolean", "description": "If true, do not show the confirmation modal when opening external links." + }, + "enableCommunityBookmarks": { + "type": "boolean", + "description": "If true, dual-write bookmarks to the standard for ATProto interop.", + "default": false } } } diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 6b13d5f..ed7789e 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -3,6 +3,10 @@ import { defineConfig } from "astro/config"; import react from "@astrojs/react"; import tailwind from "@astrojs/tailwind"; import node from "@astrojs/node"; +import { fileURLToPath } from "url"; +import { dirname, resolve } from "path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); const API_PORT = process.env.API_PORT || 8081; @@ -17,6 +21,11 @@ export default defineConfig({ defaultStrategy: "viewport", }, vite: { + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, ssr: { external: ["@resvg/resvg-js"], }, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index ad1b2ce..9d6d415 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1101,6 +1101,7 @@ export interface PreferencesResponse { subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; disableExternalLinkWarning?: boolean; + enableCommunityBookmarks?: boolean; } export async function getPreferences(): Promise { @@ -1121,6 +1122,7 @@ export async function updatePreferences(prefs: { subscribedLabelers?: LabelerSubscription[]; labelPreferences?: LabelPreference[]; disableExternalLinkWarning?: boolean; + enableCommunityBookmarks?: boolean; }): Promise { try { const res = await apiRequest("/api/preferences", { diff --git a/web/src/components/common/Card.tsx b/web/src/components/common/Card.tsx index e4e2938..e0208aa 100644 --- a/web/src/components/common/Card.tsx +++ b/web/src/components/common/Card.tsx @@ -19,6 +19,7 @@ import { Tag, Send, X, + Bookmark, } from "lucide-react"; import ShareMenu from "../modals/ShareMenu"; import AddToCollectionModal from "../modals/AddToCollectionModal"; @@ -47,6 +48,7 @@ import type { import { Avatar } from "../ui"; import CollectionIcon from "./CollectionIcon"; import ProfileHoverCard from "./ProfileHoverCard"; +import { analytics } from "../../lib/analytics"; const LABEL_DESCRIPTIONS: Record = { sexual: "Sexual Content", @@ -172,6 +174,10 @@ export default function Card({ const isSemble = item.uri?.includes("network.cosmik") || item.uri?.includes("semble"); + const isCommunityBookmark = item.uri?.includes( + "community.lexicon.bookmarks.bookmark", + ); + const safeUrlHostname = (url: string | null | undefined) => { if (!url) return null; try { @@ -212,13 +218,21 @@ export default function Card({ if (!success) { setLiked(prev.liked); setLikes(prev.likes); + } else { + analytics.capture("item_liked", { + type, + action: liked ? "unlike" : "like", + }); } }; const handleDelete = async () => { if (window.confirm("Delete this item?")) { const success = await deleteItem(item.uri, type); - if (success && onDelete) onDelete(item.uri); + if (success && onDelete) { + analytics.capture("item_deleted", { type }); + onDelete(item.uri); + } } }; @@ -479,6 +493,24 @@ export default function Card({ ); })()} + + {isCommunityBookmark && ( + + + · + + + + + Community bookmark + + + + + )}
{pageUrl && !isBookmark && !(contentWarning && !contentRevealed) && ( @@ -638,7 +670,7 @@ export default function Card({ const url = `${pageUrl}#:~:text=${sel.prefix ? encodeURIComponent(sel.prefix) + "-," : ""}${encodeURIComponent(sel.exact)}${sel.suffix ? ",-" + encodeURIComponent(sel.suffix) : ""}`; handleExternalClick(e, url); }} - className="block" + className="block break-words" > "{item.target?.selector?.exact}" diff --git a/web/src/components/feed/Composer.tsx b/web/src/components/feed/Composer.tsx index a11b46d..6dcec58 100644 --- a/web/src/components/feed/Composer.tsx +++ b/web/src/components/feed/Composer.tsx @@ -9,6 +9,7 @@ import { import type { Selector, ContentLabelValue } from "../../types"; import { X, ShieldAlert } from "lucide-react"; import TagInput from "../ui/TagInput"; +import { analytics } from "../../lib/analytics"; const SELF_LABEL_OPTIONS: { value: ContentLabelValue; label: string }[] = [ { value: "sexual", label: "Sexual" }, @@ -99,6 +100,11 @@ export default function Composer({ tags: tagList, labels: selfLabels.length > 0 ? selfLabels : undefined, }); + analytics.capture("highlight_created", { + url, + tag_count: tagList.length, + has_labels: selfLabels.length > 0, + }); } else { await createAnnotation({ url, @@ -107,6 +113,12 @@ export default function Composer({ tags: tagList, labels: selfLabels.length > 0 ? selfLabels : undefined, }); + analytics.capture("annotation_created", { + url, + has_quote: !!finalSelector, + tag_count: tagList.length, + has_labels: selfLabels.length > 0, + }); } setText(""); @@ -115,6 +127,7 @@ export default function Composer({ setSelector(null); if (onSuccess) onSuccess(); } catch (err) { + analytics.captureException(err); setError( (err instanceof Error ? err.message : "Unknown error") || "Failed to post", diff --git a/web/src/components/feed/ReplyList.tsx b/web/src/components/feed/ReplyList.tsx index c9eb6b0..9b68d51 100644 --- a/web/src/components/feed/ReplyList.tsx +++ b/web/src/components/feed/ReplyList.tsx @@ -108,7 +108,7 @@ const ReplyItem: React.FC = ({

0 ? "text-sm" : "text-sm", )} > @@ -147,7 +147,7 @@ const ReplyItem: React.FC = ({ : ""} -

+

{reply.text || reply.body?.value}

diff --git a/web/src/components/modals/AddToCollectionModal.tsx b/web/src/components/modals/AddToCollectionModal.tsx index acd7778..11da726 100644 --- a/web/src/components/modals/AddToCollectionModal.tsx +++ b/web/src/components/modals/AddToCollectionModal.tsx @@ -14,6 +14,7 @@ const EmojiPicker = React.lazy(() => import("emoji-picker-react")); import { useStore } from "@nanostores/react"; import { $user } from "../../store/auth"; import { $theme } from "../../store/theme"; +import { analytics } from "../../lib/analytics"; import { getCollections, addCollectionItem, @@ -88,6 +89,7 @@ export default function AddToCollectionModal({ setAddingTo(collectionUri); await addCollectionItem(collectionUri, annotationUri); setAddedTo((prev) => new Set([...prev, collectionUri])); + analytics.capture("item_added_to_collection"); } catch (err) { console.error(err); setError("Failed to add to collection"); diff --git a/web/src/components/modals/ShareMenu.tsx b/web/src/components/modals/ShareMenu.tsx index 85093d1..4d63023 100644 --- a/web/src/components/modals/ShareMenu.tsx +++ b/web/src/components/modals/ShareMenu.tsx @@ -14,6 +14,7 @@ import { CatskyIcon, DeerIcon, } from "../common/Icons"; +import { analytics } from "../../lib/analytics"; const SembleLogo = () => ( Semble @@ -84,6 +85,11 @@ export default function ShareMenu({ try { await navigator.clipboard.writeText(textToCopy); setCopied(key); + analytics.capture("item_shared", { + method: "copy_link", + destination: key, + item_type: type, + }); setTimeout(() => { setCopied(null); setIsOpen(false); @@ -98,6 +104,11 @@ export default function ShareMenu({ ? `${text.substring(0, 200)}...\n\n${shareUrl}` : shareUrl; const composeUrl = `https://${domain}/intent/compose?text=${encodeURIComponent(composeText)}`; + analytics.capture("item_shared", { + method: "social_app", + destination: domain, + item_type: type, + }); window.open(composeUrl, "_blank"); setIsOpen(false); }; diff --git a/web/src/components/modals/SignUpModal.tsx b/web/src/components/modals/SignUpModal.tsx index 4f91331..ad4743e 100644 --- a/web/src/components/modals/SignUpModal.tsx +++ b/web/src/components/modals/SignUpModal.tsx @@ -8,6 +8,7 @@ import { MarginIcon, } from "../common/Icons"; import { startSignup } from "../../api/client"; +import { analytics } from "../../lib/analytics"; interface Provider { id: string; @@ -169,12 +170,14 @@ export default function SignUpModal({ onClose }: SignUpModalProps) { setError(null); try { + analytics.capture("signup_initiated", { provider: provider.id }); const result = await startSignup(provider.service); if (result.authorizationUrl) { window.location.assign(result.authorizationUrl); } } catch (err) { console.error(err); + analytics.captureException(err); setError("Could not connect to this provider. Please try again."); setLoading(false); } @@ -193,6 +196,7 @@ export default function SignUpModal({ onClose }: SignUpModalProps) { } try { + analytics.capture("signup_initiated", { provider: "custom" }); const result = await startSignup(serviceUrl); if (result.authorizationUrl) { const url = new URL(result.authorizationUrl); @@ -202,6 +206,7 @@ export default function SignUpModal({ onClose }: SignUpModalProps) { } } catch (err) { console.error(err); + analytics.captureException(err); setError("Could not connect to this PDS. Please check the URL."); setLoading(false); } diff --git a/web/src/components/posthog.astro b/web/src/components/posthog.astro new file mode 100644 index 0000000..de93d39 --- /dev/null +++ b/web/src/components/posthog.astro @@ -0,0 +1,13 @@ + diff --git a/web/src/env.d.ts b/web/src/env.d.ts index ae44056..9dd9afd 100644 --- a/web/src/env.d.ts +++ b/web/src/env.d.ts @@ -5,3 +5,8 @@ declare namespace App { user: import("./types").UserProfile | null; } } + +interface Window { + __posthog_initialized?: boolean; + posthog?: import("posthog-js").PostHog; +} diff --git a/web/src/layouts/BaseLayout.astro b/web/src/layouts/BaseLayout.astro index c6ec960..e57827a 100644 --- a/web/src/layouts/BaseLayout.astro +++ b/web/src/layouts/BaseLayout.astro @@ -1,6 +1,7 @@ --- import { ClientRouter } from 'astro:transitions'; import '../styles/global.css'; +import PostHog from '../components/posthog.astro'; interface Props { title?: string; @@ -39,6 +40,7 @@ const { title = 'Margin', description = 'Annotate the web', image = 'https://mar {title} +