Add margin note synchronization (bidirectional, was previously one way)Unverified
29d5292 parent: ad10774 modified
docs/specs.md +64 -42 | @@ -124,7 +124,9 @@ A user likes an article. The liked feed surfaces popular articles and feeds into | ||
| 124 | 124 | |
| 125 | 125 | Glean also indexes records from the `at.margin.note` lexicon (owned by [margin.at](https://margin.at)). These are displayed in the UI as if they were `at.glean.annotation` records — margin notes appear alongside glean annotations on article detail pages. |
| 126 | 126 | |
| 127 | -The mapping from margin note to glean annotation: | |
| 127 | +#### Ingestion (margin.at → Glean) | |
| 128 | + | |
| 129 | +Margin notes are indexed from both Jetstream and PDS sync, same as glean records. The mapping from margin note to glean annotation: | |
| 128 | 130 | |
| 129 | 131 | | Margin note field | Annotation field | Notes | |
| 130 | 132 | | ------------------------------ | ---------------- | --------------------------------------------------------------- | |
| @@ -135,7 +137,26 @@ The mapping from margin note to glean annotation: | ||
| 135 | 137 | | `createdAt` | `created_at` | Direct mapping | |
| 136 | 138 | | _(looked up from articles DB)_ | `feed_url` | Resolved by matching `target.source` against known article URLs | |
| 137 | 139 | |
| 138 | -When no matching article exists in the local DB, the annotation is stored with an empty `feed_url`. Margin notes are indexed from both Jetstream and PDS sync, same as glean records. | |
| 140 | +When no matching article exists in the local DB, the annotation is stored with an empty `feed_url`. | |
| 141 | + | |
| 142 | +#### Mirroring (Glean → margin.at) | |
| 143 | + | |
| 144 | +When a user creates an annotation through Glean, two records are written to the user's PDS: | |
| 145 | + | |
| 146 | +1. **`at.glean.annotation`** — the primary record (canonical URI for the annotation) | |
| 147 | +2. **`at.margin.note`** — mirror for interoperability with margin.at clients | |
| 148 | + | |
| 149 | +The conversion from glean annotation to margin note uses `NewMarginNoteRecord`: | |
| 150 | + | |
| 151 | +| Glean annotation field | Margin note field | Notes | | |
| 152 | +| ---------------------- | ----------------------- | -------------------------------- | | |
| 153 | +| `articleUrl` | `target.source` | Direct mapping | | |
| 154 | +| `quote` | `target.selector.exact` | Wrapped in a `TextQuoteSelector` | | |
| 155 | +| `note` | `body.value` | `body.format` = `"text/plain"` | | |
| 156 | +| `tags` | `tags` | Direct mapping | | |
| 157 | +| _(constant)_ | `motivation` | Always `"commenting"` | | |
| 158 | + | |
| 159 | +The margin note mirror is fire-and-forget — if it fails, the glean annotation still succeeds. The Jetstream consumer will pick up the margin note create event, but `handleMarginNote` skips it via `AnnotationExistsByContent` (checks `author_did` + `article_url` + `quote` + `note`) to prevent duplicate annotations. | |
| 139 | 160 | |
| 140 | 161 | ### 3.5 `app.skyreader.feed.subscription` (External) |
| 141 | 162 | |
| @@ -433,11 +454,12 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing ` | ||
| 433 | 454 | │ └─────────────────┘ │ └──────────────────┘ |
| 434 | 455 | └──────────────────────┘ |
| 435 | 456 | |
| 436 | - AppView responsibilities: | |
| 437 | - • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like, at.margin.note, app.skyreader.feed.subscription | |
| 438 | - • Index records into SQLite | |
| 439 | - • Convert at.margin.note records to annotations (displayed alongside glean.at annotations) | |
| 440 | - • Import app.skyreader.feed.subscription records as Glean subscriptions | |
| 457 | + AppView responsibilities: | |
| 458 | + • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like, at.margin.note, app.skyreader.feed.subscription | |
| 459 | + • Index records into SQLite | |
| 460 | + • Convert at.margin.note records to annotations (displayed alongside glean.at annotations), skip if duplicate glean annotation exists | |
| 461 | + • Mirror glean annotations as at.margin.note records on user PDS for interoperability | |
| 462 | + • Import app.skyreader.feed.subscription records as Glean subscriptions | |
| 441 | 463 | • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) |
| 442 | 464 | • Host the web UI at glean.at |
| 443 | 465 | • Write to user PDS on behalf of user (when user acts through UI) |
| @@ -904,42 +926,42 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | ||
| 904 | 926 | |
| 905 | 927 | ### 8.1 Pages |
| 906 | 928 | |
| 907 | -| Route | Method | Description | | |
| 908 | -| ------------------------------ | ------ | ------------------------------------------------------------------- | | |
| 909 | -| `/` | GET | Landing page / auth redirect | | |
| 929 | +| Route | Method | Description | | |
| 930 | +| ------------------------------ | ------ | ---------------------------------------------------------------------- | | |
| 931 | +| `/` | GET | Landing page / auth redirect | | |
| 910 | 932 | | `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds | |
| 911 | -| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | |
| 912 | -| `/feeds/list` | GET | Feed list fragment (htmx partial) | | |
| 913 | -| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | | |
| 914 | -| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | |
| 915 | -| `/feeds/add` | POST | Add a single feed URL | | |
| 916 | -| `/feeds/remove` | DELETE | Remove a feed | | |
| 917 | -| `/feeds/refresh` | POST | Refresh all subscribed feeds | | |
| 918 | -| `/feeds/retry` | POST | Retry a failed feed | | |
| 919 | -| `/feeds/clear` | POST | Clear all subscriptions | | |
| 920 | -| `/feeds/dismiss` | POST | Dismiss a feed recommendation | | |
| 921 | -| `/articles` | GET | Read articles (paginated, filterable by feed) | | |
| 922 | -| `/articles/new-count` | GET | Get count of new articles (for badge updates) | | |
| 923 | -| `/articles/{id}` | GET | Article detail view | | |
| 924 | -| `/articles/{id}/read` | POST | Mark article as read | | |
| 925 | -| `/articles/{id}/unread` | POST | Mark article as unread | | |
| 926 | -| `/articles/{id}/like` | POST | Like an article | | |
| 927 | -| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | | |
| 928 | -| `/articles/mark-all-read` | POST | Mark all articles as read | | |
| 929 | -| `/articles/dismiss` | POST | Dismiss an article recommendation | | |
| 930 | -| `/trending` | GET | Community feed: articles ranked by likes (public) | | |
| 931 | -| `/library` | GET | Liked articles and annotations | | |
| 932 | -| `/library/create` | POST | Create annotation on an article | | |
| 933 | -| `/library/{id}/delete` | POST | Delete an annotation | | |
| 934 | -| `/stats` | GET | Application metrics and performance data (Prometheus, public) | | |
| 935 | -| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | |
| 936 | -| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | | |
| 937 | -| `/auth/login` | GET | Login page | | |
| 938 | -| `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | | |
| 939 | -| `/auth/resolve` | GET | Resolve handle to DID | | |
| 940 | -| `/auth/start` | POST | Start OAuth authorization flow | | |
| 941 | -| `/auth/callback` | GET | OAuth callback | | |
| 942 | -| `/terms` | GET | Terms of service | | |
| 933 | +| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | |
| 934 | +| `/feeds/list` | GET | Feed list fragment (htmx partial) | | |
| 935 | +| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | | |
| 936 | +| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | |
| 937 | +| `/feeds/add` | POST | Add a single feed URL | | |
| 938 | +| `/feeds/remove` | DELETE | Remove a feed | | |
| 939 | +| `/feeds/refresh` | POST | Refresh all subscribed feeds | | |
| 940 | +| `/feeds/retry` | POST | Retry a failed feed | | |
| 941 | +| `/feeds/clear` | POST | Clear all subscriptions | | |
| 942 | +| `/feeds/dismiss` | POST | Dismiss a feed recommendation | | |
| 943 | +| `/articles` | GET | Read articles (paginated, filterable by feed) | | |
| 944 | +| `/articles/new-count` | GET | Get count of new articles (for badge updates) | | |
| 945 | +| `/articles/{id}` | GET | Article detail view | | |
| 946 | +| `/articles/{id}/read` | POST | Mark article as read | | |
| 947 | +| `/articles/{id}/unread` | POST | Mark article as unread | | |
| 948 | +| `/articles/{id}/like` | POST | Like an article | | |
| 949 | +| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | | |
| 950 | +| `/articles/mark-all-read` | POST | Mark all articles as read | | |
| 951 | +| `/articles/dismiss` | POST | Dismiss an article recommendation | | |
| 952 | +| `/trending` | GET | Community feed: articles ranked by likes (public) | | |
| 953 | +| `/library` | GET | Liked articles and annotations | | |
| 954 | +| `/library/create` | POST | Create annotation on an article | | |
| 955 | +| `/library/{id}/delete` | POST | Delete an annotation | | |
| 956 | +| `/stats` | GET | Application metrics and performance data (Prometheus, public) | | |
| 957 | +| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | |
| 958 | +| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | | |
| 959 | +| `/auth/login` | GET | Login page | | |
| 960 | +| `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | | |
| 961 | +| `/auth/resolve` | GET | Resolve handle to DID | | |
| 962 | +| `/auth/start` | POST | Start OAuth authorization flow | | |
| 963 | +| `/auth/callback` | GET | OAuth callback | | |
| 964 | +| `/terms` | GET | Terms of service | | |
| 943 | 965 | |
| 944 | 966 | ### 8.2 htmx Patterns |
| 945 | 967 | |
| @@ -124,7 +124,9 @@ A user likes an article. The liked feed surfaces popular articles and feeds into | |||
| 124 | 124 | ||
| 125 | Glean also indexes records from the `at.margin.note` lexicon (owned by [margin.at](https://margin.at)). These are displayed in the UI as if they were `at.glean.annotation` records — margin notes appear alongside glean annotations on article detail pages. | 125 | Glean also indexes records from the `at.margin.note` lexicon (owned by [margin.at](https://margin.at)). These are displayed in the UI as if they were `at.glean.annotation` records — margin notes appear alongside glean annotations on article detail pages. |
| 126 | 126 | ||
| 127 | -The mapping from margin note to glean annotation: | 127 | +#### Ingestion (margin.at → Glean) |
| 128 | + | ||
| 129 | +Margin notes are indexed from both Jetstream and PDS sync, same as glean records. The mapping from margin note to glean annotation: | ||
| 128 | 130 | ||
| 129 | | Margin note field | Annotation field | Notes | | 131 | | Margin note field | Annotation field | Notes | |
| 130 | | ------------------------------ | ---------------- | --------------------------------------------------------------- | | 132 | | ------------------------------ | ---------------- | --------------------------------------------------------------- | |
| @@ -135,7 +137,26 @@ The mapping from margin note to glean annotation: | |||
| 135 | | `createdAt` | `created_at` | Direct mapping | | 137 | | `createdAt` | `created_at` | Direct mapping | |
| 136 | | _(looked up from articles DB)_ | `feed_url` | Resolved by matching `target.source` against known article URLs | | 138 | | _(looked up from articles DB)_ | `feed_url` | Resolved by matching `target.source` against known article URLs | |
| 137 | 139 | ||
| 138 | -When no matching article exists in the local DB, the annotation is stored with an empty `feed_url`. Margin notes are indexed from both Jetstream and PDS sync, same as glean records. | 140 | +When no matching article exists in the local DB, the annotation is stored with an empty `feed_url`. |
| 141 | + | ||
| 142 | +#### Mirroring (Glean → margin.at) | ||
| 143 | + | ||
| 144 | +When a user creates an annotation through Glean, two records are written to the user's PDS: | ||
| 145 | + | ||
| 146 | +1. **`at.glean.annotation`** — the primary record (canonical URI for the annotation) | ||
| 147 | +2. **`at.margin.note`** — mirror for interoperability with margin.at clients | ||
| 148 | + | ||
| 149 | +The conversion from glean annotation to margin note uses `NewMarginNoteRecord`: | ||
| 150 | + | ||
| 151 | +| Glean annotation field | Margin note field | Notes | | ||
| 152 | +| ---------------------- | ----------------------- | -------------------------------- | | ||
| 153 | +| `articleUrl` | `target.source` | Direct mapping | | ||
| 154 | +| `quote` | `target.selector.exact` | Wrapped in a `TextQuoteSelector` | | ||
| 155 | +| `note` | `body.value` | `body.format` = `"text/plain"` | | ||
| 156 | +| `tags` | `tags` | Direct mapping | | ||
| 157 | +| _(constant)_ | `motivation` | Always `"commenting"` | | ||
| 158 | + | ||
| 159 | +The margin note mirror is fire-and-forget — if it fails, the glean annotation still succeeds. The Jetstream consumer will pick up the margin note create event, but `handleMarginNote` skips it via `AnnotationExistsByContent` (checks `author_did` + `article_url` + `quote` + `note`) to prevent duplicate annotations. | ||
| 139 | 160 | ||
| 140 | ### 3.5 `app.skyreader.feed.subscription` (External) | 161 | ### 3.5 `app.skyreader.feed.subscription` (External) |
| 141 | 162 | ||
| @@ -433,11 +454,12 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing ` | |||
| 433 | │ └─────────────────┘ │ └──────────────────┘ | 454 | │ └─────────────────┘ │ └──────────────────┘ |
| 434 | └──────────────────────┘ | 455 | └──────────────────────┘ |
| 435 | 456 | ||
| 436 | - AppView responsibilities: | 457 | + AppView responsibilities: |
| 437 | - • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like, at.margin.note, app.skyreader.feed.subscription | 458 | + • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like, at.margin.note, app.skyreader.feed.subscription |
| 438 | - • Index records into SQLite | 459 | + • Index records into SQLite |
| 439 | - • Convert at.margin.note records to annotations (displayed alongside glean.at annotations) | 460 | + • Convert at.margin.note records to annotations (displayed alongside glean.at annotations), skip if duplicate glean annotation exists |
| 440 | - • Import app.skyreader.feed.subscription records as Glean subscriptions | 461 | + • Mirror glean annotations as at.margin.note records on user PDS for interoperability |
| 462 | + • Import app.skyreader.feed.subscription records as Glean subscriptions | ||
| 441 | • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) | 463 | • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) |
| 442 | • Host the web UI at glean.at | 464 | • Host the web UI at glean.at |
| 443 | • Write to user PDS on behalf of user (when user acts through UI) | 465 | • Write to user PDS on behalf of user (when user acts through UI) |
| @@ -904,42 +926,42 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | |||
| 904 | 926 | ||
| 905 | ### 8.1 Pages | 927 | ### 8.1 Pages |
| 906 | 928 | ||
| 907 | -| Route | Method | Description | | 929 | +| Route | Method | Description | |
| 908 | -| ------------------------------ | ------ | ------------------------------------------------------------------- | | 930 | +| ------------------------------ | ------ | ---------------------------------------------------------------------- | |
| 909 | -| `/` | GET | Landing page / auth redirect | | 931 | +| `/` | GET | Landing page / auth redirect | |
| 910 | | `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds | | 932 | | `/dashboard` | GET | Main dashboard: article recs, unread articles, trending, people, feeds | |
| 911 | -| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | 933 | +| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | |
| 912 | -| `/feeds/list` | GET | Feed list fragment (htmx partial) | | 934 | +| `/feeds/list` | GET | Feed list fragment (htmx partial) | |
| 913 | -| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | | 935 | +| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions (redirects to /feeds) | |
| 914 | -| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | 936 | +| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | |
| 915 | -| `/feeds/add` | POST | Add a single feed URL | | 937 | +| `/feeds/add` | POST | Add a single feed URL | |
| 916 | -| `/feeds/remove` | DELETE | Remove a feed | | 938 | +| `/feeds/remove` | DELETE | Remove a feed | |
| 917 | -| `/feeds/refresh` | POST | Refresh all subscribed feeds | | 939 | +| `/feeds/refresh` | POST | Refresh all subscribed feeds | |
| 918 | -| `/feeds/retry` | POST | Retry a failed feed | | 940 | +| `/feeds/retry` | POST | Retry a failed feed | |
| 919 | -| `/feeds/clear` | POST | Clear all subscriptions | | 941 | +| `/feeds/clear` | POST | Clear all subscriptions | |
| 920 | -| `/feeds/dismiss` | POST | Dismiss a feed recommendation | | 942 | +| `/feeds/dismiss` | POST | Dismiss a feed recommendation | |
| 921 | -| `/articles` | GET | Read articles (paginated, filterable by feed) | | 943 | +| `/articles` | GET | Read articles (paginated, filterable by feed) | |
| 922 | -| `/articles/new-count` | GET | Get count of new articles (for badge updates) | | 944 | +| `/articles/new-count` | GET | Get count of new articles (for badge updates) | |
| 923 | -| `/articles/{id}` | GET | Article detail view | | 945 | +| `/articles/{id}` | GET | Article detail view | |
| 924 | -| `/articles/{id}/read` | POST | Mark article as read | | 946 | +| `/articles/{id}/read` | POST | Mark article as read | |
| 925 | -| `/articles/{id}/unread` | POST | Mark article as unread | | 947 | +| `/articles/{id}/unread` | POST | Mark article as unread | |
| 926 | -| `/articles/{id}/like` | POST | Like an article | | 948 | +| `/articles/{id}/like` | POST | Like an article | |
| 927 | -| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | | 949 | +| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | |
| 928 | -| `/articles/mark-all-read` | POST | Mark all articles as read | | 950 | +| `/articles/mark-all-read` | POST | Mark all articles as read | |
| 929 | -| `/articles/dismiss` | POST | Dismiss an article recommendation | | 951 | +| `/articles/dismiss` | POST | Dismiss an article recommendation | |
| 930 | -| `/trending` | GET | Community feed: articles ranked by likes (public) | | 952 | +| `/trending` | GET | Community feed: articles ranked by likes (public) | |
| 931 | -| `/library` | GET | Liked articles and annotations | | 953 | +| `/library` | GET | Liked articles and annotations | |
| 932 | -| `/library/create` | POST | Create annotation on an article | | 954 | +| `/library/create` | POST | Create annotation on an article | |
| 933 | -| `/library/{id}/delete` | POST | Delete an annotation | | 955 | +| `/library/{id}/delete` | POST | Delete an annotation | |
| 934 | -| `/stats` | GET | Application metrics and performance data (Prometheus, public) | | 956 | +| `/stats` | GET | Application metrics and performance data (Prometheus, public) | |
| 935 | -| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | 957 | +| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | |
| 936 | -| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | | 958 | +| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | |
| 937 | -| `/auth/login` | GET | Login page | | 959 | +| `/auth/login` | GET | Login page | |
| 938 | -| `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | | 960 | +| `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | |
| 939 | -| `/auth/resolve` | GET | Resolve handle to DID | | 961 | +| `/auth/resolve` | GET | Resolve handle to DID | |
| 940 | -| `/auth/start` | POST | Start OAuth authorization flow | | 962 | +| `/auth/start` | POST | Start OAuth authorization flow | |
| 941 | -| `/auth/callback` | GET | OAuth callback | | 963 | +| `/auth/callback` | GET | OAuth callback | |
| 942 | -| `/terms` | GET | Terms of service | | 964 | +| `/terms` | GET | Terms of service | |
| 943 | 965 | ||
| 944 | ### 8.2 htmx Patterns | 966 | ### 8.2 htmx Patterns |
| 945 | 967 | ||
modified
internal/atproto/lexicon_external.go +28 -0 | @@ -78,6 +78,34 @@ type MarginNoteTimeState struct { | ||
| 78 | 78 | SourceDate string `json:"sourceDate,omitempty"` |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | +func NewMarginNoteRecord(articleURL, quote, note string, tags []string, createdAt string) MarginNoteRecord { | |
| 82 | + rec := MarginNoteRecord{ | |
| 83 | + Body: nil, | |
| 84 | + CreatedAt: createdAt, | |
| 85 | + Motivation: "commenting", | |
| 86 | + Tags: tags, | |
| 87 | + Target: MarginNoteTarget{ | |
| 88 | + Source: articleURL, | |
| 89 | + }, | |
| 90 | + } | |
| 91 | + | |
| 92 | + if quote != "" { | |
| 93 | + rec.Target.Selector = &MarginNoteSelector{ | |
| 94 | + Type: "TextQuoteSelector", | |
| 95 | + Exact: quote, | |
| 96 | + } | |
| 97 | + } | |
| 98 | + | |
| 99 | + if note != "" { | |
| 100 | + rec.Body = &MarginNoteBody{ | |
| 101 | + Format: "text/plain", | |
| 102 | + Value: note, | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + return rec | |
| 107 | +} | |
| 108 | + | |
| 81 | 109 | func (r MarginNoteRecord) ToAnnotation() (articleURL, quote, note string, tags []string) { |
| 82 | 110 | articleURL = r.Target.Source |
| 83 | 111 | if r.Body != nil { |
| @@ -78,6 +78,34 @@ type MarginNoteTimeState struct { | |||
| 78 | SourceDate string `json:"sourceDate,omitempty"` | 78 | SourceDate string `json:"sourceDate,omitempty"` |
| 79 | } | 79 | } |
| 80 | 80 | ||
| 81 | +func NewMarginNoteRecord(articleURL, quote, note string, tags []string, createdAt string) MarginNoteRecord { | ||
| 82 | + rec := MarginNoteRecord{ | ||
| 83 | + Body: nil, | ||
| 84 | + CreatedAt: createdAt, | ||
| 85 | + Motivation: "commenting", | ||
| 86 | + Tags: tags, | ||
| 87 | + Target: MarginNoteTarget{ | ||
| 88 | + Source: articleURL, | ||
| 89 | + }, | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + if quote != "" { | ||
| 93 | + rec.Target.Selector = &MarginNoteSelector{ | ||
| 94 | + Type: "TextQuoteSelector", | ||
| 95 | + Exact: quote, | ||
| 96 | + } | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + if note != "" { | ||
| 100 | + rec.Body = &MarginNoteBody{ | ||
| 101 | + Format: "text/plain", | ||
| 102 | + Value: note, | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + return rec | ||
| 107 | +} | ||
| 108 | + | ||
| 81 | func (r MarginNoteRecord) ToAnnotation() (articleURL, quote, note string, tags []string) { | 109 | func (r MarginNoteRecord) ToAnnotation() (articleURL, quote, note string, tags []string) { |
| 82 | articleURL = r.Target.Source | 110 | articleURL = r.Target.Source |
| 83 | if r.Body != nil { | 111 | if r.Body != nil { |
modified
internal/atproto/stream_handler.go +7 -0 | @@ -195,6 +195,13 @@ func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) er | ||
| 195 | 195 | if err != nil || a == nil { |
| 196 | 196 | return err |
| 197 | 197 | } |
| 198 | + exists, err := h.articles.AnnotationExistsByContent(ctx, a.AuthorDID, a.ArticleURL, a.Quote.String, a.Note.String) | |
| 199 | + if err != nil { | |
| 200 | + return err | |
| 201 | + } | |
| 202 | + if exists { | |
| 203 | + return nil | |
| 204 | + } | |
| 198 | 205 | return h.articles.CreateAnnotation(ctx, a) |
| 199 | 206 | |
| 200 | 207 | case actionUpdate: |
| @@ -195,6 +195,13 @@ func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) er | |||
| 195 | if err != nil || a == nil { | 195 | if err != nil || a == nil { |
| 196 | return err | 196 | return err |
| 197 | } | 197 | } |
| 198 | + exists, err := h.articles.AnnotationExistsByContent(ctx, a.AuthorDID, a.ArticleURL, a.Quote.String, a.Note.String) | ||
| 199 | + if err != nil { | ||
| 200 | + return err | ||
| 201 | + } | ||
| 202 | + if exists { | ||
| 203 | + return nil | ||
| 204 | + } | ||
| 198 | return h.articles.CreateAnnotation(ctx, a) | 205 | return h.articles.CreateAnnotation(ctx, a) |
| 199 | 206 | ||
| 200 | case actionUpdate: | 207 | case actionUpdate: |
added
internal/atproto/stream_handler_test.go +172 -0 | new file mode 100644 | ||
| @@ -0,0 +1,172 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "log/slog" | |
| 7 | + "os" | |
| 8 | + "testing" | |
| 9 | + "time" | |
| 10 | + | |
| 11 | + "gotest.tools/v3/assert" | |
| 12 | + | |
| 13 | + "pkg.rbrt.fr/glean/internal/db" | |
| 14 | + "pkg.rbrt.fr/glean/internal/feed" | |
| 15 | +) | |
| 16 | + | |
| 17 | +func setupStreamTestDB(t *testing.T) *db.Store { | |
| 18 | + t.Helper() | |
| 19 | + f, err := os.CreateTemp("", "glean-stream-test-*.db") | |
| 20 | + assert.NilError(t, err) | |
| 21 | + assert.NilError(t, f.Close()) | |
| 22 | + path := f.Name() | |
| 23 | + t.Cleanup(func() { | |
| 24 | + for _, suffix := range []string{"", "_users", "_users-shm", "_users-wal", "_articles", "_articles-shm", "_articles-wal", "_recs", "_recs-shm", "_recs-wal"} { | |
| 25 | + _ = os.Remove(path + suffix) | |
| 26 | + } | |
| 27 | + }) | |
| 28 | + dbs, err := db.Open(path) | |
| 29 | + assert.NilError(t, err) | |
| 30 | + t.Cleanup(func() { _ = dbs.Close() }) | |
| 31 | + return dbs | |
| 32 | +} | |
| 33 | + | |
| 34 | +func TestHandleMarginNote_SkipsDuplicateFromGleanAnnotation(t *testing.T) { | |
| 35 | + ctx := context.Background() | |
| 36 | + dbs := setupStreamTestDB(t) | |
| 37 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | |
| 38 | + | |
| 39 | + now := time.Now().Format(time.RFC3339) | |
| 40 | + | |
| 41 | + _ = dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"}) | |
| 42 | + | |
| 43 | + err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ | |
| 44 | + URI: "at://did:test:u1/at.glean.annotation/rkey1", AuthorDID: "did:test:u1", | |
| 45 | + FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | |
| 46 | + Quote: db.NullStr("selected text"), Note: db.NullStr("my note"), | |
| 47 | + CreatedAt: db.NullTime(time.Now()), | |
| 48 | + }) | |
| 49 | + assert.NilError(t, err) | |
| 50 | + | |
| 51 | + marginRec := NewMarginNoteRecord("https://a.com/1", "selected text", "my note", nil, now) | |
| 52 | + value, err := json.Marshal(marginRec) | |
| 53 | + assert.NilError(t, err) | |
| 54 | + | |
| 55 | + event := &Event{ | |
| 56 | + Type: actionCreate, | |
| 57 | + DID: "did:test:u1", | |
| 58 | + Collection: CollectionMarginNote, | |
| 59 | + RKey: "margin-rkey1", | |
| 60 | + URI: "at://did:test:u1/at.margin.note/margin-rkey1", | |
| 61 | + CID: "cid-margin1", | |
| 62 | + Value: value, | |
| 63 | + } | |
| 64 | + | |
| 65 | + err = handler.Handle(ctx, event) | |
| 66 | + assert.NilError(t, err) | |
| 67 | + | |
| 68 | + exists, err := dbs.Articles.AnnotationExists(ctx, "at://did:test:u1/at.margin.note/margin-rkey1") | |
| 69 | + assert.NilError(t, err) | |
| 70 | + assert.Equal(t, exists, false, "margin note should not create a duplicate annotation") | |
| 71 | +} | |
| 72 | + | |
| 73 | +func TestHandleMarginNote_CreatesWhenNoDuplicate(t *testing.T) { | |
| 74 | + ctx := context.Background() | |
| 75 | + dbs := setupStreamTestDB(t) | |
| 76 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | |
| 77 | + | |
| 78 | + now := time.Now().Format(time.RFC3339) | |
| 79 | + | |
| 80 | + _ = dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"}) | |
| 81 | + _ = dbs.Articles.BatchUpsertArticles(ctx, []feed.Article{{ | |
| 82 | + FeedURL: "https://a.com/feed", URL: "https://a.com/1", Title: "Test", | |
| 83 | + }}) | |
| 84 | + | |
| 85 | + marginRec := NewMarginNoteRecord("https://a.com/1", "some quote", "some note", nil, now) | |
| 86 | + value, err := json.Marshal(marginRec) | |
| 87 | + assert.NilError(t, err) | |
| 88 | + | |
| 89 | + event := &Event{ | |
| 90 | + Type: actionCreate, | |
| 91 | + DID: "did:test:u1", | |
| 92 | + Collection: CollectionMarginNote, | |
| 93 | + RKey: "margin-rkey2", | |
| 94 | + URI: "at://did:test:u1/at.margin.note/margin-rkey2", | |
| 95 | + CID: "cid-margin2", | |
| 96 | + Value: value, | |
| 97 | + } | |
| 98 | + | |
| 99 | + err = handler.Handle(ctx, event) | |
| 100 | + assert.NilError(t, err) | |
| 101 | + | |
| 102 | + exists, err := dbs.Articles.AnnotationExists(ctx, "at://did:test:u1/at.margin.note/margin-rkey2") | |
| 103 | + assert.NilError(t, err) | |
| 104 | + assert.Equal(t, exists, true, "margin note should be stored when no duplicate exists") | |
| 105 | +} | |
| 106 | + | |
| 107 | +func TestHandleMarginNote_SkipsWhenSameContentDifferentURI(t *testing.T) { | |
| 108 | + ctx := context.Background() | |
| 109 | + dbs := setupStreamTestDB(t) | |
| 110 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | |
| 111 | + | |
| 112 | + now := time.Now().Format(time.RFC3339) | |
| 113 | + | |
| 114 | + _ = dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"}) | |
| 115 | + | |
| 116 | + err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ | |
| 117 | + URI: "at://did:test:u1/at.glean.annotation/rkey3", AuthorDID: "did:test:u1", | |
| 118 | + FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | |
| 119 | + Note: db.NullStr("identical note"), | |
| 120 | + CreatedAt: db.NullTime(time.Now()), | |
| 121 | + }) | |
| 122 | + assert.NilError(t, err) | |
| 123 | + | |
| 124 | + marginRec := NewMarginNoteRecord("https://a.com/1", "", "identical note", nil, now) | |
| 125 | + value, err := json.Marshal(marginRec) | |
| 126 | + assert.NilError(t, err) | |
| 127 | + | |
| 128 | + event := &Event{ | |
| 129 | + Type: actionCreate, | |
| 130 | + DID: "did:test:u1", | |
| 131 | + Collection: CollectionMarginNote, | |
| 132 | + RKey: "margin-rkey3", | |
| 133 | + URI: "at://did:test:u1/at.margin.note/margin-rkey3", | |
| 134 | + CID: "cid-margin3", | |
| 135 | + Value: value, | |
| 136 | + } | |
| 137 | + | |
| 138 | + err = handler.Handle(ctx, event) | |
| 139 | + assert.NilError(t, err) | |
| 140 | + | |
| 141 | + var count int | |
| 142 | + err = dbs.SQLDB().QueryRow(`SELECT COUNT(*) FROM articles.annotations WHERE article_url = 'https://a.com/1'`).Scan(&count) | |
| 143 | + assert.NilError(t, err) | |
| 144 | + assert.Equal(t, count, 1) | |
| 145 | +} | |
| 146 | + | |
| 147 | +func TestHandleMarginNote_SkipsEmptyArticleURL(t *testing.T) { | |
| 148 | + ctx := context.Background() | |
| 149 | + dbs := setupStreamTestDB(t) | |
| 150 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | |
| 151 | + | |
| 152 | + marginRec := NewMarginNoteRecord("", "quote", "note", nil, time.Now().Format(time.RFC3339)) | |
| 153 | + value, err := json.Marshal(marginRec) | |
| 154 | + assert.NilError(t, err) | |
| 155 | + | |
| 156 | + event := &Event{ | |
| 157 | + Type: actionCreate, | |
| 158 | + DID: "did:test:u1", | |
| 159 | + Collection: CollectionMarginNote, | |
| 160 | + RKey: "margin-rkey4", | |
| 161 | + URI: "at://did:test:u1/at.margin.note/margin-rkey4", | |
| 162 | + CID: "cid-margin4", | |
| 163 | + Value: value, | |
| 164 | + } | |
| 165 | + | |
| 166 | + err = handler.Handle(ctx, event) | |
| 167 | + assert.NilError(t, err) | |
| 168 | + | |
| 169 | + var count int | |
| 170 | + _ = dbs.SQLDB().QueryRow(`SELECT COUNT(*) FROM articles.annotations`).Scan(&count) | |
| 171 | + assert.Equal(t, count, 0) | |
| 172 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,172 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "log/slog" | ||
| 7 | + "os" | ||
| 8 | + "testing" | ||
| 9 | + "time" | ||
| 10 | + | ||
| 11 | + "gotest.tools/v3/assert" | ||
| 12 | + | ||
| 13 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 14 | + "pkg.rbrt.fr/glean/internal/feed" | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | +func setupStreamTestDB(t *testing.T) *db.Store { | ||
| 18 | + t.Helper() | ||
| 19 | + f, err := os.CreateTemp("", "glean-stream-test-*.db") | ||
| 20 | + assert.NilError(t, err) | ||
| 21 | + assert.NilError(t, f.Close()) | ||
| 22 | + path := f.Name() | ||
| 23 | + t.Cleanup(func() { | ||
| 24 | + for _, suffix := range []string{"", "_users", "_users-shm", "_users-wal", "_articles", "_articles-shm", "_articles-wal", "_recs", "_recs-shm", "_recs-wal"} { | ||
| 25 | + _ = os.Remove(path + suffix) | ||
| 26 | + } | ||
| 27 | + }) | ||
| 28 | + dbs, err := db.Open(path) | ||
| 29 | + assert.NilError(t, err) | ||
| 30 | + t.Cleanup(func() { _ = dbs.Close() }) | ||
| 31 | + return dbs | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func TestHandleMarginNote_SkipsDuplicateFromGleanAnnotation(t *testing.T) { | ||
| 35 | + ctx := context.Background() | ||
| 36 | + dbs := setupStreamTestDB(t) | ||
| 37 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | ||
| 38 | + | ||
| 39 | + now := time.Now().Format(time.RFC3339) | ||
| 40 | + | ||
| 41 | + _ = dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"}) | ||
| 42 | + | ||
| 43 | + err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ | ||
| 44 | + URI: "at://did:test:u1/at.glean.annotation/rkey1", AuthorDID: "did:test:u1", | ||
| 45 | + FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | ||
| 46 | + Quote: db.NullStr("selected text"), Note: db.NullStr("my note"), | ||
| 47 | + CreatedAt: db.NullTime(time.Now()), | ||
| 48 | + }) | ||
| 49 | + assert.NilError(t, err) | ||
| 50 | + | ||
| 51 | + marginRec := NewMarginNoteRecord("https://a.com/1", "selected text", "my note", nil, now) | ||
| 52 | + value, err := json.Marshal(marginRec) | ||
| 53 | + assert.NilError(t, err) | ||
| 54 | + | ||
| 55 | + event := &Event{ | ||
| 56 | + Type: actionCreate, | ||
| 57 | + DID: "did:test:u1", | ||
| 58 | + Collection: CollectionMarginNote, | ||
| 59 | + RKey: "margin-rkey1", | ||
| 60 | + URI: "at://did:test:u1/at.margin.note/margin-rkey1", | ||
| 61 | + CID: "cid-margin1", | ||
| 62 | + Value: value, | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + err = handler.Handle(ctx, event) | ||
| 66 | + assert.NilError(t, err) | ||
| 67 | + | ||
| 68 | + exists, err := dbs.Articles.AnnotationExists(ctx, "at://did:test:u1/at.margin.note/margin-rkey1") | ||
| 69 | + assert.NilError(t, err) | ||
| 70 | + assert.Equal(t, exists, false, "margin note should not create a duplicate annotation") | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func TestHandleMarginNote_CreatesWhenNoDuplicate(t *testing.T) { | ||
| 74 | + ctx := context.Background() | ||
| 75 | + dbs := setupStreamTestDB(t) | ||
| 76 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | ||
| 77 | + | ||
| 78 | + now := time.Now().Format(time.RFC3339) | ||
| 79 | + | ||
| 80 | + _ = dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"}) | ||
| 81 | + _ = dbs.Articles.BatchUpsertArticles(ctx, []feed.Article{{ | ||
| 82 | + FeedURL: "https://a.com/feed", URL: "https://a.com/1", Title: "Test", | ||
| 83 | + }}) | ||
| 84 | + | ||
| 85 | + marginRec := NewMarginNoteRecord("https://a.com/1", "some quote", "some note", nil, now) | ||
| 86 | + value, err := json.Marshal(marginRec) | ||
| 87 | + assert.NilError(t, err) | ||
| 88 | + | ||
| 89 | + event := &Event{ | ||
| 90 | + Type: actionCreate, | ||
| 91 | + DID: "did:test:u1", | ||
| 92 | + Collection: CollectionMarginNote, | ||
| 93 | + RKey: "margin-rkey2", | ||
| 94 | + URI: "at://did:test:u1/at.margin.note/margin-rkey2", | ||
| 95 | + CID: "cid-margin2", | ||
| 96 | + Value: value, | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + err = handler.Handle(ctx, event) | ||
| 100 | + assert.NilError(t, err) | ||
| 101 | + | ||
| 102 | + exists, err := dbs.Articles.AnnotationExists(ctx, "at://did:test:u1/at.margin.note/margin-rkey2") | ||
| 103 | + assert.NilError(t, err) | ||
| 104 | + assert.Equal(t, exists, true, "margin note should be stored when no duplicate exists") | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +func TestHandleMarginNote_SkipsWhenSameContentDifferentURI(t *testing.T) { | ||
| 108 | + ctx := context.Background() | ||
| 109 | + dbs := setupStreamTestDB(t) | ||
| 110 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | ||
| 111 | + | ||
| 112 | + now := time.Now().Format(time.RFC3339) | ||
| 113 | + | ||
| 114 | + _ = dbs.Articles.UpsertFeed(ctx, &db.Feed{FeedURL: "https://a.com/feed"}) | ||
| 115 | + | ||
| 116 | + err := dbs.Articles.CreateAnnotation(ctx, &db.Annotation{ | ||
| 117 | + URI: "at://did:test:u1/at.glean.annotation/rkey3", AuthorDID: "did:test:u1", | ||
| 118 | + FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", | ||
| 119 | + Note: db.NullStr("identical note"), | ||
| 120 | + CreatedAt: db.NullTime(time.Now()), | ||
| 121 | + }) | ||
| 122 | + assert.NilError(t, err) | ||
| 123 | + | ||
| 124 | + marginRec := NewMarginNoteRecord("https://a.com/1", "", "identical note", nil, now) | ||
| 125 | + value, err := json.Marshal(marginRec) | ||
| 126 | + assert.NilError(t, err) | ||
| 127 | + | ||
| 128 | + event := &Event{ | ||
| 129 | + Type: actionCreate, | ||
| 130 | + DID: "did:test:u1", | ||
| 131 | + Collection: CollectionMarginNote, | ||
| 132 | + RKey: "margin-rkey3", | ||
| 133 | + URI: "at://did:test:u1/at.margin.note/margin-rkey3", | ||
| 134 | + CID: "cid-margin3", | ||
| 135 | + Value: value, | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + err = handler.Handle(ctx, event) | ||
| 139 | + assert.NilError(t, err) | ||
| 140 | + | ||
| 141 | + var count int | ||
| 142 | + err = dbs.SQLDB().QueryRow(`SELECT COUNT(*) FROM articles.annotations WHERE article_url = 'https://a.com/1'`).Scan(&count) | ||
| 143 | + assert.NilError(t, err) | ||
| 144 | + assert.Equal(t, count, 1) | ||
| 145 | +} | ||
| 146 | + | ||
| 147 | +func TestHandleMarginNote_SkipsEmptyArticleURL(t *testing.T) { | ||
| 148 | + ctx := context.Background() | ||
| 149 | + dbs := setupStreamTestDB(t) | ||
| 150 | + handler := NewStreamDBHandler(dbs.Articles, dbs.Users, slog.Default()) | ||
| 151 | + | ||
| 152 | + marginRec := NewMarginNoteRecord("", "quote", "note", nil, time.Now().Format(time.RFC3339)) | ||
| 153 | + value, err := json.Marshal(marginRec) | ||
| 154 | + assert.NilError(t, err) | ||
| 155 | + | ||
| 156 | + event := &Event{ | ||
| 157 | + Type: actionCreate, | ||
| 158 | + DID: "did:test:u1", | ||
| 159 | + Collection: CollectionMarginNote, | ||
| 160 | + RKey: "margin-rkey4", | ||
| 161 | + URI: "at://did:test:u1/at.margin.note/margin-rkey4", | ||
| 162 | + CID: "cid-margin4", | ||
| 163 | + Value: value, | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + err = handler.Handle(ctx, event) | ||
| 167 | + assert.NilError(t, err) | ||
| 168 | + | ||
| 169 | + var count int | ||
| 170 | + _ = dbs.SQLDB().QueryRow(`SELECT COUNT(*) FROM articles.annotations`).Scan(&count) | ||
| 171 | + assert.Equal(t, count, 0) | ||
| 172 | +} | ||
modified
internal/db/batch_test.go +55 -0 | @@ -558,3 +558,58 @@ func TestDeleteOrphanedAnnotations_RemovesOrphaned(t *testing.T) { | ||
| 558 | 558 | assert.NilError(t, err) |
| 559 | 559 | assert.Equal(t, exists, false) |
| 560 | 560 | } |
| 561 | + | |
| 562 | +func TestAnnotationExistsByContent_FindsExactMatch(t *testing.T) { | |
| 563 | + ctx := context.Background() | |
| 564 | + dbs := setupTestDB(t) | |
| 565 | + | |
| 566 | + now := NullTime(time.Now()) | |
| 567 | + err := dbs.Articles.CreateAnnotation(ctx, &Annotation{ | |
| 568 | + URI: "at://glean:ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", | |
| 569 | + ArticleURL: "https://a.com/1", Quote: NullStr("selected text"), Note: NullStr("my note"), | |
| 570 | + CreatedAt: now, | |
| 571 | + }) | |
| 572 | + assert.NilError(t, err) | |
| 573 | + | |
| 574 | + exists, err := dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "selected text", "my note") | |
| 575 | + assert.NilError(t, err) | |
| 576 | + assert.Equal(t, exists, true) | |
| 577 | +} | |
| 578 | + | |
| 579 | +func TestAnnotationExistsByContent_NoMatch(t *testing.T) { | |
| 580 | + ctx := context.Background() | |
| 581 | + dbs := setupTestDB(t) | |
| 582 | + | |
| 583 | + now := NullTime(time.Now()) | |
| 584 | + err := dbs.Articles.CreateAnnotation(ctx, &Annotation{ | |
| 585 | + URI: "at://glean:ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", | |
| 586 | + ArticleURL: "https://a.com/1", Quote: NullStr("selected text"), Note: NullStr("my note"), | |
| 587 | + CreatedAt: now, | |
| 588 | + }) | |
| 589 | + assert.NilError(t, err) | |
| 590 | + | |
| 591 | + exists, err := dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "different quote", "my note") | |
| 592 | + assert.NilError(t, err) | |
| 593 | + assert.Equal(t, exists, false) | |
| 594 | +} | |
| 595 | + | |
| 596 | +func TestAnnotationExistsByContent_EmptyFields(t *testing.T) { | |
| 597 | + ctx := context.Background() | |
| 598 | + dbs := setupTestDB(t) | |
| 599 | + | |
| 600 | + now := NullTime(time.Now()) | |
| 601 | + err := dbs.Articles.CreateAnnotation(ctx, &Annotation{ | |
| 602 | + URI: "at://glean:ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", | |
| 603 | + ArticleURL: "https://a.com/1", Note: NullStr("just a note"), | |
| 604 | + CreatedAt: now, | |
| 605 | + }) | |
| 606 | + assert.NilError(t, err) | |
| 607 | + | |
| 608 | + exists, err := dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "", "just a note") | |
| 609 | + assert.NilError(t, err) | |
| 610 | + assert.Equal(t, exists, true) | |
| 611 | + | |
| 612 | + exists, err = dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "", "") | |
| 613 | + assert.NilError(t, err) | |
| 614 | + assert.Equal(t, exists, false) | |
| 615 | +} | |
| @@ -558,3 +558,58 @@ func TestDeleteOrphanedAnnotations_RemovesOrphaned(t *testing.T) { | |||
| 558 | assert.NilError(t, err) | 558 | assert.NilError(t, err) |
| 559 | assert.Equal(t, exists, false) | 559 | assert.Equal(t, exists, false) |
| 560 | } | 560 | } |
| 561 | + | ||
| 562 | +func TestAnnotationExistsByContent_FindsExactMatch(t *testing.T) { | ||
| 563 | + ctx := context.Background() | ||
| 564 | + dbs := setupTestDB(t) | ||
| 565 | + | ||
| 566 | + now := NullTime(time.Now()) | ||
| 567 | + err := dbs.Articles.CreateAnnotation(ctx, &Annotation{ | ||
| 568 | + URI: "at://glean:ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", | ||
| 569 | + ArticleURL: "https://a.com/1", Quote: NullStr("selected text"), Note: NullStr("my note"), | ||
| 570 | + CreatedAt: now, | ||
| 571 | + }) | ||
| 572 | + assert.NilError(t, err) | ||
| 573 | + | ||
| 574 | + exists, err := dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "selected text", "my note") | ||
| 575 | + assert.NilError(t, err) | ||
| 576 | + assert.Equal(t, exists, true) | ||
| 577 | +} | ||
| 578 | + | ||
| 579 | +func TestAnnotationExistsByContent_NoMatch(t *testing.T) { | ||
| 580 | + ctx := context.Background() | ||
| 581 | + dbs := setupTestDB(t) | ||
| 582 | + | ||
| 583 | + now := NullTime(time.Now()) | ||
| 584 | + err := dbs.Articles.CreateAnnotation(ctx, &Annotation{ | ||
| 585 | + URI: "at://glean:ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", | ||
| 586 | + ArticleURL: "https://a.com/1", Quote: NullStr("selected text"), Note: NullStr("my note"), | ||
| 587 | + CreatedAt: now, | ||
| 588 | + }) | ||
| 589 | + assert.NilError(t, err) | ||
| 590 | + | ||
| 591 | + exists, err := dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "different quote", "my note") | ||
| 592 | + assert.NilError(t, err) | ||
| 593 | + assert.Equal(t, exists, false) | ||
| 594 | +} | ||
| 595 | + | ||
| 596 | +func TestAnnotationExistsByContent_EmptyFields(t *testing.T) { | ||
| 597 | + ctx := context.Background() | ||
| 598 | + dbs := setupTestDB(t) | ||
| 599 | + | ||
| 600 | + now := NullTime(time.Now()) | ||
| 601 | + err := dbs.Articles.CreateAnnotation(ctx, &Annotation{ | ||
| 602 | + URI: "at://glean:ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", | ||
| 603 | + ArticleURL: "https://a.com/1", Note: NullStr("just a note"), | ||
| 604 | + CreatedAt: now, | ||
| 605 | + }) | ||
| 606 | + assert.NilError(t, err) | ||
| 607 | + | ||
| 608 | + exists, err := dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "", "just a note") | ||
| 609 | + assert.NilError(t, err) | ||
| 610 | + assert.Equal(t, exists, true) | ||
| 611 | + | ||
| 612 | + exists, err = dbs.Articles.AnnotationExistsByContent(ctx, "did:test:u1", "https://a.com/1", "", "") | ||
| 613 | + assert.NilError(t, err) | ||
| 614 | + assert.Equal(t, exists, false) | ||
| 615 | +} | ||
modified
internal/db/social.go +15 -0 | @@ -96,6 +96,21 @@ func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, | ||
| 96 | 96 | return true, nil |
| 97 | 97 | } |
| 98 | 98 | |
| 99 | +func (s *ArticleStore) AnnotationExistsByContent(ctx context.Context, authorDID, articleURL, quote, note string) (bool, error) { | |
| 100 | + var exists int | |
| 101 | + err := s.db.QueryRowContext(ctx, ` | |
| 102 | + SELECT 1 FROM articles.annotations | |
| 103 | + WHERE author_did = ? AND article_url = ? AND COALESCE(quote, '') = COALESCE(?, '') AND COALESCE(note, '') = COALESCE(?, '') | |
| 104 | + `, authorDID, articleURL, quote, note).Scan(&exists) | |
| 105 | + if err == sql.ErrNoRows { | |
| 106 | + return false, nil | |
| 107 | + } | |
| 108 | + if err != nil { | |
| 109 | + return false, err | |
| 110 | + } | |
| 111 | + return true, nil | |
| 112 | +} | |
| 113 | + | |
| 99 | 114 | func (s *ArticleStore) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { |
| 100 | 115 | var conds []string |
| 101 | 116 | var args []any |
| @@ -96,6 +96,21 @@ func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, | |||
| 96 | return true, nil | 96 | return true, nil |
| 97 | } | 97 | } |
| 98 | 98 | ||
| 99 | +func (s *ArticleStore) AnnotationExistsByContent(ctx context.Context, authorDID, articleURL, quote, note string) (bool, error) { | ||
| 100 | + var exists int | ||
| 101 | + err := s.db.QueryRowContext(ctx, ` | ||
| 102 | + SELECT 1 FROM articles.annotations | ||
| 103 | + WHERE author_did = ? AND article_url = ? AND COALESCE(quote, '') = COALESCE(?, '') AND COALESCE(note, '') = COALESCE(?, '') | ||
| 104 | + `, authorDID, articleURL, quote, note).Scan(&exists) | ||
| 105 | + if err == sql.ErrNoRows { | ||
| 106 | + return false, nil | ||
| 107 | + } | ||
| 108 | + if err != nil { | ||
| 109 | + return false, err | ||
| 110 | + } | ||
| 111 | + return true, nil | ||
| 112 | +} | ||
| 113 | + | ||
| 99 | func (s *ArticleStore) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { | 114 | func (s *ArticleStore) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { |
| 100 | var conds []string | 115 | var conds []string |
| 101 | var args []any | 116 | var args []any |
modified
internal/server/annotations_handler.go +11 -0 | @@ -139,6 +139,17 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 139 | 139 | } |
| 140 | 140 | a.URI = uri |
| 141 | 141 | a.CID = sql.NullString{String: cid, Valid: true} |
| 142 | + | |
| 143 | + marginRec := atproto.NewMarginNoteRecord( | |
| 144 | + a.ArticleURL, | |
| 145 | + a.Quote.String, | |
| 146 | + a.Note.String, | |
| 147 | + tags, | |
| 148 | + time.Now().Format(time.RFC3339), | |
| 149 | + ) | |
| 150 | + if _, _, err := client.CreateRecord(ctx, user.DID, atproto.CollectionMarginNote, marginRec); err != nil { | |
| 151 | + s.logger.Error("failed to write margin note to PDS", "error", err) | |
| 152 | + } | |
| 142 | 153 | } else { |
| 143 | 154 | a.URI = fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()) |
| 144 | 155 | } |
| @@ -139,6 +139,17 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | |||
| 139 | } | 139 | } |
| 140 | a.URI = uri | 140 | a.URI = uri |
| 141 | a.CID = sql.NullString{String: cid, Valid: true} | 141 | a.CID = sql.NullString{String: cid, Valid: true} |
| 142 | + | ||
| 143 | + marginRec := atproto.NewMarginNoteRecord( | ||
| 144 | + a.ArticleURL, | ||
| 145 | + a.Quote.String, | ||
| 146 | + a.Note.String, | ||
| 147 | + tags, | ||
| 148 | + time.Now().Format(time.RFC3339), | ||
| 149 | + ) | ||
| 150 | + if _, _, err := client.CreateRecord(ctx, user.DID, atproto.CollectionMarginNote, marginRec); err != nil { | ||
| 151 | + s.logger.Error("failed to write margin note to PDS", "error", err) | ||
| 152 | + } | ||
| 142 | } else { | 153 | } else { |
| 143 | a.URI = fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()) | 154 | a.URI = fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()) |
| 144 | } | 155 | } |
modified
internal/server/server.go +1 -0 | @@ -43,6 +43,7 @@ var oauthScopes = []string{ | ||
| 43 | 43 | fmt.Sprintf("repo:%s", atproto.CollectionSubscription), |
| 44 | 44 | fmt.Sprintf("repo:%s", atproto.CollectionLike), |
| 45 | 45 | fmt.Sprintf("repo:%s", atproto.CollectionAnnotation), |
| 46 | + fmt.Sprintf("repo:%s", atproto.CollectionMarginNote), | |
| 46 | 47 | |
| 47 | 48 | "rpc:at.glean.listSubscriptions?aud=*", |
| 48 | 49 | "rpc:at.glean.listLikes?aud=*", |
| @@ -43,6 +43,7 @@ var oauthScopes = []string{ | |||
| 43 | fmt.Sprintf("repo:%s", atproto.CollectionSubscription), | 43 | fmt.Sprintf("repo:%s", atproto.CollectionSubscription), |
| 44 | fmt.Sprintf("repo:%s", atproto.CollectionLike), | 44 | fmt.Sprintf("repo:%s", atproto.CollectionLike), |
| 45 | fmt.Sprintf("repo:%s", atproto.CollectionAnnotation), | 45 | fmt.Sprintf("repo:%s", atproto.CollectionAnnotation), |
| 46 | + fmt.Sprintf("repo:%s", atproto.CollectionMarginNote), | ||
| 46 | 47 | ||
| 47 | "rpc:at.glean.listSubscriptions?aud=*", | 48 | "rpc:at.glean.listSubscriptions?aud=*", |
| 48 | "rpc:at.glean.listLikes?aud=*", | 49 | "rpc:at.glean.listLikes?aud=*", |