Improve language selector flow and fix daily digest dashboard issueUnverified
422e1d0 parent: 867e4ef modified
docs/specs.md +17 -9 | @@ -408,6 +408,7 @@ The `/articles` page is the main reading view: | ||
| 408 | 408 | - **Share**: Share to Bluesky |
| 409 | 409 | - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) |
| 410 | 410 | - **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings. |
| 411 | +- **Daily digest**: An AI-generated summary of unread articles, shown on the dashboard when enabled in profile settings. The LLM receives the titles and summaries of up to 50 unread articles and produces a grouped overview with linked references. A "Read" button marks all digest articles as read. Digests are cached per user for 24 hours and generated via singleflight to avoid duplicate LLM calls. | |
| 411 | 412 | |
| 412 | 413 | ### 4.7 Feed Discovery from Content |
| 413 | 414 | |
| @@ -745,7 +746,7 @@ Users can dismiss recommendations they don't want to see again: | ||
| 745 | 746 | - Dismissed items are excluded from all future recommendation queries |
| 746 | 747 | - Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed |
| 747 | 748 | |
| 748 | -Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it. | |
| 749 | +Recommended articles use the same card template as regular articles, with an additional "Hide" button that dismisses the recommendation. Dismissing only removes that specific article from recommendations — it does not affect the user's likes or signal weights, and similar articles may still be recommended. | |
| 749 | 750 | |
| 750 | 751 | ### 7.6 Auto-Tuned Signal Weights |
| 751 | 752 | |
| @@ -888,11 +889,11 @@ The embedder uses the official `github.com/openai/openai-go` SDK with `option.Wi | ||
| 888 | 889 | |
| 889 | 890 | ### 7.14 LLM Client (optional) |
| 890 | 891 | |
| 891 | -When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint. | |
| 892 | +When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification and summarization tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint. | |
| 892 | 893 | |
| 893 | -**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to English (`en`). | |
| 894 | +**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to `'unknown'` rather than a specific language, ensuring unclassifiable articles aren't miscategorized. | |
| 894 | 895 | |
| 895 | -Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. | |
| 896 | +Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. The daily digest feature also requires an LLM to generate summaries — without it, the digest is unavailable. | |
| 896 | 897 | |
| 897 | 898 | vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536): |
| 898 | 899 | |
| @@ -954,8 +955,11 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | ||
| 954 | 955 | | `/library/{id}/delete` | POST | Delete an annotation | |
| 955 | 956 | | `/stats` | GET | Application metrics and performance data (Prometheus, public) | |
| 956 | 957 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | |
| 957 | -| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | | |
| 958 | -| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) | | |
| 958 | +| `/settings/languages/{code}` | POST | Toggle a preferred recommendation language (htmx, requires auth) | | |
| 959 | +| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) | | |
| 960 | +| `/settings/digest-enabled` | POST | Toggle daily digest setting (htmx, requires auth) | | |
| 961 | +| `/digest` | GET | Daily digest fragment (LLM summary of unread articles, htmx partial) | | |
| 962 | +| `/digest/mark-read` | POST | Mark digest articles as read | | |
| 959 | 963 | | `/auth/login` | GET | Login page | |
| 960 | 964 | | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | |
| 961 | 965 | | `/auth/resolve` | GET | Resolve handle to DID | |
| @@ -1004,7 +1008,8 @@ glean/ | ||
| 1004 | 1008 | │ │ ├── social.go # Like, annotation queries |
| 1005 | 1009 | │ │ ├── follow.go # Follow queries |
| 1006 | 1010 | │ │ ├── oauth_store.go # OAuth session storage |
| 1007 | -│ │ └── store.go # FeedStore adapter for scheduler | |
| 1011 | +│ │ ├── user_settings.go # User settings queries | |
| 1012 | +│ │ ├── store.go # FeedStore adapter for scheduler | |
| 1008 | 1013 | │ ├── feed/ |
| 1009 | 1014 | │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser |
| 1010 | 1015 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher |
| @@ -1016,7 +1021,7 @@ glean/ | ||
| 1016 | 1021 | │ │ └── scraper.go # Full article content scraper |
| 1017 | 1022 | │ ├── metrics/ |
| 1018 | 1023 | │ │ └── metrics.go # Prometheus metrics definitions |
| 1019 | -│ ├── ai/ | |
| 1024 | +│ ├── ml/ | |
| 1020 | 1025 | │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers |
| 1021 | 1026 | │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation |
| 1022 | 1027 | │ ├── cluster/ |
| @@ -1040,7 +1045,9 @@ glean/ | ||
| 1040 | 1045 | │ │ ├── stats_handler.go # Stats handler (Prometheus metrics display) |
| 1041 | 1046 | │ │ ├── index_handler.go # Landing page handler |
| 1042 | 1047 | │ │ ├── profile_handler.go # Public profile handler |
| 1043 | -│ │ ├── settings_handler.go # User settings (language preferences) | |
| 1048 | +│ │ ├── settings_handler.go # User settings (language preferences, digest toggle) | |
| 1049 | +│ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read) | |
| 1050 | +│ │ ├── recs_handler.go # Recommendation dismiss handlers | |
| 1044 | 1051 | │ │ ├── terms_handler.go # Terms of service handler |
| 1045 | 1052 | │ │ ├── pagination.go # Pagination helpers |
| 1046 | 1053 | │ │ ├── middleware.go # Auth, logging, CSRF middleware |
| @@ -1189,3 +1196,4 @@ All PDS records are public. There is no notion of private data on the AT Protoco | ||
| 1189 | 1196 | ## 13. Future Considerations |
| 1190 | 1197 | |
| 1191 | 1198 | - **Email digest**: Periodic email with top articles from subscribed feeds |
| 1199 | +- **Digest personalization**: Feed the user's liked topics and reading patterns into the digest prompt for more targeted summaries | |
| @@ -408,6 +408,7 @@ The `/articles` page is the main reading view: | |||
| 408 | - **Share**: Share to Bluesky | 408 | - **Share**: Share to Bluesky |
| 409 | - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) | 409 | - **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) |
| 410 | - **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings. | 410 | - **Expanded view**: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via `IntersectionObserver` after being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings. |
| 411 | +- **Daily digest**: An AI-generated summary of unread articles, shown on the dashboard when enabled in profile settings. The LLM receives the titles and summaries of up to 50 unread articles and produces a grouped overview with linked references. A "Read" button marks all digest articles as read. Digests are cached per user for 24 hours and generated via singleflight to avoid duplicate LLM calls. | ||
| 411 | 412 | ||
| 412 | ### 4.7 Feed Discovery from Content | 413 | ### 4.7 Feed Discovery from Content |
| 413 | 414 | ||
| @@ -745,7 +746,7 @@ Users can dismiss recommendations they don't want to see again: | |||
| 745 | - Dismissed items are excluded from all future recommendation queries | 746 | - Dismissed items are excluded from all future recommendation queries |
| 746 | - Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed | 747 | - Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed |
| 747 | 748 | ||
| 748 | -Impression tracking (`recommendation_impressions`) records how many times each recommendation was shown and whether the user acted on it. | 749 | +Recommended articles use the same card template as regular articles, with an additional "Hide" button that dismisses the recommendation. Dismissing only removes that specific article from recommendations — it does not affect the user's likes or signal weights, and similar articles may still be recommended. |
| 749 | 750 | ||
| 750 | ### 7.6 Auto-Tuned Signal Weights | 751 | ### 7.6 Auto-Tuned Signal Weights |
| 751 | 752 | ||
| @@ -888,11 +889,11 @@ The embedder uses the official `github.com/openai/openai-go` SDK with `option.Wi | |||
| 888 | 889 | ||
| 889 | ### 7.14 LLM Client (optional) | 890 | ### 7.14 LLM Client (optional) |
| 890 | 891 | ||
| 891 | -When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint. | 892 | +When `GLEAN_LLM_BASE_URL` is configured, an LLM client is available for text classification and summarization tasks. It uses the same `github.com/openai/openai-go` SDK pointed at any OpenAI-compatible `/v1/chat/completions` endpoint. |
| 892 | 893 | ||
| 893 | -**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to English (`en`). | 894 | +**Language detection**: The cron job calls `DetectLanguages` in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to `articles.language`. Articles that are already classified (non-empty `language`) are skipped. An empty response from the LLM defaults to `'unknown'` rather than a specific language, ensuring unclassifiable articles aren't miscategorized. |
| 894 | 895 | ||
| 895 | -Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. | 896 | +Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. The daily digest feature also requires an LLM to generate summaries — without it, the digest is unavailable. |
| 896 | 897 | ||
| 897 | vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536): | 898 | vec0 tables are created dynamically at startup with the configured dimension (`GLEAN_EMBED_DIMENSION`, default 1536): |
| 898 | 899 | ||
| @@ -954,8 +955,11 @@ The server renders HTML fragments that htmx swaps into the page. No JSON API nee | |||
| 954 | | `/library/{id}/delete` | POST | Delete an annotation | | 955 | | `/library/{id}/delete` | POST | Delete an annotation | |
| 955 | | `/stats` | GET | Application metrics and performance data (Prometheus, public) | | 956 | | `/stats` | GET | Application metrics and performance data (Prometheus, public) | |
| 956 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | 957 | | `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | |
| 957 | -| `/settings/languages` | POST | Save preferred recommendation languages (htmx, requires auth) | | 958 | +| `/settings/languages/{code}` | POST | Toggle a preferred recommendation language (htmx, requires auth) | |
| 958 | -| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) | | 959 | +| `/settings/expanded-view` | POST | Toggle expanded article view setting (htmx, requires auth) | |
| 960 | +| `/settings/digest-enabled` | POST | Toggle daily digest setting (htmx, requires auth) | | ||
| 961 | +| `/digest` | GET | Daily digest fragment (LLM summary of unread articles, htmx partial) | | ||
| 962 | +| `/digest/mark-read` | POST | Mark digest articles as read | | ||
| 959 | | `/auth/login` | GET | Login page | | 963 | | `/auth/login` | GET | Login page | |
| 960 | | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | | 964 | | `/auth/register` | GET | Register with Eurosky (OAuth flow with hardcoded PDS) | |
| 961 | | `/auth/resolve` | GET | Resolve handle to DID | | 965 | | `/auth/resolve` | GET | Resolve handle to DID | |
| @@ -1004,7 +1008,8 @@ glean/ | |||
| 1004 | │ │ ├── social.go # Like, annotation queries | 1008 | │ │ ├── social.go # Like, annotation queries |
| 1005 | │ │ ├── follow.go # Follow queries | 1009 | │ │ ├── follow.go # Follow queries |
| 1006 | │ │ ├── oauth_store.go # OAuth session storage | 1010 | │ │ ├── oauth_store.go # OAuth session storage |
| 1007 | -│ │ └── store.go # FeedStore adapter for scheduler | 1011 | +│ │ ├── user_settings.go # User settings queries |
| 1012 | +│ │ ├── store.go # FeedStore adapter for scheduler | ||
| 1008 | │ ├── feed/ | 1013 | │ ├── feed/ |
| 1009 | │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser | 1014 | │ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser |
| 1010 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher | 1015 | │ │ ├── fetcher.go # Scheduler with dedup + Fetcher |
| @@ -1016,7 +1021,7 @@ glean/ | |||
| 1016 | │ │ └── scraper.go # Full article content scraper | 1021 | │ │ └── scraper.go # Full article content scraper |
| 1017 | │ ├── metrics/ | 1022 | │ ├── metrics/ |
| 1018 | │ │ └── metrics.go # Prometheus metrics definitions | 1023 | │ │ └── metrics.go # Prometheus metrics definitions |
| 1019 | -│ ├── ai/ | 1024 | +│ ├── ml/ |
| 1020 | │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers | 1025 | │ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers |
| 1021 | │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation | 1026 | │ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation |
| 1022 | │ ├── cluster/ | 1027 | │ ├── cluster/ |
| @@ -1040,7 +1045,9 @@ glean/ | |||
| 1040 | │ │ ├── stats_handler.go # Stats handler (Prometheus metrics display) | 1045 | │ │ ├── stats_handler.go # Stats handler (Prometheus metrics display) |
| 1041 | │ │ ├── index_handler.go # Landing page handler | 1046 | │ │ ├── index_handler.go # Landing page handler |
| 1042 | │ │ ├── profile_handler.go # Public profile handler | 1047 | │ │ ├── profile_handler.go # Public profile handler |
| 1043 | -│ │ ├── settings_handler.go # User settings (language preferences) | 1048 | +│ │ ├── settings_handler.go # User settings (language preferences, digest toggle) |
| 1049 | +│ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read) | ||
| 1050 | +│ │ ├── recs_handler.go # Recommendation dismiss handlers | ||
| 1044 | │ │ ├── terms_handler.go # Terms of service handler | 1051 | │ │ ├── terms_handler.go # Terms of service handler |
| 1045 | │ │ ├── pagination.go # Pagination helpers | 1052 | │ │ ├── pagination.go # Pagination helpers |
| 1046 | │ │ ├── middleware.go # Auth, logging, CSRF middleware | 1053 | │ │ ├── middleware.go # Auth, logging, CSRF middleware |
| @@ -1189,3 +1196,4 @@ All PDS records are public. There is no notion of private data on the AT Protoco | |||
| 1189 | ## 13. Future Considerations | 1196 | ## 13. Future Considerations |
| 1190 | 1197 | ||
| 1191 | - **Email digest**: Periodic email with top articles from subscribed feeds | 1198 | - **Email digest**: Periodic email with top articles from subscribed feeds |
| 1199 | +- **Digest personalization**: Feed the user's liked topics and reading patterns into the digest prompt for more targeted summaries | ||
modified
internal/server/server.go +1 -1 | @@ -221,7 +221,7 @@ func (s *Server) setupRoutes() { | ||
| 221 | 221 | |
| 222 | 222 | s.router.Route("/settings", func(r chi.Router) { |
| 223 | 223 | r.Use(s.requireAuth) |
| 224 | - r.Post("/languages", s.handleUpdateLanguages) | |
| 224 | + r.Post("/languages/{code}", s.handleToggleLanguage) | |
| 225 | 225 | r.Post("/expanded-view", s.handleToggleExpandedView) |
| 226 | 226 | r.Post("/digest-enabled", s.handleToggleDigestEnabled) |
| 227 | 227 | }) |
| @@ -221,7 +221,7 @@ func (s *Server) setupRoutes() { | |||
| 221 | 221 | ||
| 222 | s.router.Route("/settings", func(r chi.Router) { | 222 | s.router.Route("/settings", func(r chi.Router) { |
| 223 | r.Use(s.requireAuth) | 223 | r.Use(s.requireAuth) |
| 224 | - r.Post("/languages", s.handleUpdateLanguages) | 224 | + r.Post("/languages/{code}", s.handleToggleLanguage) |
| 225 | r.Post("/expanded-view", s.handleToggleExpandedView) | 225 | r.Post("/expanded-view", s.handleToggleExpandedView) |
| 226 | r.Post("/digest-enabled", s.handleToggleDigestEnabled) | 226 | r.Post("/digest-enabled", s.handleToggleDigestEnabled) |
| 227 | }) | 227 | }) |
modified
internal/server/settings_handler.go +26 -10 | @@ -2,16 +2,16 @@ package server | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | - "strings" | |
| 6 | 5 | |
| 7 | 6 | "pkg.rbrt.fr/glean/internal/ml" |
| 8 | 7 | ) |
| 9 | 8 | |
| 10 | -func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) { | |
| 9 | +func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) { | |
| 11 | 10 | user := currentUser(r) |
| 12 | 11 | |
| 13 | - if err := r.ParseForm(); err != nil { | |
| 14 | - http.Error(w, err.Error(), http.StatusBadRequest) | |
| 12 | + lang := r.PathValue("code") | |
| 13 | + if lang == "" { | |
| 14 | + http.Error(w, "missing language code", http.StatusBadRequest) | |
| 15 | 15 | return |
| 16 | 16 | } |
| 17 | 17 | |
| @@ -19,16 +19,32 @@ func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) { | ||
| 19 | 19 | for _, known := range ml.KnownLanguages() { |
| 20 | 20 | valid[known.Code] = true |
| 21 | 21 | } |
| 22 | + if !valid[lang] { | |
| 23 | + http.Error(w, "unknown language", http.StatusBadRequest) | |
| 24 | + return | |
| 25 | + } | |
| 22 | 26 | |
| 23 | - var filtered []string | |
| 24 | - for _, l := range r.Form["languages"] { | |
| 25 | - l = strings.TrimSpace(l) | |
| 26 | - if valid[l] { | |
| 27 | - filtered = append(filtered, l) | |
| 27 | + current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID) | |
| 28 | + if err != nil { | |
| 29 | + s.logger.Error("failed to get languages", "error", err) | |
| 30 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 31 | + return | |
| 32 | + } | |
| 33 | + | |
| 34 | + var updated []string | |
| 35 | + found := false | |
| 36 | + for _, l := range current { | |
| 37 | + if l == lang { | |
| 38 | + found = true | |
| 39 | + continue | |
| 28 | 40 | } |
| 41 | + updated = append(updated, l) | |
| 42 | + } | |
| 43 | + if !found { | |
| 44 | + updated = append(updated, lang) | |
| 29 | 45 | } |
| 30 | 46 | |
| 31 | - if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, filtered); err != nil { | |
| 47 | + if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil { | |
| 32 | 48 | s.logger.Error("failed to update languages", "error", err) |
| 33 | 49 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 34 | 50 | return |
| @@ -2,16 +2,16 @@ package server | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "net/http" | 4 | "net/http" |
| 5 | - "strings" | ||
| 6 | 5 | ||
| 7 | "pkg.rbrt.fr/glean/internal/ml" | 6 | "pkg.rbrt.fr/glean/internal/ml" |
| 8 | ) | 7 | ) |
| 9 | 8 | ||
| 10 | -func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) { | 9 | +func (s *Server) handleToggleLanguage(w http.ResponseWriter, r *http.Request) { |
| 11 | user := currentUser(r) | 10 | user := currentUser(r) |
| 12 | 11 | ||
| 13 | - if err := r.ParseForm(); err != nil { | 12 | + lang := r.PathValue("code") |
| 14 | - http.Error(w, err.Error(), http.StatusBadRequest) | 13 | + if lang == "" { |
| 14 | + http.Error(w, "missing language code", http.StatusBadRequest) | ||
| 15 | return | 15 | return |
| 16 | } | 16 | } |
| 17 | 17 | ||
| @@ -19,16 +19,32 @@ func (s *Server) handleUpdateLanguages(w http.ResponseWriter, r *http.Request) { | |||
| 19 | for _, known := range ml.KnownLanguages() { | 19 | for _, known := range ml.KnownLanguages() { |
| 20 | valid[known.Code] = true | 20 | valid[known.Code] = true |
| 21 | } | 21 | } |
| 22 | + if !valid[lang] { | ||
| 23 | + http.Error(w, "unknown language", http.StatusBadRequest) | ||
| 24 | + return | ||
| 25 | + } | ||
| 22 | 26 | ||
| 23 | - var filtered []string | 27 | + current, err := s.dbs.Users.GetLanguages(r.Context(), user.DID) |
| 24 | - for _, l := range r.Form["languages"] { | 28 | + if err != nil { |
| 25 | - l = strings.TrimSpace(l) | 29 | + s.logger.Error("failed to get languages", "error", err) |
| 26 | - if valid[l] { | 30 | + http.Error(w, err.Error(), http.StatusInternalServerError) |
| 27 | - filtered = append(filtered, l) | 31 | + return |
| 32 | + } | ||
| 33 | + | ||
| 34 | + var updated []string | ||
| 35 | + found := false | ||
| 36 | + for _, l := range current { | ||
| 37 | + if l == lang { | ||
| 38 | + found = true | ||
| 39 | + continue | ||
| 28 | } | 40 | } |
| 41 | + updated = append(updated, l) | ||
| 42 | + } | ||
| 43 | + if !found { | ||
| 44 | + updated = append(updated, lang) | ||
| 29 | } | 45 | } |
| 30 | 46 | ||
| 31 | - if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, filtered); err != nil { | 47 | + if err := s.dbs.Users.UpdateLanguages(r.Context(), user.DID, updated); err != nil { |
| 32 | s.logger.Error("failed to update languages", "error", err) | 48 | s.logger.Error("failed to update languages", "error", err) |
| 33 | http.Error(w, err.Error(), http.StatusInternalServerError) | 49 | http.Error(w, err.Error(), http.StatusInternalServerError) |
| 34 | return | 50 | return |
modified
internal/tmpl/dashboard.html +1 -1 | @@ -82,7 +82,7 @@ | ||
| 82 | 82 | </div> |
| 83 | 83 | {{end}} |
| 84 | 84 | |
| 85 | -{{if .DigestEnabled}} | |
| 85 | +{{if and .DigestEnabled (gt .UnreadCount 0)}} | |
| 86 | 86 | <div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> |
| 87 | 87 | <div class="mb-6"> |
| 88 | 88 | <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> |
| @@ -82,7 +82,7 @@ | |||
| 82 | </div> | 82 | </div> |
| 83 | {{end}} | 83 | {{end}} |
| 84 | 84 | ||
| 85 | -{{if .DigestEnabled}} | 85 | +{{if and .DigestEnabled (gt .UnreadCount 0)}} |
| 86 | <div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> | 86 | <div id="digest" hx-get="/digest" hx-trigger="load" hx-swap="innerHTML" hx-indicator="#digest"> |
| 87 | <div class="mb-6"> | 87 | <div class="mb-6"> |
| 88 | <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> | 88 | <h2 class="text-lg font-semibold text-spot-text mb-4">Daily digest</h2> |
modified
internal/tmpl/profile.html +6 -22 | @@ -51,34 +51,18 @@ | ||
| 51 | 51 | </div> |
| 52 | 52 | </form> |
| 53 | 53 | <hr class="border-spot-divider"> |
| 54 | - <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()"> | |
| 54 | + <div> | |
| 55 | 55 | <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p> |
| 56 | 56 | <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p> |
| 57 | - <div class="flex flex-wrap gap-1.5 mb-4"> | |
| 57 | + <div class="flex flex-wrap gap-1.5"> | |
| 58 | 58 | {{range .AvailableLanguages}} |
| 59 | - <label class="lang-pill cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"> | |
| 60 | - <input type="checkbox" name="languages" value="{{.Code}}" {{if containsString $.UserLanguages .Code}}checked{{end}} class="hidden"> | |
| 61 | - {{.Name}} | |
| 62 | - </label> | |
| 59 | + <form hx-post="/settings/languages/{{.Code}}" hx-swap="none" hx-on::after-request="location.reload()" class="inline"> | |
| 60 | + <button type="submit" class="cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition border-0 {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">{{.Name}}</button> | |
| 61 | + </form> | |
| 63 | 62 | {{end}} |
| 64 | 63 | </div> |
| 65 | - <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button> | |
| 66 | - </form> | |
| 64 | + </div> | |
| 67 | 65 | </div> |
| 68 | - <script> | |
| 69 | - document.querySelectorAll('#lang-form .lang-pill input[type=checkbox]').forEach(function(cb) { | |
| 70 | - cb.addEventListener('change', function() { | |
| 71 | - var pill = this.closest('.lang-pill'); | |
| 72 | - if (this.checked) { | |
| 73 | - pill.classList.remove('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text'); | |
| 74 | - pill.classList.add('bg-spot-active-pill-bg', 'text-spot-active-pill-text'); | |
| 75 | - } else { | |
| 76 | - pill.classList.remove('bg-spot-active-pill-bg', 'text-spot-active-pill-text'); | |
| 77 | - pill.classList.add('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text'); | |
| 78 | - } | |
| 79 | - }); | |
| 80 | - }); | |
| 81 | - </script> | |
| 82 | 66 | {{end}} |
| 83 | 67 | |
| 84 | 68 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2> |
| @@ -51,34 +51,18 @@ | |||
| 51 | </div> | 51 | </div> |
| 52 | </form> | 52 | </form> |
| 53 | <hr class="border-spot-divider"> | 53 | <hr class="border-spot-divider"> |
| 54 | - <form id="lang-form" hx-post="/settings/languages" hx-swap="none" hx-on::after-request="location.reload()"> | 54 | + <div> |
| 55 | <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p> | 55 | <p class="text-sm font-semibold text-spot-text mb-1">Recommendation languages</p> |
| 56 | <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p> | 56 | <p class="text-xs text-spot-secondary mb-4">Filter article recommendations to specific languages. Leave empty to show all.</p> |
| 57 | - <div class="flex flex-wrap gap-1.5 mb-4"> | 57 | + <div class="flex flex-wrap gap-1.5"> |
| 58 | {{range .AvailableLanguages}} | 58 | {{range .AvailableLanguages}} |
| 59 | - <label class="lang-pill cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}"> | 59 | + <form hx-post="/settings/languages/{{.Code}}" hx-swap="none" hx-on::after-request="location.reload()" class="inline"> |
| 60 | - <input type="checkbox" name="languages" value="{{.Code}}" {{if containsString $.UserLanguages .Code}}checked{{end}} class="hidden"> | 60 | + <button type="submit" class="cursor-pointer text-sm px-4 py-1.5 rounded-pill font-bold transition border-0 {{if containsString $.UserLanguages .Code}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}}">{{.Name}}</button> |
| 61 | - {{.Name}} | 61 | + </form> |
| 62 | - </label> | ||
| 63 | {{end}} | 62 | {{end}} |
| 64 | </div> | 63 | </div> |
| 65 | - <button type="submit" class="bg-spot-green text-white rounded-pill px-5 py-1.5 text-xs font-bold uppercase tracking-button hover:brightness-110 transition">Save</button> | 64 | + </div> |
| 66 | - </form> | ||
| 67 | </div> | 65 | </div> |
| 68 | - <script> | ||
| 69 | - document.querySelectorAll('#lang-form .lang-pill input[type=checkbox]').forEach(function(cb) { | ||
| 70 | - cb.addEventListener('change', function() { | ||
| 71 | - var pill = this.closest('.lang-pill'); | ||
| 72 | - if (this.checked) { | ||
| 73 | - pill.classList.remove('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text'); | ||
| 74 | - pill.classList.add('bg-spot-active-pill-bg', 'text-spot-active-pill-text'); | ||
| 75 | - } else { | ||
| 76 | - pill.classList.remove('bg-spot-active-pill-bg', 'text-spot-active-pill-text'); | ||
| 77 | - pill.classList.add('bg-spot-hover', 'text-spot-secondary', 'hover:text-spot-text'); | ||
| 78 | - } | ||
| 79 | - }); | ||
| 80 | - }); | ||
| 81 | - </script> | ||
| 82 | {{end}} | 66 | {{end}} |
| 83 | 67 | ||
| 84 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2> | 68 | <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide mb-3">Feeds</h2> |
modified
readme.md +23 -22 | @@ -13,6 +13,7 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away | ||
| 13 | 13 | - [margin.at](https://margin.at) annotations displayed alongside glean annotations |
| 14 | 14 | - A trending page showing what's popular across all users |
| 15 | 15 | - Feed and people recommendations based on reading overlap |
| 16 | +- Daily digest with an AI-generated summary of your unread articles | |
| 16 | 17 | - OPML import and export |
| 17 | 18 | - Sign in with Bluesky / Atmosphere account — no new account needed |
| 18 | 19 | |
| @@ -51,28 +52,28 @@ Then open `http://localhost:8080`. | ||
| 51 | 52 | |
| 52 | 53 | ## Configuration |
| 53 | 54 | |
| 54 | -| Variable | Default | What it does | | |
| 55 | -| ---------------------------- | -------------------------- | ---------------------------------------------------------- | | |
| 56 | -| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | | |
| 57 | -| `GLEAN_ADDR` | `:8080` | Listen address | | |
| 58 | -| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | | |
| 59 | -| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | | |
| 60 | -| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | | |
| 61 | -| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | | |
| 62 | -| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | | |
| 63 | -| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | |
| 64 | -| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | | |
| 65 | -| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | | |
| 66 | -| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | | |
| 67 | -| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) | | |
| 68 | -| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | | |
| 69 | -| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | | |
| 70 | -| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | | |
| 71 | -| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | | |
| 72 | -| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection (see below) | | |
| 73 | -| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | | |
| 74 | -| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | | |
| 75 | -| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | | |
| 55 | +| Variable | Default | What it does | | |
| 56 | +| ---------------------------- | -------------------------- | ------------------------------------------------------------------------ | | |
| 57 | +| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | | |
| 58 | +| `GLEAN_ADDR` | `:8080` | Listen address | | |
| 59 | +| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | | |
| 60 | +| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | | |
| 61 | +| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | | |
| 62 | +| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | | |
| 63 | +| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | | |
| 64 | +| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | |
| 65 | +| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | | |
| 66 | +| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | | |
| 67 | +| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | | |
| 68 | +| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) | | |
| 69 | +| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | | |
| 70 | +| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | | |
| 71 | +| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | | |
| 72 | +| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | | |
| 73 | +| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) | | |
| 74 | +| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | | |
| 75 | +| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | | |
| 76 | +| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | | |
| 76 | 77 | |
| 77 | 78 | For production: |
| 78 | 79 | |
| @@ -13,6 +13,7 @@ Your subscriptions live as records on your PDS. You own them. If Glean goes away | |||
| 13 | - [margin.at](https://margin.at) annotations displayed alongside glean annotations | 13 | - [margin.at](https://margin.at) annotations displayed alongside glean annotations |
| 14 | - A trending page showing what's popular across all users | 14 | - A trending page showing what's popular across all users |
| 15 | - Feed and people recommendations based on reading overlap | 15 | - Feed and people recommendations based on reading overlap |
| 16 | +- Daily digest with an AI-generated summary of your unread articles | ||
| 16 | - OPML import and export | 17 | - OPML import and export |
| 17 | - Sign in with Bluesky / Atmosphere account — no new account needed | 18 | - Sign in with Bluesky / Atmosphere account — no new account needed |
| 18 | 19 | ||
| @@ -51,28 +52,28 @@ Then open `http://localhost:8080`. | |||
| 51 | 52 | ||
| 52 | ## Configuration | 53 | ## Configuration |
| 53 | 54 | ||
| 54 | -| Variable | Default | What it does | | 55 | +| Variable | Default | What it does | |
| 55 | -| ---------------------------- | -------------------------- | ---------------------------------------------------------- | | 56 | +| ---------------------------- | -------------------------- | ------------------------------------------------------------------------ | |
| 56 | -| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | | 57 | +| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | |
| 57 | -| `GLEAN_ADDR` | `:8080` | Listen address | | 58 | +| `GLEAN_ADDR` | `:8080` | Listen address | |
| 58 | -| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | | 59 | +| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | |
| 59 | -| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | | 60 | +| `GLEAN_JETSTREAM` | `wss://jetstream.glean.at` | Jetstream WebSocket URL | |
| 60 | -| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | | 61 | +| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | |
| 61 | -| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | | 62 | +| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | |
| 62 | -| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | | 63 | +| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | |
| 63 | -| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | 64 | +| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | |
| 64 | -| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | | 65 | +| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | |
| 65 | -| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | | 66 | +| `GLEAN_PLC_URL` | `https://didplc.glean.at` | PLC directory URL for DID resolution | |
| 66 | -| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | | 67 | +| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | |
| 67 | -| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) | | 68 | +| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL (leave empty for localhost dev) | |
| 68 | -| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | | 69 | +| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | |
| 69 | -| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | | 70 | +| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | |
| 70 | -| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | | 71 | +| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | |
| 71 | -| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | | 72 | +| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | |
| 72 | -| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection (see below) | | 73 | +| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) | |
| 73 | -| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | | 74 | +| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | |
| 74 | -| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | | 75 | +| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | |
| 75 | -| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | | 76 | +| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | |
| 76 | 77 | ||
| 77 | For production: | 78 | For production: |
| 78 | 79 | ||