Support margin notes and add external lexicons in repoUnverified
e230487 parent: a33920b modified
docs/specs.md +124 -65 | @@ -120,7 +120,24 @@ A user likes an article. The liked feed surfaces popular articles and feeds into | ||
| 120 | 120 | } |
| 121 | 121 | ``` |
| 122 | 122 | |
| 123 | -### 3.4 AppView Query Lexicons | |
| 123 | +### 3.4 `at.margin.note` (External) | |
| 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. | |
| 126 | + | |
| 127 | +The mapping from margin note to glean annotation: | |
| 128 | + | |
| 129 | +| Margin note field | Annotation field | Notes | | |
| 130 | +| ------------------------------ | ---------------- | --------------------------------------------------------------- | | |
| 131 | +| `target.source` | `article_url` | W3C SpecificResource source URL | | |
| 132 | +| `body.value` | `note` | Text content of the annotation | | |
| 133 | +| `target.selector.exact` | `quote` | TextQuoteSelector exact text | | |
| 134 | +| `tags` | `tags` | Direct mapping | | |
| 135 | +| `createdAt` | `created_at` | Direct mapping | | |
| 136 | +| _(looked up from articles DB)_ | `feed_url` | Resolved by matching `target.source` against known article URLs | | |
| 137 | + | |
| 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. | |
| 139 | + | |
| 140 | +### 3.5 AppView Query Lexicons | |
| 124 | 141 | |
| 125 | 142 | As an AppView, Glean serves the following XRPC query endpoints. Other AT Protocol applications can call these to access indexed `at.glean.*` data without implementing their own indexer. |
| 126 | 143 | |
| @@ -209,7 +226,7 @@ Get feed recommendations for a user based on clustering. | ||
| 209 | 226 | |
| 210 | 227 | ``` |
| 211 | 228 | Input: |
| 212 | - repo: string (DID of the user) | |
| 229 | + repo: string (DID of the user, query parameter) | |
| 213 | 230 | limit?: integer (default 20, max 50) |
| 214 | 231 | |
| 215 | 232 | Output: |
| @@ -217,12 +234,12 @@ Output: | ||
| 217 | 234 | people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }] |
| 218 | 235 | ``` |
| 219 | 236 | |
| 220 | -### 3.5 AppView Jetstream Consumption | |
| 237 | +### 3.6 AppView Jetstream Consumption | |
| 221 | 238 | |
| 222 | 239 | Glean subscribes to a Jetstream endpoint (`GLEAN_JETSTREAM`, default `wss://jetstream2.fr.hose.cam`) for all `at.glean.*` records: |
| 223 | 240 | |
| 224 | 241 | ``` |
| 225 | -SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like"] | |
| 242 | +SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like", "app.bsky.graph.follow", "sh.tangled.graph.follow", "at.margin.note"] | |
| 226 | 243 | ``` |
| 227 | 244 | |
| 228 | 245 | On each event: |
| @@ -299,25 +316,16 @@ Go's `encoding/xml` for RSS and Atom. A simple `encoding/json` for JSON Feed. | ||
| 299 | 316 | Each parser returns a normalized `Feed` and a slice of `Article` structs: |
| 300 | 317 | |
| 301 | 318 | ```go |
| 302 | -type Feed struct { | |
| 303 | - URL string | |
| 304 | - Title string | |
| 305 | - SiteURL string | |
| 306 | - Description string | |
| 307 | - Type string // "rss", "atom", "json" | |
| 308 | - ETag string | |
| 309 | - LastModified string | |
| 310 | -} | |
| 311 | - | |
| 312 | 319 | type Article struct { |
| 313 | - GUID string | |
| 314 | - Title string | |
| 315 | - URL string | |
| 316 | - Author string | |
| 317 | - Content string | |
| 318 | - Summary string | |
| 319 | - Published time.Time | |
| 320 | - Updated time.Time | |
| 320 | + FeedURL string | |
| 321 | + GUID string | |
| 322 | + Title string | |
| 323 | + URL string | |
| 324 | + Author string | |
| 325 | + Content string | |
| 326 | + Summary string | |
| 327 | + Published time.Time | |
| 328 | + Updated time.Time | |
| 321 | 329 | } |
| 322 | 330 | ``` |
| 323 | 331 | |
| @@ -337,14 +345,18 @@ CREATE TABLE articles ( | ||
| 337 | 345 | author TEXT, |
| 338 | 346 | summary TEXT, |
| 339 | 347 | content TEXT, |
| 348 | + full_content TEXT, | |
| 340 | 349 | published DATETIME, |
| 341 | 350 | updated DATETIME, |
| 342 | 351 | fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 343 | 352 | UNIQUE(feed_url, guid) |
| 344 | 353 | ); |
| 354 | + | |
| 355 | +CREATE INDEX idx_articles_feed ON articles(feed_url); | |
| 356 | +CREATE INDEX idx_articles_published ON articles(published DESC); | |
| 345 | 357 | ``` |
| 346 | 358 | |
| 347 | -Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.). | |
| 359 | +Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. `full_content` stores scraped article content fetched from the original URL. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.). | |
| 348 | 360 | |
| 349 | 361 | ### 4.5 Read State |
| 350 | 362 | |
| @@ -415,9 +427,10 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing ` | ||
| 415 | 427 | │ └─────────────────┘ │ └──────────────────┘ |
| 416 | 428 | └──────────────────────┘ |
| 417 | 429 | |
| 418 | - AppView responsibilities: | |
| 419 | - • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like | |
| 420 | - • Index records into SQLite | |
| 430 | + AppView responsibilities: | |
| 431 | + • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like, at.margin.note | |
| 432 | + • Index records into SQLite | |
| 433 | + • Convert at.margin.note records to annotations (displayed alongside glean.at annotations) | |
| 421 | 434 | • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) |
| 422 | 435 | • Host the web UI at glean.at |
| 423 | 436 | • Write to user PDS on behalf of user (when user acts through UI) |
| @@ -515,13 +528,10 @@ CREATE TABLE read_state ( | ||
| 515 | 528 | article_id INTEGER NOT NULL REFERENCES articles(id), |
| 516 | 529 | is_read BOOLEAN NOT NULL DEFAULT 0, |
| 517 | 530 | read_at DATETIME, |
| 518 | - is_starred BOOLEAN NOT NULL DEFAULT 0, | |
| 519 | - starred_at DATETIME, | |
| 520 | 531 | PRIMARY KEY (user_did, article_id) |
| 521 | 532 | ); |
| 522 | 533 | |
| 523 | 534 | CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0; |
| 524 | -CREATE INDEX idx_read_state_starred ON read_state(user_did, is_starred) WHERE is_starred = 1; | |
| 525 | 535 | ``` |
| 526 | 536 | |
| 527 | 537 | ### 6.6 Annotations, Likes |
| @@ -580,7 +590,39 @@ CREATE TABLE user_similarity ( | ||
| 580 | 590 | ); |
| 581 | 591 | ``` |
| 582 | 592 | |
| 583 | -## 7. Clustering & Recommendations | |
| 593 | +### 6.8 Follows | |
| 594 | + | |
| 595 | +Tracks follow relationships between users (from `app.bsky.graph.follow` and `sh.tangled.graph.follow` records). | |
| 596 | + | |
| 597 | +```sql | |
| 598 | +CREATE TABLE follows ( | |
| 599 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 600 | + target_did TEXT NOT NULL, | |
| 601 | + uri TEXT, | |
| 602 | + cid TEXT, | |
| 603 | + followed_at DATETIME, | |
| 604 | + PRIMARY KEY (user_did, target_did) | |
| 605 | +); | |
| 606 | + | |
| 607 | +CREATE INDEX idx_follows_user ON follows(user_did); | |
| 608 | +CREATE INDEX idx_follows_target ON follows(target_did); | |
| 609 | +``` | |
| 610 | + | |
| 611 | +### 6.9 OAuth Storage | |
| 612 | + | |
| 613 | +```sql | |
| 614 | +CREATE TABLE oauth_auth_requests ( | |
| 615 | + state TEXT PRIMARY KEY, | |
| 616 | + data TEXT NOT NULL | |
| 617 | +); | |
| 618 | + | |
| 619 | +CREATE TABLE oauth_sessions ( | |
| 620 | + account_did TEXT NOT NULL, | |
| 621 | + session_id TEXT NOT NULL, | |
| 622 | + data TEXT NOT NULL, | |
| 623 | + PRIMARY KEY (account_did, session_id) | |
| 624 | +); | |
| 625 | +``` | |
| 584 | 626 | |
| 585 | 627 | Glean has two complementary recommendation signals: |
| 586 | 628 | |
| @@ -626,11 +668,9 @@ score(feed) = Σ J(target, U) for each user U subscribed to feed | ||
| 626 | 668 | 4. Return top N articles as recommendations |
| 627 | 669 | |
| 628 | 670 | ``` |
| 629 | -score(article) = Σ 1/logN(likers(article)) for each user U who liked it | |
| 671 | +score(article) = Σ J(target, U) for each similar user U who liked the article | |
| 630 | 672 | ``` |
| 631 | 673 | |
| 632 | -The `1/logN` weighting avoids over-recommending articles from very large feeds. | |
| 633 | - | |
| 634 | 674 | **People recommendations (to follow on Bluesky):** |
| 635 | 675 | |
| 636 | 676 | 1. Compute user similarity for all pairs |
| @@ -657,10 +697,12 @@ For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard | ||
| 657 | 697 | |
| 658 | 698 | A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 6h): |
| 659 | 699 | |
| 660 | -1. **Jetstream ingestion**: Subscribe to Jetstream for `at.glean.*` records | |
| 661 | -2. **Index new records**: Parse lexicon records, upsert into SQLite | |
| 662 | -3. **Compute similarities**: Batch-update the `feed_similarity`, `user_similarity`, and `article_co_like` tables | |
| 663 | -4. **Generate recommendations**: Materialize top recommendations per user into cache tables | |
| 700 | +1. **Compute feed similarity**: Batch-update the `feed_similarity` table (Jaccard over subscriber sets) | |
| 701 | +2. **Compute user similarity**: Batch-update the `user_similarity` table (Jaccard over subscription sets, boosted by follow relationships) | |
| 702 | +3. **Generate feed recommendations**: Materialize top feed recommendations per user into `user_feed_recommendations` | |
| 703 | +4. **Generate article recommendations**: Materialize top article recommendations per user into `user_article_recommendations` | |
| 704 | + | |
| 705 | +Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron. | |
| 664 | 706 | |
| 665 | 707 | ```sql |
| 666 | 708 | CREATE TABLE user_feed_recommendations ( |
| @@ -687,28 +729,31 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | ||
| 687 | 729 | |
| 688 | 730 | ### 8.1 Pages |
| 689 | 731 | |
| 690 | -| Route | Method | Description | | |
| 691 | -| ------------------------- | ------ | -------------------------------------------------------- | | |
| 692 | -| `/` | GET | Landing page / auth redirect | | |
| 693 | -| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | |
| 694 | -| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | |
| 695 | -| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions | | |
| 696 | -| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | |
| 697 | -| `/feeds/add` | POST | Add a single feed URL | | |
| 698 | -| `/feeds/remove` | DELETE | Remove a feed | | |
| 699 | -| `/feeds/refresh` | POST | Refresh all subscribed feeds | | |
| 700 | -| `/feeds/clear` | POST | Clear all subscriptions | | |
| 701 | -| `/articles` | GET | Read articles (paginated, filterable by feed) | | |
| 702 | -| `/articles/{id}` | GET | Article detail view | | |
| 703 | -| `/articles/{id}/read` | POST | Mark article as read | | |
| 704 | -| `/articles/{id}/unread` | POST | Mark article as unread | | |
| 705 | -| `/articles/{id}/like` | POST | Like an article | | |
| 706 | -| `/articles/mark-all-read` | POST | Mark all articles as read | | |
| 707 | -| `/trending` | GET | Community feed: articles ranked by likes | | |
| 708 | -| `/library` | GET | Liked articles and annotations | | |
| 709 | -| `/library/create` | POST | Create annotation on an article | | |
| 710 | -| `/library/{id}/delete` | POST | Delete an annotation | | |
| 711 | -| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | |
| 732 | +| Route | Method | Description | | |
| 733 | +| ------------------------------ | ------ | -------------------------------------------------------- | | |
| 734 | +| `/` | GET | Landing page / auth redirect | | |
| 735 | +| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | |
| 736 | +| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | |
| 737 | +| `/feeds/list` | GET | Feed list fragment (htmx partial) | | |
| 738 | +| `/feeds/discover-url` | GET | Discover feed URL from a website | | |
| 739 | +| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions | | |
| 740 | +| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | |
| 741 | +| `/feeds/add` | POST | Add a single feed URL | | |
| 742 | +| `/feeds/remove` | DELETE | Remove a feed | | |
| 743 | +| `/feeds/refresh` | POST | Refresh all subscribed feeds | | |
| 744 | +| `/feeds/clear` | POST | Clear all subscriptions | | |
| 745 | +| `/articles` | GET | Read articles (paginated, filterable by feed) | | |
| 746 | +| `/articles/{id}` | GET | Article detail view | | |
| 747 | +| `/articles/{id}/read` | POST | Mark article as read | | |
| 748 | +| `/articles/{id}/unread` | POST | Mark article as unread | | |
| 749 | +| `/articles/{id}/like` | POST | Like an article | | |
| 750 | +| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | | |
| 751 | +| `/articles/mark-all-read` | POST | Mark all articles as read | | |
| 752 | +| `/trending` | GET | Community feed: articles ranked by likes | | |
| 753 | +| `/library` | GET | Liked articles and annotations | | |
| 754 | +| `/library/create` | POST | Create annotation on an article | | |
| 755 | +| `/library/{id}/delete` | POST | Delete an annotation | | |
| 756 | +| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | |
| 712 | 757 | |
| 713 | 758 | ### 8.2 htmx Patterns |
| 714 | 759 | |
| @@ -726,12 +771,19 @@ glean/ | ||
| 726 | 771 | ├── go.sum |
| 727 | 772 | ├── Dockerfile |
| 728 | 773 | ├── Makefile |
| 774 | +├── lexicons/ | |
| 775 | +│ └── at/ | |
| 776 | +│ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like) | |
| 777 | +│ └── margin/ # External lexicon schemas (note.json for at.margin.note) | |
| 729 | 778 | ├── internal/ |
| 730 | 779 | │ ├── atproto/ |
| 731 | 780 | │ │ ├── auth.go # DID resolution, OAuth flow |
| 732 | 781 | │ │ ├── client.go # XRPC client (write to user PDS) |
| 733 | 782 | │ │ ├── jetstream.go # Subscribe to Jetstream via official client |
| 734 | 783 | │ │ ├── stream_handler.go # Stream event → DB handler |
| 784 | +│ │ ├── lexicon.go # Lexicon record types (at.glean.*, maintained by hand) | |
| 785 | +│ │ ├── lexicon_external.go # External lexicon record types (FollowRecord, MarginNoteRecord) | |
| 786 | +│ │ ├── lexicon_test.go # Test: Go structs match lexicon JSON schemas | |
| 735 | 787 | │ │ ├── sync.go # PDS record reconciliation |
| 736 | 788 | │ │ └── xrpc.go # XRPC query handlers (AppView endpoints) |
| 737 | 789 | │ ├── db/ |
| @@ -740,6 +792,7 @@ glean/ | ||
| 740 | 792 | │ │ ├── feed.go # Feed + subscription queries |
| 741 | 793 | │ │ ├── article.go # Article queries |
| 742 | 794 | │ │ ├── social.go # Like, annotation queries |
| 795 | +│ │ ├── follow.go # Follow queries | |
| 743 | 796 | │ │ ├── cluster.go # Similarity + recommendation queries |
| 744 | 797 | │ │ ├── oauth_store.go # OAuth session storage |
| 745 | 798 | │ │ └── store.go # FeedStore adapter for scheduler |
| @@ -748,21 +801,25 @@ glean/ | ||
| 748 | 801 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher |
| 749 | 802 | │ │ ├── discover.go # Feed auto-discovery from URLs |
| 750 | 803 | │ │ └── opml.go # OPML import/export |
| 804 | +│ ├── scraper/ | |
| 805 | +│ │ └── scraper.go # Full article content scraper | |
| 751 | 806 | │ ├── metrics/ |
| 752 | 807 | │ │ └── metrics.go # Prometheus metrics definitions |
| 753 | 808 | │ ├── cluster/ |
| 754 | 809 | │ │ ├── jaccard.go # Jaccard similarity computation |
| 755 | -│ │ ├── recommender.go # Feed + people recommendation logic | |
| 810 | +│ │ ├── recommender.go # Feed + people recommendation queries | |
| 756 | 811 | │ │ └── cron.go # Background recomputation scheduler |
| 757 | 812 | │ ├── server/ |
| 758 | 813 | │ │ ├── server.go # HTTP server, router setup |
| 759 | 814 | │ │ ├── auth_handler.go # OAuth login/callback |
| 760 | 815 | │ │ ├── feeds_handler.go # Feed management handlers |
| 761 | 816 | │ │ ├── articles_handler.go # Article reading handlers |
| 817 | +│ │ ├── annotations_handler.go # Annotation handlers | |
| 762 | 818 | │ │ ├── dashboard_handler.go # Dashboard handler |
| 763 | 819 | │ │ ├── trending_handler.go # Trending handler |
| 764 | -│ │ ├── library_handler.go # Library (likes + annotations) | |
| 820 | +│ │ ├── index_handler.go # Landing page handler | |
| 765 | 821 | │ │ ├── profile_handler.go # Public profile handler |
| 822 | +│ │ ├── pagination.go # Pagination helpers | |
| 766 | 823 | │ │ ├── middleware.go # Auth, logging, CSRF middleware |
| 767 | 824 | │ │ └── session.go # Session management |
| 768 | 825 | │ ├── sanitize/ |
| @@ -770,7 +827,6 @@ glean/ | ||
| 770 | 827 | │ └── tmpl/ |
| 771 | 828 | │ ├── base.html # Base template with htmx + Tailwind |
| 772 | 829 | │ ├── index.html # Landing page |
| 773 | -│ ├── login.html # Login page | |
| 774 | 830 | │ ├── dashboard.html # Dashboard |
| 775 | 831 | │ ├── feeds.html # Feed management |
| 776 | 832 | │ ├── articles.html # Article listing |
| @@ -865,10 +921,13 @@ Glean exposes a `/metrics` endpoint for monitoring. Key metrics: | ||
| 865 | 921 | - **`glean_feed_fetch_duration_seconds`** — Histogram of feed fetch latency |
| 866 | 922 | - **`glean_articles_upserted_total`** — Counter of articles stored from feeds |
| 867 | 923 | - **`glean_jetstream_events_total`** — Jetstream events labeled by collection and action |
| 924 | +- **`glean_jetstream_errors_total`** — Jetstream handler errors | |
| 925 | +- **`glean_jetstream_reconnects_total`** — Jetstream reconnection count | |
| 868 | 926 | - **`glean_http_requests_total`** — HTTP request counts labeled by method, path, and status |
| 927 | +- **`glean_pds_sync_runs_total`** / **`glean_pds_sync_errors_total`** — PDS sync runs and errors | |
| 869 | 928 | - **`glean_cluster_runs_total`** / **`glean_cluster_duration_seconds`** — Recommendation engine runs and timing |
| 870 | 929 | |
| 871 | -### 12.3 Why htmx? | |
| 930 | +### 12.4 Why htmx? | |
| 872 | 931 | |
| 873 | 932 | - No JavaScript build pipeline |
| 874 | 933 | - Server renders everything — simpler mental model |
| @@ -876,7 +935,7 @@ Glean exposes a `/metrics` endpoint for monitoring. Key metrics: | ||
| 876 | 935 | - Perfect fit for a read-centric application |
| 877 | 936 | - TailwindCSS handles styling without writing custom CSS |
| 878 | 937 | |
| 879 | -### 12.4 AppView Architecture | |
| 938 | +### 12.5 AppView Architecture | |
| 880 | 939 | |
| 881 | 940 | Glean operates as an AT Protocol AppView. This means: |
| 882 | 941 | |
| @@ -885,7 +944,7 @@ Glean operates as an AT Protocol AppView. This means: | ||
| 885 | 944 | - **Query path**: Other AT Protocol apps can query Glean's XRPC endpoints to access indexed data (subscriptions, annotations, likes, recommendations) without building their own indexer. |
| 886 | 945 | - **Trade-off**: Article content (fetched from RSS feeds) is local-only and not part of the AT Protocol layer. Only individual feed subscription records (`at.glean.subscription`) live on the PDS. |
| 887 | 946 | |
| 888 | -### 12.5 Privacy Model | |
| 947 | +### 12.6 Privacy Model | |
| 889 | 948 | |
| 890 | 949 | All PDS records are public. There is no notion of private data on the AT Protocol — everything stored on the repo is visible. Users should be aware that annotations, subscriptions, and likes are all public records. |
| 891 | 950 | |
| @@ -120,7 +120,24 @@ A user likes an article. The liked feed surfaces popular articles and feeds into | |||
| 120 | } | 120 | } |
| 121 | ``` | 121 | ``` |
| 122 | 122 | ||
| 123 | -### 3.4 AppView Query Lexicons | 123 | +### 3.4 `at.margin.note` (External) |
| 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. | ||
| 126 | + | ||
| 127 | +The mapping from margin note to glean annotation: | ||
| 128 | + | ||
| 129 | +| Margin note field | Annotation field | Notes | | ||
| 130 | +| ------------------------------ | ---------------- | --------------------------------------------------------------- | | ||
| 131 | +| `target.source` | `article_url` | W3C SpecificResource source URL | | ||
| 132 | +| `body.value` | `note` | Text content of the annotation | | ||
| 133 | +| `target.selector.exact` | `quote` | TextQuoteSelector exact text | | ||
| 134 | +| `tags` | `tags` | Direct mapping | | ||
| 135 | +| `createdAt` | `created_at` | Direct mapping | | ||
| 136 | +| _(looked up from articles DB)_ | `feed_url` | Resolved by matching `target.source` against known article URLs | | ||
| 137 | + | ||
| 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. | ||
| 139 | + | ||
| 140 | +### 3.5 AppView Query Lexicons | ||
| 124 | 141 | ||
| 125 | As an AppView, Glean serves the following XRPC query endpoints. Other AT Protocol applications can call these to access indexed `at.glean.*` data without implementing their own indexer. | 142 | As an AppView, Glean serves the following XRPC query endpoints. Other AT Protocol applications can call these to access indexed `at.glean.*` data without implementing their own indexer. |
| 126 | 143 | ||
| @@ -209,7 +226,7 @@ Get feed recommendations for a user based on clustering. | |||
| 209 | 226 | ||
| 210 | ``` | 227 | ``` |
| 211 | Input: | 228 | Input: |
| 212 | - repo: string (DID of the user) | 229 | + repo: string (DID of the user, query parameter) |
| 213 | limit?: integer (default 20, max 50) | 230 | limit?: integer (default 20, max 50) |
| 214 | 231 | ||
| 215 | Output: | 232 | Output: |
| @@ -217,12 +234,12 @@ Output: | |||
| 217 | people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }] | 234 | people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }] |
| 218 | ``` | 235 | ``` |
| 219 | 236 | ||
| 220 | -### 3.5 AppView Jetstream Consumption | 237 | +### 3.6 AppView Jetstream Consumption |
| 221 | 238 | ||
| 222 | Glean subscribes to a Jetstream endpoint (`GLEAN_JETSTREAM`, default `wss://jetstream2.fr.hose.cam`) for all `at.glean.*` records: | 239 | Glean subscribes to a Jetstream endpoint (`GLEAN_JETSTREAM`, default `wss://jetstream2.fr.hose.cam`) for all `at.glean.*` records: |
| 223 | 240 | ||
| 224 | ``` | 241 | ``` |
| 225 | -SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like"] | 242 | +SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like", "app.bsky.graph.follow", "sh.tangled.graph.follow", "at.margin.note"] |
| 226 | ``` | 243 | ``` |
| 227 | 244 | ||
| 228 | On each event: | 245 | On each event: |
| @@ -299,25 +316,16 @@ Go's `encoding/xml` for RSS and Atom. A simple `encoding/json` for JSON Feed. | |||
| 299 | Each parser returns a normalized `Feed` and a slice of `Article` structs: | 316 | Each parser returns a normalized `Feed` and a slice of `Article` structs: |
| 300 | 317 | ||
| 301 | ```go | 318 | ```go |
| 302 | -type Feed struct { | ||
| 303 | - URL string | ||
| 304 | - Title string | ||
| 305 | - SiteURL string | ||
| 306 | - Description string | ||
| 307 | - Type string // "rss", "atom", "json" | ||
| 308 | - ETag string | ||
| 309 | - LastModified string | ||
| 310 | -} | ||
| 311 | - | ||
| 312 | type Article struct { | 319 | type Article struct { |
| 313 | - GUID string | 320 | + FeedURL string |
| 314 | - Title string | 321 | + GUID string |
| 315 | - URL string | 322 | + Title string |
| 316 | - Author string | 323 | + URL string |
| 317 | - Content string | 324 | + Author string |
| 318 | - Summary string | 325 | + Content string |
| 319 | - Published time.Time | 326 | + Summary string |
| 320 | - Updated time.Time | 327 | + Published time.Time |
| 328 | + Updated time.Time | ||
| 321 | } | 329 | } |
| 322 | ``` | 330 | ``` |
| 323 | 331 | ||
| @@ -337,14 +345,18 @@ CREATE TABLE articles ( | |||
| 337 | author TEXT, | 345 | author TEXT, |
| 338 | summary TEXT, | 346 | summary TEXT, |
| 339 | content TEXT, | 347 | content TEXT, |
| 348 | + full_content TEXT, | ||
| 340 | published DATETIME, | 349 | published DATETIME, |
| 341 | updated DATETIME, | 350 | updated DATETIME, |
| 342 | fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | 351 | fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 343 | UNIQUE(feed_url, guid) | 352 | UNIQUE(feed_url, guid) |
| 344 | ); | 353 | ); |
| 354 | + | ||
| 355 | +CREATE INDEX idx_articles_feed ON articles(feed_url); | ||
| 356 | +CREATE INDEX idx_articles_published ON articles(published DESC); | ||
| 345 | ``` | 357 | ``` |
| 346 | 358 | ||
| 347 | -Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.). | 359 | +Content is stored as raw HTML from the feed's `<content:encoded>`, `<summary>`, or JSON Feed `content_html`. `full_content` stores scraped article content fetched from the original URL. The server renders it in a sanitized view (strip `<script>`, `<iframe>`, etc.). |
| 348 | 360 | ||
| 349 | ### 4.5 Read State | 361 | ### 4.5 Read State |
| 350 | 362 | ||
| @@ -415,9 +427,10 @@ Glean runs as a single Go binary that fills three roles: **AppView** (indexing ` | |||
| 415 | │ └─────────────────┘ │ └──────────────────┘ | 427 | │ └─────────────────┘ │ └──────────────────┘ |
| 416 | └──────────────────────┘ | 428 | └──────────────────────┘ |
| 417 | 429 | ||
| 418 | - AppView responsibilities: | 430 | + AppView responsibilities: |
| 419 | - • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like | 431 | + • Subscribe to Jetstream for at.glean.subscription, at.glean.annotation, at.glean.like, at.margin.note |
| 420 | - • Index records into SQLite | 432 | + • Index records into SQLite |
| 433 | + • Convert at.margin.note records to annotations (displayed alongside glean.at annotations) | ||
| 421 | • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) | 434 | • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) |
| 422 | • Host the web UI at glean.at | 435 | • Host the web UI at glean.at |
| 423 | • Write to user PDS on behalf of user (when user acts through UI) | 436 | • Write to user PDS on behalf of user (when user acts through UI) |
| @@ -515,13 +528,10 @@ CREATE TABLE read_state ( | |||
| 515 | article_id INTEGER NOT NULL REFERENCES articles(id), | 528 | article_id INTEGER NOT NULL REFERENCES articles(id), |
| 516 | is_read BOOLEAN NOT NULL DEFAULT 0, | 529 | is_read BOOLEAN NOT NULL DEFAULT 0, |
| 517 | read_at DATETIME, | 530 | read_at DATETIME, |
| 518 | - is_starred BOOLEAN NOT NULL DEFAULT 0, | ||
| 519 | - starred_at DATETIME, | ||
| 520 | PRIMARY KEY (user_did, article_id) | 531 | PRIMARY KEY (user_did, article_id) |
| 521 | ); | 532 | ); |
| 522 | 533 | ||
| 523 | CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0; | 534 | CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0; |
| 524 | -CREATE INDEX idx_read_state_starred ON read_state(user_did, is_starred) WHERE is_starred = 1; | ||
| 525 | ``` | 535 | ``` |
| 526 | 536 | ||
| 527 | ### 6.6 Annotations, Likes | 537 | ### 6.6 Annotations, Likes |
| @@ -580,7 +590,39 @@ CREATE TABLE user_similarity ( | |||
| 580 | ); | 590 | ); |
| 581 | ``` | 591 | ``` |
| 582 | 592 | ||
| 583 | -## 7. Clustering & Recommendations | 593 | +### 6.8 Follows |
| 594 | + | ||
| 595 | +Tracks follow relationships between users (from `app.bsky.graph.follow` and `sh.tangled.graph.follow` records). | ||
| 596 | + | ||
| 597 | +```sql | ||
| 598 | +CREATE TABLE follows ( | ||
| 599 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 600 | + target_did TEXT NOT NULL, | ||
| 601 | + uri TEXT, | ||
| 602 | + cid TEXT, | ||
| 603 | + followed_at DATETIME, | ||
| 604 | + PRIMARY KEY (user_did, target_did) | ||
| 605 | +); | ||
| 606 | + | ||
| 607 | +CREATE INDEX idx_follows_user ON follows(user_did); | ||
| 608 | +CREATE INDEX idx_follows_target ON follows(target_did); | ||
| 609 | +``` | ||
| 610 | + | ||
| 611 | +### 6.9 OAuth Storage | ||
| 612 | + | ||
| 613 | +```sql | ||
| 614 | +CREATE TABLE oauth_auth_requests ( | ||
| 615 | + state TEXT PRIMARY KEY, | ||
| 616 | + data TEXT NOT NULL | ||
| 617 | +); | ||
| 618 | + | ||
| 619 | +CREATE TABLE oauth_sessions ( | ||
| 620 | + account_did TEXT NOT NULL, | ||
| 621 | + session_id TEXT NOT NULL, | ||
| 622 | + data TEXT NOT NULL, | ||
| 623 | + PRIMARY KEY (account_did, session_id) | ||
| 624 | +); | ||
| 625 | +``` | ||
| 584 | 626 | ||
| 585 | Glean has two complementary recommendation signals: | 627 | Glean has two complementary recommendation signals: |
| 586 | 628 | ||
| @@ -626,11 +668,9 @@ score(feed) = Σ J(target, U) for each user U subscribed to feed | |||
| 626 | 4. Return top N articles as recommendations | 668 | 4. Return top N articles as recommendations |
| 627 | 669 | ||
| 628 | ``` | 670 | ``` |
| 629 | -score(article) = Σ 1/logN(likers(article)) for each user U who liked it | 671 | +score(article) = Σ J(target, U) for each similar user U who liked the article |
| 630 | ``` | 672 | ``` |
| 631 | 673 | ||
| 632 | -The `1/logN` weighting avoids over-recommending articles from very large feeds. | ||
| 633 | - | ||
| 634 | **People recommendations (to follow on Bluesky):** | 674 | **People recommendations (to follow on Bluesky):** |
| 635 | 675 | ||
| 636 | 1. Compute user similarity for all pairs | 676 | 1. Compute user similarity for all pairs |
| @@ -657,10 +697,12 @@ For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard | |||
| 657 | 697 | ||
| 658 | A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 6h): | 698 | A background goroutine runs on a configurable schedule (`GLEAN_CLUSTER_INTERVAL`, default 6h): |
| 659 | 699 | ||
| 660 | -1. **Jetstream ingestion**: Subscribe to Jetstream for `at.glean.*` records | 700 | +1. **Compute feed similarity**: Batch-update the `feed_similarity` table (Jaccard over subscriber sets) |
| 661 | -2. **Index new records**: Parse lexicon records, upsert into SQLite | 701 | +2. **Compute user similarity**: Batch-update the `user_similarity` table (Jaccard over subscription sets, boosted by follow relationships) |
| 662 | -3. **Compute similarities**: Batch-update the `feed_similarity`, `user_similarity`, and `article_co_like` tables | 702 | +3. **Generate feed recommendations**: Materialize top feed recommendations per user into `user_feed_recommendations` |
| 663 | -4. **Generate recommendations**: Materialize top recommendations per user into cache tables | 703 | +4. **Generate article recommendations**: Materialize top article recommendations per user into `user_article_recommendations` |
| 704 | + | ||
| 705 | +Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron. | ||
| 664 | 706 | ||
| 665 | ```sql | 707 | ```sql |
| 666 | CREATE TABLE user_feed_recommendations ( | 708 | CREATE TABLE user_feed_recommendations ( |
| @@ -687,28 +729,31 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | |||
| 687 | 729 | ||
| 688 | ### 8.1 Pages | 730 | ### 8.1 Pages |
| 689 | 731 | ||
| 690 | -| Route | Method | Description | | 732 | +| Route | Method | Description | |
| 691 | -| ------------------------- | ------ | -------------------------------------------------------- | | 733 | +| ------------------------------ | ------ | -------------------------------------------------------- | |
| 692 | -| `/` | GET | Landing page / auth redirect | | 734 | +| `/` | GET | Landing page / auth redirect | |
| 693 | -| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | 735 | +| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | |
| 694 | -| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | 736 | +| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | |
| 695 | -| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions | | 737 | +| `/feeds/list` | GET | Feed list fragment (htmx partial) | |
| 696 | -| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | 738 | +| `/feeds/discover-url` | GET | Discover feed URL from a website | |
| 697 | -| `/feeds/add` | POST | Add a single feed URL | | 739 | +| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions | |
| 698 | -| `/feeds/remove` | DELETE | Remove a feed | | 740 | +| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | |
| 699 | -| `/feeds/refresh` | POST | Refresh all subscribed feeds | | 741 | +| `/feeds/add` | POST | Add a single feed URL | |
| 700 | -| `/feeds/clear` | POST | Clear all subscriptions | | 742 | +| `/feeds/remove` | DELETE | Remove a feed | |
| 701 | -| `/articles` | GET | Read articles (paginated, filterable by feed) | | 743 | +| `/feeds/refresh` | POST | Refresh all subscribed feeds | |
| 702 | -| `/articles/{id}` | GET | Article detail view | | 744 | +| `/feeds/clear` | POST | Clear all subscriptions | |
| 703 | -| `/articles/{id}/read` | POST | Mark article as read | | 745 | +| `/articles` | GET | Read articles (paginated, filterable by feed) | |
| 704 | -| `/articles/{id}/unread` | POST | Mark article as unread | | 746 | +| `/articles/{id}` | GET | Article detail view | |
| 705 | -| `/articles/{id}/like` | POST | Like an article | | 747 | +| `/articles/{id}/read` | POST | Mark article as read | |
| 706 | -| `/articles/mark-all-read` | POST | Mark all articles as read | | 748 | +| `/articles/{id}/unread` | POST | Mark article as unread | |
| 707 | -| `/trending` | GET | Community feed: articles ranked by likes | | 749 | +| `/articles/{id}/like` | POST | Like an article | |
| 708 | -| `/library` | GET | Liked articles and annotations | | 750 | +| `/articles/{id}/fetch-content` | POST | Fetch full article content from original URL | |
| 709 | -| `/library/create` | POST | Create annotation on an article | | 751 | +| `/articles/mark-all-read` | POST | Mark all articles as read | |
| 710 | -| `/library/{id}/delete` | POST | Delete an annotation | | 752 | +| `/trending` | GET | Community feed: articles ranked by likes | |
| 711 | -| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | 753 | +| `/library` | GET | Liked articles and annotations | |
| 754 | +| `/library/create` | POST | Create annotation on an article | | ||
| 755 | +| `/library/{id}/delete` | POST | Delete an annotation | | ||
| 756 | +| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | ||
| 712 | 757 | ||
| 713 | ### 8.2 htmx Patterns | 758 | ### 8.2 htmx Patterns |
| 714 | 759 | ||
| @@ -726,12 +771,19 @@ glean/ | |||
| 726 | ├── go.sum | 771 | ├── go.sum |
| 727 | ├── Dockerfile | 772 | ├── Dockerfile |
| 728 | ├── Makefile | 773 | ├── Makefile |
| 774 | +├── lexicons/ | ||
| 775 | +│ └── at/ | ||
| 776 | +│ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like) | ||
| 777 | +│ └── margin/ # External lexicon schemas (note.json for at.margin.note) | ||
| 729 | ├── internal/ | 778 | ├── internal/ |
| 730 | │ ├── atproto/ | 779 | │ ├── atproto/ |
| 731 | │ │ ├── auth.go # DID resolution, OAuth flow | 780 | │ │ ├── auth.go # DID resolution, OAuth flow |
| 732 | │ │ ├── client.go # XRPC client (write to user PDS) | 781 | │ │ ├── client.go # XRPC client (write to user PDS) |
| 733 | │ │ ├── jetstream.go # Subscribe to Jetstream via official client | 782 | │ │ ├── jetstream.go # Subscribe to Jetstream via official client |
| 734 | │ │ ├── stream_handler.go # Stream event → DB handler | 783 | │ │ ├── stream_handler.go # Stream event → DB handler |
| 784 | +│ │ ├── lexicon.go # Lexicon record types (at.glean.*, maintained by hand) | ||
| 785 | +│ │ ├── lexicon_external.go # External lexicon record types (FollowRecord, MarginNoteRecord) | ||
| 786 | +│ │ ├── lexicon_test.go # Test: Go structs match lexicon JSON schemas | ||
| 735 | │ │ ├── sync.go # PDS record reconciliation | 787 | │ │ ├── sync.go # PDS record reconciliation |
| 736 | │ │ └── xrpc.go # XRPC query handlers (AppView endpoints) | 788 | │ │ └── xrpc.go # XRPC query handlers (AppView endpoints) |
| 737 | │ ├── db/ | 789 | │ ├── db/ |
| @@ -740,6 +792,7 @@ glean/ | |||
| 740 | │ │ ├── feed.go # Feed + subscription queries | 792 | │ │ ├── feed.go # Feed + subscription queries |
| 741 | │ │ ├── article.go # Article queries | 793 | │ │ ├── article.go # Article queries |
| 742 | │ │ ├── social.go # Like, annotation queries | 794 | │ │ ├── social.go # Like, annotation queries |
| 795 | +│ │ ├── follow.go # Follow queries | ||
| 743 | │ │ ├── cluster.go # Similarity + recommendation queries | 796 | │ │ ├── cluster.go # Similarity + recommendation queries |
| 744 | │ │ ├── oauth_store.go # OAuth session storage | 797 | │ │ ├── oauth_store.go # OAuth session storage |
| 745 | │ │ └── store.go # FeedStore adapter for scheduler | 798 | │ │ └── store.go # FeedStore adapter for scheduler |
| @@ -748,21 +801,25 @@ glean/ | |||
| 748 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher | 801 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher |
| 749 | │ │ ├── discover.go # Feed auto-discovery from URLs | 802 | │ │ ├── discover.go # Feed auto-discovery from URLs |
| 750 | │ │ └── opml.go # OPML import/export | 803 | │ │ └── opml.go # OPML import/export |
| 804 | +│ ├── scraper/ | ||
| 805 | +│ │ └── scraper.go # Full article content scraper | ||
| 751 | │ ├── metrics/ | 806 | │ ├── metrics/ |
| 752 | │ │ └── metrics.go # Prometheus metrics definitions | 807 | │ │ └── metrics.go # Prometheus metrics definitions |
| 753 | │ ├── cluster/ | 808 | │ ├── cluster/ |
| 754 | │ │ ├── jaccard.go # Jaccard similarity computation | 809 | │ │ ├── jaccard.go # Jaccard similarity computation |
| 755 | -│ │ ├── recommender.go # Feed + people recommendation logic | 810 | +│ │ ├── recommender.go # Feed + people recommendation queries |
| 756 | │ │ └── cron.go # Background recomputation scheduler | 811 | │ │ └── cron.go # Background recomputation scheduler |
| 757 | │ ├── server/ | 812 | │ ├── server/ |
| 758 | │ │ ├── server.go # HTTP server, router setup | 813 | │ │ ├── server.go # HTTP server, router setup |
| 759 | │ │ ├── auth_handler.go # OAuth login/callback | 814 | │ │ ├── auth_handler.go # OAuth login/callback |
| 760 | │ │ ├── feeds_handler.go # Feed management handlers | 815 | │ │ ├── feeds_handler.go # Feed management handlers |
| 761 | │ │ ├── articles_handler.go # Article reading handlers | 816 | │ │ ├── articles_handler.go # Article reading handlers |
| 817 | +│ │ ├── annotations_handler.go # Annotation handlers | ||
| 762 | │ │ ├── dashboard_handler.go # Dashboard handler | 818 | │ │ ├── dashboard_handler.go # Dashboard handler |
| 763 | │ │ ├── trending_handler.go # Trending handler | 819 | │ │ ├── trending_handler.go # Trending handler |
| 764 | -│ │ ├── library_handler.go # Library (likes + annotations) | 820 | +│ │ ├── index_handler.go # Landing page handler |
| 765 | │ │ ├── profile_handler.go # Public profile handler | 821 | │ │ ├── profile_handler.go # Public profile handler |
| 822 | +│ │ ├── pagination.go # Pagination helpers | ||
| 766 | │ │ ├── middleware.go # Auth, logging, CSRF middleware | 823 | │ │ ├── middleware.go # Auth, logging, CSRF middleware |
| 767 | │ │ └── session.go # Session management | 824 | │ │ └── session.go # Session management |
| 768 | │ ├── sanitize/ | 825 | │ ├── sanitize/ |
| @@ -770,7 +827,6 @@ glean/ | |||
| 770 | │ └── tmpl/ | 827 | │ └── tmpl/ |
| 771 | │ ├── base.html # Base template with htmx + Tailwind | 828 | │ ├── base.html # Base template with htmx + Tailwind |
| 772 | │ ├── index.html # Landing page | 829 | │ ├── index.html # Landing page |
| 773 | -│ ├── login.html # Login page | ||
| 774 | │ ├── dashboard.html # Dashboard | 830 | │ ├── dashboard.html # Dashboard |
| 775 | │ ├── feeds.html # Feed management | 831 | │ ├── feeds.html # Feed management |
| 776 | │ ├── articles.html # Article listing | 832 | │ ├── articles.html # Article listing |
| @@ -865,10 +921,13 @@ Glean exposes a `/metrics` endpoint for monitoring. Key metrics: | |||
| 865 | - **`glean_feed_fetch_duration_seconds`** — Histogram of feed fetch latency | 921 | - **`glean_feed_fetch_duration_seconds`** — Histogram of feed fetch latency |
| 866 | - **`glean_articles_upserted_total`** — Counter of articles stored from feeds | 922 | - **`glean_articles_upserted_total`** — Counter of articles stored from feeds |
| 867 | - **`glean_jetstream_events_total`** — Jetstream events labeled by collection and action | 923 | - **`glean_jetstream_events_total`** — Jetstream events labeled by collection and action |
| 924 | +- **`glean_jetstream_errors_total`** — Jetstream handler errors | ||
| 925 | +- **`glean_jetstream_reconnects_total`** — Jetstream reconnection count | ||
| 868 | - **`glean_http_requests_total`** — HTTP request counts labeled by method, path, and status | 926 | - **`glean_http_requests_total`** — HTTP request counts labeled by method, path, and status |
| 927 | +- **`glean_pds_sync_runs_total`** / **`glean_pds_sync_errors_total`** — PDS sync runs and errors | ||
| 869 | - **`glean_cluster_runs_total`** / **`glean_cluster_duration_seconds`** — Recommendation engine runs and timing | 928 | - **`glean_cluster_runs_total`** / **`glean_cluster_duration_seconds`** — Recommendation engine runs and timing |
| 870 | 929 | ||
| 871 | -### 12.3 Why htmx? | 930 | +### 12.4 Why htmx? |
| 872 | 931 | ||
| 873 | - No JavaScript build pipeline | 932 | - No JavaScript build pipeline |
| 874 | - Server renders everything — simpler mental model | 933 | - Server renders everything — simpler mental model |
| @@ -876,7 +935,7 @@ Glean exposes a `/metrics` endpoint for monitoring. Key metrics: | |||
| 876 | - Perfect fit for a read-centric application | 935 | - Perfect fit for a read-centric application |
| 877 | - TailwindCSS handles styling without writing custom CSS | 936 | - TailwindCSS handles styling without writing custom CSS |
| 878 | 937 | ||
| 879 | -### 12.4 AppView Architecture | 938 | +### 12.5 AppView Architecture |
| 880 | 939 | ||
| 881 | Glean operates as an AT Protocol AppView. This means: | 940 | Glean operates as an AT Protocol AppView. This means: |
| 882 | 941 | ||
| @@ -885,7 +944,7 @@ Glean operates as an AT Protocol AppView. This means: | |||
| 885 | - **Query path**: Other AT Protocol apps can query Glean's XRPC endpoints to access indexed data (subscriptions, annotations, likes, recommendations) without building their own indexer. | 944 | - **Query path**: Other AT Protocol apps can query Glean's XRPC endpoints to access indexed data (subscriptions, annotations, likes, recommendations) without building their own indexer. |
| 886 | - **Trade-off**: Article content (fetched from RSS feeds) is local-only and not part of the AT Protocol layer. Only individual feed subscription records (`at.glean.subscription`) live on the PDS. | 945 | - **Trade-off**: Article content (fetched from RSS feeds) is local-only and not part of the AT Protocol layer. Only individual feed subscription records (`at.glean.subscription`) live on the PDS. |
| 887 | 946 | ||
| 888 | -### 12.5 Privacy Model | 947 | +### 12.6 Privacy Model |
| 889 | 948 | ||
| 890 | All PDS records are public. There is no notion of private data on the AT Protocol — everything stored on the repo is visible. Users should be aware that annotations, subscriptions, and likes are all public records. | 949 | All PDS records are public. There is no notion of private data on the AT Protocol — everything stored on the repo is visible. Users should be aware that annotations, subscriptions, and likes are all public records. |
| 891 | 950 | ||
modified
internal/atproto/auth.go +4 -2 | @@ -8,8 +8,10 @@ import ( | ||
| 8 | 8 | "github.com/bluesky-social/indigo/atproto/syntax" |
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | -type DIDDocument = identity.DIDDocument | |
| 12 | -type Identity = identity.Identity | |
| 11 | +type ( | |
| 12 | + DIDDocument = identity.DIDDocument | |
| 13 | + Identity = identity.Identity | |
| 14 | +) | |
| 13 | 15 | |
| 14 | 16 | func ResolveHandle(ctx context.Context, handle string) (string, error) { |
| 15 | 17 | h, err := syntax.ParseHandle(handle) |
| @@ -8,8 +8,10 @@ import ( | |||
| 8 | "github.com/bluesky-social/indigo/atproto/syntax" | 8 | "github.com/bluesky-social/indigo/atproto/syntax" |
| 9 | ) | 9 | ) |
| 10 | 10 | ||
| 11 | -type DIDDocument = identity.DIDDocument | 11 | +type ( |
| 12 | -type Identity = identity.Identity | 12 | + DIDDocument = identity.DIDDocument |
| 13 | + Identity = identity.Identity | ||
| 14 | +) | ||
| 13 | 15 | ||
| 14 | func ResolveHandle(ctx context.Context, handle string) (string, error) { | 16 | func ResolveHandle(ctx context.Context, handle string) (string, error) { |
| 15 | h, err := syntax.ParseHandle(handle) | 17 | h, err := syntax.ParseHandle(handle) |
modified
internal/atproto/jetstream.go +6 -5 | @@ -94,11 +94,12 @@ func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slo | ||
| 94 | 94 | "User-Agent": "glean/1.0", |
| 95 | 95 | }, |
| 96 | 96 | WantedCollections: []string{ |
| 97 | - "at.glean.subscription", | |
| 98 | - "at.glean.annotation", | |
| 99 | - "at.glean.like", | |
| 100 | - "app.bsky.graph.follow", | |
| 101 | - "sh.tangled.graph.follow", | |
| 97 | + CollectionSubscription, | |
| 98 | + CollectionAnnotation, | |
| 99 | + CollectionLike, | |
| 100 | + CollectionBskyFollow, | |
| 101 | + CollectionTangledFollow, | |
| 102 | + CollectionMarginNote, | |
| 102 | 103 | }, |
| 103 | 104 | } |
| 104 | 105 | |
| @@ -94,11 +94,12 @@ func NewJetstreamConsumer(jetstreamURL string, handler EventHandler, logger *slo | |||
| 94 | "User-Agent": "glean/1.0", | 94 | "User-Agent": "glean/1.0", |
| 95 | }, | 95 | }, |
| 96 | WantedCollections: []string{ | 96 | WantedCollections: []string{ |
| 97 | - "at.glean.subscription", | 97 | + CollectionSubscription, |
| 98 | - "at.glean.annotation", | 98 | + CollectionAnnotation, |
| 99 | - "at.glean.like", | 99 | + CollectionLike, |
| 100 | - "app.bsky.graph.follow", | 100 | + CollectionBskyFollow, |
| 101 | - "sh.tangled.graph.follow", | 101 | + CollectionTangledFollow, |
| 102 | + CollectionMarginNote, | ||
| 102 | }, | 103 | }, |
| 103 | } | 104 | } |
| 104 | 105 | ||
modified
internal/atproto/lexicon.go +9 -5 | @@ -7,6 +7,15 @@ import ( | ||
| 7 | 7 | "time" |
| 8 | 8 | ) |
| 9 | 9 | |
| 10 | +const ( | |
| 11 | + CollectionSubscription = "at.glean.subscription" | |
| 12 | + CollectionAnnotation = "at.glean.annotation" | |
| 13 | + CollectionLike = "at.glean.like" | |
| 14 | + CollectionMarginNote = "at.margin.note" | |
| 15 | + CollectionBskyFollow = "app.bsky.graph.follow" | |
| 16 | + CollectionTangledFollow = "sh.tangled.graph.follow" | |
| 17 | +) | |
| 18 | + | |
| 10 | 19 | type SubscriptionRecord struct { |
| 11 | 20 | CreatedAt string `json:"createdAt"` |
| 12 | 21 | FeedURL string `json:"feedUrl"` |
| @@ -30,11 +39,6 @@ type LikeRecord struct { | ||
| 30 | 39 | ArticleURL string `json:"articleUrl"` |
| 31 | 40 | } |
| 32 | 41 | |
| 33 | -type FollowRecord struct { | |
| 34 | - Subject string `json:"subject"` | |
| 35 | - CreatedAt string `json:"createdAt"` | |
| 36 | -} | |
| 37 | - | |
| 38 | 42 | type Record struct { |
| 39 | 43 | URI string |
| 40 | 44 | CID string |
| @@ -7,6 +7,15 @@ import ( | |||
| 7 | "time" | 7 | "time" |
| 8 | ) | 8 | ) |
| 9 | 9 | ||
| 10 | +const ( | ||
| 11 | + CollectionSubscription = "at.glean.subscription" | ||
| 12 | + CollectionAnnotation = "at.glean.annotation" | ||
| 13 | + CollectionLike = "at.glean.like" | ||
| 14 | + CollectionMarginNote = "at.margin.note" | ||
| 15 | + CollectionBskyFollow = "app.bsky.graph.follow" | ||
| 16 | + CollectionTangledFollow = "sh.tangled.graph.follow" | ||
| 17 | +) | ||
| 18 | + | ||
| 10 | type SubscriptionRecord struct { | 19 | type SubscriptionRecord struct { |
| 11 | CreatedAt string `json:"createdAt"` | 20 | CreatedAt string `json:"createdAt"` |
| 12 | FeedURL string `json:"feedUrl"` | 21 | FeedURL string `json:"feedUrl"` |
| @@ -30,11 +39,6 @@ type LikeRecord struct { | |||
| 30 | ArticleURL string `json:"articleUrl"` | 39 | ArticleURL string `json:"articleUrl"` |
| 31 | } | 40 | } |
| 32 | 41 | ||
| 33 | -type FollowRecord struct { | ||
| 34 | - Subject string `json:"subject"` | ||
| 35 | - CreatedAt string `json:"createdAt"` | ||
| 36 | -} | ||
| 37 | - | ||
| 38 | type Record struct { | 42 | type Record struct { |
| 39 | URI string | 43 | URI string |
| 40 | CID string | 44 | CID string |
added
internal/atproto/lexicon_external.go +74 -0 | new file mode 100644 | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +// External lexicon record types (not owned by glean.at). | |
| 2 | +// These are maintained by hand to match the upstream lexicon schemas. | |
| 3 | +// See lexicon_test.go for the test ensuring these stay in sync with lexicons/. | |
| 4 | +package atproto | |
| 5 | + | |
| 6 | +import "encoding/json" | |
| 7 | + | |
| 8 | +type FollowRecord struct { | |
| 9 | + Subject string `json:"subject"` | |
| 10 | + CreatedAt string `json:"createdAt"` | |
| 11 | + Via json.RawMessage `json:"via,omitempty"` | |
| 12 | +} | |
| 13 | + | |
| 14 | +type MarginNoteRecord struct { | |
| 15 | + Body *MarginNoteBody `json:"body,omitempty"` | |
| 16 | + Color string `json:"color,omitempty"` | |
| 17 | + CreatedAt string `json:"createdAt"` | |
| 18 | + Facets json.RawMessage `json:"facets,omitempty"` | |
| 19 | + Generator *MarginNoteGenerator `json:"generator,omitempty"` | |
| 20 | + Labels json.RawMessage `json:"labels,omitempty"` | |
| 21 | + ModifiedAt string `json:"modifiedAt,omitempty"` | |
| 22 | + Motivation string `json:"motivation"` | |
| 23 | + Rights string `json:"rights,omitempty"` | |
| 24 | + Tags []string `json:"tags,omitempty"` | |
| 25 | + Target MarginNoteTarget `json:"target"` | |
| 26 | +} | |
| 27 | + | |
| 28 | +type MarginNoteBody struct { | |
| 29 | + Format string `json:"format,omitempty"` | |
| 30 | + URI string `json:"uri,omitempty"` | |
| 31 | + Value string `json:"value,omitempty"` | |
| 32 | +} | |
| 33 | + | |
| 34 | +type MarginNoteGenerator struct { | |
| 35 | + Homepage string `json:"homepage,omitempty"` | |
| 36 | + ID string `json:"id,omitempty"` | |
| 37 | + Name string `json:"name,omitempty"` | |
| 38 | +} | |
| 39 | + | |
| 40 | +type MarginNoteSelector struct { | |
| 41 | + ConformsTo string `json:"conformsTo,omitempty"` | |
| 42 | + End int `json:"end,omitempty"` | |
| 43 | + Exact string `json:"exact,omitempty"` | |
| 44 | + Prefix string `json:"prefix,omitempty"` | |
| 45 | + Start int `json:"start,omitempty"` | |
| 46 | + Suffix string `json:"suffix,omitempty"` | |
| 47 | + Type string `json:"type"` | |
| 48 | + Value string `json:"value,omitempty"` | |
| 49 | +} | |
| 50 | + | |
| 51 | +type MarginNoteTarget struct { | |
| 52 | + Selector *MarginNoteSelector `json:"selector,omitempty"` | |
| 53 | + Source string `json:"source"` | |
| 54 | + SourceHash string `json:"sourceHash,omitempty"` | |
| 55 | + State *MarginNoteTimeState `json:"state,omitempty"` | |
| 56 | + Title string `json:"title,omitempty"` | |
| 57 | +} | |
| 58 | + | |
| 59 | +type MarginNoteTimeState struct { | |
| 60 | + Cached string `json:"cached,omitempty"` | |
| 61 | + SourceDate string `json:"sourceDate,omitempty"` | |
| 62 | +} | |
| 63 | + | |
| 64 | +func (r MarginNoteRecord) ToAnnotation() (articleURL, quote, note string, tags []string) { | |
| 65 | + articleURL = r.Target.Source | |
| 66 | + if r.Body != nil { | |
| 67 | + note = r.Body.Value | |
| 68 | + } | |
| 69 | + if r.Target.Selector != nil && r.Target.Selector.Exact != "" { | |
| 70 | + quote = r.Target.Selector.Exact | |
| 71 | + } | |
| 72 | + tags = r.Tags | |
| 73 | + return | |
| 74 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | +// External lexicon record types (not owned by glean.at). | ||
| 2 | +// These are maintained by hand to match the upstream lexicon schemas. | ||
| 3 | +// See lexicon_test.go for the test ensuring these stay in sync with lexicons/. | ||
| 4 | +package atproto | ||
| 5 | + | ||
| 6 | +import "encoding/json" | ||
| 7 | + | ||
| 8 | +type FollowRecord struct { | ||
| 9 | + Subject string `json:"subject"` | ||
| 10 | + CreatedAt string `json:"createdAt"` | ||
| 11 | + Via json.RawMessage `json:"via,omitempty"` | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +type MarginNoteRecord struct { | ||
| 15 | + Body *MarginNoteBody `json:"body,omitempty"` | ||
| 16 | + Color string `json:"color,omitempty"` | ||
| 17 | + CreatedAt string `json:"createdAt"` | ||
| 18 | + Facets json.RawMessage `json:"facets,omitempty"` | ||
| 19 | + Generator *MarginNoteGenerator `json:"generator,omitempty"` | ||
| 20 | + Labels json.RawMessage `json:"labels,omitempty"` | ||
| 21 | + ModifiedAt string `json:"modifiedAt,omitempty"` | ||
| 22 | + Motivation string `json:"motivation"` | ||
| 23 | + Rights string `json:"rights,omitempty"` | ||
| 24 | + Tags []string `json:"tags,omitempty"` | ||
| 25 | + Target MarginNoteTarget `json:"target"` | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +type MarginNoteBody struct { | ||
| 29 | + Format string `json:"format,omitempty"` | ||
| 30 | + URI string `json:"uri,omitempty"` | ||
| 31 | + Value string `json:"value,omitempty"` | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +type MarginNoteGenerator struct { | ||
| 35 | + Homepage string `json:"homepage,omitempty"` | ||
| 36 | + ID string `json:"id,omitempty"` | ||
| 37 | + Name string `json:"name,omitempty"` | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +type MarginNoteSelector struct { | ||
| 41 | + ConformsTo string `json:"conformsTo,omitempty"` | ||
| 42 | + End int `json:"end,omitempty"` | ||
| 43 | + Exact string `json:"exact,omitempty"` | ||
| 44 | + Prefix string `json:"prefix,omitempty"` | ||
| 45 | + Start int `json:"start,omitempty"` | ||
| 46 | + Suffix string `json:"suffix,omitempty"` | ||
| 47 | + Type string `json:"type"` | ||
| 48 | + Value string `json:"value,omitempty"` | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +type MarginNoteTarget struct { | ||
| 52 | + Selector *MarginNoteSelector `json:"selector,omitempty"` | ||
| 53 | + Source string `json:"source"` | ||
| 54 | + SourceHash string `json:"sourceHash,omitempty"` | ||
| 55 | + State *MarginNoteTimeState `json:"state,omitempty"` | ||
| 56 | + Title string `json:"title,omitempty"` | ||
| 57 | +} | ||
| 58 | + | ||
| 59 | +type MarginNoteTimeState struct { | ||
| 60 | + Cached string `json:"cached,omitempty"` | ||
| 61 | + SourceDate string `json:"sourceDate,omitempty"` | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +func (r MarginNoteRecord) ToAnnotation() (articleURL, quote, note string, tags []string) { | ||
| 65 | + articleURL = r.Target.Source | ||
| 66 | + if r.Body != nil { | ||
| 67 | + note = r.Body.Value | ||
| 68 | + } | ||
| 69 | + if r.Target.Selector != nil && r.Target.Selector.Exact != "" { | ||
| 70 | + quote = r.Target.Selector.Exact | ||
| 71 | + } | ||
| 72 | + tags = r.Tags | ||
| 73 | + return | ||
| 74 | +} | ||
modified
internal/atproto/lexicon_test.go +23 -6 | @@ -15,9 +15,13 @@ func lexiconPath(filename string) string { | ||
| 15 | 15 | return filepath.Join("..", "..", "lexicons", "at", "glean", filename) |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | -func readLexiconProperties(t *testing.T, filename string) map[string]any { | |
| 18 | +func lexiconPathFromRoot(relPath string) string { | |
| 19 | + return filepath.Join("..", "..", "lexicons", relPath) | |
| 20 | +} | |
| 21 | + | |
| 22 | +func readLexiconPropertiesFromPath(t *testing.T, path string) map[string]any { | |
| 19 | 23 | t.Helper() |
| 20 | - data, err := os.ReadFile(lexiconPath(filename)) | |
| 24 | + data, err := os.ReadFile(path) | |
| 21 | 25 | assert.NilError(t, err) |
| 22 | 26 | |
| 23 | 27 | var schema struct { |
| @@ -30,13 +34,18 @@ func readLexiconProperties(t *testing.T, filename string) map[string]any { | ||
| 30 | 34 | } `json:"defs"` |
| 31 | 35 | } |
| 32 | 36 | assert.NilError(t, json.Unmarshal(data, &schema)) |
| 33 | - assert.Assert(t, len(schema.Defs.Main.Record.Properties) > 0, "no properties found in %s", filename) | |
| 37 | + assert.Assert(t, len(schema.Defs.Main.Record.Properties) > 0, "no properties found in %s", path) | |
| 34 | 38 | return schema.Defs.Main.Record.Properties |
| 35 | 39 | } |
| 36 | 40 | |
| 37 | 41 | func assertStructMatchesLexicon[T any](t *testing.T, filename string) { |
| 38 | 42 | t.Helper() |
| 39 | - properties := readLexiconProperties(t, filename) | |
| 43 | + assertStructMatchesLexiconPath[T](t, lexiconPath(filename)) | |
| 44 | +} | |
| 45 | + | |
| 46 | +func assertStructMatchesLexiconPath[T any](t *testing.T, path string) { | |
| 47 | + t.Helper() | |
| 48 | + properties := readLexiconPropertiesFromPath(t, path) | |
| 40 | 49 | |
| 41 | 50 | var zero T |
| 42 | 51 | typ := reflect.TypeOf(zero) |
| @@ -52,12 +61,12 @@ func assertStructMatchesLexicon[T any](t *testing.T, filename string) { | ||
| 52 | 61 | } |
| 53 | 62 | |
| 54 | 63 | for prop := range properties { |
| 55 | - assert.Assert(t, jsonTags[prop], "lexicon property %q missing from %s (lexicon file: %s)", prop, typ.Name(), filename) | |
| 64 | + assert.Assert(t, jsonTags[prop], "lexicon property %q missing from %s (lexicon file: %s)", prop, typ.Name(), path) | |
| 56 | 65 | } |
| 57 | 66 | |
| 58 | 67 | for name := range jsonTags { |
| 59 | 68 | _, exists := properties[name] |
| 60 | - assert.Assert(t, exists, "Go field %q in %s missing from lexicon %s", name, typ.Name(), filename) | |
| 69 | + assert.Assert(t, exists, "Go field %q in %s missing from lexicon %s", name, typ.Name(), path) | |
| 61 | 70 | } |
| 62 | 71 | } |
| 63 | 72 | |
| @@ -72,3 +81,11 @@ func TestAnnotationRecordMatchesLexicon(t *testing.T) { | ||
| 72 | 81 | func TestLikeRecordMatchesLexicon(t *testing.T) { |
| 73 | 82 | assertStructMatchesLexicon[LikeRecord](t, "like.json") |
| 74 | 83 | } |
| 84 | + | |
| 85 | +func TestFollowRecordMatchesLexicon(t *testing.T) { | |
| 86 | + assertStructMatchesLexiconPath[FollowRecord](t, lexiconPathFromRoot("app/bsky/graph/follow.json")) | |
| 87 | +} | |
| 88 | + | |
| 89 | +func TestMarginNoteRecordMatchesLexicon(t *testing.T) { | |
| 90 | + assertStructMatchesLexiconPath[MarginNoteRecord](t, lexiconPathFromRoot("at/margin/note.json")) | |
| 91 | +} | |
| @@ -15,9 +15,13 @@ func lexiconPath(filename string) string { | |||
| 15 | return filepath.Join("..", "..", "lexicons", "at", "glean", filename) | 15 | return filepath.Join("..", "..", "lexicons", "at", "glean", filename) |
| 16 | } | 16 | } |
| 17 | 17 | ||
| 18 | -func readLexiconProperties(t *testing.T, filename string) map[string]any { | 18 | +func lexiconPathFromRoot(relPath string) string { |
| 19 | + return filepath.Join("..", "..", "lexicons", relPath) | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +func readLexiconPropertiesFromPath(t *testing.T, path string) map[string]any { | ||
| 19 | t.Helper() | 23 | t.Helper() |
| 20 | - data, err := os.ReadFile(lexiconPath(filename)) | 24 | + data, err := os.ReadFile(path) |
| 21 | assert.NilError(t, err) | 25 | assert.NilError(t, err) |
| 22 | 26 | ||
| 23 | var schema struct { | 27 | var schema struct { |
| @@ -30,13 +34,18 @@ func readLexiconProperties(t *testing.T, filename string) map[string]any { | |||
| 30 | } `json:"defs"` | 34 | } `json:"defs"` |
| 31 | } | 35 | } |
| 32 | assert.NilError(t, json.Unmarshal(data, &schema)) | 36 | assert.NilError(t, json.Unmarshal(data, &schema)) |
| 33 | - assert.Assert(t, len(schema.Defs.Main.Record.Properties) > 0, "no properties found in %s", filename) | 37 | + assert.Assert(t, len(schema.Defs.Main.Record.Properties) > 0, "no properties found in %s", path) |
| 34 | return schema.Defs.Main.Record.Properties | 38 | return schema.Defs.Main.Record.Properties |
| 35 | } | 39 | } |
| 36 | 40 | ||
| 37 | func assertStructMatchesLexicon[T any](t *testing.T, filename string) { | 41 | func assertStructMatchesLexicon[T any](t *testing.T, filename string) { |
| 38 | t.Helper() | 42 | t.Helper() |
| 39 | - properties := readLexiconProperties(t, filename) | 43 | + assertStructMatchesLexiconPath[T](t, lexiconPath(filename)) |
| 44 | +} | ||
| 45 | + | ||
| 46 | +func assertStructMatchesLexiconPath[T any](t *testing.T, path string) { | ||
| 47 | + t.Helper() | ||
| 48 | + properties := readLexiconPropertiesFromPath(t, path) | ||
| 40 | 49 | ||
| 41 | var zero T | 50 | var zero T |
| 42 | typ := reflect.TypeOf(zero) | 51 | typ := reflect.TypeOf(zero) |
| @@ -52,12 +61,12 @@ func assertStructMatchesLexicon[T any](t *testing.T, filename string) { | |||
| 52 | } | 61 | } |
| 53 | 62 | ||
| 54 | for prop := range properties { | 63 | for prop := range properties { |
| 55 | - assert.Assert(t, jsonTags[prop], "lexicon property %q missing from %s (lexicon file: %s)", prop, typ.Name(), filename) | 64 | + assert.Assert(t, jsonTags[prop], "lexicon property %q missing from %s (lexicon file: %s)", prop, typ.Name(), path) |
| 56 | } | 65 | } |
| 57 | 66 | ||
| 58 | for name := range jsonTags { | 67 | for name := range jsonTags { |
| 59 | _, exists := properties[name] | 68 | _, exists := properties[name] |
| 60 | - assert.Assert(t, exists, "Go field %q in %s missing from lexicon %s", name, typ.Name(), filename) | 69 | + assert.Assert(t, exists, "Go field %q in %s missing from lexicon %s", name, typ.Name(), path) |
| 61 | } | 70 | } |
| 62 | } | 71 | } |
| 63 | 72 | ||
| @@ -72,3 +81,11 @@ func TestAnnotationRecordMatchesLexicon(t *testing.T) { | |||
| 72 | func TestLikeRecordMatchesLexicon(t *testing.T) { | 81 | func TestLikeRecordMatchesLexicon(t *testing.T) { |
| 73 | assertStructMatchesLexicon[LikeRecord](t, "like.json") | 82 | assertStructMatchesLexicon[LikeRecord](t, "like.json") |
| 74 | } | 83 | } |
| 84 | + | ||
| 85 | +func TestFollowRecordMatchesLexicon(t *testing.T) { | ||
| 86 | + assertStructMatchesLexiconPath[FollowRecord](t, lexiconPathFromRoot("app/bsky/graph/follow.json")) | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +func TestMarginNoteRecordMatchesLexicon(t *testing.T) { | ||
| 90 | + assertStructMatchesLexiconPath[MarginNoteRecord](t, lexiconPathFromRoot("at/margin/note.json")) | ||
| 91 | +} | ||
modified
internal/atproto/stream_handler.go +49 -4 | @@ -21,13 +21,15 @@ func NewStreamDBHandler(database *db.DB, logger *slog.Logger) *StreamDBHandler { | ||
| 21 | 21 | |
| 22 | 22 | func (h *StreamDBHandler) Handle(ctx context.Context, event *Event) error { |
| 23 | 23 | switch event.Collection { |
| 24 | - case "at.glean.subscription": | |
| 24 | + case CollectionSubscription: | |
| 25 | 25 | return h.handleSubscription(ctx, event) |
| 26 | - case "at.glean.like": | |
| 26 | + case CollectionLike: | |
| 27 | 27 | return h.handleLike(ctx, event) |
| 28 | - case "at.glean.annotation": | |
| 28 | + case CollectionAnnotation: | |
| 29 | 29 | return h.handleAnnotation(ctx, event) |
| 30 | - case "app.bsky.graph.follow", "sh.tangled.graph.follow": | |
| 30 | + case CollectionMarginNote: | |
| 31 | + return h.handleMarginNote(ctx, event) | |
| 32 | + case CollectionBskyFollow, CollectionTangledFollow: | |
| 31 | 33 | return h.handleFollow(ctx, event) |
| 32 | 34 | } |
| 33 | 35 | return nil |
| @@ -155,3 +157,46 @@ func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error | ||
| 155 | 157 | } |
| 156 | 158 | return nil |
| 157 | 159 | } |
| 160 | + | |
| 161 | +func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) error { | |
| 162 | + switch event.Type { | |
| 163 | + case "create", "update": | |
| 164 | + var rec MarginNoteRecord | |
| 165 | + if err := json.Unmarshal(event.Value, &rec); err != nil { | |
| 166 | + return err | |
| 167 | + } | |
| 168 | + | |
| 169 | + articleURL, quote, note, tags := rec.ToAnnotation() | |
| 170 | + if articleURL == "" { | |
| 171 | + return nil | |
| 172 | + } | |
| 173 | + | |
| 174 | + feedURL := h.resolveFeedURL(ctx, articleURL) | |
| 175 | + | |
| 176 | + t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 177 | + a := &db.Annotation{ | |
| 178 | + URI: event.URI, | |
| 179 | + AuthorDID: event.DID, | |
| 180 | + FeedURL: feedURL, | |
| 181 | + ArticleURL: articleURL, | |
| 182 | + Quote: db.NullStr(quote), | |
| 183 | + Note: db.NullStr(note), | |
| 184 | + Tags: db.NullStrTags(tags), | |
| 185 | + CreatedAt: sql.NullTime{Time: t, Valid: true}, | |
| 186 | + CID: sql.NullString{String: event.CID, Valid: event.CID != ""}, | |
| 187 | + } | |
| 188 | + return h.db.CreateAnnotation(ctx, a) | |
| 189 | + | |
| 190 | + case "delete": | |
| 191 | + return h.db.DeleteAnnotation(ctx, event.URI) | |
| 192 | + } | |
| 193 | + return nil | |
| 194 | +} | |
| 195 | + | |
| 196 | +func (h *StreamDBHandler) resolveFeedURL(ctx context.Context, articleURL string) string { | |
| 197 | + article, err := h.db.GetArticleByURL(ctx, articleURL) | |
| 198 | + if err != nil { | |
| 199 | + return "" | |
| 200 | + } | |
| 201 | + return article.FeedURL | |
| 202 | +} | |
| @@ -21,13 +21,15 @@ func NewStreamDBHandler(database *db.DB, logger *slog.Logger) *StreamDBHandler { | |||
| 21 | 21 | ||
| 22 | func (h *StreamDBHandler) Handle(ctx context.Context, event *Event) error { | 22 | func (h *StreamDBHandler) Handle(ctx context.Context, event *Event) error { |
| 23 | switch event.Collection { | 23 | switch event.Collection { |
| 24 | - case "at.glean.subscription": | 24 | + case CollectionSubscription: |
| 25 | return h.handleSubscription(ctx, event) | 25 | return h.handleSubscription(ctx, event) |
| 26 | - case "at.glean.like": | 26 | + case CollectionLike: |
| 27 | return h.handleLike(ctx, event) | 27 | return h.handleLike(ctx, event) |
| 28 | - case "at.glean.annotation": | 28 | + case CollectionAnnotation: |
| 29 | return h.handleAnnotation(ctx, event) | 29 | return h.handleAnnotation(ctx, event) |
| 30 | - case "app.bsky.graph.follow", "sh.tangled.graph.follow": | 30 | + case CollectionMarginNote: |
| 31 | + return h.handleMarginNote(ctx, event) | ||
| 32 | + case CollectionBskyFollow, CollectionTangledFollow: | ||
| 31 | return h.handleFollow(ctx, event) | 33 | return h.handleFollow(ctx, event) |
| 32 | } | 34 | } |
| 33 | return nil | 35 | return nil |
| @@ -155,3 +157,46 @@ func (h *StreamDBHandler) handleFollow(ctx context.Context, event *Event) error | |||
| 155 | } | 157 | } |
| 156 | return nil | 158 | return nil |
| 157 | } | 159 | } |
| 160 | + | ||
| 161 | +func (h *StreamDBHandler) handleMarginNote(ctx context.Context, event *Event) error { | ||
| 162 | + switch event.Type { | ||
| 163 | + case "create", "update": | ||
| 164 | + var rec MarginNoteRecord | ||
| 165 | + if err := json.Unmarshal(event.Value, &rec); err != nil { | ||
| 166 | + return err | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + articleURL, quote, note, tags := rec.ToAnnotation() | ||
| 170 | + if articleURL == "" { | ||
| 171 | + return nil | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + feedURL := h.resolveFeedURL(ctx, articleURL) | ||
| 175 | + | ||
| 176 | + t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | ||
| 177 | + a := &db.Annotation{ | ||
| 178 | + URI: event.URI, | ||
| 179 | + AuthorDID: event.DID, | ||
| 180 | + FeedURL: feedURL, | ||
| 181 | + ArticleURL: articleURL, | ||
| 182 | + Quote: db.NullStr(quote), | ||
| 183 | + Note: db.NullStr(note), | ||
| 184 | + Tags: db.NullStrTags(tags), | ||
| 185 | + CreatedAt: sql.NullTime{Time: t, Valid: true}, | ||
| 186 | + CID: sql.NullString{String: event.CID, Valid: event.CID != ""}, | ||
| 187 | + } | ||
| 188 | + return h.db.CreateAnnotation(ctx, a) | ||
| 189 | + | ||
| 190 | + case "delete": | ||
| 191 | + return h.db.DeleteAnnotation(ctx, event.URI) | ||
| 192 | + } | ||
| 193 | + return nil | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +func (h *StreamDBHandler) resolveFeedURL(ctx context.Context, articleURL string) string { | ||
| 197 | + article, err := h.db.GetArticleByURL(ctx, articleURL) | ||
| 198 | + if err != nil { | ||
| 199 | + return "" | ||
| 200 | + } | ||
| 201 | + return article.FeedURL | ||
| 202 | +} | ||
modified
internal/atproto/sync.go +46 -4 | @@ -33,15 +33,18 @@ func NewSync(database *db.DB, client *Client, logger *slog.Logger) *Sync { | ||
| 33 | 33 | func (s *Sync) Run(ctx context.Context, userDID string) error { |
| 34 | 34 | s.logger.Info("syncing from PDS", "did", userDID) |
| 35 | 35 | |
| 36 | - if err := s.syncCollection(ctx, userDID, "at.glean.subscription", s.reconcileSubscription); err != nil { | |
| 36 | + if err := s.syncCollection(ctx, userDID, CollectionSubscription, s.reconcileSubscription); err != nil { | |
| 37 | 37 | s.logger.Error("sync subscriptions failed", "error", err, "did", userDID) |
| 38 | 38 | } |
| 39 | - if err := s.syncCollection(ctx, userDID, "at.glean.like", s.reconcileLike); err != nil { | |
| 39 | + if err := s.syncCollection(ctx, userDID, CollectionLike, s.reconcileLike); err != nil { | |
| 40 | 40 | s.logger.Error("sync likes failed", "error", err, "did", userDID) |
| 41 | 41 | } |
| 42 | - if err := s.syncCollection(ctx, userDID, "at.glean.annotation", s.reconcileAnnotation); err != nil { | |
| 42 | + if err := s.syncCollection(ctx, userDID, CollectionAnnotation, s.reconcileAnnotation); err != nil { | |
| 43 | 43 | s.logger.Error("sync annotations failed", "error", err, "did", userDID) |
| 44 | 44 | } |
| 45 | + if err := s.syncCollection(ctx, userDID, CollectionMarginNote, s.reconcileMarginNote); err != nil { | |
| 46 | + s.logger.Error("sync margin notes failed", "error", err, "did", userDID) | |
| 47 | + } | |
| 45 | 48 | if err := s.syncFollows(ctx, userDID); err != nil { |
| 46 | 49 | s.logger.Error("sync follows failed", "error", err, "did", userDID) |
| 47 | 50 | } |
| @@ -163,10 +166,49 @@ func (s *Sync) reconcileAnnotation(ctx context.Context, userDID, uri, cid string | ||
| 163 | 166 | return s.db.CreateAnnotation(ctx, a) |
| 164 | 167 | } |
| 165 | 168 | |
| 169 | +func (s *Sync) reconcileMarginNote(ctx context.Context, userDID, uri, cid string, value json.RawMessage) error { | |
| 170 | + var rec MarginNoteRecord | |
| 171 | + if err := json.Unmarshal(value, &rec); err != nil { | |
| 172 | + return err | |
| 173 | + } | |
| 174 | + | |
| 175 | + articleURL, quote, note, tags := rec.ToAnnotation() | |
| 176 | + if articleURL == "" { | |
| 177 | + return nil | |
| 178 | + } | |
| 179 | + | |
| 180 | + var existing []*db.Annotation | |
| 181 | + existing, _ = s.db.ListAnnotations(ctx, "", articleURL, userDID, 100, 0) | |
| 182 | + for _, a := range existing { | |
| 183 | + if a.URI == uri { | |
| 184 | + return nil | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + feedURL := "" | |
| 189 | + if article, err := s.db.GetArticleByURL(ctx, articleURL); err == nil { | |
| 190 | + feedURL = article.FeedURL | |
| 191 | + } | |
| 192 | + | |
| 193 | + t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | |
| 194 | + a := &db.Annotation{ | |
| 195 | + URI: uri, | |
| 196 | + AuthorDID: userDID, | |
| 197 | + FeedURL: feedURL, | |
| 198 | + ArticleURL: articleURL, | |
| 199 | + Quote: db.NullStr(quote), | |
| 200 | + Note: db.NullStr(note), | |
| 201 | + Tags: db.NullStrTags(tags), | |
| 202 | + CreatedAt: db.NullTime(t), | |
| 203 | + CID: db.NullStr(cid), | |
| 204 | + } | |
| 205 | + return s.db.CreateAnnotation(ctx, a) | |
| 206 | +} | |
| 207 | + | |
| 166 | 208 | func (s *Sync) syncFollows(ctx context.Context, userDID string) error { |
| 167 | 209 | activeFollows := make(map[string]db.Follow) |
| 168 | 210 | |
| 169 | - for _, collection := range []string{"app.bsky.graph.follow", "sh.tangled.graph.follow"} { | |
| 211 | + for _, collection := range []string{CollectionBskyFollow, CollectionTangledFollow} { | |
| 170 | 212 | cursor := "" |
| 171 | 213 | for { |
| 172 | 214 | records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) |
| @@ -33,15 +33,18 @@ func NewSync(database *db.DB, client *Client, logger *slog.Logger) *Sync { | |||
| 33 | func (s *Sync) Run(ctx context.Context, userDID string) error { | 33 | func (s *Sync) Run(ctx context.Context, userDID string) error { |
| 34 | s.logger.Info("syncing from PDS", "did", userDID) | 34 | s.logger.Info("syncing from PDS", "did", userDID) |
| 35 | 35 | ||
| 36 | - if err := s.syncCollection(ctx, userDID, "at.glean.subscription", s.reconcileSubscription); err != nil { | 36 | + if err := s.syncCollection(ctx, userDID, CollectionSubscription, s.reconcileSubscription); err != nil { |
| 37 | s.logger.Error("sync subscriptions failed", "error", err, "did", userDID) | 37 | s.logger.Error("sync subscriptions failed", "error", err, "did", userDID) |
| 38 | } | 38 | } |
| 39 | - if err := s.syncCollection(ctx, userDID, "at.glean.like", s.reconcileLike); err != nil { | 39 | + if err := s.syncCollection(ctx, userDID, CollectionLike, s.reconcileLike); err != nil { |
| 40 | s.logger.Error("sync likes failed", "error", err, "did", userDID) | 40 | s.logger.Error("sync likes failed", "error", err, "did", userDID) |
| 41 | } | 41 | } |
| 42 | - if err := s.syncCollection(ctx, userDID, "at.glean.annotation", s.reconcileAnnotation); err != nil { | 42 | + if err := s.syncCollection(ctx, userDID, CollectionAnnotation, s.reconcileAnnotation); err != nil { |
| 43 | s.logger.Error("sync annotations failed", "error", err, "did", userDID) | 43 | s.logger.Error("sync annotations failed", "error", err, "did", userDID) |
| 44 | } | 44 | } |
| 45 | + if err := s.syncCollection(ctx, userDID, CollectionMarginNote, s.reconcileMarginNote); err != nil { | ||
| 46 | + s.logger.Error("sync margin notes failed", "error", err, "did", userDID) | ||
| 47 | + } | ||
| 45 | if err := s.syncFollows(ctx, userDID); err != nil { | 48 | if err := s.syncFollows(ctx, userDID); err != nil { |
| 46 | s.logger.Error("sync follows failed", "error", err, "did", userDID) | 49 | s.logger.Error("sync follows failed", "error", err, "did", userDID) |
| 47 | } | 50 | } |
| @@ -163,10 +166,49 @@ func (s *Sync) reconcileAnnotation(ctx context.Context, userDID, uri, cid string | |||
| 163 | return s.db.CreateAnnotation(ctx, a) | 166 | return s.db.CreateAnnotation(ctx, a) |
| 164 | } | 167 | } |
| 165 | 168 | ||
| 169 | +func (s *Sync) reconcileMarginNote(ctx context.Context, userDID, uri, cid string, value json.RawMessage) error { | ||
| 170 | + var rec MarginNoteRecord | ||
| 171 | + if err := json.Unmarshal(value, &rec); err != nil { | ||
| 172 | + return err | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + articleURL, quote, note, tags := rec.ToAnnotation() | ||
| 176 | + if articleURL == "" { | ||
| 177 | + return nil | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + var existing []*db.Annotation | ||
| 181 | + existing, _ = s.db.ListAnnotations(ctx, "", articleURL, userDID, 100, 0) | ||
| 182 | + for _, a := range existing { | ||
| 183 | + if a.URI == uri { | ||
| 184 | + return nil | ||
| 185 | + } | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + feedURL := "" | ||
| 189 | + if article, err := s.db.GetArticleByURL(ctx, articleURL); err == nil { | ||
| 190 | + feedURL = article.FeedURL | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + t, _ := time.Parse(time.RFC3339, rec.CreatedAt) | ||
| 194 | + a := &db.Annotation{ | ||
| 195 | + URI: uri, | ||
| 196 | + AuthorDID: userDID, | ||
| 197 | + FeedURL: feedURL, | ||
| 198 | + ArticleURL: articleURL, | ||
| 199 | + Quote: db.NullStr(quote), | ||
| 200 | + Note: db.NullStr(note), | ||
| 201 | + Tags: db.NullStrTags(tags), | ||
| 202 | + CreatedAt: db.NullTime(t), | ||
| 203 | + CID: db.NullStr(cid), | ||
| 204 | + } | ||
| 205 | + return s.db.CreateAnnotation(ctx, a) | ||
| 206 | +} | ||
| 207 | + | ||
| 166 | func (s *Sync) syncFollows(ctx context.Context, userDID string) error { | 208 | func (s *Sync) syncFollows(ctx context.Context, userDID string) error { |
| 167 | activeFollows := make(map[string]db.Follow) | 209 | activeFollows := make(map[string]db.Follow) |
| 168 | 210 | ||
| 169 | - for _, collection := range []string{"app.bsky.graph.follow", "sh.tangled.graph.follow"} { | 211 | + for _, collection := range []string{CollectionBskyFollow, CollectionTangledFollow} { |
| 170 | cursor := "" | 212 | cursor := "" |
| 171 | for { | 213 | for { |
| 172 | records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) | 214 | records, next, err := s.client.ListRecords(ctx, userDID, collection, 100, cursor) |
modified
internal/atproto/xrpc.go +6 -3 | @@ -61,7 +61,7 @@ func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) | ||
| 61 | 61 | } |
| 62 | 62 | |
| 63 | 63 | sv := SubscriptionView{ |
| 64 | - URI: fmtATURI(repo, "at.glean.subscription", strconv.Itoa(id)), | |
| 64 | + URI: fmtATURI(repo, CollectionSubscription, strconv.Itoa(id)), | |
| 65 | 65 | Value: SubscriptionRecord{ |
| 66 | 66 | CreatedAt: addedAt.String, |
| 67 | 67 | FeedURL: feedURL, |
| @@ -300,8 +300,11 @@ func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) { | ||
| 300 | 300 | } |
| 301 | 301 | |
| 302 | 302 | func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { |
| 303 | - repo := chi.URLParam(r, "repo") | |
| 304 | - limit := parseIntParam(r, "limit", 10) | |
| 303 | + repo := r.URL.Query().Get("repo") | |
| 304 | + limit := parseIntParam(r, "limit", 20) | |
| 305 | + if limit > 50 { | |
| 306 | + limit = 50 | |
| 307 | + } | |
| 305 | 308 | |
| 306 | 309 | feedRows, err := h.db.QueryContext(r.Context(), ` |
| 307 | 310 | SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score |
| @@ -61,7 +61,7 @@ func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) | |||
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | sv := SubscriptionView{ | 63 | sv := SubscriptionView{ |
| 64 | - URI: fmtATURI(repo, "at.glean.subscription", strconv.Itoa(id)), | 64 | + URI: fmtATURI(repo, CollectionSubscription, strconv.Itoa(id)), |
| 65 | Value: SubscriptionRecord{ | 65 | Value: SubscriptionRecord{ |
| 66 | CreatedAt: addedAt.String, | 66 | CreatedAt: addedAt.String, |
| 67 | FeedURL: feedURL, | 67 | FeedURL: feedURL, |
| @@ -300,8 +300,11 @@ func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) { | |||
| 300 | } | 300 | } |
| 301 | 301 | ||
| 302 | func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { | 302 | func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { |
| 303 | - repo := chi.URLParam(r, "repo") | 303 | + repo := r.URL.Query().Get("repo") |
| 304 | - limit := parseIntParam(r, "limit", 10) | 304 | + limit := parseIntParam(r, "limit", 20) |
| 305 | + if limit > 50 { | ||
| 306 | + limit = 50 | ||
| 307 | + } | ||
| 305 | 308 | ||
| 306 | feedRows, err := h.db.QueryContext(r.Context(), ` | 309 | feedRows, err := h.db.QueryContext(r.Context(), ` |
| 307 | SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score | 310 | SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score |
modified
internal/db/article.go +14 -0 | @@ -265,3 +265,17 @@ func (db *DB) UpdateArticleFullContent(ctx context.Context, id int64, fullConten | ||
| 265 | 265 | `, fullContent, id) |
| 266 | 266 | return err |
| 267 | 267 | } |
| 268 | + | |
| 269 | +func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error) { | |
| 270 | + a := &Article{} | |
| 271 | + err := db.QueryRowContext(ctx, ` | |
| 272 | + SELECT id, feed_url, guid, title, url, author, summary, content, full_content, published, updated, fetched_at | |
| 273 | + FROM articles WHERE url = ? | |
| 274 | + LIMIT 1 | |
| 275 | + `, url).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author, | |
| 276 | + &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt) | |
| 277 | + if err != nil { | |
| 278 | + return nil, err | |
| 279 | + } | |
| 280 | + return a, nil | |
| 281 | +} | |
| @@ -265,3 +265,17 @@ func (db *DB) UpdateArticleFullContent(ctx context.Context, id int64, fullConten | |||
| 265 | `, fullContent, id) | 265 | `, fullContent, id) |
| 266 | return err | 266 | return err |
| 267 | } | 267 | } |
| 268 | + | ||
| 269 | +func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error) { | ||
| 270 | + a := &Article{} | ||
| 271 | + err := db.QueryRowContext(ctx, ` | ||
| 272 | + SELECT id, feed_url, guid, title, url, author, summary, content, full_content, published, updated, fetched_at | ||
| 273 | + FROM articles WHERE url = ? | ||
| 274 | + LIMIT 1 | ||
| 275 | + `, url).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author, | ||
| 276 | + &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt) | ||
| 277 | + if err != nil { | ||
| 278 | + return nil, err | ||
| 279 | + } | ||
| 280 | + return a, nil | ||
| 281 | +} | ||
modified
internal/server/annotations_handler.go +1 -1 | @@ -89,7 +89,7 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | ||
| 89 | 89 | Note: a.Note.String, |
| 90 | 90 | Rating: int(a.Rating.Int64), |
| 91 | 91 | } |
| 92 | - uri, cid, err := client.CreateRecord(r.Context(), user.DID, "at.glean.annotation", record) | |
| 92 | + uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record) | |
| 93 | 93 | if err != nil { |
| 94 | 94 | s.logger.Error("failed to write annotation to PDS", "error", err) |
| 95 | 95 | http.Error(w, "failed to write annotation to PDS: "+err.Error(), http.StatusBadGateway) |
| @@ -89,7 +89,7 @@ func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) | |||
| 89 | Note: a.Note.String, | 89 | Note: a.Note.String, |
| 90 | Rating: int(a.Rating.Int64), | 90 | Rating: int(a.Rating.Int64), |
| 91 | } | 91 | } |
| 92 | - uri, cid, err := client.CreateRecord(r.Context(), user.DID, "at.glean.annotation", record) | 92 | + uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionAnnotation, record) |
| 93 | if err != nil { | 93 | if err != nil { |
| 94 | s.logger.Error("failed to write annotation to PDS", "error", err) | 94 | s.logger.Error("failed to write annotation to PDS", "error", err) |
| 95 | http.Error(w, "failed to write annotation to PDS: "+err.Error(), http.StatusBadGateway) | 95 | http.Error(w, "failed to write annotation to PDS: "+err.Error(), http.StatusBadGateway) |
modified
internal/server/articles_handler.go +1 -1 | @@ -186,7 +186,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | ||
| 186 | 186 | } |
| 187 | 187 | |
| 188 | 188 | if client := s.pdsClientForUser(r); client != nil { |
| 189 | - uri, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.like", likeRecord) | |
| 189 | + uri, _, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionLike, likeRecord) | |
| 190 | 190 | if err != nil { |
| 191 | 191 | s.logger.Error("failed to write like to PDS", "error", err) |
| 192 | 192 | http.Error(w, "failed to write like to PDS: "+err.Error(), http.StatusBadGateway) |
| @@ -186,7 +186,7 @@ func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | |||
| 186 | } | 186 | } |
| 187 | 187 | ||
| 188 | if client := s.pdsClientForUser(r); client != nil { | 188 | if client := s.pdsClientForUser(r); client != nil { |
| 189 | - uri, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.like", likeRecord) | 189 | + uri, _, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionLike, likeRecord) |
| 190 | if err != nil { | 190 | if err != nil { |
| 191 | s.logger.Error("failed to write like to PDS", "error", err) | 191 | s.logger.Error("failed to write like to PDS", "error", err) |
| 192 | http.Error(w, "failed to write like to PDS: "+err.Error(), http.StatusBadGateway) | 192 | http.Error(w, "failed to write like to PDS: "+err.Error(), http.StatusBadGateway) |
modified
internal/server/feeds_handler.go +2 -2 | @@ -108,7 +108,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 108 | 108 | Title: feedTitle, |
| 109 | 109 | Category: category, |
| 110 | 110 | } |
| 111 | - uri, cid, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record) | |
| 111 | + uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record) | |
| 112 | 112 | if err != nil { |
| 113 | 113 | s.logger.Error("failed to write subscription to PDS", "error", err) |
| 114 | 114 | http.Error(w, "failed to write subscription to PDS: "+err.Error(), http.StatusBadGateway) |
| @@ -228,7 +228,7 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | ||
| 228 | 228 | Title: fu.Title, |
| 229 | 229 | Category: fu.Category, |
| 230 | 230 | } |
| 231 | - uri, cid, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record) | |
| 231 | + uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record) | |
| 232 | 232 | if err != nil { |
| 233 | 233 | s.logger.Error("failed to write subscription to PDS", "error", err, "url", fu.URL) |
| 234 | 234 | continue |
| @@ -108,7 +108,7 @@ func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |||
| 108 | Title: feedTitle, | 108 | Title: feedTitle, |
| 109 | Category: category, | 109 | Category: category, |
| 110 | } | 110 | } |
| 111 | - uri, cid, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record) | 111 | + uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record) |
| 112 | if err != nil { | 112 | if err != nil { |
| 113 | s.logger.Error("failed to write subscription to PDS", "error", err) | 113 | s.logger.Error("failed to write subscription to PDS", "error", err) |
| 114 | http.Error(w, "failed to write subscription to PDS: "+err.Error(), http.StatusBadGateway) | 114 | http.Error(w, "failed to write subscription to PDS: "+err.Error(), http.StatusBadGateway) |
| @@ -228,7 +228,7 @@ func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | |||
| 228 | Title: fu.Title, | 228 | Title: fu.Title, |
| 229 | Category: fu.Category, | 229 | Category: fu.Category, |
| 230 | } | 230 | } |
| 231 | - uri, cid, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record) | 231 | + uri, cid, err := client.CreateRecord(r.Context(), user.DID, atproto.CollectionSubscription, record) |
| 232 | if err != nil { | 232 | if err != nil { |
| 233 | s.logger.Error("failed to write subscription to PDS", "error", err, "url", fu.URL) | 233 | s.logger.Error("failed to write subscription to PDS", "error", err, "url", fu.URL) |
| 234 | continue | 234 | continue |
added
lexicons/app/bsky/graph/follow.json +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +{ | |
| 2 | + "defs": { | |
| 3 | + "main": { | |
| 4 | + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", | |
| 5 | + "key": "tid", | |
| 6 | + "record": { | |
| 7 | + "properties": { | |
| 8 | + "createdAt": { | |
| 9 | + "format": "datetime", | |
| 10 | + "type": "string" | |
| 11 | + }, | |
| 12 | + "subject": { | |
| 13 | + "format": "did", | |
| 14 | + "type": "string" | |
| 15 | + }, | |
| 16 | + "via": { | |
| 17 | + "ref": "com.atproto.repo.strongRef", | |
| 18 | + "type": "ref" | |
| 19 | + } | |
| 20 | + }, | |
| 21 | + "required": [ | |
| 22 | + "subject", | |
| 23 | + "createdAt" | |
| 24 | + ], | |
| 25 | + "type": "object" | |
| 26 | + }, | |
| 27 | + "type": "record" | |
| 28 | + } | |
| 29 | + }, | |
| 30 | + "id": "app.bsky.graph.follow", | |
| 31 | + "lexicon": 1 | |
| 32 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +{ | ||
| 2 | + "defs": { | ||
| 3 | + "main": { | ||
| 4 | + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", | ||
| 5 | + "key": "tid", | ||
| 6 | + "record": { | ||
| 7 | + "properties": { | ||
| 8 | + "createdAt": { | ||
| 9 | + "format": "datetime", | ||
| 10 | + "type": "string" | ||
| 11 | + }, | ||
| 12 | + "subject": { | ||
| 13 | + "format": "did", | ||
| 14 | + "type": "string" | ||
| 15 | + }, | ||
| 16 | + "via": { | ||
| 17 | + "ref": "com.atproto.repo.strongRef", | ||
| 18 | + "type": "ref" | ||
| 19 | + } | ||
| 20 | + }, | ||
| 21 | + "required": [ | ||
| 22 | + "subject", | ||
| 23 | + "createdAt" | ||
| 24 | + ], | ||
| 25 | + "type": "object" | ||
| 26 | + }, | ||
| 27 | + "type": "record" | ||
| 28 | + } | ||
| 29 | + }, | ||
| 30 | + "id": "app.bsky.graph.follow", | ||
| 31 | + "lexicon": 1 | ||
| 32 | +} | ||
added
lexicons/at/margin/note.json +243 -0 | new file mode 100644 | ||
| @@ -0,0 +1,243 @@ | ||
| 1 | +{ | |
| 2 | + "defs": { | |
| 3 | + "body": { | |
| 4 | + "description": "Annotation body - the content of the annotation", | |
| 5 | + "properties": { | |
| 6 | + "format": { | |
| 7 | + "default": "text/plain", | |
| 8 | + "description": "MIME type of the body content", | |
| 9 | + "type": "string" | |
| 10 | + }, | |
| 11 | + "uri": { | |
| 12 | + "description": "Reference to external body content", | |
| 13 | + "format": "uri", | |
| 14 | + "type": "string" | |
| 15 | + }, | |
| 16 | + "value": { | |
| 17 | + "description": "Text content of the annotation. For bookmarks, this is the description.", | |
| 18 | + "maxGraphemes": 3000, | |
| 19 | + "maxLength": 10000, | |
| 20 | + "type": "string" | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + "type": "object" | |
| 24 | + }, | |
| 25 | + "generator": { | |
| 26 | + "description": "The client/agent that created this record", | |
| 27 | + "properties": { | |
| 28 | + "homepage": { | |
| 29 | + "format": "uri", | |
| 30 | + "type": "string" | |
| 31 | + }, | |
| 32 | + "id": { | |
| 33 | + "format": "uri", | |
| 34 | + "type": "string" | |
| 35 | + }, | |
| 36 | + "name": { | |
| 37 | + "type": "string" | |
| 38 | + } | |
| 39 | + }, | |
| 40 | + "type": "object" | |
| 41 | + }, | |
| 42 | + "main": { | |
| 43 | + "description": "A W3C-compliant web annotation stored on the AT Protocol", | |
| 44 | + "key": "tid", | |
| 45 | + "record": { | |
| 46 | + "properties": { | |
| 47 | + "body": { | |
| 48 | + "description": "The annotation content (text or reference). For bookmarks, use body.value for the description.", | |
| 49 | + "ref": "#body", | |
| 50 | + "type": "ref" | |
| 51 | + }, | |
| 52 | + "color": { | |
| 53 | + "description": "Highlight color tint", | |
| 54 | + "maxLength": 20, | |
| 55 | + "type": "string" | |
| 56 | + }, | |
| 57 | + "createdAt": { | |
| 58 | + "format": "datetime", | |
| 59 | + "type": "string" | |
| 60 | + }, | |
| 61 | + "facets": { | |
| 62 | + "description": "Rich text facets (e.g. mentions, links)", | |
| 63 | + "items": { | |
| 64 | + "ref": "app.bsky.richtext.facet", | |
| 65 | + "type": "ref" | |
| 66 | + }, | |
| 67 | + "type": "array" | |
| 68 | + }, | |
| 69 | + "generator": { | |
| 70 | + "description": "The client/agent that created this record", | |
| 71 | + "ref": "#generator", | |
| 72 | + "type": "ref" | |
| 73 | + }, | |
| 74 | + "labels": { | |
| 75 | + "description": "Self-applied content labels for this annotation", | |
| 76 | + "ref": "com.atproto.label.defs#selfLabels", | |
| 77 | + "type": "ref" | |
| 78 | + }, | |
| 79 | + "modifiedAt": { | |
| 80 | + "description": "When this record was last modified", | |
| 81 | + "format": "datetime", | |
| 82 | + "type": "string" | |
| 83 | + }, | |
| 84 | + "motivation": { | |
| 85 | + "description": "W3C motivation for the annotation", | |
| 86 | + "knownValues": [ | |
| 87 | + "commenting", | |
| 88 | + "highlighting", | |
| 89 | + "bookmarking", | |
| 90 | + "tagging", | |
| 91 | + "describing", | |
| 92 | + "linking", | |
| 93 | + "replying", | |
| 94 | + "editing", | |
| 95 | + "questioning", | |
| 96 | + "assessing" | |
| 97 | + ], | |
| 98 | + "type": "string" | |
| 99 | + }, | |
| 100 | + "rights": { | |
| 101 | + "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)", | |
| 102 | + "format": "uri", | |
| 103 | + "type": "string" | |
| 104 | + }, | |
| 105 | + "tags": { | |
| 106 | + "description": "Tags for categorization", | |
| 107 | + "items": { | |
| 108 | + "maxGraphemes": 32, | |
| 109 | + "maxLength": 64, | |
| 110 | + "type": "string" | |
| 111 | + }, | |
| 112 | + "maxLength": 10, | |
| 113 | + "type": "array" | |
| 114 | + }, | |
| 115 | + "target": { | |
| 116 | + "description": "The resource being annotated with optional selector", | |
| 117 | + "ref": "#target", | |
| 118 | + "type": "ref" | |
| 119 | + } | |
| 120 | + }, | |
| 121 | + "required": [ | |
| 122 | + "motivation", | |
| 123 | + "target", | |
| 124 | + "createdAt" | |
| 125 | + ], | |
| 126 | + "type": "object" | |
| 127 | + }, | |
| 128 | + "type": "record" | |
| 129 | + }, | |
| 130 | + "selector": { | |
| 131 | + "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.", | |
| 132 | + "properties": { | |
| 133 | + "conformsTo": { | |
| 134 | + "description": "FragmentSelector: URI of the specification the fragment conforms to", | |
| 135 | + "format": "uri", | |
| 136 | + "type": "string" | |
| 137 | + }, | |
| 138 | + "end": { | |
| 139 | + "description": "TextPositionSelector: end character offset (exclusive)", | |
| 140 | + "minimum": 0, | |
| 141 | + "type": "integer" | |
| 142 | + }, | |
| 143 | + "exact": { | |
| 144 | + "description": "TextQuoteSelector: the exact text being selected", | |
| 145 | + "maxGraphemes": 1500, | |
| 146 | + "maxLength": 5000, | |
| 147 | + "type": "string" | |
| 148 | + }, | |
| 149 | + "prefix": { | |
| 150 | + "description": "TextQuoteSelector: text immediately before the selection, for disambiguation", | |
| 151 | + "maxGraphemes": 150, | |
| 152 | + "maxLength": 500, | |
| 153 | + "type": "string" | |
| 154 | + }, | |
| 155 | + "start": { | |
| 156 | + "description": "TextPositionSelector: start character offset (inclusive)", | |
| 157 | + "minimum": 0, | |
| 158 | + "type": "integer" | |
| 159 | + }, | |
| 160 | + "suffix": { | |
| 161 | + "description": "TextQuoteSelector: text immediately after the selection, for disambiguation", | |
| 162 | + "maxGraphemes": 150, | |
| 163 | + "maxLength": 500, | |
| 164 | + "type": "string" | |
| 165 | + }, | |
| 166 | + "type": { | |
| 167 | + "description": "W3C selector type identifier", | |
| 168 | + "knownValues": [ | |
| 169 | + "TextQuoteSelector", | |
| 170 | + "TextPositionSelector", | |
| 171 | + "CssSelector", | |
| 172 | + "XPathSelector", | |
| 173 | + "FragmentSelector", | |
| 174 | + "RangeSelector" | |
| 175 | + ], | |
| 176 | + "type": "string" | |
| 177 | + }, | |
| 178 | + "value": { | |
| 179 | + "description": "CssSelector/XPathSelector/FragmentSelector: the selector expression or fragment value", | |
| 180 | + "maxLength": 2000, | |
| 181 | + "type": "string" | |
| 182 | + } | |
| 183 | + }, | |
| 184 | + "required": [ | |
| 185 | + "type" | |
| 186 | + ], | |
| 187 | + "type": "object" | |
| 188 | + }, | |
| 189 | + "target": { | |
| 190 | + "description": "W3C SpecificResource - the target with optional selector", | |
| 191 | + "properties": { | |
| 192 | + "selector": { | |
| 193 | + "description": "W3C Selector to identify the annotated segment. Uses W3C 'type' field (not ATProto $type) per the Web Annotation Data Model.", | |
| 194 | + "ref": "#selector", | |
| 195 | + "type": "ref" | |
| 196 | + }, | |
| 197 | + "source": { | |
| 198 | + "description": "The URL being annotated", | |
| 199 | + "format": "uri", | |
| 200 | + "type": "string" | |
| 201 | + }, | |
| 202 | + "sourceHash": { | |
| 203 | + "description": "SHA256 hash of normalized URL for indexing", | |
| 204 | + "type": "string" | |
| 205 | + }, | |
| 206 | + "state": { | |
| 207 | + "description": "State of the resource at annotation time", | |
| 208 | + "ref": "#timeState", | |
| 209 | + "type": "ref" | |
| 210 | + }, | |
| 211 | + "title": { | |
| 212 | + "description": "Page title at time of annotation", | |
| 213 | + "maxLength": 500, | |
| 214 | + "type": "string" | |
| 215 | + } | |
| 216 | + }, | |
| 217 | + "required": [ | |
| 218 | + "source" | |
| 219 | + ], | |
| 220 | + "type": "object" | |
| 221 | + }, | |
| 222 | + "timeState": { | |
| 223 | + "description": "W3C TimeState - record when content was captured", | |
| 224 | + "properties": { | |
| 225 | + "cached": { | |
| 226 | + "description": "URL to cached/archived version", | |
| 227 | + "format": "uri", | |
| 228 | + "type": "string" | |
| 229 | + }, | |
| 230 | + "sourceDate": { | |
| 231 | + "description": "When the source was accessed", | |
| 232 | + "format": "datetime", | |
| 233 | + "type": "string" | |
| 234 | + } | |
| 235 | + }, | |
| 236 | + "type": "object" | |
| 237 | + } | |
| 238 | + }, | |
| 239 | + "description": "W3C Web Annotation Data Model compliant unified note record for ATProto", | |
| 240 | + "id": "at.margin.note", | |
| 241 | + "lexicon": 1, | |
| 242 | + "revision": 3 | |
| 243 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,243 @@ | |||
| 1 | +{ | ||
| 2 | + "defs": { | ||
| 3 | + "body": { | ||
| 4 | + "description": "Annotation body - the content of the annotation", | ||
| 5 | + "properties": { | ||
| 6 | + "format": { | ||
| 7 | + "default": "text/plain", | ||
| 8 | + "description": "MIME type of the body content", | ||
| 9 | + "type": "string" | ||
| 10 | + }, | ||
| 11 | + "uri": { | ||
| 12 | + "description": "Reference to external body content", | ||
| 13 | + "format": "uri", | ||
| 14 | + "type": "string" | ||
| 15 | + }, | ||
| 16 | + "value": { | ||
| 17 | + "description": "Text content of the annotation. For bookmarks, this is the description.", | ||
| 18 | + "maxGraphemes": 3000, | ||
| 19 | + "maxLength": 10000, | ||
| 20 | + "type": "string" | ||
| 21 | + } | ||
| 22 | + }, | ||
| 23 | + "type": "object" | ||
| 24 | + }, | ||
| 25 | + "generator": { | ||
| 26 | + "description": "The client/agent that created this record", | ||
| 27 | + "properties": { | ||
| 28 | + "homepage": { | ||
| 29 | + "format": "uri", | ||
| 30 | + "type": "string" | ||
| 31 | + }, | ||
| 32 | + "id": { | ||
| 33 | + "format": "uri", | ||
| 34 | + "type": "string" | ||
| 35 | + }, | ||
| 36 | + "name": { | ||
| 37 | + "type": "string" | ||
| 38 | + } | ||
| 39 | + }, | ||
| 40 | + "type": "object" | ||
| 41 | + }, | ||
| 42 | + "main": { | ||
| 43 | + "description": "A W3C-compliant web annotation stored on the AT Protocol", | ||
| 44 | + "key": "tid", | ||
| 45 | + "record": { | ||
| 46 | + "properties": { | ||
| 47 | + "body": { | ||
| 48 | + "description": "The annotation content (text or reference). For bookmarks, use body.value for the description.", | ||
| 49 | + "ref": "#body", | ||
| 50 | + "type": "ref" | ||
| 51 | + }, | ||
| 52 | + "color": { | ||
| 53 | + "description": "Highlight color tint", | ||
| 54 | + "maxLength": 20, | ||
| 55 | + "type": "string" | ||
| 56 | + }, | ||
| 57 | + "createdAt": { | ||
| 58 | + "format": "datetime", | ||
| 59 | + "type": "string" | ||
| 60 | + }, | ||
| 61 | + "facets": { | ||
| 62 | + "description": "Rich text facets (e.g. mentions, links)", | ||
| 63 | + "items": { | ||
| 64 | + "ref": "app.bsky.richtext.facet", | ||
| 65 | + "type": "ref" | ||
| 66 | + }, | ||
| 67 | + "type": "array" | ||
| 68 | + }, | ||
| 69 | + "generator": { | ||
| 70 | + "description": "The client/agent that created this record", | ||
| 71 | + "ref": "#generator", | ||
| 72 | + "type": "ref" | ||
| 73 | + }, | ||
| 74 | + "labels": { | ||
| 75 | + "description": "Self-applied content labels for this annotation", | ||
| 76 | + "ref": "com.atproto.label.defs#selfLabels", | ||
| 77 | + "type": "ref" | ||
| 78 | + }, | ||
| 79 | + "modifiedAt": { | ||
| 80 | + "description": "When this record was last modified", | ||
| 81 | + "format": "datetime", | ||
| 82 | + "type": "string" | ||
| 83 | + }, | ||
| 84 | + "motivation": { | ||
| 85 | + "description": "W3C motivation for the annotation", | ||
| 86 | + "knownValues": [ | ||
| 87 | + "commenting", | ||
| 88 | + "highlighting", | ||
| 89 | + "bookmarking", | ||
| 90 | + "tagging", | ||
| 91 | + "describing", | ||
| 92 | + "linking", | ||
| 93 | + "replying", | ||
| 94 | + "editing", | ||
| 95 | + "questioning", | ||
| 96 | + "assessing" | ||
| 97 | + ], | ||
| 98 | + "type": "string" | ||
| 99 | + }, | ||
| 100 | + "rights": { | ||
| 101 | + "description": "License URI (e.g., https://creativecommons.org/licenses/by/4.0/)", | ||
| 102 | + "format": "uri", | ||
| 103 | + "type": "string" | ||
| 104 | + }, | ||
| 105 | + "tags": { | ||
| 106 | + "description": "Tags for categorization", | ||
| 107 | + "items": { | ||
| 108 | + "maxGraphemes": 32, | ||
| 109 | + "maxLength": 64, | ||
| 110 | + "type": "string" | ||
| 111 | + }, | ||
| 112 | + "maxLength": 10, | ||
| 113 | + "type": "array" | ||
| 114 | + }, | ||
| 115 | + "target": { | ||
| 116 | + "description": "The resource being annotated with optional selector", | ||
| 117 | + "ref": "#target", | ||
| 118 | + "type": "ref" | ||
| 119 | + } | ||
| 120 | + }, | ||
| 121 | + "required": [ | ||
| 122 | + "motivation", | ||
| 123 | + "target", | ||
| 124 | + "createdAt" | ||
| 125 | + ], | ||
| 126 | + "type": "object" | ||
| 127 | + }, | ||
| 128 | + "type": "record" | ||
| 129 | + }, | ||
| 130 | + "selector": { | ||
| 131 | + "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.", | ||
| 132 | + "properties": { | ||
| 133 | + "conformsTo": { | ||
| 134 | + "description": "FragmentSelector: URI of the specification the fragment conforms to", | ||
| 135 | + "format": "uri", | ||
| 136 | + "type": "string" | ||
| 137 | + }, | ||
| 138 | + "end": { | ||
| 139 | + "description": "TextPositionSelector: end character offset (exclusive)", | ||
| 140 | + "minimum": 0, | ||
| 141 | + "type": "integer" | ||
| 142 | + }, | ||
| 143 | + "exact": { | ||
| 144 | + "description": "TextQuoteSelector: the exact text being selected", | ||
| 145 | + "maxGraphemes": 1500, | ||
| 146 | + "maxLength": 5000, | ||
| 147 | + "type": "string" | ||
| 148 | + }, | ||
| 149 | + "prefix": { | ||
| 150 | + "description": "TextQuoteSelector: text immediately before the selection, for disambiguation", | ||
| 151 | + "maxGraphemes": 150, | ||
| 152 | + "maxLength": 500, | ||
| 153 | + "type": "string" | ||
| 154 | + }, | ||
| 155 | + "start": { | ||
| 156 | + "description": "TextPositionSelector: start character offset (inclusive)", | ||
| 157 | + "minimum": 0, | ||
| 158 | + "type": "integer" | ||
| 159 | + }, | ||
| 160 | + "suffix": { | ||
| 161 | + "description": "TextQuoteSelector: text immediately after the selection, for disambiguation", | ||
| 162 | + "maxGraphemes": 150, | ||
| 163 | + "maxLength": 500, | ||
| 164 | + "type": "string" | ||
| 165 | + }, | ||
| 166 | + "type": { | ||
| 167 | + "description": "W3C selector type identifier", | ||
| 168 | + "knownValues": [ | ||
| 169 | + "TextQuoteSelector", | ||
| 170 | + "TextPositionSelector", | ||
| 171 | + "CssSelector", | ||
| 172 | + "XPathSelector", | ||
| 173 | + "FragmentSelector", | ||
| 174 | + "RangeSelector" | ||
| 175 | + ], | ||
| 176 | + "type": "string" | ||
| 177 | + }, | ||
| 178 | + "value": { | ||
| 179 | + "description": "CssSelector/XPathSelector/FragmentSelector: the selector expression or fragment value", | ||
| 180 | + "maxLength": 2000, | ||
| 181 | + "type": "string" | ||
| 182 | + } | ||
| 183 | + }, | ||
| 184 | + "required": [ | ||
| 185 | + "type" | ||
| 186 | + ], | ||
| 187 | + "type": "object" | ||
| 188 | + }, | ||
| 189 | + "target": { | ||
| 190 | + "description": "W3C SpecificResource - the target with optional selector", | ||
| 191 | + "properties": { | ||
| 192 | + "selector": { | ||
| 193 | + "description": "W3C Selector to identify the annotated segment. Uses W3C 'type' field (not ATProto $type) per the Web Annotation Data Model.", | ||
| 194 | + "ref": "#selector", | ||
| 195 | + "type": "ref" | ||
| 196 | + }, | ||
| 197 | + "source": { | ||
| 198 | + "description": "The URL being annotated", | ||
| 199 | + "format": "uri", | ||
| 200 | + "type": "string" | ||
| 201 | + }, | ||
| 202 | + "sourceHash": { | ||
| 203 | + "description": "SHA256 hash of normalized URL for indexing", | ||
| 204 | + "type": "string" | ||
| 205 | + }, | ||
| 206 | + "state": { | ||
| 207 | + "description": "State of the resource at annotation time", | ||
| 208 | + "ref": "#timeState", | ||
| 209 | + "type": "ref" | ||
| 210 | + }, | ||
| 211 | + "title": { | ||
| 212 | + "description": "Page title at time of annotation", | ||
| 213 | + "maxLength": 500, | ||
| 214 | + "type": "string" | ||
| 215 | + } | ||
| 216 | + }, | ||
| 217 | + "required": [ | ||
| 218 | + "source" | ||
| 219 | + ], | ||
| 220 | + "type": "object" | ||
| 221 | + }, | ||
| 222 | + "timeState": { | ||
| 223 | + "description": "W3C TimeState - record when content was captured", | ||
| 224 | + "properties": { | ||
| 225 | + "cached": { | ||
| 226 | + "description": "URL to cached/archived version", | ||
| 227 | + "format": "uri", | ||
| 228 | + "type": "string" | ||
| 229 | + }, | ||
| 230 | + "sourceDate": { | ||
| 231 | + "description": "When the source was accessed", | ||
| 232 | + "format": "datetime", | ||
| 233 | + "type": "string" | ||
| 234 | + } | ||
| 235 | + }, | ||
| 236 | + "type": "object" | ||
| 237 | + } | ||
| 238 | + }, | ||
| 239 | + "description": "W3C Web Annotation Data Model compliant unified note record for ATProto", | ||
| 240 | + "id": "at.margin.note", | ||
| 241 | + "lexicon": 1, | ||
| 242 | + "revision": 3 | ||
| 243 | +} | ||