feat: glean mvpUnverified
cef27d7 parent: ac90b90 modified
.gitignore +7 -0 | @@ -20,3 +20,10 @@ go.work.sum | ||
| 20 | 20 | |
| 21 | 21 | # env file |
| 22 | 22 | .env |
| 23 | + | |
| 24 | +# database | |
| 25 | +*.db | |
| 26 | +*.db.journal | |
| 27 | + | |
| 28 | +# tailwind output | |
| 29 | +static/output.css | |
| @@ -20,3 +20,10 @@ go.work.sum | |||
| 20 | 20 | ||
| 21 | # env file | 21 | # env file |
| 22 | .env | 22 | .env |
| 23 | + | ||
| 24 | +# database | ||
| 25 | +*.db | ||
| 26 | +*.db.journal | ||
| 27 | + | ||
| 28 | +# tailwind output | ||
| 29 | +static/output.css | ||
added
docs/design.md +315 -0 | new file mode 100644 | ||
| @@ -0,0 +1,315 @@ | ||
| 1 | +# Design System Inspired by Spotify | |
| 2 | + | |
| 3 | +## 1. Visual Theme & Atmosphere | |
| 4 | + | |
| 5 | +Spotify's web interface is a dark, immersive music player that wraps listeners in a near-black cocoon (`#121212`, `#181818`, `#1f1f1f`) where album art and content become the primary source of color. The design philosophy is "content-first darkness" — the UI recedes into shadow so that music, podcasts, and playlists can glow. Every surface is a shade of charcoal, creating a theater-like environment where the only true color comes from the brand Accent Purple (`#a855f7`) and the album artwork itself. | |
| 6 | + | |
| 7 | +The typography uses SpotifyMixUI and SpotifyMixUITitle — proprietary fonts from the CircularSp family (Circular by Lineto, customized for Spotify) with an extensive fallback stack that includes Arabic, Hebrew, Cyrillic, Greek, Devanagari, and CJK fonts, reflecting Spotify's global reach. The type system is compact and functional: 700 (bold) for emphasis and navigation, 600 (semibold) for secondary emphasis, and 400 (regular) for body. Buttons use uppercase with positive letter-spacing (1.4px–2px) for a systematic, label-like quality. | |
| 8 | + | |
| 9 | +What distinguishes Spotify is its pill-and-circle geometry. Primary buttons use 500px–9999px radius (full pill), circular play buttons use 50% radius, and search inputs are 500px pills. Combined with heavy shadows (`rgba(0,0,0,0.5) 0px 8px 24px`) on elevated elements and a unique inset border-shadow combo (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`), the result is an interface that feels like a premium audio device — tactile, rounded, and built for touch. | |
| 10 | + | |
| 11 | +**Key Characteristics:** | |
| 12 | + | |
| 13 | +- Near-black immersive dark theme (`#121212`–`#1f1f1f`) — UI disappears behind content | |
| 14 | +- Accent Purple (`#a855f7`) as singular brand accent — never decorative, always functional | |
| 15 | +- SpotifyMixUI/CircularSp font family with global script support | |
| 16 | +- Pill buttons (500px–9999px) and circular controls (50%) — rounded, touch-optimized | |
| 17 | +- Uppercase button labels with wide letter-spacing (1.4px–2px) | |
| 18 | +- Heavy shadows on elevated elements (`rgba(0,0,0,0.5) 0px 8px 24px`) | |
| 19 | +- Semantic colors: negative red (`#f3727f`), warning orange (`#ffa42b`), announcement blue (`#539df5`) | |
| 20 | +- Album art as the primary color source — the UI is achromatic by design | |
| 21 | +- Light/dark theme toggle — persisted in localStorage, dark is default | |
| 22 | + | |
| 23 | +## 2. Color Palette & Roles | |
| 24 | + | |
| 25 | +All theme-dependent colors use CSS custom properties defined on `:root` (dark, default) and `[data-theme="light"]` (light). Accent and semantic colors are constant across themes. | |
| 26 | + | |
| 27 | +### Tailwind Token Mapping | |
| 28 | + | |
| 29 | +The `spot.*` namespace provides semantic tokens. Theme-dependent tokens resolve to CSS variables. | |
| 30 | + | |
| 31 | +| Token | CSS Variable | Dark (default) | Light | Use | | |
| 32 | +| ---------------------- | --------------------- | -------------------------------- | -------------------------------- | -------------------------------- | | |
| 33 | +| `spot.purple` | — | `#a855f7` | `#a855f7` | Primary accent | | |
| 34 | +| `spot.purple-border` | — | `#9333ea` | `#9333ea` | Accent border variant | | |
| 35 | +| `spot.bg` | `--spot-bg` | `#121212` | `#f5f5f5` | Page background | | |
| 36 | +| `spot.surface` | `--spot-surface` | `#181818` | `#ffffff` | Cards, containers, sidebar | | |
| 37 | +| `spot.hover` | `--spot-hover` | `#1f1f1f` | `#f3f4f6` | Interactive surface, input bg | | |
| 38 | +| `spot.hover-50` | `--spot-hover-50` | `rgba(31,31,31,0.5)` | `rgba(243,244,246,0.5)` | Card hover with transparency | | |
| 39 | +| `spot.text` | `--spot-text` | `#ffffff` | `#111827` | Primary text, headings | | |
| 40 | +| `spot.secondary` | `--spot-secondary` | `#b3b3b3` | `#6b7280` | Secondary text, muted labels | | |
| 41 | +| `spot.body` | `--spot-body` | `#cbcbcb` | `#374151` | Article body text | | |
| 42 | +| `spot.muted` | `--spot-muted` | `#4d4d4d` | `#9ca3af` | Very muted text, rank numbers | | |
| 43 | +| `spot.divider` | `--spot-divider` | `rgba(77,77,77,0.2)` | `#e5e7eb` | Subtle border dividers | | |
| 44 | +| `spot.divider-30` | `--spot-divider-30` | `rgba(77,77,77,0.3)` | `#d1d5db` | Slightly stronger dividers, hr | | |
| 45 | +| `spot.outline` | `--spot-outline` | `#7c7c7c` | `#d1d5db` | Visible borders on buttons/inputs| | |
| 46 | +| `spot.placeholder` | `--spot-placeholder` | `#4d4d4d` | `#9ca3af` | Input placeholder text | | |
| 47 | +| `spot.active-pill-bg` | `--spot-active-bg` | `#ffffff` | `#111827` | Active filter pill background | | |
| 48 | +| `spot.active-pill-text`| `--spot-active-text` | `#121212` | `#ffffff` | Active filter pill text | | |
| 49 | +| `spot.shadow` | `--spot-shadow` | `rgba(0,0,0,0.3) 0px 8px 8px` | `rgba(0,0,0,0.08) 0px 2px 8px` | Card elevation shadow | | |
| 50 | +| `spot.shadow-heavy` | `--spot-shadow-heavy` | `rgba(0,0,0,0.5) 0px 8px 24px` | `rgba(0,0,0,0.1) 0px 4px 16px` | Dialog/elevated panel shadow | | |
| 51 | +| `spot.red` | — | `#f3727f` | `#f3727f` | Error states | | |
| 52 | +| `spot.orange` | — | `#ffa42b` | `#ffa42b` | Warning states | | |
| 53 | +| `spot.blue` | — | `#539df5` | `#539df5` | Info states | | |
| 54 | + | |
| 55 | +### Shadows | |
| 56 | + | |
| 57 | +- **Card** (`var(--spot-shadow)`): Cards, dropdowns | |
| 58 | +- **Heavy** (`var(--spot-shadow-heavy)`): Dialogs, menus, elevated panels | |
| 59 | +- **Inset Border** (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`): Input border-shadow combo (dark only) | |
| 60 | + | |
| 61 | +## 3. Theme Toggle | |
| 62 | + | |
| 63 | +The app supports dark (default) and light themes. Theme preference is stored in `localStorage` under the key `theme`. | |
| 64 | + | |
| 65 | +### Implementation | |
| 66 | + | |
| 67 | +- CSS custom properties are defined on `:root` for dark and `[data-theme="light"]` for light | |
| 68 | +- A `<script>` block runs before render to set `data-theme` on `<html>`, preventing flash of wrong theme | |
| 69 | +- Toggle buttons appear in the sidebar footer (desktop) and mobile top bar | |
| 70 | +- Icon: moon (when in dark mode) / sun (when in light mode) | |
| 71 | +- Default: dark | |
| 72 | + | |
| 73 | +### CSS Variables | |
| 74 | + | |
| 75 | +```css | |
| 76 | +:root { | |
| 77 | + --spot-bg: #121212; | |
| 78 | + --spot-surface: #181818; | |
| 79 | + --spot-hover: #1f1f1f; | |
| 80 | + --spot-hover-50: rgba(31,31,31,0.5); | |
| 81 | + --spot-text: #ffffff; | |
| 82 | + --spot-secondary: #b3b3b3; | |
| 83 | + --spot-body: #cbcbcb; | |
| 84 | + --spot-muted: #4d4d4d; | |
| 85 | + --spot-divider: rgba(77,77,77,0.2); | |
| 86 | + --spot-divider-30: rgba(77,77,77,0.3); | |
| 87 | + --spot-outline: #7c7c7c; | |
| 88 | + --spot-placeholder: #4d4d4d; | |
| 89 | + --spot-active-bg: #ffffff; | |
| 90 | + --spot-active-text: #121212; | |
| 91 | + --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px; | |
| 92 | + --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px; | |
| 93 | +} | |
| 94 | +[data-theme="light"] { | |
| 95 | + --spot-bg: #f5f5f5; | |
| 96 | + --spot-surface: #ffffff; | |
| 97 | + --spot-hover: #f3f4f6; | |
| 98 | + --spot-hover-50: rgba(243,244,246,0.5); | |
| 99 | + --spot-text: #111827; | |
| 100 | + --spot-secondary: #6b7280; | |
| 101 | + --spot-body: #374151; | |
| 102 | + --spot-muted: #9ca3af; | |
| 103 | + --spot-divider: #e5e7eb; | |
| 104 | + --spot-divider-30: #d1d5db; | |
| 105 | + --spot-outline: #d1d5db; | |
| 106 | + --spot-placeholder: #9ca3af; | |
| 107 | + --spot-active-bg: #111827; | |
| 108 | + --spot-active-text: #ffffff; | |
| 109 | + --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px; | |
| 110 | + --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px; | |
| 111 | +} | |
| 112 | +``` | |
| 113 | + | |
| 114 | +## 4. Typography Rules | |
| 115 | + | |
| 116 | +### Font Families | |
| 117 | + | |
| 118 | +- **Title**: `SpotifyMixUITitle`, fallbacks: `CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, Helvetica Neue, helvetica, arial, Hiragino Sans, Hiragino Kaku Gothic ProN, Meiryo, MS Gothic` | |
| 119 | +- **UI / Body**: `SpotifyMixUI`, same fallback stack | |
| 120 | + | |
| 121 | +### Hierarchy | |
| 122 | + | |
| 123 | +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | | |
| 124 | +| ---------------- | ----------------- | ---------------- | ------- | ------------ | -------------- | ---------------------------- | | |
| 125 | +| Section Title | SpotifyMixUITitle | 24px (1.50rem) | 700 | normal | normal | Bold title weight | | |
| 126 | +| Feature Heading | SpotifyMixUI | 18px (1.13rem) | 600 | 1.30 (tight) | normal | Semibold section heads | | |
| 127 | +| Body Bold | SpotifyMixUI | 16px (1.00rem) | 700 | normal | normal | Emphasized text | | |
| 128 | +| Body | SpotifyMixUI | 16px (1.00rem) | 400 | normal | normal | Standard body | | |
| 129 | +| Button Uppercase | SpotifyMixUI | 14px (0.88rem) | 600–700 | 1.00 (tight) | 1.4px–2px | `text-transform: uppercase` | | |
| 130 | +| Button | SpotifyMixUI | 14px (0.88rem) | 700 | normal | 0.14px | Standard button | | |
| 131 | +| Nav Link Bold | SpotifyMixUI | 14px (0.88rem) | 700 | normal | normal | Navigation | | |
| 132 | +| Nav Link | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Inactive nav | | |
| 133 | +| Caption Bold | SpotifyMixUI | 14px (0.88rem) | 700 | 1.50–1.54 | normal | Bold metadata | | |
| 134 | +| Caption | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Metadata | | |
| 135 | +| Small Bold | SpotifyMixUI | 12px (0.75rem) | 700 | 1.50 | normal | Tags, counts | | |
| 136 | +| Small | SpotifyMixUI | 12px (0.75rem) | 400 | normal | normal | Fine print | | |
| 137 | +| Badge | SpotifyMixUI | 10.5px (0.66rem) | 600 | 1.33 | normal | `text-transform: capitalize` | | |
| 138 | +| Micro | SpotifyMixUI | 10px (0.63rem) | 400 | normal | normal | Smallest text | | |
| 139 | + | |
| 140 | +### Principles | |
| 141 | + | |
| 142 | +- **Bold/regular binary**: Most text is either 700 (bold) or 400 (regular), with 600 used sparingly. This creates a clear visual hierarchy through weight contrast rather than size variation. | |
| 143 | +- **Uppercase buttons as system**: Button labels use uppercase + wide letter-spacing (1.4px–2px), creating a systematic "label" voice distinct from content text. | |
| 144 | +- **Compact sizing**: The range is 10px–24px — narrower than most systems. Spotify's type is compact and functional, designed for scanning playlists, not reading articles. | |
| 145 | +- **Global script support**: The extensive fallback stack (Arabic, Hebrew, Cyrillic, Greek, Devanagari, CJK) reflects Spotify's 180+ market reach. | |
| 146 | + | |
| 147 | +## 5. Component Stylings | |
| 148 | + | |
| 149 | +### Buttons | |
| 150 | + | |
| 151 | +**Accent Pill** | |
| 152 | + | |
| 153 | +- Background: `#a855f7` | |
| 154 | +- Text: `var(--spot-bg)` (dark in dark mode, light in light mode) | |
| 155 | +- Padding: 8px 16px | |
| 156 | +- Radius: 9999px (full pill) | |
| 157 | +- Use: Primary CTAs, add buttons | |
| 158 | + | |
| 159 | +**Dark Pill** | |
| 160 | + | |
| 161 | +- Background: `var(--spot-hover)` | |
| 162 | +- Text: `var(--spot-text)` or `var(--spot-secondary)` | |
| 163 | +- Padding: 8px 16px | |
| 164 | +- Radius: 9999px (full pill) | |
| 165 | +- Use: Navigation pills, secondary actions | |
| 166 | + | |
| 167 | +**Outlined Pill** | |
| 168 | + | |
| 169 | +- Background: transparent | |
| 170 | +- Text: `var(--spot-text)` | |
| 171 | +- Border: `1px solid var(--spot-outline)` | |
| 172 | +- Radius: 9999px | |
| 173 | +- Use: Follow buttons, secondary actions | |
| 174 | + | |
| 175 | +**Circular Play** | |
| 176 | + | |
| 177 | +- Background: `#a855f7` | |
| 178 | +- Text: `var(--spot-bg)` | |
| 179 | +- Padding: 12px | |
| 180 | +- Radius: 50% (circle) | |
| 181 | +- Use: Play/pause controls | |
| 182 | + | |
| 183 | +### Cards & Containers | |
| 184 | + | |
| 185 | +- Background: `var(--spot-surface)` | |
| 186 | +- Radius: 6px–8px | |
| 187 | +- No visible borders on most cards | |
| 188 | +- Hover: `var(--spot-hover-50)` background | |
| 189 | +- Shadow: `var(--spot-shadow)` on elevated | |
| 190 | + | |
| 191 | +### Inputs | |
| 192 | + | |
| 193 | +- Background: `var(--spot-hover)` | |
| 194 | +- Text: `var(--spot-text)` | |
| 195 | +- Radius: 500px (pill) | |
| 196 | +- Focus ring: `#a855f7` | |
| 197 | +- Placeholder: `var(--spot-placeholder)` | |
| 198 | + | |
| 199 | +### Navigation | |
| 200 | + | |
| 201 | +- Sidebar: `var(--spot-bg)` background | |
| 202 | +- Active items: 14px weight 700, `var(--spot-text)` | |
| 203 | +- Inactive items: 14px weight 400, `var(--spot-secondary)` | |
| 204 | +- Circular icon buttons (50% radius) | |
| 205 | +- Brand logo top-left in purple | |
| 206 | + | |
| 207 | +## 6. Layout Principles | |
| 208 | + | |
| 209 | +### Spacing System | |
| 210 | + | |
| 211 | +- Base unit: 8px | |
| 212 | +- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 15px, 16px, 20px | |
| 213 | + | |
| 214 | +### Grid & Container | |
| 215 | + | |
| 216 | +- Sidebar (fixed) + main content area | |
| 217 | +- Grid-based album/playlist cards | |
| 218 | +- Responsive content area fills remaining space | |
| 219 | + | |
| 220 | +### Whitespace Philosophy | |
| 221 | + | |
| 222 | +- **Dark compression**: Spotify packs content densely — playlist grids, track lists, and navigation are all tightly spaced. The dark background provides visual rest between elements without needing large gaps. | |
| 223 | +- **Content density over breathing room**: This is an app, not a marketing site. Every pixel serves the listening experience. | |
| 224 | + | |
| 225 | +### Border Radius Scale | |
| 226 | + | |
| 227 | +- Minimal (2px): Badges, explicit tags | |
| 228 | +- Subtle (4px): Inputs, small elements | |
| 229 | +- Standard (6px): Album art containers, cards | |
| 230 | +- Comfortable (8px): Sections, dialogs | |
| 231 | +- Medium (10px–20px): Panels, overlay elements | |
| 232 | +- Large (100px): Large pill buttons | |
| 233 | +- Pill (500px): Primary buttons, search input | |
| 234 | +- Full Pill (9999px): Navigation pills, search | |
| 235 | +- Circle (50%): Play buttons, avatars, icons | |
| 236 | + | |
| 237 | +## 7. Depth & Elevation | |
| 238 | + | |
| 239 | +| Level | Treatment | Use | | |
| 240 | +| ------------------ | ---------------------------- | ------------------------------ | | |
| 241 | +| Base (Level 0) | `var(--spot-bg)` background | Deepest layer, page background | | |
| 242 | +| Surface (Level 1) | `var(--spot-surface)` | Cards, sidebar, containers | | |
| 243 | +| Elevated (Level 2) | `var(--spot-shadow)` | Dropdown menus, hover cards | | |
| 244 | +| Dialog (Level 3) | `var(--spot-shadow-heavy)` | Modals, overlays, menus | | |
| 245 | + | |
| 246 | +## 8. Do's and Don'ts | |
| 247 | + | |
| 248 | +### Do | |
| 249 | + | |
| 250 | +- Use semantic color tokens (`spot-bg`, `spot-surface`, `spot-text`, etc.) — they adapt to theme | |
| 251 | +- Apply Accent Purple (`#a855f7`) only for play controls, active states, and primary CTAs | |
| 252 | +- Use pill shape (500px–9999px) for all buttons — circular (50%) for play controls | |
| 253 | +- Apply uppercase + wide letter-spacing (1.4px–2px) on button labels | |
| 254 | +- Keep typography compact (10px–24px range) — this is an app, not a magazine | |
| 255 | +- Use theme-aware shadows via CSS variables | |
| 256 | +- Test all components in both dark and light themes | |
| 257 | + | |
| 258 | +### Don't | |
| 259 | + | |
| 260 | +- Don't use Accent Purple decoratively or on backgrounds — it's functional only | |
| 261 | +- Don't hardcode theme-dependent colors — use CSS variable-backed tokens | |
| 262 | +- Don't skip the pill/circle geometry on buttons — square buttons break the identity | |
| 263 | +- Don't use hardcoded shadow values — use `shadow-spot` and `shadow-spot-heavy` | |
| 264 | +- Don't add additional brand colors — purple + achromatic grays is the complete palette | |
| 265 | +- Don't use `text-white` or `bg-white` directly — use `text-spot-text` and `bg-spot-active-pill-bg` | |
| 266 | +- Don't expose raw gray borders — use `border-spot-divider` or `border-spot-outline` | |
| 267 | + | |
| 268 | +## 9. Responsive Behavior | |
| 269 | + | |
| 270 | +### Breakpoints | |
| 271 | + | |
| 272 | +| Name | Width | Key Changes | | |
| 273 | +| ------------- | ----------- | --------------------- | | |
| 274 | +| Mobile Small | <425px | Compact mobile layout | | |
| 275 | +| Mobile | 425–576px | Standard mobile | | |
| 276 | +| Tablet | 576–768px | 2-column grid | | |
| 277 | +| Tablet Large | 768–896px | Expanded layout | | |
| 278 | +| Desktop Small | 896–1024px | Sidebar visible | | |
| 279 | +| Desktop | 1024–1280px | Full desktop layout | | |
| 280 | +| Large Desktop | >1280px | Expanded grid | | |
| 281 | + | |
| 282 | +### Collapsing Strategy | |
| 283 | + | |
| 284 | +- Sidebar: full → collapsed → hidden | |
| 285 | +- Album grid: 5 columns → 3 → 2 → 1 | |
| 286 | +- Search: pill input maintained, width adjusts | |
| 287 | +- Navigation: sidebar → bottom bar on mobile | |
| 288 | +- Theme toggle: always accessible in sidebar footer / mobile header | |
| 289 | + | |
| 290 | +## 10. Agent Prompt Guide | |
| 291 | + | |
| 292 | +### Quick Color Reference | |
| 293 | + | |
| 294 | +| Role | Token | Value (dark) | | |
| 295 | +| -------------- | -------------------- | -------------- | | |
| 296 | +| Background | `bg-spot-bg` | `#121212` | | |
| 297 | +| Surface | `bg-spot-surface` | `#181818` | | |
| 298 | +| Hover | `bg-spot-hover` | `#1f1f1f` | | |
| 299 | +| Text primary | `text-spot-text` | `#ffffff` | | |
| 300 | +| Text secondary | `text-spot-secondary`| `#b3b3b3` | | |
| 301 | +| Text body | `text-spot-body` | `#cbcbcb` | | |
| 302 | +| Text muted | `text-spot-muted` | `#4d4d4d` | | |
| 303 | +| Accent | `text-spot-purple` | `#a855f7` | | |
| 304 | +| Divider | `border-spot-divider`| `rgba(...)` | | |
| 305 | +| Outline | `border-spot-outline`| `#7c7c7c` | | |
| 306 | +| Error | `text-spot-red` | `#f3727f` | | |
| 307 | + | |
| 308 | +### Iteration Guide | |
| 309 | + | |
| 310 | +1. Use semantic tokens (`spot-bg`, `spot-surface`, `spot-text`) — they handle theme switching | |
| 311 | +2. Accent Purple (`spot-purple`) for functional highlights only (active, CTA) | |
| 312 | +3. Pill everything — 500px for large, 9999px for small, 50% for circular | |
| 313 | +4. Uppercase + wide tracking on buttons — the systematic label voice | |
| 314 | +5. Theme-aware shadows via `shadow-spot` and `shadow-spot-heavy` | |
| 315 | +6. Never hardcode `text-white` or `bg-white` — use semantic tokens | |
| new file mode 100644 | |||
| @@ -0,0 +1,315 @@ | |||
| 1 | +# Design System Inspired by Spotify | ||
| 2 | + | ||
| 3 | +## 1. Visual Theme & Atmosphere | ||
| 4 | + | ||
| 5 | +Spotify's web interface is a dark, immersive music player that wraps listeners in a near-black cocoon (`#121212`, `#181818`, `#1f1f1f`) where album art and content become the primary source of color. The design philosophy is "content-first darkness" — the UI recedes into shadow so that music, podcasts, and playlists can glow. Every surface is a shade of charcoal, creating a theater-like environment where the only true color comes from the brand Accent Purple (`#a855f7`) and the album artwork itself. | ||
| 6 | + | ||
| 7 | +The typography uses SpotifyMixUI and SpotifyMixUITitle — proprietary fonts from the CircularSp family (Circular by Lineto, customized for Spotify) with an extensive fallback stack that includes Arabic, Hebrew, Cyrillic, Greek, Devanagari, and CJK fonts, reflecting Spotify's global reach. The type system is compact and functional: 700 (bold) for emphasis and navigation, 600 (semibold) for secondary emphasis, and 400 (regular) for body. Buttons use uppercase with positive letter-spacing (1.4px–2px) for a systematic, label-like quality. | ||
| 8 | + | ||
| 9 | +What distinguishes Spotify is its pill-and-circle geometry. Primary buttons use 500px–9999px radius (full pill), circular play buttons use 50% radius, and search inputs are 500px pills. Combined with heavy shadows (`rgba(0,0,0,0.5) 0px 8px 24px`) on elevated elements and a unique inset border-shadow combo (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`), the result is an interface that feels like a premium audio device — tactile, rounded, and built for touch. | ||
| 10 | + | ||
| 11 | +**Key Characteristics:** | ||
| 12 | + | ||
| 13 | +- Near-black immersive dark theme (`#121212`–`#1f1f1f`) — UI disappears behind content | ||
| 14 | +- Accent Purple (`#a855f7`) as singular brand accent — never decorative, always functional | ||
| 15 | +- SpotifyMixUI/CircularSp font family with global script support | ||
| 16 | +- Pill buttons (500px–9999px) and circular controls (50%) — rounded, touch-optimized | ||
| 17 | +- Uppercase button labels with wide letter-spacing (1.4px–2px) | ||
| 18 | +- Heavy shadows on elevated elements (`rgba(0,0,0,0.5) 0px 8px 24px`) | ||
| 19 | +- Semantic colors: negative red (`#f3727f`), warning orange (`#ffa42b`), announcement blue (`#539df5`) | ||
| 20 | +- Album art as the primary color source — the UI is achromatic by design | ||
| 21 | +- Light/dark theme toggle — persisted in localStorage, dark is default | ||
| 22 | + | ||
| 23 | +## 2. Color Palette & Roles | ||
| 24 | + | ||
| 25 | +All theme-dependent colors use CSS custom properties defined on `:root` (dark, default) and `[data-theme="light"]` (light). Accent and semantic colors are constant across themes. | ||
| 26 | + | ||
| 27 | +### Tailwind Token Mapping | ||
| 28 | + | ||
| 29 | +The `spot.*` namespace provides semantic tokens. Theme-dependent tokens resolve to CSS variables. | ||
| 30 | + | ||
| 31 | +| Token | CSS Variable | Dark (default) | Light | Use | | ||
| 32 | +| ---------------------- | --------------------- | -------------------------------- | -------------------------------- | -------------------------------- | | ||
| 33 | +| `spot.purple` | — | `#a855f7` | `#a855f7` | Primary accent | | ||
| 34 | +| `spot.purple-border` | — | `#9333ea` | `#9333ea` | Accent border variant | | ||
| 35 | +| `spot.bg` | `--spot-bg` | `#121212` | `#f5f5f5` | Page background | | ||
| 36 | +| `spot.surface` | `--spot-surface` | `#181818` | `#ffffff` | Cards, containers, sidebar | | ||
| 37 | +| `spot.hover` | `--spot-hover` | `#1f1f1f` | `#f3f4f6` | Interactive surface, input bg | | ||
| 38 | +| `spot.hover-50` | `--spot-hover-50` | `rgba(31,31,31,0.5)` | `rgba(243,244,246,0.5)` | Card hover with transparency | | ||
| 39 | +| `spot.text` | `--spot-text` | `#ffffff` | `#111827` | Primary text, headings | | ||
| 40 | +| `spot.secondary` | `--spot-secondary` | `#b3b3b3` | `#6b7280` | Secondary text, muted labels | | ||
| 41 | +| `spot.body` | `--spot-body` | `#cbcbcb` | `#374151` | Article body text | | ||
| 42 | +| `spot.muted` | `--spot-muted` | `#4d4d4d` | `#9ca3af` | Very muted text, rank numbers | | ||
| 43 | +| `spot.divider` | `--spot-divider` | `rgba(77,77,77,0.2)` | `#e5e7eb` | Subtle border dividers | | ||
| 44 | +| `spot.divider-30` | `--spot-divider-30` | `rgba(77,77,77,0.3)` | `#d1d5db` | Slightly stronger dividers, hr | | ||
| 45 | +| `spot.outline` | `--spot-outline` | `#7c7c7c` | `#d1d5db` | Visible borders on buttons/inputs| | ||
| 46 | +| `spot.placeholder` | `--spot-placeholder` | `#4d4d4d` | `#9ca3af` | Input placeholder text | | ||
| 47 | +| `spot.active-pill-bg` | `--spot-active-bg` | `#ffffff` | `#111827` | Active filter pill background | | ||
| 48 | +| `spot.active-pill-text`| `--spot-active-text` | `#121212` | `#ffffff` | Active filter pill text | | ||
| 49 | +| `spot.shadow` | `--spot-shadow` | `rgba(0,0,0,0.3) 0px 8px 8px` | `rgba(0,0,0,0.08) 0px 2px 8px` | Card elevation shadow | | ||
| 50 | +| `spot.shadow-heavy` | `--spot-shadow-heavy` | `rgba(0,0,0,0.5) 0px 8px 24px` | `rgba(0,0,0,0.1) 0px 4px 16px` | Dialog/elevated panel shadow | | ||
| 51 | +| `spot.red` | — | `#f3727f` | `#f3727f` | Error states | | ||
| 52 | +| `spot.orange` | — | `#ffa42b` | `#ffa42b` | Warning states | | ||
| 53 | +| `spot.blue` | — | `#539df5` | `#539df5` | Info states | | ||
| 54 | + | ||
| 55 | +### Shadows | ||
| 56 | + | ||
| 57 | +- **Card** (`var(--spot-shadow)`): Cards, dropdowns | ||
| 58 | +- **Heavy** (`var(--spot-shadow-heavy)`): Dialogs, menus, elevated panels | ||
| 59 | +- **Inset Border** (`rgb(18,18,18) 0px 1px 0px, rgb(124,124,124) 0px 0px 0px 1px inset`): Input border-shadow combo (dark only) | ||
| 60 | + | ||
| 61 | +## 3. Theme Toggle | ||
| 62 | + | ||
| 63 | +The app supports dark (default) and light themes. Theme preference is stored in `localStorage` under the key `theme`. | ||
| 64 | + | ||
| 65 | +### Implementation | ||
| 66 | + | ||
| 67 | +- CSS custom properties are defined on `:root` for dark and `[data-theme="light"]` for light | ||
| 68 | +- A `<script>` block runs before render to set `data-theme` on `<html>`, preventing flash of wrong theme | ||
| 69 | +- Toggle buttons appear in the sidebar footer (desktop) and mobile top bar | ||
| 70 | +- Icon: moon (when in dark mode) / sun (when in light mode) | ||
| 71 | +- Default: dark | ||
| 72 | + | ||
| 73 | +### CSS Variables | ||
| 74 | + | ||
| 75 | +```css | ||
| 76 | +:root { | ||
| 77 | + --spot-bg: #121212; | ||
| 78 | + --spot-surface: #181818; | ||
| 79 | + --spot-hover: #1f1f1f; | ||
| 80 | + --spot-hover-50: rgba(31,31,31,0.5); | ||
| 81 | + --spot-text: #ffffff; | ||
| 82 | + --spot-secondary: #b3b3b3; | ||
| 83 | + --spot-body: #cbcbcb; | ||
| 84 | + --spot-muted: #4d4d4d; | ||
| 85 | + --spot-divider: rgba(77,77,77,0.2); | ||
| 86 | + --spot-divider-30: rgba(77,77,77,0.3); | ||
| 87 | + --spot-outline: #7c7c7c; | ||
| 88 | + --spot-placeholder: #4d4d4d; | ||
| 89 | + --spot-active-bg: #ffffff; | ||
| 90 | + --spot-active-text: #121212; | ||
| 91 | + --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px; | ||
| 92 | + --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px; | ||
| 93 | +} | ||
| 94 | +[data-theme="light"] { | ||
| 95 | + --spot-bg: #f5f5f5; | ||
| 96 | + --spot-surface: #ffffff; | ||
| 97 | + --spot-hover: #f3f4f6; | ||
| 98 | + --spot-hover-50: rgba(243,244,246,0.5); | ||
| 99 | + --spot-text: #111827; | ||
| 100 | + --spot-secondary: #6b7280; | ||
| 101 | + --spot-body: #374151; | ||
| 102 | + --spot-muted: #9ca3af; | ||
| 103 | + --spot-divider: #e5e7eb; | ||
| 104 | + --spot-divider-30: #d1d5db; | ||
| 105 | + --spot-outline: #d1d5db; | ||
| 106 | + --spot-placeholder: #9ca3af; | ||
| 107 | + --spot-active-bg: #111827; | ||
| 108 | + --spot-active-text: #ffffff; | ||
| 109 | + --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px; | ||
| 110 | + --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px; | ||
| 111 | +} | ||
| 112 | +``` | ||
| 113 | + | ||
| 114 | +## 4. Typography Rules | ||
| 115 | + | ||
| 116 | +### Font Families | ||
| 117 | + | ||
| 118 | +- **Title**: `SpotifyMixUITitle`, fallbacks: `CircularSp-Arab, CircularSp-Hebr, CircularSp-Cyrl, CircularSp-Grek, CircularSp-Deva, Helvetica Neue, helvetica, arial, Hiragino Sans, Hiragino Kaku Gothic ProN, Meiryo, MS Gothic` | ||
| 119 | +- **UI / Body**: `SpotifyMixUI`, same fallback stack | ||
| 120 | + | ||
| 121 | +### Hierarchy | ||
| 122 | + | ||
| 123 | +| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes | | ||
| 124 | +| ---------------- | ----------------- | ---------------- | ------- | ------------ | -------------- | ---------------------------- | | ||
| 125 | +| Section Title | SpotifyMixUITitle | 24px (1.50rem) | 700 | normal | normal | Bold title weight | | ||
| 126 | +| Feature Heading | SpotifyMixUI | 18px (1.13rem) | 600 | 1.30 (tight) | normal | Semibold section heads | | ||
| 127 | +| Body Bold | SpotifyMixUI | 16px (1.00rem) | 700 | normal | normal | Emphasized text | | ||
| 128 | +| Body | SpotifyMixUI | 16px (1.00rem) | 400 | normal | normal | Standard body | | ||
| 129 | +| Button Uppercase | SpotifyMixUI | 14px (0.88rem) | 600–700 | 1.00 (tight) | 1.4px–2px | `text-transform: uppercase` | | ||
| 130 | +| Button | SpotifyMixUI | 14px (0.88rem) | 700 | normal | 0.14px | Standard button | | ||
| 131 | +| Nav Link Bold | SpotifyMixUI | 14px (0.88rem) | 700 | normal | normal | Navigation | | ||
| 132 | +| Nav Link | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Inactive nav | | ||
| 133 | +| Caption Bold | SpotifyMixUI | 14px (0.88rem) | 700 | 1.50–1.54 | normal | Bold metadata | | ||
| 134 | +| Caption | SpotifyMixUI | 14px (0.88rem) | 400 | normal | normal | Metadata | | ||
| 135 | +| Small Bold | SpotifyMixUI | 12px (0.75rem) | 700 | 1.50 | normal | Tags, counts | | ||
| 136 | +| Small | SpotifyMixUI | 12px (0.75rem) | 400 | normal | normal | Fine print | | ||
| 137 | +| Badge | SpotifyMixUI | 10.5px (0.66rem) | 600 | 1.33 | normal | `text-transform: capitalize` | | ||
| 138 | +| Micro | SpotifyMixUI | 10px (0.63rem) | 400 | normal | normal | Smallest text | | ||
| 139 | + | ||
| 140 | +### Principles | ||
| 141 | + | ||
| 142 | +- **Bold/regular binary**: Most text is either 700 (bold) or 400 (regular), with 600 used sparingly. This creates a clear visual hierarchy through weight contrast rather than size variation. | ||
| 143 | +- **Uppercase buttons as system**: Button labels use uppercase + wide letter-spacing (1.4px–2px), creating a systematic "label" voice distinct from content text. | ||
| 144 | +- **Compact sizing**: The range is 10px–24px — narrower than most systems. Spotify's type is compact and functional, designed for scanning playlists, not reading articles. | ||
| 145 | +- **Global script support**: The extensive fallback stack (Arabic, Hebrew, Cyrillic, Greek, Devanagari, CJK) reflects Spotify's 180+ market reach. | ||
| 146 | + | ||
| 147 | +## 5. Component Stylings | ||
| 148 | + | ||
| 149 | +### Buttons | ||
| 150 | + | ||
| 151 | +**Accent Pill** | ||
| 152 | + | ||
| 153 | +- Background: `#a855f7` | ||
| 154 | +- Text: `var(--spot-bg)` (dark in dark mode, light in light mode) | ||
| 155 | +- Padding: 8px 16px | ||
| 156 | +- Radius: 9999px (full pill) | ||
| 157 | +- Use: Primary CTAs, add buttons | ||
| 158 | + | ||
| 159 | +**Dark Pill** | ||
| 160 | + | ||
| 161 | +- Background: `var(--spot-hover)` | ||
| 162 | +- Text: `var(--spot-text)` or `var(--spot-secondary)` | ||
| 163 | +- Padding: 8px 16px | ||
| 164 | +- Radius: 9999px (full pill) | ||
| 165 | +- Use: Navigation pills, secondary actions | ||
| 166 | + | ||
| 167 | +**Outlined Pill** | ||
| 168 | + | ||
| 169 | +- Background: transparent | ||
| 170 | +- Text: `var(--spot-text)` | ||
| 171 | +- Border: `1px solid var(--spot-outline)` | ||
| 172 | +- Radius: 9999px | ||
| 173 | +- Use: Follow buttons, secondary actions | ||
| 174 | + | ||
| 175 | +**Circular Play** | ||
| 176 | + | ||
| 177 | +- Background: `#a855f7` | ||
| 178 | +- Text: `var(--spot-bg)` | ||
| 179 | +- Padding: 12px | ||
| 180 | +- Radius: 50% (circle) | ||
| 181 | +- Use: Play/pause controls | ||
| 182 | + | ||
| 183 | +### Cards & Containers | ||
| 184 | + | ||
| 185 | +- Background: `var(--spot-surface)` | ||
| 186 | +- Radius: 6px–8px | ||
| 187 | +- No visible borders on most cards | ||
| 188 | +- Hover: `var(--spot-hover-50)` background | ||
| 189 | +- Shadow: `var(--spot-shadow)` on elevated | ||
| 190 | + | ||
| 191 | +### Inputs | ||
| 192 | + | ||
| 193 | +- Background: `var(--spot-hover)` | ||
| 194 | +- Text: `var(--spot-text)` | ||
| 195 | +- Radius: 500px (pill) | ||
| 196 | +- Focus ring: `#a855f7` | ||
| 197 | +- Placeholder: `var(--spot-placeholder)` | ||
| 198 | + | ||
| 199 | +### Navigation | ||
| 200 | + | ||
| 201 | +- Sidebar: `var(--spot-bg)` background | ||
| 202 | +- Active items: 14px weight 700, `var(--spot-text)` | ||
| 203 | +- Inactive items: 14px weight 400, `var(--spot-secondary)` | ||
| 204 | +- Circular icon buttons (50% radius) | ||
| 205 | +- Brand logo top-left in purple | ||
| 206 | + | ||
| 207 | +## 6. Layout Principles | ||
| 208 | + | ||
| 209 | +### Spacing System | ||
| 210 | + | ||
| 211 | +- Base unit: 8px | ||
| 212 | +- Scale: 1px, 2px, 3px, 4px, 5px, 6px, 8px, 10px, 12px, 14px, 15px, 16px, 20px | ||
| 213 | + | ||
| 214 | +### Grid & Container | ||
| 215 | + | ||
| 216 | +- Sidebar (fixed) + main content area | ||
| 217 | +- Grid-based album/playlist cards | ||
| 218 | +- Responsive content area fills remaining space | ||
| 219 | + | ||
| 220 | +### Whitespace Philosophy | ||
| 221 | + | ||
| 222 | +- **Dark compression**: Spotify packs content densely — playlist grids, track lists, and navigation are all tightly spaced. The dark background provides visual rest between elements without needing large gaps. | ||
| 223 | +- **Content density over breathing room**: This is an app, not a marketing site. Every pixel serves the listening experience. | ||
| 224 | + | ||
| 225 | +### Border Radius Scale | ||
| 226 | + | ||
| 227 | +- Minimal (2px): Badges, explicit tags | ||
| 228 | +- Subtle (4px): Inputs, small elements | ||
| 229 | +- Standard (6px): Album art containers, cards | ||
| 230 | +- Comfortable (8px): Sections, dialogs | ||
| 231 | +- Medium (10px–20px): Panels, overlay elements | ||
| 232 | +- Large (100px): Large pill buttons | ||
| 233 | +- Pill (500px): Primary buttons, search input | ||
| 234 | +- Full Pill (9999px): Navigation pills, search | ||
| 235 | +- Circle (50%): Play buttons, avatars, icons | ||
| 236 | + | ||
| 237 | +## 7. Depth & Elevation | ||
| 238 | + | ||
| 239 | +| Level | Treatment | Use | | ||
| 240 | +| ------------------ | ---------------------------- | ------------------------------ | | ||
| 241 | +| Base (Level 0) | `var(--spot-bg)` background | Deepest layer, page background | | ||
| 242 | +| Surface (Level 1) | `var(--spot-surface)` | Cards, sidebar, containers | | ||
| 243 | +| Elevated (Level 2) | `var(--spot-shadow)` | Dropdown menus, hover cards | | ||
| 244 | +| Dialog (Level 3) | `var(--spot-shadow-heavy)` | Modals, overlays, menus | | ||
| 245 | + | ||
| 246 | +## 8. Do's and Don'ts | ||
| 247 | + | ||
| 248 | +### Do | ||
| 249 | + | ||
| 250 | +- Use semantic color tokens (`spot-bg`, `spot-surface`, `spot-text`, etc.) — they adapt to theme | ||
| 251 | +- Apply Accent Purple (`#a855f7`) only for play controls, active states, and primary CTAs | ||
| 252 | +- Use pill shape (500px–9999px) for all buttons — circular (50%) for play controls | ||
| 253 | +- Apply uppercase + wide letter-spacing (1.4px–2px) on button labels | ||
| 254 | +- Keep typography compact (10px–24px range) — this is an app, not a magazine | ||
| 255 | +- Use theme-aware shadows via CSS variables | ||
| 256 | +- Test all components in both dark and light themes | ||
| 257 | + | ||
| 258 | +### Don't | ||
| 259 | + | ||
| 260 | +- Don't use Accent Purple decoratively or on backgrounds — it's functional only | ||
| 261 | +- Don't hardcode theme-dependent colors — use CSS variable-backed tokens | ||
| 262 | +- Don't skip the pill/circle geometry on buttons — square buttons break the identity | ||
| 263 | +- Don't use hardcoded shadow values — use `shadow-spot` and `shadow-spot-heavy` | ||
| 264 | +- Don't add additional brand colors — purple + achromatic grays is the complete palette | ||
| 265 | +- Don't use `text-white` or `bg-white` directly — use `text-spot-text` and `bg-spot-active-pill-bg` | ||
| 266 | +- Don't expose raw gray borders — use `border-spot-divider` or `border-spot-outline` | ||
| 267 | + | ||
| 268 | +## 9. Responsive Behavior | ||
| 269 | + | ||
| 270 | +### Breakpoints | ||
| 271 | + | ||
| 272 | +| Name | Width | Key Changes | | ||
| 273 | +| ------------- | ----------- | --------------------- | | ||
| 274 | +| Mobile Small | <425px | Compact mobile layout | | ||
| 275 | +| Mobile | 425–576px | Standard mobile | | ||
| 276 | +| Tablet | 576–768px | 2-column grid | | ||
| 277 | +| Tablet Large | 768–896px | Expanded layout | | ||
| 278 | +| Desktop Small | 896–1024px | Sidebar visible | | ||
| 279 | +| Desktop | 1024–1280px | Full desktop layout | | ||
| 280 | +| Large Desktop | >1280px | Expanded grid | | ||
| 281 | + | ||
| 282 | +### Collapsing Strategy | ||
| 283 | + | ||
| 284 | +- Sidebar: full → collapsed → hidden | ||
| 285 | +- Album grid: 5 columns → 3 → 2 → 1 | ||
| 286 | +- Search: pill input maintained, width adjusts | ||
| 287 | +- Navigation: sidebar → bottom bar on mobile | ||
| 288 | +- Theme toggle: always accessible in sidebar footer / mobile header | ||
| 289 | + | ||
| 290 | +## 10. Agent Prompt Guide | ||
| 291 | + | ||
| 292 | +### Quick Color Reference | ||
| 293 | + | ||
| 294 | +| Role | Token | Value (dark) | | ||
| 295 | +| -------------- | -------------------- | -------------- | | ||
| 296 | +| Background | `bg-spot-bg` | `#121212` | | ||
| 297 | +| Surface | `bg-spot-surface` | `#181818` | | ||
| 298 | +| Hover | `bg-spot-hover` | `#1f1f1f` | | ||
| 299 | +| Text primary | `text-spot-text` | `#ffffff` | | ||
| 300 | +| Text secondary | `text-spot-secondary`| `#b3b3b3` | | ||
| 301 | +| Text body | `text-spot-body` | `#cbcbcb` | | ||
| 302 | +| Text muted | `text-spot-muted` | `#4d4d4d` | | ||
| 303 | +| Accent | `text-spot-purple` | `#a855f7` | | ||
| 304 | +| Divider | `border-spot-divider`| `rgba(...)` | | ||
| 305 | +| Outline | `border-spot-outline`| `#7c7c7c` | | ||
| 306 | +| Error | `text-spot-red` | `#f3727f` | | ||
| 307 | + | ||
| 308 | +### Iteration Guide | ||
| 309 | + | ||
| 310 | +1. Use semantic tokens (`spot-bg`, `spot-surface`, `spot-text`) — they handle theme switching | ||
| 311 | +2. Accent Purple (`spot-purple`) for functional highlights only (active, CTA) | ||
| 312 | +3. Pill everything — 500px for large, 9999px for small, 50% for circular | ||
| 313 | +4. Uppercase + wide tracking on buttons — the systematic label voice | ||
| 314 | +5. Theme-aware shadows via `shadow-spot` and `shadow-spot-heavy` | ||
| 315 | +6. Never hardcode `text-white` or `bg-white` — use semantic tokens | ||
added
docs/specs.md +839 -0 | new file mode 100644 | ||
| @@ -0,0 +1,839 @@ | ||
| 1 | +# Glean - Design Document | |
| 2 | + | |
| 3 | +## 1. Overview | |
| 4 | + | |
| 5 | +Glean is a social RSS reader built on the AT Protocol. It operates as an **AppView** for the `at.glean.*` lexicon namespace: it indexes records from the relay firehose, serves XRPC query endpoints, and provides the web UI at [glean.at](https://glean.at). | |
| 6 | + | |
| 7 | +Users store their RSS feed subscriptions as individual lexicon records on their PDS (one record per feed). Glean's AppView consumes the firehose, indexes those records, fetches the referenced RSS feeds, and serves both the reader UI and public XRPC APIs for the `at.glean.*` namespace. | |
| 8 | + | |
| 9 | +The core idea: your RSS subscriptions are a strong signal about your interests. When enough people expose theirs, you can discover both **people** (who reads the same things) and **content** (what similar readers follow that you don't). | |
| 10 | + | |
| 11 | +## 2. Stack | |
| 12 | + | |
| 13 | +| Layer | Technology | | |
| 14 | +| ---------------- | ---------------------------------- | | |
| 15 | +| Backend | Go | | |
| 16 | +| Database | SQLite (via `modernc.org/sqlite`) | | |
| 17 | +| Frontend | htmx + TailwindCSS | | |
| 18 | +| Auth | AT Protocol OAuth / DID resolution | | |
| 19 | +| AT Protocol role | AppView for `at.glean.*` lexicons | | |
| 20 | +| Data source | AT Relay firehose → SQLite index | | |
| 21 | + | |
| 22 | +## 3. AT Protocol Lexicons | |
| 23 | + | |
| 24 | +All user data lives on their PDS. The server does not own user data — it indexes and aggregates it. | |
| 25 | + | |
| 26 | +### 3.1 `at.glean.subscription` | |
| 27 | + | |
| 28 | +A single RSS feed subscription. One record per feed per user. Created automatically when a user subscribes to a feed. During onboarding, existing subscriptions can be bulk-imported from an OPML file — the OPML is parsed and individual subscription records are created. | |
| 29 | + | |
| 30 | +```json | |
| 31 | +{ | |
| 32 | + "lexicon": 1, | |
| 33 | + "id": "at.glean.subscription", | |
| 34 | + "defs": { | |
| 35 | + "main": { | |
| 36 | + "type": "record", | |
| 37 | + "key": "tid", | |
| 38 | + "description": "A single RSS feed subscription.", | |
| 39 | + "record": { | |
| 40 | + "type": "object", | |
| 41 | + "required": ["feedUrl"], | |
| 42 | + "properties": { | |
| 43 | + "createdAt": { "type": "string", "format": "datetime" }, | |
| 44 | + "feedUrl": { "type": "string" }, | |
| 45 | + "title": { "type": "string" }, | |
| 46 | + "category": { "type": "string" } | |
| 47 | + } | |
| 48 | + } | |
| 49 | + } | |
| 50 | + } | |
| 51 | +} | |
| 52 | +``` | |
| 53 | + | |
| 54 | +#### OPML Import/Export | |
| 55 | + | |
| 56 | +OPML is **not** part of the lexicon. It is only used as a transport format: | |
| 57 | + | |
| 58 | +- **Import (onboarding)**: User uploads an OPML file. Glean parses it, validates each feed URL, creates individual `at.glean.subscription` records on the user's PDS, and indexes them locally. | |
| 59 | +- **Export (offboarding)**: Glean reads the user's `at.glean.subscription` records from their PDS and generates an OPML file for download. The user's repository remains the canonical source. | |
| 60 | + | |
| 61 | +### 3.2 `at.glean.annotation` | |
| 62 | + | |
| 63 | +Reading notes on an article: quote a passage, tag it, rate it, or write a note. A user can have many annotations per article. These are public records on the PDS — users can always share an annotation on Bluesky if they want discussion. | |
| 64 | + | |
| 65 | +```json | |
| 66 | +{ | |
| 67 | + "lexicon": 1, | |
| 68 | + "id": "at.glean.annotation", | |
| 69 | + "defs": { | |
| 70 | + "main": { | |
| 71 | + "type": "record", | |
| 72 | + "key": "tid", | |
| 73 | + "description": "Reading note on a specific RSS article.", | |
| 74 | + "record": { | |
| 75 | + "type": "object", | |
| 76 | + "required": ["feedUrl", "articleUrl"], | |
| 77 | + "properties": { | |
| 78 | + "createdAt": { "type": "string", "format": "datetime" }, | |
| 79 | + "feedUrl": { "type": "string" }, | |
| 80 | + "articleUrl": { "type": "string" }, | |
| 81 | + "quote": { "type": "string", "maxGraphemes": 5000 }, | |
| 82 | + "note": { "type": "string", "maxGraphemes": 500 }, | |
| 83 | + "tags": { | |
| 84 | + "type": "array", | |
| 85 | + "items": { "type": "string", "maxGraphemes": 50 }, | |
| 86 | + "maxLength": 10 | |
| 87 | + }, | |
| 88 | + "rating": { "type": "integer", "minimum": 1, "maximum": 5 } | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | |
| 94 | +``` | |
| 95 | + | |
| 96 | +### 3.3 `at.glean.like` | |
| 97 | + | |
| 98 | +A user likes an article. The liked feed surfaces popular articles and feeds into discovery. Likes also feed into the recommendation system. | |
| 99 | + | |
| 100 | +```json | |
| 101 | +{ | |
| 102 | + "lexicon": 1, | |
| 103 | + "id": "at.glean.like", | |
| 104 | + "defs": { | |
| 105 | + "main": { | |
| 106 | + "type": "record", | |
| 107 | + "key": "tid", | |
| 108 | + "description": "Like an RSS article.", | |
| 109 | + "record": { | |
| 110 | + "type": "object", | |
| 111 | + "required": ["feedUrl", "articleUrl"], | |
| 112 | + "properties": { | |
| 113 | + "createdAt": { "type": "string", "format": "datetime" }, | |
| 114 | + "feedUrl": { "type": "string" }, | |
| 115 | + "articleUrl": { "type": "string" } | |
| 116 | + } | |
| 117 | + } | |
| 118 | + } | |
| 119 | + } | |
| 120 | +} | |
| 121 | +``` | |
| 122 | + | |
| 123 | +### 3.4 AppView Query Lexicons | |
| 124 | + | |
| 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. | |
| 126 | + | |
| 127 | +#### `at.glean.listSubscriptions` | |
| 128 | + | |
| 129 | +List subscriptions from a repo, with optional filtering. | |
| 130 | + | |
| 131 | +``` | |
| 132 | +Input: | |
| 133 | + repo: string (DID of the user) | |
| 134 | + category?: string | |
| 135 | + limit?: integer (default 50, max 100) | |
| 136 | + cursor?: string | |
| 137 | + | |
| 138 | +Output: | |
| 139 | + cursor?: string | |
| 140 | + subscriptions: [{ uri, cid, value: at.glean.subscription#main, indexedAt }] | |
| 141 | +``` | |
| 142 | + | |
| 143 | +#### `at.glean.listFeedLists` | |
| 144 | + | |
| 145 | +List subscription lists from multiple repos, with optional filtering. | |
| 146 | + | |
| 147 | +``` | |
| 148 | +Input: | |
| 149 | + actors?: string[] (filter by DIDs) | |
| 150 | + limit?: integer (default 50, max 100) | |
| 151 | + cursor?: string | |
| 152 | + | |
| 153 | +Output: | |
| 154 | + cursor?: string | |
| 155 | + feeds: [{ did, subscriptionCount, subscriptions: [{ feedUrl, title, category }] }] | |
| 156 | +``` | |
| 157 | + | |
| 158 | +#### `at.glean.listAnnotations` | |
| 159 | + | |
| 160 | +List annotations for an article, a feed, or by a user. | |
| 161 | + | |
| 162 | +``` | |
| 163 | +Input: | |
| 164 | + feedUrl?: string | |
| 165 | + articleUrl?: string | |
| 166 | + author?: string (DID) | |
| 167 | + limit?: integer (default 50, max 100) | |
| 168 | + cursor?: string | |
| 169 | + | |
| 170 | +Output: | |
| 171 | + cursor?: string | |
| 172 | + annotations: [{ uri, cid, author: { did, handle }, value, indexedAt }] | |
| 173 | +``` | |
| 174 | + | |
| 175 | +#### `at.glean.listLikes` | |
| 176 | + | |
| 177 | +List liked articles, optionally filtered by user or feed. | |
| 178 | + | |
| 179 | +``` | |
| 180 | +Input: | |
| 181 | + author?: string (DID) | |
| 182 | + feedUrl?: string | |
| 183 | + limit?: integer (default 50, max 100) | |
| 184 | + cursor?: string | |
| 185 | + | |
| 186 | +Output: | |
| 187 | + cursor?: string | |
| 188 | + likes: [{ uri, cid, author: { did, handle }, value: at.glean.like#main, indexedAt }] | |
| 189 | +``` | |
| 190 | + | |
| 191 | +#### `at.glean.getTrending` | |
| 192 | + | |
| 193 | +Articles with the most likes, forming the community feed. | |
| 194 | + | |
| 195 | +``` | |
| 196 | +Input: | |
| 197 | + limit?: integer (default 50, max 100) | |
| 198 | + cursor?: string | |
| 199 | + since?: string (datetime) | |
| 200 | + | |
| 201 | +Output: | |
| 202 | + cursor?: string | |
| 203 | + articles: [{ feedUrl, articleUrl, title, likeCount, annotations: [...] }] | |
| 204 | +``` | |
| 205 | + | |
| 206 | +#### `at.glean.getRecommendations` | |
| 207 | + | |
| 208 | +Get feed recommendations for a user based on clustering. | |
| 209 | + | |
| 210 | +``` | |
| 211 | +Input: | |
| 212 | + repo: string (DID of the user) | |
| 213 | + limit?: integer (default 20, max 50) | |
| 214 | + | |
| 215 | +Output: | |
| 216 | + feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }] | |
| 217 | + people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }] | |
| 218 | +``` | |
| 219 | + | |
| 220 | +### 3.5 AppView Firehose Consumption | |
| 221 | + | |
| 222 | +Glean subscribes to the AT Relay firehose (`wss://bsky.network`) for all `at.glean.*` records: | |
| 223 | + | |
| 224 | +``` | |
| 225 | +SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like"] | |
| 226 | +``` | |
| 227 | + | |
| 228 | +On each event: | |
| 229 | + | |
| 230 | +- **create**: Insert record into local SQLite, update materialized counts | |
| 231 | +- **delete**: Tombstone the record (soft delete to preserve foreign key integrity) | |
| 232 | +- **update**: Replace the record's CID and value | |
| 233 | + | |
| 234 | +The AppView does not handle writes. Users write records to their own PDS. Glean only reads them from the firehose. | |
| 235 | + | |
| 236 | +## 4. RSS Reader | |
| 237 | + | |
| 238 | +Glean is first and foremost an RSS reader. It fetches, parses, and stores articles from RSS, Atom, and JSON feeds so users can read them in a clean interface. | |
| 239 | + | |
| 240 | +### 4.1 Feed Fetching | |
| 241 | + | |
| 242 | +A background scheduler polls subscribed feeds at regular intervals. | |
| 243 | + | |
| 244 | +``` | |
| 245 | + ┌─────────────────────────┐ | |
| 246 | + │ Feed Scheduler │ | |
| 247 | + │ (background goroutine) │ | |
| 248 | + └────────┬────────────────┘ | |
| 249 | + │ every N minutes | |
| 250 | + ┌────────▼────────────────┐ | |
| 251 | + │ Feed Fetcher │ | |
| 252 | + │ │ | |
| 253 | + │ 1. SELECT feeds where │ | |
| 254 | + │ next_fetch <= now │ | |
| 255 | + │ 2. Respect ETag/If-None-│ | |
| 256 | + │ Match / Last-Modified│ | |
| 257 | + │ 3. GET feed URL │ | |
| 258 | + │ 4. Parse XML/JSON │ | |
| 259 | + │ 5. Upsert articles │ | |
| 260 | + │ 6. Update feed metadata│ | |
| 261 | + └────────┬────────────────┘ | |
| 262 | + │ | |
| 263 | + ┌──────────────┼──────────────┐ | |
| 264 | + │ │ │ | |
| 265 | + ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ | |
| 266 | + │RSS/XML │ │Atom/XML │ │JSON Feed │ | |
| 267 | + │Parser │ │Parser │ │Parser │ | |
| 268 | + └─────────┘ └───────────┘ └───────────┘ | |
| 269 | +``` | |
| 270 | + | |
| 271 | +### 4.2 Fetch Schedule | |
| 272 | + | |
| 273 | +Feeds are not all fetched at the same frequency. The scheduler adapts based on: | |
| 274 | + | |
| 275 | +- **Base interval**: Default 30 minutes | |
| 276 | +- **Feed-level override**: User can set per-feed refresh rate (15min / 30min / 1h / 3h / 6h / 12h / daily) | |
| 277 | +- **Adaptive backoff**: If a feed has not published new articles in the last N fetches, increase the interval. If it starts publishing again, decrease back. | |
| 278 | +- **HTTP cache**: Honor `ETag` and `Last-Modified` headers to skip parsing when nothing changed (304 Not Modified) | |
| 279 | +- **Error backoff**: On failure, double the interval up to 24h, reset on success | |
| 280 | + | |
| 281 | +```sql | |
| 282 | +ALTER TABLE feeds ADD COLUMN fetch_interval_minutes INTEGER NOT NULL DEFAULT 30; | |
| 283 | +ALTER TABLE feeds ADD COLUMN next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP; | |
| 284 | +ALTER TABLE feeds ADD COLUMN consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0; | |
| 285 | +ALTER TABLE feeds ADD COLUMN error_count INTEGER NOT NULL DEFAULT 0; | |
| 286 | +``` | |
| 287 | + | |
| 288 | +### 4.3 Feed Parsing | |
| 289 | + | |
| 290 | +Go's `encoding/xml` for RSS and Atom. A simple `encoding/json` for JSON Feed. | |
| 291 | + | |
| 292 | +Each parser returns a normalized `Feed` and a slice of `Article` structs: | |
| 293 | + | |
| 294 | +```go | |
| 295 | +type Feed struct { | |
| 296 | + URL string | |
| 297 | + Title string | |
| 298 | + SiteURL string | |
| 299 | + Description string | |
| 300 | + Type string // "rss", "atom", "json" | |
| 301 | + ETag string | |
| 302 | + LastModified string | |
| 303 | +} | |
| 304 | + | |
| 305 | +type Article struct { | |
| 306 | + GUID string | |
| 307 | + Title string | |
| 308 | + URL string | |
| 309 | + Author string | |
| 310 | + Content string | |
| 311 | + Summary string | |
| 312 | + Published time.Time | |
| 313 | + Updated time.Time | |
| 314 | +} | |
| 315 | +``` | |
| 316 | + | |
| 317 | +Articles are deduplicated by `(feed_url, guid)`. On upsert, only the article metadata changes — reading state is preserved. | |
| 318 | + | |
| 319 | +### 4.4 Article Content | |
| 320 | + | |
| 321 | +Glean stores article content locally so the reading experience is fast and consistent: | |
| 322 | + | |
| 323 | +```sql | |
| 324 | +CREATE TABLE articles ( | |
| 325 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 326 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 327 | + guid TEXT NOT NULL, | |
| 328 | + title TEXT NOT NULL DEFAULT '', | |
| 329 | + url TEXT, | |
| 330 | + author TEXT, | |
| 331 | + summary TEXT, | |
| 332 | + content TEXT, | |
| 333 | + published DATETIME, | |
| 334 | + updated DATETIME, | |
| 335 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 336 | + UNIQUE(feed_url, guid) | |
| 337 | +); | |
| 338 | +``` | |
| 339 | + | |
| 340 | +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.). | |
| 341 | + | |
| 342 | +### 4.5 Read State | |
| 343 | + | |
| 344 | +Read/unread state is tracked per user per article: | |
| 345 | + | |
| 346 | +```sql | |
| 347 | +CREATE TABLE read_state ( | |
| 348 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 349 | + article_id INTEGER NOT NULL REFERENCES articles(id), | |
| 350 | + is_read BOOLEAN NOT NULL DEFAULT 0, | |
| 351 | + read_at DATETIME, | |
| 352 | + PRIMARY KEY (user_did, article_id) | |
| 353 | +); | |
| 354 | + | |
| 355 | +CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0; | |
| 356 | +``` | |
| 357 | + | |
| 358 | +### 4.6 Reading Experience | |
| 359 | + | |
| 360 | +The `/articles` page is the main reading view: | |
| 361 | + | |
| 362 | +- **River of news**: Chronological list of all unread articles across all subscriptions | |
| 363 | +- **Feed filter**: Narrow to a single feed or category | |
| 364 | +- **Mark as read**: Individual or "mark all above" / "mark all" | |
| 365 | +- **Like**: Endorse an article (public, synced to Bluesky PDS) | |
| 366 | +- **Open original**: Title links to the source article | |
| 367 | +- **Share**: Share to Bluesky | |
| 368 | +- **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) | |
| 369 | + | |
| 370 | +### 4.7 Feed Discovery from Content | |
| 371 | + | |
| 372 | +Beyond the clustering system, Glean also discovers new feeds from article content: | |
| 373 | + | |
| 374 | +- **Auto-discovery**: When fetching a feed, parse `<link rel="alternate" type="application/rss+xml">` from the feed's site URL to discover related feeds | |
| 375 | +- **Feedfavicon**: Fetch `favicon.ico` or `/apple-touch-icon.png` from the feed's site URL for display | |
| 376 | +- **Dead feed detection**: If a feed fails for 7 consecutive fetches (14 days at base interval), mark it as dead. Notify the user and offer to remove it. | |
| 377 | + | |
| 378 | +## 5. System Architecture | |
| 379 | + | |
| 380 | +Glean runs as a single Go binary that fills three roles: **AppView** (indexing `at.glean.*` records from the firehose, serving XRPC queries), **RSS reader** (fetching and storing feed content), and **web UI** (htmx frontend). | |
| 381 | + | |
| 382 | +``` | |
| 383 | + AT Relay (bsky.network) | |
| 384 | + │ firehose | |
| 385 | + ▼ | |
| 386 | + ┌─────────────────────┐ | |
| 387 | + │ Go Server (glean.at)│ | |
| 388 | + │ │ | |
| 389 | + Browser ──HTTP──► │ ┌────────────────┐ │ ──XRPC queries──► Other AT apps | |
| 390 | + (htmx + TW) │ │ Router │ │ | |
| 391 | + │ │ ┌───────────┐ │ │ | |
| 392 | + │ │ │ Handlers │ │ │ | |
| 393 | + │ │ │ (UI + XRPC)│ │ │ | |
| 394 | + │ │ └─────┬─────┘ │ │ | |
| 395 | + │ └────────┼────────┘ │ | |
| 396 | + │ │ │ | |
| 397 | + │ ┌────────▼────────┐ │ ┌──────────────────┐ | |
| 398 | + │ │ Service Layer │ │ │ Feed Scheduler │ | |
| 399 | + │ │ │──┼──sync──►│ (goroutine) │ | |
| 400 | + │ └────────┬────────┘ │ │ Fetcher + Parser│ | |
| 401 | + │ │ │ └────────┬─────────┘ | |
| 402 | + │ ┌────────▼────────┐ │ │ | |
| 403 | + │ │ SQLite │ │ RSS/Atom/JSON feeds | |
| 404 | + │ │ (firehose idx, │ │ | |
| 405 | + │ │ articles, │ │ ┌──────────────────┐ | |
| 406 | + │ │ read state, │ │ │ Cluster Engine │ | |
| 407 | + │ │ clustering) │◄─┼────────►│ (periodic cron) │ | |
| 408 | + │ └─────────────────┘ │ └──────────────────┘ | |
| 409 | + └──────────────────────┘ | |
| 410 | + | |
| 411 | + AppView responsibilities: | |
| 412 | + • Subscribe to firehose for at.glean.subscription, at.glean.annotation, at.glean.like | |
| 413 | + • Index records into SQLite | |
| 414 | + • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) | |
| 415 | + • Host the web UI at glean.at | |
| 416 | + • Write to user PDS on behalf of user (when user acts through UI) | |
| 417 | +``` | |
| 418 | + | |
| 419 | +## 6. Database Schema (SQLite) | |
| 420 | + | |
| 421 | +### 6.1 Users | |
| 422 | + | |
| 423 | +```sql | |
| 424 | +CREATE TABLE users ( | |
| 425 | + did TEXT PRIMARY KEY, | |
| 426 | + handle TEXT NOT NULL, | |
| 427 | + display_name TEXT, | |
| 428 | + avatar_url TEXT, | |
| 429 | + indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 430 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 431 | +); | |
| 432 | +``` | |
| 433 | + | |
| 434 | +### 6.2 Feed Subscriptions | |
| 435 | + | |
| 436 | +Indexed from `at.glean.subscription` records on user PDS. | |
| 437 | + | |
| 438 | +```sql | |
| 439 | +CREATE TABLE subscriptions ( | |
| 440 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 441 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 442 | + feed_url TEXT NOT NULL, | |
| 443 | + category TEXT, | |
| 444 | + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 445 | + UNIQUE(user_did, feed_url) | |
| 446 | +); | |
| 447 | + | |
| 448 | +CREATE INDEX idx_subscriptions_feed ON subscriptions(feed_url); | |
| 449 | +CREATE INDEX idx_subscriptions_user ON subscriptions(user_did); | |
| 450 | +``` | |
| 451 | + | |
| 452 | +### 6.3 Feeds | |
| 453 | + | |
| 454 | +Master list of all known RSS feeds. | |
| 455 | + | |
| 456 | +```sql | |
| 457 | +CREATE TABLE feeds ( | |
| 458 | + feed_url TEXT PRIMARY KEY, | |
| 459 | + title TEXT, | |
| 460 | + site_url TEXT, | |
| 461 | + description TEXT, | |
| 462 | + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')), | |
| 463 | + last_fetched_at DATETIME, | |
| 464 | + last_error TEXT, | |
| 465 | + subscriber_count INTEGER NOT NULL DEFAULT 0 | |
| 466 | +); | |
| 467 | +``` | |
| 468 | + | |
| 469 | +### 6.4 Articles | |
| 470 | + | |
| 471 | +Fetched from RSS feeds. Only fetched for feeds that have local subscribers. | |
| 472 | + | |
| 473 | +```sql | |
| 474 | +CREATE TABLE articles ( | |
| 475 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 476 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 477 | + guid TEXT NOT NULL, | |
| 478 | + title TEXT, | |
| 479 | + url TEXT, | |
| 480 | + author TEXT, | |
| 481 | + published DATETIME, | |
| 482 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 483 | + UNIQUE(feed_url, guid) | |
| 484 | +); | |
| 485 | + | |
| 486 | +CREATE INDEX idx_articles_feed ON articles(feed_url); | |
| 487 | +CREATE INDEX idx_articles_published ON articles(published DESC); | |
| 488 | +``` | |
| 489 | + | |
| 490 | +### 6.5 Annotations, Likes | |
| 491 | + | |
| 492 | +Local mirror of AT Protocol lexicon records for fast querying. | |
| 493 | + | |
| 494 | +```sql | |
| 495 | +CREATE TABLE annotations ( | |
| 496 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 497 | + uri TEXT NOT NULL UNIQUE, | |
| 498 | + author_did TEXT NOT NULL REFERENCES users(did), | |
| 499 | + feed_url TEXT NOT NULL, | |
| 500 | + article_url TEXT NOT NULL, | |
| 501 | + quote TEXT, | |
| 502 | + note TEXT, | |
| 503 | + tags TEXT, | |
| 504 | + rating INTEGER, | |
| 505 | + created_at DATETIME NOT NULL | |
| 506 | +); | |
| 507 | + | |
| 508 | +CREATE TABLE likes ( | |
| 509 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 510 | + uri TEXT NOT NULL UNIQUE, | |
| 511 | + author_did TEXT NOT NULL REFERENCES users(did), | |
| 512 | + feed_url TEXT NOT NULL, | |
| 513 | + article_url TEXT NOT NULL, | |
| 514 | + created_at DATETIME NOT NULL, | |
| 515 | + UNIQUE(author_did, feed_url, article_url) | |
| 516 | +); | |
| 517 | +``` | |
| 518 | + | |
| 519 | +### 6.6 Cluster Precomputation | |
| 520 | + | |
| 521 | +Stores precomputed similarity data to avoid recalculating on every request. | |
| 522 | + | |
| 523 | +```sql | |
| 524 | +CREATE TABLE feed_similarity ( | |
| 525 | + feed_a TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 526 | + feed_b TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 527 | + jaccard REAL NOT NULL, | |
| 528 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 529 | + PRIMARY KEY (feed_a, feed_b), | |
| 530 | + CHECK(feed_a < feed_b) | |
| 531 | +); | |
| 532 | + | |
| 533 | +CREATE TABLE user_similarity ( | |
| 534 | + user_a TEXT NOT NULL REFERENCES users(did), | |
| 535 | + user_b TEXT NOT NULL REFERENCES users(did), | |
| 536 | + jaccard REAL NOT NULL, | |
| 537 | + common_feeds INTEGER NOT NULL, | |
| 538 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 539 | + PRIMARY KEY (user_a, user_b), | |
| 540 | + CHECK(user_a < user_b) | |
| 541 | +); | |
| 542 | +``` | |
| 543 | + | |
| 544 | +## 7. Clustering & Recommendations | |
| 545 | + | |
| 546 | +Glean has two complementary recommendation signals: | |
| 547 | + | |
| 548 | +- **Subscriptions** (Jaccard similarity): "Who reads the same feeds?" → feed and people discovery | |
| 549 | +- **Likes** (co-occurrence): "Who likes the same articles?" → article and feed discovery | |
| 550 | + | |
| 551 | +### 7.1 Feed Co-occurrence (Jaccard Similarity) | |
| 552 | + | |
| 553 | +For any two feeds, the similarity is the Jaccard index of their subscriber sets: | |
| 554 | + | |
| 555 | +``` | |
| 556 | +J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)| | |
| 557 | +``` | |
| 558 | + | |
| 559 | +This is recomputed periodically (cron job) or incrementally when subscriptions change. | |
| 560 | + | |
| 561 | +### 7.2 User Similarity | |
| 562 | + | |
| 563 | +For any two users, compute Jaccard over their subscription sets: | |
| 564 | + | |
| 565 | +``` | |
| 566 | +J(U1, U2) = |feeds(U1) ∩ feeds(U2)| / |feeds(U1) ∪ feeds(U2)| | |
| 567 | +``` | |
| 568 | + | |
| 569 | +### 7.3 Recommendation Algorithms | |
| 570 | + | |
| 571 | +**Feed recommendations (on glean.at):** | |
| 572 | + | |
| 573 | +1. Find users with Jaccard > 0.2 (similar readers) | |
| 574 | +2. Collect feeds those users subscribe to that the target user does not | |
| 575 | +3. Rank by frequency (how many similar users subscribe) and average similarity | |
| 576 | +4. Return top N feeds as recommendations | |
| 577 | + | |
| 578 | +``` | |
| 579 | +score(feed) = Σ J(target, U) for each user U subscribed to feed | |
| 580 | +``` | |
| 581 | + | |
| 582 | +**Article recommendations (on glean.at, from likes):** | |
| 583 | + | |
| 584 | +1. Find users who liked articles that the target user also liked | |
| 585 | +2. Collect articles those users liked that the target has not | |
| 586 | +3. Rank by frequency and recency | |
| 587 | +4. Return top N articles as recommendations | |
| 588 | + | |
| 589 | +``` | |
| 590 | +score(article) = Σ 1/logN(likers(article)) for each user U who liked it | |
| 591 | +``` | |
| 592 | + | |
| 593 | +The `1/logN` weighting avoids over-recommending articles from very large feeds. | |
| 594 | + | |
| 595 | +**People recommendations (to follow on Bluesky):** | |
| 596 | + | |
| 597 | +1. Compute user similarity for all pairs | |
| 598 | +2. Return users with highest Jaccard, linking to their Bluesky profile for follow | |
| 599 | + | |
| 600 | +### 7.4 Implementation | |
| 601 | + | |
| 602 | +For the initial version, brute-force Jaccard with SQLite is sufficient (scale: ~10k users, ~100k subscriptions). The query is: | |
| 603 | + | |
| 604 | +```sql | |
| 605 | +SELECT s2.feed_url, COUNT(*) as overlap_count | |
| 606 | +FROM subscriptions s1 | |
| 607 | +JOIN subscriptions s2 ON s1.feed_url = s2.feed_url | |
| 608 | +WHERE s1.user_did = ? AND s2.user_did != ? | |
| 609 | +AND s2.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | |
| 610 | +GROUP BY s2.feed_url | |
| 611 | +ORDER BY overlap_count DESC | |
| 612 | +LIMIT 20; | |
| 613 | +``` | |
| 614 | + | |
| 615 | +For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard in sub-linear time. | |
| 616 | + | |
| 617 | +### 7.5 Clustering Engine (Cron) | |
| 618 | + | |
| 619 | +A background goroutine runs on a schedule (e.g., every 6 hours): | |
| 620 | + | |
| 621 | +1. **Firehose ingestion**: Subscribe to AT Protocol firehose for `at.glean.*` records | |
| 622 | +2. **Index new records**: Parse lexicon records, upsert into SQLite | |
| 623 | +3. **Compute similarities**: Batch-update the `feed_similarity`, `user_similarity`, and `article_co_like` tables | |
| 624 | +4. **Generate recommendations**: Materialize top recommendations per user into cache tables | |
| 625 | + | |
| 626 | +```sql | |
| 627 | +CREATE TABLE user_feed_recommendations ( | |
| 628 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 629 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 630 | + score REAL NOT NULL, | |
| 631 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 632 | + PRIMARY KEY (user_did, feed_url) | |
| 633 | +); | |
| 634 | + | |
| 635 | +CREATE TABLE user_article_recommendations ( | |
| 636 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 637 | + feed_url TEXT NOT NULL, | |
| 638 | + article_url TEXT NOT NULL, | |
| 639 | + score REAL NOT NULL, | |
| 640 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 641 | + PRIMARY KEY (user_did, feed_url, article_url) | |
| 642 | +); | |
| 643 | +``` | |
| 644 | + | |
| 645 | +## 8. HTTP API / htmx Endpoints | |
| 646 | + | |
| 647 | +The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend. | |
| 648 | + | |
| 649 | +### 8.1 Pages | |
| 650 | + | |
| 651 | +| Route | Method | Description | | |
| 652 | +| ---------------------- | ------ | -------------------------------------------------------- | | |
| 653 | +| `/` | GET | Landing page / auth redirect | | |
| 654 | +| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | |
| 655 | +| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | |
| 656 | +| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions | | |
| 657 | +| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | |
| 658 | +| `/feeds/add` | POST | Add a single feed URL | | |
| 659 | +| `/feeds/remove` | DELETE | Remove a feed | | |
| 660 | +| `/articles` | GET | Read articles (paginated, filterable by feed) | | |
| 661 | +| `/trending` | GET | Community feed: articles ranked by likes | | |
| 662 | +| `/discover` | GET | Feed recommendations + similar people | | |
| 663 | +| `/discover/feeds` | GET | Recommended feeds | | |
| 664 | +| `/discover/people` | GET | People with similar reading habits | | |
| 665 | +| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | |
| 666 | +| `/articles/{id}/like` | POST | Like an article (amplify into community feed) | | |
| 667 | +| `/annotations` | GET | View your annotations | | |
| 668 | +| `/annotations/create` | POST | Create annotation on an article | | |
| 669 | + | |
| 670 | +### 8.2 htmx Patterns | |
| 671 | + | |
| 672 | +- **Feed list**: `<div hx-get="/feeds/list" hx-trigger="load">` renders the subscription list as a fragment | |
| 673 | +- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination | |
| 674 | +- **Like button**: `<button hx-post="/articles/{id}/like" hx-swap="outerHTML">` self-updates the button state | |
| 675 | +- **OPML upload**: `<form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" hx-target="#feed-list">` | |
| 676 | + | |
| 677 | +## 9. Project Structure | |
| 678 | + | |
| 679 | +``` | |
| 680 | +glean/ | |
| 681 | +├── main.go # Entry point, wire everything | |
| 682 | +├── go.mod | |
| 683 | +├── go.sum | |
| 684 | +├── internal/ | |
| 685 | +│ ├── atproto/ | |
| 686 | +│ │ ├── auth.go # DID resolution, OAuth flow | |
| 687 | +│ │ ├── client.go # XRPC client (write to user PDS) | |
| 688 | +│ │ ├── firehose.go # Subscribe to AT Relay firehose | |
| 689 | +│ │ ├── lexicon.go # Lexicon record types + validation | |
| 690 | +│ │ └── xrpc.go # XRPC query handlers (AppView endpoints) | |
| 691 | +│ ├── db/ | |
| 692 | +│ │ ├── db.go # SQLite connection, migrations | |
| 693 | +│ │ ├── user.go # User queries | |
| 694 | +│ │ ├── feed.go # Feed + subscription queries | |
| 695 | +│ │ ├── article.go # Article queries | |
| 696 | +│ │ ├── social.go # Like, annotation queries | |
| 697 | +│ │ └── cluster.go # Similarity + recommendation queries | |
| 698 | +│ ├── feed/ | |
| 699 | +│ │ ├── parser.go # RSS/Atom/JSON feed parser | |
| 700 | +│ │ ├── fetcher.go # Fetch and parse feeds from URLs | |
| 701 | +│ │ └── opml.go # OPML import/export | |
| 702 | +│ ├── cluster/ | |
| 703 | +│ │ ├── jaccard.go # Jaccard similarity computation | |
| 704 | +│ │ ├── recommender.go # Feed + people recommendation logic | |
| 705 | +│ │ └── cron.go # Background recomputation scheduler | |
| 706 | +│ ├── server/ | |
| 707 | +│ │ ├── server.go # HTTP server, router setup | |
| 708 | +│ │ ├── middleware.go # Auth, logging, CSRF middleware | |
| 709 | +│ │ ├── session.go # Session management (cookie + DID) | |
| 710 | +│ │ └── handlers/ | |
| 711 | +│ │ ├── dashboard.go | |
| 712 | +│ │ ├── feeds.go | |
| 713 | +│ │ ├── articles.go | |
| 714 | +│ │ ├── trending.go | |
| 715 | +│ │ ├── discover.go | |
| 716 | +│ │ ├── profile.go | |
| 717 | +│ │ ├── annotations.go | |
| 718 | +│ │ └── auth.go | |
| 719 | +│ └── tmpl/ | |
| 720 | +│ ├── base.html # Base template with htmx + Tailwind | |
| 721 | +│ ├── partials/ | |
| 722 | +│ │ ├── feed-list.html | |
| 723 | +│ │ ├── article-list.html | |
| 724 | +│ │ ├── like-button.html | |
| 725 | +│ │ ├── annotation-card.html | |
| 726 | +│ │ ├── recommendation-card.html | |
| 727 | +│ │ └── profile-card.html | |
| 728 | +│ ├── dashboard.html | |
| 729 | +│ ├── feeds.html | |
| 730 | +│ ├── articles.html | |
| 731 | +│ ├── trending.html | |
| 732 | +│ ├── discover.html | |
| 733 | +│ ├── profile.html | |
| 734 | +│ └── annotations.html | |
| 735 | +├── static/ | |
| 736 | +│ ├── input.css # Tailwind input | |
| 737 | +│ └── output.css # Tailwind compiled output | |
| 738 | +├── docs/ | |
| 739 | +│ └── design.md # This document | |
| 740 | +└── tailwind.config.js | |
| 741 | +``` | |
| 742 | + | |
| 743 | +## 10. Auth Flow | |
| 744 | + | |
| 745 | +1. User visits `/`, clicks "Sign in with Bluesky" (or any AT Proto PDS) | |
| 746 | +2. Server redirects to AT Protocol OAuth authorization endpoint | |
| 747 | +3. User authorizes on their PDS | |
| 748 | +4. PDS redirects back with authorization code | |
| 749 | +5. Server exchanges code for access token + refresh token | |
| 750 | +6. Server resolves DID and creates a session (cookie with encrypted DID) | |
| 751 | +7. On each request, middleware decrypts session, loads user from DB (or creates if new) | |
| 752 | + | |
| 753 | +The server never stores the user's AT Protocol password. It stores the session tokens for XRPC calls to the user's PDS (to read/write their lexicon records). | |
| 754 | + | |
| 755 | +## 11. Data Flow | |
| 756 | + | |
| 757 | +### 11.1 User Imports OPML | |
| 758 | + | |
| 759 | +``` | |
| 760 | +Browser ──POST /feeds/opml/upload──► Server | |
| 761 | + │ | |
| 762 | + ├─► Parse OPML, extract feed URLs | |
| 763 | + ├─► Fetch each feed, validate + store in `feeds` table | |
| 764 | + ├─► For each feed, create an `at.glean.subscription` record | |
| 765 | + │ via XRPC write to user's PDS | |
| 766 | + ├─► Insert subscriptions in local `subscriptions` table | |
| 767 | + └─◄ Return updated feed list fragment (htmx) | |
| 768 | +``` | |
| 769 | + | |
| 770 | +### 11.2 Reading the Feed | |
| 771 | + | |
| 772 | +``` | |
| 773 | +Browser ──GET /articles──► Server | |
| 774 | + │ | |
| 775 | + ├─► Query articles for user's subscriptions | |
| 776 | + ├─► Render article-list.html partial | |
| 777 | + └─◄ Return HTML fragment (htmx) | |
| 778 | +``` | |
| 779 | + | |
| 780 | +### 11.3 Recommendations | |
| 781 | + | |
| 782 | +``` | |
| 783 | +Cron (every 6h) ──► Cluster Engine | |
| 784 | + │ | |
| 785 | + ├─► SELECT user similarity pairs | |
| 786 | + ├─► Compute recommendation scores | |
| 787 | + └─► INSERT into user_feed_recommendations | |
| 788 | + | |
| 789 | +Browser ──GET /discover/feeds──► Server | |
| 790 | + │ | |
| 791 | + ├─► SELECT from user_feed_recommendations | |
| 792 | + ├─► Fetch feed metadata | |
| 793 | + └─◄ Render recommendation cards (htmx) | |
| 794 | +``` | |
| 795 | + | |
| 796 | +## 12. Key Design Decisions | |
| 797 | + | |
| 798 | +### 12.1 Why use lexicon records for feed subscriptions? | |
| 799 | + | |
| 800 | +- **User sovereignty**: Each feed subscription lives as a record on the user's PDS. They can export them, move PDS, or revoke access at any time. | |
| 801 | +- **Interoperability**: Any AT Protocol app can read the lexicon and integrate with Glean data. | |
| 802 | +- **No lock-in**: If Glean shuts down, the user's data is intact on their PDS. | |
| 803 | +- **OPML as transport only**: OPML is a common interchange format used solely for import (onboarding from existing readers) and export (offboarding). The canonical representation is always the lexicon — individual `at.glean.subscription` records, one per feed. | |
| 804 | + | |
| 805 | +### 12.2 Why SQLite? | |
| 806 | + | |
| 807 | +- Single-binary deployment, no external database dependency | |
| 808 | +- More than sufficient for the expected scale (tens of thousands of users) | |
| 809 | +- Go's `database/sql` interface makes it easy to swap later if needed | |
| 810 | +- Matches the project's philosophy of simplicity | |
| 811 | + | |
| 812 | +### 12.3 Why htmx? | |
| 813 | + | |
| 814 | +- No JavaScript build pipeline | |
| 815 | +- Server renders everything — simpler mental model | |
| 816 | +- Progressive enhancement works naturally | |
| 817 | +- Perfect fit for a read-centric application | |
| 818 | +- TailwindCSS handles styling without writing custom CSS | |
| 819 | + | |
| 820 | +### 12.4 AppView Architecture | |
| 821 | + | |
| 822 | +Glean operates as an AT Protocol AppView. This means: | |
| 823 | + | |
| 824 | +- **Read path**: All `at.glean.*` data is consumed from the Relay firehose, not by polling individual PDS instances. The firehose handler runs as a persistent goroutine, upserting records into SQLite as they arrive. | |
| 825 | +- **Write path**: Users write records to their own PDS (via standard AT Protocol `com.atproto.repo.createRecord` / `deleteRecord`). Glean never stores user data directly — it only indexes what the firehose delivers. | |
| 826 | +- **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. | |
| 827 | +- **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. | |
| 828 | + | |
| 829 | +### 12.5 Privacy Model | |
| 830 | + | |
| 831 | +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. | |
| 832 | + | |
| 833 | +## 13. Future Considerations | |
| 834 | + | |
| 835 | +- **MinHash/LSH**: Replace brute-force Jaccard when user count exceeds ~50k | |
| 836 | +- **Full-text search**: Add FTS5 virtual table on articles for search | |
| 837 | +- **Feed groups / reading lists**: Allow users to create curated lists (separate lexicon) | |
| 838 | +- **Email digest**: Periodic email with top articles from subscribed feeds | |
| 839 | +- **Multi-AppView scaling**: Distribute firehose consumption across multiple instances behind a load balancer | |
| new file mode 100644 | |||
| @@ -0,0 +1,839 @@ | |||
| 1 | +# Glean - Design Document | ||
| 2 | + | ||
| 3 | +## 1. Overview | ||
| 4 | + | ||
| 5 | +Glean is a social RSS reader built on the AT Protocol. It operates as an **AppView** for the `at.glean.*` lexicon namespace: it indexes records from the relay firehose, serves XRPC query endpoints, and provides the web UI at [glean.at](https://glean.at). | ||
| 6 | + | ||
| 7 | +Users store their RSS feed subscriptions as individual lexicon records on their PDS (one record per feed). Glean's AppView consumes the firehose, indexes those records, fetches the referenced RSS feeds, and serves both the reader UI and public XRPC APIs for the `at.glean.*` namespace. | ||
| 8 | + | ||
| 9 | +The core idea: your RSS subscriptions are a strong signal about your interests. When enough people expose theirs, you can discover both **people** (who reads the same things) and **content** (what similar readers follow that you don't). | ||
| 10 | + | ||
| 11 | +## 2. Stack | ||
| 12 | + | ||
| 13 | +| Layer | Technology | | ||
| 14 | +| ---------------- | ---------------------------------- | | ||
| 15 | +| Backend | Go | | ||
| 16 | +| Database | SQLite (via `modernc.org/sqlite`) | | ||
| 17 | +| Frontend | htmx + TailwindCSS | | ||
| 18 | +| Auth | AT Protocol OAuth / DID resolution | | ||
| 19 | +| AT Protocol role | AppView for `at.glean.*` lexicons | | ||
| 20 | +| Data source | AT Relay firehose → SQLite index | | ||
| 21 | + | ||
| 22 | +## 3. AT Protocol Lexicons | ||
| 23 | + | ||
| 24 | +All user data lives on their PDS. The server does not own user data — it indexes and aggregates it. | ||
| 25 | + | ||
| 26 | +### 3.1 `at.glean.subscription` | ||
| 27 | + | ||
| 28 | +A single RSS feed subscription. One record per feed per user. Created automatically when a user subscribes to a feed. During onboarding, existing subscriptions can be bulk-imported from an OPML file — the OPML is parsed and individual subscription records are created. | ||
| 29 | + | ||
| 30 | +```json | ||
| 31 | +{ | ||
| 32 | + "lexicon": 1, | ||
| 33 | + "id": "at.glean.subscription", | ||
| 34 | + "defs": { | ||
| 35 | + "main": { | ||
| 36 | + "type": "record", | ||
| 37 | + "key": "tid", | ||
| 38 | + "description": "A single RSS feed subscription.", | ||
| 39 | + "record": { | ||
| 40 | + "type": "object", | ||
| 41 | + "required": ["feedUrl"], | ||
| 42 | + "properties": { | ||
| 43 | + "createdAt": { "type": "string", "format": "datetime" }, | ||
| 44 | + "feedUrl": { "type": "string" }, | ||
| 45 | + "title": { "type": "string" }, | ||
| 46 | + "category": { "type": "string" } | ||
| 47 | + } | ||
| 48 | + } | ||
| 49 | + } | ||
| 50 | + } | ||
| 51 | +} | ||
| 52 | +``` | ||
| 53 | + | ||
| 54 | +#### OPML Import/Export | ||
| 55 | + | ||
| 56 | +OPML is **not** part of the lexicon. It is only used as a transport format: | ||
| 57 | + | ||
| 58 | +- **Import (onboarding)**: User uploads an OPML file. Glean parses it, validates each feed URL, creates individual `at.glean.subscription` records on the user's PDS, and indexes them locally. | ||
| 59 | +- **Export (offboarding)**: Glean reads the user's `at.glean.subscription` records from their PDS and generates an OPML file for download. The user's repository remains the canonical source. | ||
| 60 | + | ||
| 61 | +### 3.2 `at.glean.annotation` | ||
| 62 | + | ||
| 63 | +Reading notes on an article: quote a passage, tag it, rate it, or write a note. A user can have many annotations per article. These are public records on the PDS — users can always share an annotation on Bluesky if they want discussion. | ||
| 64 | + | ||
| 65 | +```json | ||
| 66 | +{ | ||
| 67 | + "lexicon": 1, | ||
| 68 | + "id": "at.glean.annotation", | ||
| 69 | + "defs": { | ||
| 70 | + "main": { | ||
| 71 | + "type": "record", | ||
| 72 | + "key": "tid", | ||
| 73 | + "description": "Reading note on a specific RSS article.", | ||
| 74 | + "record": { | ||
| 75 | + "type": "object", | ||
| 76 | + "required": ["feedUrl", "articleUrl"], | ||
| 77 | + "properties": { | ||
| 78 | + "createdAt": { "type": "string", "format": "datetime" }, | ||
| 79 | + "feedUrl": { "type": "string" }, | ||
| 80 | + "articleUrl": { "type": "string" }, | ||
| 81 | + "quote": { "type": "string", "maxGraphemes": 5000 }, | ||
| 82 | + "note": { "type": "string", "maxGraphemes": 500 }, | ||
| 83 | + "tags": { | ||
| 84 | + "type": "array", | ||
| 85 | + "items": { "type": "string", "maxGraphemes": 50 }, | ||
| 86 | + "maxLength": 10 | ||
| 87 | + }, | ||
| 88 | + "rating": { "type": "integer", "minimum": 1, "maximum": 5 } | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + } | ||
| 93 | +} | ||
| 94 | +``` | ||
| 95 | + | ||
| 96 | +### 3.3 `at.glean.like` | ||
| 97 | + | ||
| 98 | +A user likes an article. The liked feed surfaces popular articles and feeds into discovery. Likes also feed into the recommendation system. | ||
| 99 | + | ||
| 100 | +```json | ||
| 101 | +{ | ||
| 102 | + "lexicon": 1, | ||
| 103 | + "id": "at.glean.like", | ||
| 104 | + "defs": { | ||
| 105 | + "main": { | ||
| 106 | + "type": "record", | ||
| 107 | + "key": "tid", | ||
| 108 | + "description": "Like an RSS article.", | ||
| 109 | + "record": { | ||
| 110 | + "type": "object", | ||
| 111 | + "required": ["feedUrl", "articleUrl"], | ||
| 112 | + "properties": { | ||
| 113 | + "createdAt": { "type": "string", "format": "datetime" }, | ||
| 114 | + "feedUrl": { "type": "string" }, | ||
| 115 | + "articleUrl": { "type": "string" } | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | +} | ||
| 121 | +``` | ||
| 122 | + | ||
| 123 | +### 3.4 AppView Query Lexicons | ||
| 124 | + | ||
| 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. | ||
| 126 | + | ||
| 127 | +#### `at.glean.listSubscriptions` | ||
| 128 | + | ||
| 129 | +List subscriptions from a repo, with optional filtering. | ||
| 130 | + | ||
| 131 | +``` | ||
| 132 | +Input: | ||
| 133 | + repo: string (DID of the user) | ||
| 134 | + category?: string | ||
| 135 | + limit?: integer (default 50, max 100) | ||
| 136 | + cursor?: string | ||
| 137 | + | ||
| 138 | +Output: | ||
| 139 | + cursor?: string | ||
| 140 | + subscriptions: [{ uri, cid, value: at.glean.subscription#main, indexedAt }] | ||
| 141 | +``` | ||
| 142 | + | ||
| 143 | +#### `at.glean.listFeedLists` | ||
| 144 | + | ||
| 145 | +List subscription lists from multiple repos, with optional filtering. | ||
| 146 | + | ||
| 147 | +``` | ||
| 148 | +Input: | ||
| 149 | + actors?: string[] (filter by DIDs) | ||
| 150 | + limit?: integer (default 50, max 100) | ||
| 151 | + cursor?: string | ||
| 152 | + | ||
| 153 | +Output: | ||
| 154 | + cursor?: string | ||
| 155 | + feeds: [{ did, subscriptionCount, subscriptions: [{ feedUrl, title, category }] }] | ||
| 156 | +``` | ||
| 157 | + | ||
| 158 | +#### `at.glean.listAnnotations` | ||
| 159 | + | ||
| 160 | +List annotations for an article, a feed, or by a user. | ||
| 161 | + | ||
| 162 | +``` | ||
| 163 | +Input: | ||
| 164 | + feedUrl?: string | ||
| 165 | + articleUrl?: string | ||
| 166 | + author?: string (DID) | ||
| 167 | + limit?: integer (default 50, max 100) | ||
| 168 | + cursor?: string | ||
| 169 | + | ||
| 170 | +Output: | ||
| 171 | + cursor?: string | ||
| 172 | + annotations: [{ uri, cid, author: { did, handle }, value, indexedAt }] | ||
| 173 | +``` | ||
| 174 | + | ||
| 175 | +#### `at.glean.listLikes` | ||
| 176 | + | ||
| 177 | +List liked articles, optionally filtered by user or feed. | ||
| 178 | + | ||
| 179 | +``` | ||
| 180 | +Input: | ||
| 181 | + author?: string (DID) | ||
| 182 | + feedUrl?: string | ||
| 183 | + limit?: integer (default 50, max 100) | ||
| 184 | + cursor?: string | ||
| 185 | + | ||
| 186 | +Output: | ||
| 187 | + cursor?: string | ||
| 188 | + likes: [{ uri, cid, author: { did, handle }, value: at.glean.like#main, indexedAt }] | ||
| 189 | +``` | ||
| 190 | + | ||
| 191 | +#### `at.glean.getTrending` | ||
| 192 | + | ||
| 193 | +Articles with the most likes, forming the community feed. | ||
| 194 | + | ||
| 195 | +``` | ||
| 196 | +Input: | ||
| 197 | + limit?: integer (default 50, max 100) | ||
| 198 | + cursor?: string | ||
| 199 | + since?: string (datetime) | ||
| 200 | + | ||
| 201 | +Output: | ||
| 202 | + cursor?: string | ||
| 203 | + articles: [{ feedUrl, articleUrl, title, likeCount, annotations: [...] }] | ||
| 204 | +``` | ||
| 205 | + | ||
| 206 | +#### `at.glean.getRecommendations` | ||
| 207 | + | ||
| 208 | +Get feed recommendations for a user based on clustering. | ||
| 209 | + | ||
| 210 | +``` | ||
| 211 | +Input: | ||
| 212 | + repo: string (DID of the user) | ||
| 213 | + limit?: integer (default 20, max 50) | ||
| 214 | + | ||
| 215 | +Output: | ||
| 216 | + feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }] | ||
| 217 | + people: [{ did, handle, displayName, avatar, jaccard, commonFeeds }] | ||
| 218 | +``` | ||
| 219 | + | ||
| 220 | +### 3.5 AppView Firehose Consumption | ||
| 221 | + | ||
| 222 | +Glean subscribes to the AT Relay firehose (`wss://bsky.network`) for all `at.glean.*` records: | ||
| 223 | + | ||
| 224 | +``` | ||
| 225 | +SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like"] | ||
| 226 | +``` | ||
| 227 | + | ||
| 228 | +On each event: | ||
| 229 | + | ||
| 230 | +- **create**: Insert record into local SQLite, update materialized counts | ||
| 231 | +- **delete**: Tombstone the record (soft delete to preserve foreign key integrity) | ||
| 232 | +- **update**: Replace the record's CID and value | ||
| 233 | + | ||
| 234 | +The AppView does not handle writes. Users write records to their own PDS. Glean only reads them from the firehose. | ||
| 235 | + | ||
| 236 | +## 4. RSS Reader | ||
| 237 | + | ||
| 238 | +Glean is first and foremost an RSS reader. It fetches, parses, and stores articles from RSS, Atom, and JSON feeds so users can read them in a clean interface. | ||
| 239 | + | ||
| 240 | +### 4.1 Feed Fetching | ||
| 241 | + | ||
| 242 | +A background scheduler polls subscribed feeds at regular intervals. | ||
| 243 | + | ||
| 244 | +``` | ||
| 245 | + ┌─────────────────────────┐ | ||
| 246 | + │ Feed Scheduler │ | ||
| 247 | + │ (background goroutine) │ | ||
| 248 | + └────────┬────────────────┘ | ||
| 249 | + │ every N minutes | ||
| 250 | + ┌────────▼────────────────┐ | ||
| 251 | + │ Feed Fetcher │ | ||
| 252 | + │ │ | ||
| 253 | + │ 1. SELECT feeds where │ | ||
| 254 | + │ next_fetch <= now │ | ||
| 255 | + │ 2. Respect ETag/If-None-│ | ||
| 256 | + │ Match / Last-Modified│ | ||
| 257 | + │ 3. GET feed URL │ | ||
| 258 | + │ 4. Parse XML/JSON │ | ||
| 259 | + │ 5. Upsert articles │ | ||
| 260 | + │ 6. Update feed metadata│ | ||
| 261 | + └────────┬────────────────┘ | ||
| 262 | + │ | ||
| 263 | + ┌──────────────┼──────────────┐ | ||
| 264 | + │ │ │ | ||
| 265 | + ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ | ||
| 266 | + │RSS/XML │ │Atom/XML │ │JSON Feed │ | ||
| 267 | + │Parser │ │Parser │ │Parser │ | ||
| 268 | + └─────────┘ └───────────┘ └───────────┘ | ||
| 269 | +``` | ||
| 270 | + | ||
| 271 | +### 4.2 Fetch Schedule | ||
| 272 | + | ||
| 273 | +Feeds are not all fetched at the same frequency. The scheduler adapts based on: | ||
| 274 | + | ||
| 275 | +- **Base interval**: Default 30 minutes | ||
| 276 | +- **Feed-level override**: User can set per-feed refresh rate (15min / 30min / 1h / 3h / 6h / 12h / daily) | ||
| 277 | +- **Adaptive backoff**: If a feed has not published new articles in the last N fetches, increase the interval. If it starts publishing again, decrease back. | ||
| 278 | +- **HTTP cache**: Honor `ETag` and `Last-Modified` headers to skip parsing when nothing changed (304 Not Modified) | ||
| 279 | +- **Error backoff**: On failure, double the interval up to 24h, reset on success | ||
| 280 | + | ||
| 281 | +```sql | ||
| 282 | +ALTER TABLE feeds ADD COLUMN fetch_interval_minutes INTEGER NOT NULL DEFAULT 30; | ||
| 283 | +ALTER TABLE feeds ADD COLUMN next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP; | ||
| 284 | +ALTER TABLE feeds ADD COLUMN consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0; | ||
| 285 | +ALTER TABLE feeds ADD COLUMN error_count INTEGER NOT NULL DEFAULT 0; | ||
| 286 | +``` | ||
| 287 | + | ||
| 288 | +### 4.3 Feed Parsing | ||
| 289 | + | ||
| 290 | +Go's `encoding/xml` for RSS and Atom. A simple `encoding/json` for JSON Feed. | ||
| 291 | + | ||
| 292 | +Each parser returns a normalized `Feed` and a slice of `Article` structs: | ||
| 293 | + | ||
| 294 | +```go | ||
| 295 | +type Feed struct { | ||
| 296 | + URL string | ||
| 297 | + Title string | ||
| 298 | + SiteURL string | ||
| 299 | + Description string | ||
| 300 | + Type string // "rss", "atom", "json" | ||
| 301 | + ETag string | ||
| 302 | + LastModified string | ||
| 303 | +} | ||
| 304 | + | ||
| 305 | +type Article struct { | ||
| 306 | + GUID string | ||
| 307 | + Title string | ||
| 308 | + URL string | ||
| 309 | + Author string | ||
| 310 | + Content string | ||
| 311 | + Summary string | ||
| 312 | + Published time.Time | ||
| 313 | + Updated time.Time | ||
| 314 | +} | ||
| 315 | +``` | ||
| 316 | + | ||
| 317 | +Articles are deduplicated by `(feed_url, guid)`. On upsert, only the article metadata changes — reading state is preserved. | ||
| 318 | + | ||
| 319 | +### 4.4 Article Content | ||
| 320 | + | ||
| 321 | +Glean stores article content locally so the reading experience is fast and consistent: | ||
| 322 | + | ||
| 323 | +```sql | ||
| 324 | +CREATE TABLE articles ( | ||
| 325 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 326 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 327 | + guid TEXT NOT NULL, | ||
| 328 | + title TEXT NOT NULL DEFAULT '', | ||
| 329 | + url TEXT, | ||
| 330 | + author TEXT, | ||
| 331 | + summary TEXT, | ||
| 332 | + content TEXT, | ||
| 333 | + published DATETIME, | ||
| 334 | + updated DATETIME, | ||
| 335 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 336 | + UNIQUE(feed_url, guid) | ||
| 337 | +); | ||
| 338 | +``` | ||
| 339 | + | ||
| 340 | +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.). | ||
| 341 | + | ||
| 342 | +### 4.5 Read State | ||
| 343 | + | ||
| 344 | +Read/unread state is tracked per user per article: | ||
| 345 | + | ||
| 346 | +```sql | ||
| 347 | +CREATE TABLE read_state ( | ||
| 348 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 349 | + article_id INTEGER NOT NULL REFERENCES articles(id), | ||
| 350 | + is_read BOOLEAN NOT NULL DEFAULT 0, | ||
| 351 | + read_at DATETIME, | ||
| 352 | + PRIMARY KEY (user_did, article_id) | ||
| 353 | +); | ||
| 354 | + | ||
| 355 | +CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0; | ||
| 356 | +``` | ||
| 357 | + | ||
| 358 | +### 4.6 Reading Experience | ||
| 359 | + | ||
| 360 | +The `/articles` page is the main reading view: | ||
| 361 | + | ||
| 362 | +- **River of news**: Chronological list of all unread articles across all subscriptions | ||
| 363 | +- **Feed filter**: Narrow to a single feed or category | ||
| 364 | +- **Mark as read**: Individual or "mark all above" / "mark all" | ||
| 365 | +- **Like**: Endorse an article (public, synced to Bluesky PDS) | ||
| 366 | +- **Open original**: Title links to the source article | ||
| 367 | +- **Share**: Share to Bluesky | ||
| 368 | +- **Keyboard navigation**: `j`/`k` to navigate, `l` to like, `m` to mark read (progressive enhancement via a small `<script>` block) | ||
| 369 | + | ||
| 370 | +### 4.7 Feed Discovery from Content | ||
| 371 | + | ||
| 372 | +Beyond the clustering system, Glean also discovers new feeds from article content: | ||
| 373 | + | ||
| 374 | +- **Auto-discovery**: When fetching a feed, parse `<link rel="alternate" type="application/rss+xml">` from the feed's site URL to discover related feeds | ||
| 375 | +- **Feedfavicon**: Fetch `favicon.ico` or `/apple-touch-icon.png` from the feed's site URL for display | ||
| 376 | +- **Dead feed detection**: If a feed fails for 7 consecutive fetches (14 days at base interval), mark it as dead. Notify the user and offer to remove it. | ||
| 377 | + | ||
| 378 | +## 5. System Architecture | ||
| 379 | + | ||
| 380 | +Glean runs as a single Go binary that fills three roles: **AppView** (indexing `at.glean.*` records from the firehose, serving XRPC queries), **RSS reader** (fetching and storing feed content), and **web UI** (htmx frontend). | ||
| 381 | + | ||
| 382 | +``` | ||
| 383 | + AT Relay (bsky.network) | ||
| 384 | + │ firehose | ||
| 385 | + ▼ | ||
| 386 | + ┌─────────────────────┐ | ||
| 387 | + │ Go Server (glean.at)│ | ||
| 388 | + │ │ | ||
| 389 | + Browser ──HTTP──► │ ┌────────────────┐ │ ──XRPC queries──► Other AT apps | ||
| 390 | + (htmx + TW) │ │ Router │ │ | ||
| 391 | + │ │ ┌───────────┐ │ │ | ||
| 392 | + │ │ │ Handlers │ │ │ | ||
| 393 | + │ │ │ (UI + XRPC)│ │ │ | ||
| 394 | + │ │ └─────┬─────┘ │ │ | ||
| 395 | + │ └────────┼────────┘ │ | ||
| 396 | + │ │ │ | ||
| 397 | + │ ┌────────▼────────┐ │ ┌──────────────────┐ | ||
| 398 | + │ │ Service Layer │ │ │ Feed Scheduler │ | ||
| 399 | + │ │ │──┼──sync──►│ (goroutine) │ | ||
| 400 | + │ └────────┬────────┘ │ │ Fetcher + Parser│ | ||
| 401 | + │ │ │ └────────┬─────────┘ | ||
| 402 | + │ ┌────────▼────────┐ │ │ | ||
| 403 | + │ │ SQLite │ │ RSS/Atom/JSON feeds | ||
| 404 | + │ │ (firehose idx, │ │ | ||
| 405 | + │ │ articles, │ │ ┌──────────────────┐ | ||
| 406 | + │ │ read state, │ │ │ Cluster Engine │ | ||
| 407 | + │ │ clustering) │◄─┼────────►│ (periodic cron) │ | ||
| 408 | + │ └─────────────────┘ │ └──────────────────┘ | ||
| 409 | + └──────────────────────┘ | ||
| 410 | + | ||
| 411 | + AppView responsibilities: | ||
| 412 | + • Subscribe to firehose for at.glean.subscription, at.glean.annotation, at.glean.like | ||
| 413 | + • Index records into SQLite | ||
| 414 | + • Serve XRPC query endpoints (at.glean.listSubscriptions, etc.) | ||
| 415 | + • Host the web UI at glean.at | ||
| 416 | + • Write to user PDS on behalf of user (when user acts through UI) | ||
| 417 | +``` | ||
| 418 | + | ||
| 419 | +## 6. Database Schema (SQLite) | ||
| 420 | + | ||
| 421 | +### 6.1 Users | ||
| 422 | + | ||
| 423 | +```sql | ||
| 424 | +CREATE TABLE users ( | ||
| 425 | + did TEXT PRIMARY KEY, | ||
| 426 | + handle TEXT NOT NULL, | ||
| 427 | + display_name TEXT, | ||
| 428 | + avatar_url TEXT, | ||
| 429 | + indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 430 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 431 | +); | ||
| 432 | +``` | ||
| 433 | + | ||
| 434 | +### 6.2 Feed Subscriptions | ||
| 435 | + | ||
| 436 | +Indexed from `at.glean.subscription` records on user PDS. | ||
| 437 | + | ||
| 438 | +```sql | ||
| 439 | +CREATE TABLE subscriptions ( | ||
| 440 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 441 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 442 | + feed_url TEXT NOT NULL, | ||
| 443 | + category TEXT, | ||
| 444 | + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 445 | + UNIQUE(user_did, feed_url) | ||
| 446 | +); | ||
| 447 | + | ||
| 448 | +CREATE INDEX idx_subscriptions_feed ON subscriptions(feed_url); | ||
| 449 | +CREATE INDEX idx_subscriptions_user ON subscriptions(user_did); | ||
| 450 | +``` | ||
| 451 | + | ||
| 452 | +### 6.3 Feeds | ||
| 453 | + | ||
| 454 | +Master list of all known RSS feeds. | ||
| 455 | + | ||
| 456 | +```sql | ||
| 457 | +CREATE TABLE feeds ( | ||
| 458 | + feed_url TEXT PRIMARY KEY, | ||
| 459 | + title TEXT, | ||
| 460 | + site_url TEXT, | ||
| 461 | + description TEXT, | ||
| 462 | + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')), | ||
| 463 | + last_fetched_at DATETIME, | ||
| 464 | + last_error TEXT, | ||
| 465 | + subscriber_count INTEGER NOT NULL DEFAULT 0 | ||
| 466 | +); | ||
| 467 | +``` | ||
| 468 | + | ||
| 469 | +### 6.4 Articles | ||
| 470 | + | ||
| 471 | +Fetched from RSS feeds. Only fetched for feeds that have local subscribers. | ||
| 472 | + | ||
| 473 | +```sql | ||
| 474 | +CREATE TABLE articles ( | ||
| 475 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 476 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 477 | + guid TEXT NOT NULL, | ||
| 478 | + title TEXT, | ||
| 479 | + url TEXT, | ||
| 480 | + author TEXT, | ||
| 481 | + published DATETIME, | ||
| 482 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 483 | + UNIQUE(feed_url, guid) | ||
| 484 | +); | ||
| 485 | + | ||
| 486 | +CREATE INDEX idx_articles_feed ON articles(feed_url); | ||
| 487 | +CREATE INDEX idx_articles_published ON articles(published DESC); | ||
| 488 | +``` | ||
| 489 | + | ||
| 490 | +### 6.5 Annotations, Likes | ||
| 491 | + | ||
| 492 | +Local mirror of AT Protocol lexicon records for fast querying. | ||
| 493 | + | ||
| 494 | +```sql | ||
| 495 | +CREATE TABLE annotations ( | ||
| 496 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 497 | + uri TEXT NOT NULL UNIQUE, | ||
| 498 | + author_did TEXT NOT NULL REFERENCES users(did), | ||
| 499 | + feed_url TEXT NOT NULL, | ||
| 500 | + article_url TEXT NOT NULL, | ||
| 501 | + quote TEXT, | ||
| 502 | + note TEXT, | ||
| 503 | + tags TEXT, | ||
| 504 | + rating INTEGER, | ||
| 505 | + created_at DATETIME NOT NULL | ||
| 506 | +); | ||
| 507 | + | ||
| 508 | +CREATE TABLE likes ( | ||
| 509 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 510 | + uri TEXT NOT NULL UNIQUE, | ||
| 511 | + author_did TEXT NOT NULL REFERENCES users(did), | ||
| 512 | + feed_url TEXT NOT NULL, | ||
| 513 | + article_url TEXT NOT NULL, | ||
| 514 | + created_at DATETIME NOT NULL, | ||
| 515 | + UNIQUE(author_did, feed_url, article_url) | ||
| 516 | +); | ||
| 517 | +``` | ||
| 518 | + | ||
| 519 | +### 6.6 Cluster Precomputation | ||
| 520 | + | ||
| 521 | +Stores precomputed similarity data to avoid recalculating on every request. | ||
| 522 | + | ||
| 523 | +```sql | ||
| 524 | +CREATE TABLE feed_similarity ( | ||
| 525 | + feed_a TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 526 | + feed_b TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 527 | + jaccard REAL NOT NULL, | ||
| 528 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 529 | + PRIMARY KEY (feed_a, feed_b), | ||
| 530 | + CHECK(feed_a < feed_b) | ||
| 531 | +); | ||
| 532 | + | ||
| 533 | +CREATE TABLE user_similarity ( | ||
| 534 | + user_a TEXT NOT NULL REFERENCES users(did), | ||
| 535 | + user_b TEXT NOT NULL REFERENCES users(did), | ||
| 536 | + jaccard REAL NOT NULL, | ||
| 537 | + common_feeds INTEGER NOT NULL, | ||
| 538 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 539 | + PRIMARY KEY (user_a, user_b), | ||
| 540 | + CHECK(user_a < user_b) | ||
| 541 | +); | ||
| 542 | +``` | ||
| 543 | + | ||
| 544 | +## 7. Clustering & Recommendations | ||
| 545 | + | ||
| 546 | +Glean has two complementary recommendation signals: | ||
| 547 | + | ||
| 548 | +- **Subscriptions** (Jaccard similarity): "Who reads the same feeds?" → feed and people discovery | ||
| 549 | +- **Likes** (co-occurrence): "Who likes the same articles?" → article and feed discovery | ||
| 550 | + | ||
| 551 | +### 7.1 Feed Co-occurrence (Jaccard Similarity) | ||
| 552 | + | ||
| 553 | +For any two feeds, the similarity is the Jaccard index of their subscriber sets: | ||
| 554 | + | ||
| 555 | +``` | ||
| 556 | +J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)| | ||
| 557 | +``` | ||
| 558 | + | ||
| 559 | +This is recomputed periodically (cron job) or incrementally when subscriptions change. | ||
| 560 | + | ||
| 561 | +### 7.2 User Similarity | ||
| 562 | + | ||
| 563 | +For any two users, compute Jaccard over their subscription sets: | ||
| 564 | + | ||
| 565 | +``` | ||
| 566 | +J(U1, U2) = |feeds(U1) ∩ feeds(U2)| / |feeds(U1) ∪ feeds(U2)| | ||
| 567 | +``` | ||
| 568 | + | ||
| 569 | +### 7.3 Recommendation Algorithms | ||
| 570 | + | ||
| 571 | +**Feed recommendations (on glean.at):** | ||
| 572 | + | ||
| 573 | +1. Find users with Jaccard > 0.2 (similar readers) | ||
| 574 | +2. Collect feeds those users subscribe to that the target user does not | ||
| 575 | +3. Rank by frequency (how many similar users subscribe) and average similarity | ||
| 576 | +4. Return top N feeds as recommendations | ||
| 577 | + | ||
| 578 | +``` | ||
| 579 | +score(feed) = Σ J(target, U) for each user U subscribed to feed | ||
| 580 | +``` | ||
| 581 | + | ||
| 582 | +**Article recommendations (on glean.at, from likes):** | ||
| 583 | + | ||
| 584 | +1. Find users who liked articles that the target user also liked | ||
| 585 | +2. Collect articles those users liked that the target has not | ||
| 586 | +3. Rank by frequency and recency | ||
| 587 | +4. Return top N articles as recommendations | ||
| 588 | + | ||
| 589 | +``` | ||
| 590 | +score(article) = Σ 1/logN(likers(article)) for each user U who liked it | ||
| 591 | +``` | ||
| 592 | + | ||
| 593 | +The `1/logN` weighting avoids over-recommending articles from very large feeds. | ||
| 594 | + | ||
| 595 | +**People recommendations (to follow on Bluesky):** | ||
| 596 | + | ||
| 597 | +1. Compute user similarity for all pairs | ||
| 598 | +2. Return users with highest Jaccard, linking to their Bluesky profile for follow | ||
| 599 | + | ||
| 600 | +### 7.4 Implementation | ||
| 601 | + | ||
| 602 | +For the initial version, brute-force Jaccard with SQLite is sufficient (scale: ~10k users, ~100k subscriptions). The query is: | ||
| 603 | + | ||
| 604 | +```sql | ||
| 605 | +SELECT s2.feed_url, COUNT(*) as overlap_count | ||
| 606 | +FROM subscriptions s1 | ||
| 607 | +JOIN subscriptions s2 ON s1.feed_url = s2.feed_url | ||
| 608 | +WHERE s1.user_did = ? AND s2.user_did != ? | ||
| 609 | +AND s2.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | ||
| 610 | +GROUP BY s2.feed_url | ||
| 611 | +ORDER BY overlap_count DESC | ||
| 612 | +LIMIT 20; | ||
| 613 | +``` | ||
| 614 | + | ||
| 615 | +For larger scale, move to MinHash + LSH (banded hashing) to approximate Jaccard in sub-linear time. | ||
| 616 | + | ||
| 617 | +### 7.5 Clustering Engine (Cron) | ||
| 618 | + | ||
| 619 | +A background goroutine runs on a schedule (e.g., every 6 hours): | ||
| 620 | + | ||
| 621 | +1. **Firehose ingestion**: Subscribe to AT Protocol firehose for `at.glean.*` records | ||
| 622 | +2. **Index new records**: Parse lexicon records, upsert into SQLite | ||
| 623 | +3. **Compute similarities**: Batch-update the `feed_similarity`, `user_similarity`, and `article_co_like` tables | ||
| 624 | +4. **Generate recommendations**: Materialize top recommendations per user into cache tables | ||
| 625 | + | ||
| 626 | +```sql | ||
| 627 | +CREATE TABLE user_feed_recommendations ( | ||
| 628 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 629 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 630 | + score REAL NOT NULL, | ||
| 631 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 632 | + PRIMARY KEY (user_did, feed_url) | ||
| 633 | +); | ||
| 634 | + | ||
| 635 | +CREATE TABLE user_article_recommendations ( | ||
| 636 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 637 | + feed_url TEXT NOT NULL, | ||
| 638 | + article_url TEXT NOT NULL, | ||
| 639 | + score REAL NOT NULL, | ||
| 640 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 641 | + PRIMARY KEY (user_did, feed_url, article_url) | ||
| 642 | +); | ||
| 643 | +``` | ||
| 644 | + | ||
| 645 | +## 8. HTTP API / htmx Endpoints | ||
| 646 | + | ||
| 647 | +The server renders HTML fragments that htmx swaps into the page. No JSON API needed for the frontend. | ||
| 648 | + | ||
| 649 | +### 8.1 Pages | ||
| 650 | + | ||
| 651 | +| Route | Method | Description | | ||
| 652 | +| ---------------------- | ------ | -------------------------------------------------------- | | ||
| 653 | +| `/` | GET | Landing page / auth redirect | | ||
| 654 | +| `/dashboard` | GET | Main dashboard: unread articles, recommendations sidebar | | ||
| 655 | +| `/feeds` | GET | Manage RSS subscriptions (OPML import for onboarding) | | ||
| 656 | +| `/feeds/opml/upload` | POST | Upload OPML file to bulk-import subscriptions | | ||
| 657 | +| `/feeds/opml/download` | GET | Export subscriptions as OPML (offboarding) | | ||
| 658 | +| `/feeds/add` | POST | Add a single feed URL | | ||
| 659 | +| `/feeds/remove` | DELETE | Remove a feed | | ||
| 660 | +| `/articles` | GET | Read articles (paginated, filterable by feed) | | ||
| 661 | +| `/trending` | GET | Community feed: articles ranked by likes | | ||
| 662 | +| `/discover` | GET | Feed recommendations + similar people | | ||
| 663 | +| `/discover/feeds` | GET | Recommended feeds | | ||
| 664 | +| `/discover/people` | GET | People with similar reading habits | | ||
| 665 | +| `/profile/{did}` | GET | Public profile: their feeds, likes, annotations | | ||
| 666 | +| `/articles/{id}/like` | POST | Like an article (amplify into community feed) | | ||
| 667 | +| `/annotations` | GET | View your annotations | | ||
| 668 | +| `/annotations/create` | POST | Create annotation on an article | | ||
| 669 | + | ||
| 670 | +### 8.2 htmx Patterns | ||
| 671 | + | ||
| 672 | +- **Feed list**: `<div hx-get="/feeds/list" hx-trigger="load">` renders the subscription list as a fragment | ||
| 673 | +- **Infinite scroll articles**: `<div hx-get="/articles?page=2" hx-trigger="intersect">` for pagination | ||
| 674 | +- **Like button**: `<button hx-post="/articles/{id}/like" hx-swap="outerHTML">` self-updates the button state | ||
| 675 | +- **OPML upload**: `<form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" hx-target="#feed-list">` | ||
| 676 | + | ||
| 677 | +## 9. Project Structure | ||
| 678 | + | ||
| 679 | +``` | ||
| 680 | +glean/ | ||
| 681 | +├── main.go # Entry point, wire everything | ||
| 682 | +├── go.mod | ||
| 683 | +├── go.sum | ||
| 684 | +├── internal/ | ||
| 685 | +│ ├── atproto/ | ||
| 686 | +│ │ ├── auth.go # DID resolution, OAuth flow | ||
| 687 | +│ │ ├── client.go # XRPC client (write to user PDS) | ||
| 688 | +│ │ ├── firehose.go # Subscribe to AT Relay firehose | ||
| 689 | +│ │ ├── lexicon.go # Lexicon record types + validation | ||
| 690 | +│ │ └── xrpc.go # XRPC query handlers (AppView endpoints) | ||
| 691 | +│ ├── db/ | ||
| 692 | +│ │ ├── db.go # SQLite connection, migrations | ||
| 693 | +│ │ ├── user.go # User queries | ||
| 694 | +│ │ ├── feed.go # Feed + subscription queries | ||
| 695 | +│ │ ├── article.go # Article queries | ||
| 696 | +│ │ ├── social.go # Like, annotation queries | ||
| 697 | +│ │ └── cluster.go # Similarity + recommendation queries | ||
| 698 | +│ ├── feed/ | ||
| 699 | +│ │ ├── parser.go # RSS/Atom/JSON feed parser | ||
| 700 | +│ │ ├── fetcher.go # Fetch and parse feeds from URLs | ||
| 701 | +│ │ └── opml.go # OPML import/export | ||
| 702 | +│ ├── cluster/ | ||
| 703 | +│ │ ├── jaccard.go # Jaccard similarity computation | ||
| 704 | +│ │ ├── recommender.go # Feed + people recommendation logic | ||
| 705 | +│ │ └── cron.go # Background recomputation scheduler | ||
| 706 | +│ ├── server/ | ||
| 707 | +│ │ ├── server.go # HTTP server, router setup | ||
| 708 | +│ │ ├── middleware.go # Auth, logging, CSRF middleware | ||
| 709 | +│ │ ├── session.go # Session management (cookie + DID) | ||
| 710 | +│ │ └── handlers/ | ||
| 711 | +│ │ ├── dashboard.go | ||
| 712 | +│ │ ├── feeds.go | ||
| 713 | +│ │ ├── articles.go | ||
| 714 | +│ │ ├── trending.go | ||
| 715 | +│ │ ├── discover.go | ||
| 716 | +│ │ ├── profile.go | ||
| 717 | +│ │ ├── annotations.go | ||
| 718 | +│ │ └── auth.go | ||
| 719 | +│ └── tmpl/ | ||
| 720 | +│ ├── base.html # Base template with htmx + Tailwind | ||
| 721 | +│ ├── partials/ | ||
| 722 | +│ │ ├── feed-list.html | ||
| 723 | +│ │ ├── article-list.html | ||
| 724 | +│ │ ├── like-button.html | ||
| 725 | +│ │ ├── annotation-card.html | ||
| 726 | +│ │ ├── recommendation-card.html | ||
| 727 | +│ │ └── profile-card.html | ||
| 728 | +│ ├── dashboard.html | ||
| 729 | +│ ├── feeds.html | ||
| 730 | +│ ├── articles.html | ||
| 731 | +│ ├── trending.html | ||
| 732 | +│ ├── discover.html | ||
| 733 | +│ ├── profile.html | ||
| 734 | +│ └── annotations.html | ||
| 735 | +├── static/ | ||
| 736 | +│ ├── input.css # Tailwind input | ||
| 737 | +│ └── output.css # Tailwind compiled output | ||
| 738 | +├── docs/ | ||
| 739 | +│ └── design.md # This document | ||
| 740 | +└── tailwind.config.js | ||
| 741 | +``` | ||
| 742 | + | ||
| 743 | +## 10. Auth Flow | ||
| 744 | + | ||
| 745 | +1. User visits `/`, clicks "Sign in with Bluesky" (or any AT Proto PDS) | ||
| 746 | +2. Server redirects to AT Protocol OAuth authorization endpoint | ||
| 747 | +3. User authorizes on their PDS | ||
| 748 | +4. PDS redirects back with authorization code | ||
| 749 | +5. Server exchanges code for access token + refresh token | ||
| 750 | +6. Server resolves DID and creates a session (cookie with encrypted DID) | ||
| 751 | +7. On each request, middleware decrypts session, loads user from DB (or creates if new) | ||
| 752 | + | ||
| 753 | +The server never stores the user's AT Protocol password. It stores the session tokens for XRPC calls to the user's PDS (to read/write their lexicon records). | ||
| 754 | + | ||
| 755 | +## 11. Data Flow | ||
| 756 | + | ||
| 757 | +### 11.1 User Imports OPML | ||
| 758 | + | ||
| 759 | +``` | ||
| 760 | +Browser ──POST /feeds/opml/upload──► Server | ||
| 761 | + │ | ||
| 762 | + ├─► Parse OPML, extract feed URLs | ||
| 763 | + ├─► Fetch each feed, validate + store in `feeds` table | ||
| 764 | + ├─► For each feed, create an `at.glean.subscription` record | ||
| 765 | + │ via XRPC write to user's PDS | ||
| 766 | + ├─► Insert subscriptions in local `subscriptions` table | ||
| 767 | + └─◄ Return updated feed list fragment (htmx) | ||
| 768 | +``` | ||
| 769 | + | ||
| 770 | +### 11.2 Reading the Feed | ||
| 771 | + | ||
| 772 | +``` | ||
| 773 | +Browser ──GET /articles──► Server | ||
| 774 | + │ | ||
| 775 | + ├─► Query articles for user's subscriptions | ||
| 776 | + ├─► Render article-list.html partial | ||
| 777 | + └─◄ Return HTML fragment (htmx) | ||
| 778 | +``` | ||
| 779 | + | ||
| 780 | +### 11.3 Recommendations | ||
| 781 | + | ||
| 782 | +``` | ||
| 783 | +Cron (every 6h) ──► Cluster Engine | ||
| 784 | + │ | ||
| 785 | + ├─► SELECT user similarity pairs | ||
| 786 | + ├─► Compute recommendation scores | ||
| 787 | + └─► INSERT into user_feed_recommendations | ||
| 788 | + | ||
| 789 | +Browser ──GET /discover/feeds──► Server | ||
| 790 | + │ | ||
| 791 | + ├─► SELECT from user_feed_recommendations | ||
| 792 | + ├─► Fetch feed metadata | ||
| 793 | + └─◄ Render recommendation cards (htmx) | ||
| 794 | +``` | ||
| 795 | + | ||
| 796 | +## 12. Key Design Decisions | ||
| 797 | + | ||
| 798 | +### 12.1 Why use lexicon records for feed subscriptions? | ||
| 799 | + | ||
| 800 | +- **User sovereignty**: Each feed subscription lives as a record on the user's PDS. They can export them, move PDS, or revoke access at any time. | ||
| 801 | +- **Interoperability**: Any AT Protocol app can read the lexicon and integrate with Glean data. | ||
| 802 | +- **No lock-in**: If Glean shuts down, the user's data is intact on their PDS. | ||
| 803 | +- **OPML as transport only**: OPML is a common interchange format used solely for import (onboarding from existing readers) and export (offboarding). The canonical representation is always the lexicon — individual `at.glean.subscription` records, one per feed. | ||
| 804 | + | ||
| 805 | +### 12.2 Why SQLite? | ||
| 806 | + | ||
| 807 | +- Single-binary deployment, no external database dependency | ||
| 808 | +- More than sufficient for the expected scale (tens of thousands of users) | ||
| 809 | +- Go's `database/sql` interface makes it easy to swap later if needed | ||
| 810 | +- Matches the project's philosophy of simplicity | ||
| 811 | + | ||
| 812 | +### 12.3 Why htmx? | ||
| 813 | + | ||
| 814 | +- No JavaScript build pipeline | ||
| 815 | +- Server renders everything — simpler mental model | ||
| 816 | +- Progressive enhancement works naturally | ||
| 817 | +- Perfect fit for a read-centric application | ||
| 818 | +- TailwindCSS handles styling without writing custom CSS | ||
| 819 | + | ||
| 820 | +### 12.4 AppView Architecture | ||
| 821 | + | ||
| 822 | +Glean operates as an AT Protocol AppView. This means: | ||
| 823 | + | ||
| 824 | +- **Read path**: All `at.glean.*` data is consumed from the Relay firehose, not by polling individual PDS instances. The firehose handler runs as a persistent goroutine, upserting records into SQLite as they arrive. | ||
| 825 | +- **Write path**: Users write records to their own PDS (via standard AT Protocol `com.atproto.repo.createRecord` / `deleteRecord`). Glean never stores user data directly — it only indexes what the firehose delivers. | ||
| 826 | +- **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. | ||
| 827 | +- **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. | ||
| 828 | + | ||
| 829 | +### 12.5 Privacy Model | ||
| 830 | + | ||
| 831 | +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. | ||
| 832 | + | ||
| 833 | +## 13. Future Considerations | ||
| 834 | + | ||
| 835 | +- **MinHash/LSH**: Replace brute-force Jaccard when user count exceeds ~50k | ||
| 836 | +- **Full-text search**: Add FTS5 virtual table on articles for search | ||
| 837 | +- **Feed groups / reading lists**: Allow users to create curated lists (separate lexicon) | ||
| 838 | +- **Email digest**: Periodic email with top articles from subscribed feeds | ||
| 839 | +- **Multi-AppView scaling**: Distribute firehose consumption across multiple instances behind a load balancer | ||
modified
go.mod +27 -4 | @@ -4,6 +4,14 @@ go 1.26.2 | ||
| 4 | 4 | |
| 5 | 5 | tool github.com/golangci/golangci-lint/cmd/golangci-lint |
| 6 | 6 | |
| 7 | +require ( | |
| 8 | + github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d | |
| 9 | + github.com/go-chi/chi/v5 v5.2.5 | |
| 10 | + github.com/go-chi/cors v1.2.2 | |
| 11 | + github.com/gorilla/websocket v1.5.3 | |
| 12 | + modernc.org/sqlite v1.49.1 | |
| 13 | +) | |
| 14 | + | |
| 7 | 15 | require ( |
| 8 | 16 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect |
| 9 | 17 | 4d63.com/gochecknoglobals v0.2.2 // indirect |
| @@ -43,6 +51,8 @@ require ( | ||
| 43 | 51 | github.com/daixiang0/gci v0.13.5 // indirect |
| 44 | 52 | github.com/davecgh/go-spew v1.1.1 // indirect |
| 45 | 53 | github.com/denis-tingaikin/go-header v0.5.0 // indirect |
| 54 | + github.com/dustin/go-humanize v1.0.1 // indirect | |
| 55 | + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect | |
| 46 | 56 | github.com/ettle/strcase v0.2.0 // indirect |
| 47 | 57 | github.com/fatih/color v1.18.0 // indirect |
| 48 | 58 | github.com/fatih/structtag v1.2.0 // indirect |
| @@ -71,6 +81,7 @@ require ( | ||
| 71 | 81 | github.com/golangci/revgrep v0.8.0 // indirect |
| 72 | 82 | github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect |
| 73 | 83 | github.com/google/go-cmp v0.7.0 // indirect |
| 84 | + github.com/google/uuid v1.6.0 // indirect | |
| 74 | 85 | github.com/gordonklaus/ineffassign v0.1.0 // indirect |
| 75 | 86 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect |
| 76 | 87 | github.com/gostaticanalysis/comment v1.5.0 // indirect |
| @@ -111,7 +122,9 @@ require ( | ||
| 111 | 122 | github.com/mitchellh/go-homedir v1.1.0 // indirect |
| 112 | 123 | github.com/mitchellh/mapstructure v1.5.0 // indirect |
| 113 | 124 | github.com/moricho/tparallel v0.3.2 // indirect |
| 125 | + github.com/mr-tron/base58 v1.2.0 // indirect | |
| 114 | 126 | github.com/nakabonne/nestif v0.3.1 // indirect |
| 127 | + github.com/ncruces/go-strftime v1.0.0 // indirect | |
| 115 | 128 | github.com/nishanths/exhaustive v0.12.0 // indirect |
| 116 | 129 | github.com/nishanths/predeclared v0.2.2 // indirect |
| 117 | 130 | github.com/nunnatsa/ginkgolinter v0.19.1 // indirect |
| @@ -130,6 +143,7 @@ require ( | ||
| 130 | 143 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect |
| 131 | 144 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect |
| 132 | 145 | github.com/raeperd/recvcheck v0.2.0 // indirect |
| 146 | + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 133 | 147 | github.com/rivo/uniseg v0.4.7 // indirect |
| 134 | 148 | github.com/rogpeppe/go-internal v1.14.1 // indirect |
| 135 | 149 | github.com/ryancurrah/gomodguard v1.3.5 // indirect |
| @@ -170,22 +184,31 @@ require ( | ||
| 170 | 184 | github.com/yeya24/promlinter v0.3.0 // indirect |
| 171 | 185 | github.com/ykadowak/zerologlint v0.1.5 // indirect |
| 172 | 186 | gitlab.com/bosi/decorder v0.4.2 // indirect |
| 187 | + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect | |
| 188 | + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect | |
| 173 | 189 | go-simpler.org/musttag v0.13.0 // indirect |
| 174 | 190 | go-simpler.org/sloglint v0.9.0 // indirect |
| 175 | 191 | go.uber.org/automaxprocs v1.6.0 // indirect |
| 176 | 192 | go.uber.org/multierr v1.11.0 // indirect |
| 177 | 193 | go.uber.org/zap v1.26.0 // indirect |
| 194 | + golang.org/x/crypto v0.35.0 // indirect | |
| 178 | 195 | golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect |
| 179 | - golang.org/x/mod v0.24.0 // indirect | |
| 180 | - golang.org/x/sync v0.12.0 // indirect | |
| 181 | - golang.org/x/sys v0.31.0 // indirect | |
| 196 | + golang.org/x/mod v0.33.0 // indirect | |
| 197 | + golang.org/x/sync v0.20.0 // indirect | |
| 198 | + golang.org/x/sys v0.42.0 // indirect | |
| 182 | 199 | golang.org/x/text v0.22.0 // indirect |
| 183 | - golang.org/x/tools v0.31.0 // indirect | |
| 200 | + golang.org/x/time v0.10.0 // indirect | |
| 201 | + golang.org/x/tools v0.42.0 // indirect | |
| 202 | + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect | |
| 203 | + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect | |
| 184 | 204 | google.golang.org/protobuf v1.36.5 // indirect |
| 185 | 205 | gopkg.in/ini.v1 v1.67.0 // indirect |
| 186 | 206 | gopkg.in/yaml.v2 v2.4.0 // indirect |
| 187 | 207 | gopkg.in/yaml.v3 v3.0.1 // indirect |
| 188 | 208 | honnef.co/go/tools v0.6.1 // indirect |
| 209 | + modernc.org/libc v1.72.0 // indirect | |
| 210 | + modernc.org/mathutil v1.7.1 // indirect | |
| 211 | + modernc.org/memory v1.11.0 // indirect | |
| 189 | 212 | mvdan.cc/gofumpt v0.7.0 // indirect |
| 190 | 213 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect |
| 191 | 214 | ) |
| @@ -4,6 +4,14 @@ go 1.26.2 | |||
| 4 | 4 | ||
| 5 | tool github.com/golangci/golangci-lint/cmd/golangci-lint | 5 | tool github.com/golangci/golangci-lint/cmd/golangci-lint |
| 6 | 6 | ||
| 7 | +require ( | ||
| 8 | + github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d | ||
| 9 | + github.com/go-chi/chi/v5 v5.2.5 | ||
| 10 | + github.com/go-chi/cors v1.2.2 | ||
| 11 | + github.com/gorilla/websocket v1.5.3 | ||
| 12 | + modernc.org/sqlite v1.49.1 | ||
| 13 | +) | ||
| 14 | + | ||
| 7 | require ( | 15 | require ( |
| 8 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect | 16 | 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect |
| 9 | 4d63.com/gochecknoglobals v0.2.2 // indirect | 17 | 4d63.com/gochecknoglobals v0.2.2 // indirect |
| @@ -43,6 +51,8 @@ require ( | |||
| 43 | github.com/daixiang0/gci v0.13.5 // indirect | 51 | github.com/daixiang0/gci v0.13.5 // indirect |
| 44 | github.com/davecgh/go-spew v1.1.1 // indirect | 52 | github.com/davecgh/go-spew v1.1.1 // indirect |
| 45 | github.com/denis-tingaikin/go-header v0.5.0 // indirect | 53 | github.com/denis-tingaikin/go-header v0.5.0 // indirect |
| 54 | + github.com/dustin/go-humanize v1.0.1 // indirect | ||
| 55 | + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect | ||
| 46 | github.com/ettle/strcase v0.2.0 // indirect | 56 | github.com/ettle/strcase v0.2.0 // indirect |
| 47 | github.com/fatih/color v1.18.0 // indirect | 57 | github.com/fatih/color v1.18.0 // indirect |
| 48 | github.com/fatih/structtag v1.2.0 // indirect | 58 | github.com/fatih/structtag v1.2.0 // indirect |
| @@ -71,6 +81,7 @@ require ( | |||
| 71 | github.com/golangci/revgrep v0.8.0 // indirect | 81 | github.com/golangci/revgrep v0.8.0 // indirect |
| 72 | github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect | 82 | github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed // indirect |
| 73 | github.com/google/go-cmp v0.7.0 // indirect | 83 | github.com/google/go-cmp v0.7.0 // indirect |
| 84 | + github.com/google/uuid v1.6.0 // indirect | ||
| 74 | github.com/gordonklaus/ineffassign v0.1.0 // indirect | 85 | github.com/gordonklaus/ineffassign v0.1.0 // indirect |
| 75 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect | 86 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect |
| 76 | github.com/gostaticanalysis/comment v1.5.0 // indirect | 87 | github.com/gostaticanalysis/comment v1.5.0 // indirect |
| @@ -111,7 +122,9 @@ require ( | |||
| 111 | github.com/mitchellh/go-homedir v1.1.0 // indirect | 122 | github.com/mitchellh/go-homedir v1.1.0 // indirect |
| 112 | github.com/mitchellh/mapstructure v1.5.0 // indirect | 123 | github.com/mitchellh/mapstructure v1.5.0 // indirect |
| 113 | github.com/moricho/tparallel v0.3.2 // indirect | 124 | github.com/moricho/tparallel v0.3.2 // indirect |
| 125 | + github.com/mr-tron/base58 v1.2.0 // indirect | ||
| 114 | github.com/nakabonne/nestif v0.3.1 // indirect | 126 | github.com/nakabonne/nestif v0.3.1 // indirect |
| 127 | + github.com/ncruces/go-strftime v1.0.0 // indirect | ||
| 115 | github.com/nishanths/exhaustive v0.12.0 // indirect | 128 | github.com/nishanths/exhaustive v0.12.0 // indirect |
| 116 | github.com/nishanths/predeclared v0.2.2 // indirect | 129 | github.com/nishanths/predeclared v0.2.2 // indirect |
| 117 | github.com/nunnatsa/ginkgolinter v0.19.1 // indirect | 130 | github.com/nunnatsa/ginkgolinter v0.19.1 // indirect |
| @@ -130,6 +143,7 @@ require ( | |||
| 130 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect | 143 | github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect |
| 131 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect | 144 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect |
| 132 | github.com/raeperd/recvcheck v0.2.0 // indirect | 145 | github.com/raeperd/recvcheck v0.2.0 // indirect |
| 146 | + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | ||
| 133 | github.com/rivo/uniseg v0.4.7 // indirect | 147 | github.com/rivo/uniseg v0.4.7 // indirect |
| 134 | github.com/rogpeppe/go-internal v1.14.1 // indirect | 148 | github.com/rogpeppe/go-internal v1.14.1 // indirect |
| 135 | github.com/ryancurrah/gomodguard v1.3.5 // indirect | 149 | github.com/ryancurrah/gomodguard v1.3.5 // indirect |
| @@ -170,22 +184,31 @@ require ( | |||
| 170 | github.com/yeya24/promlinter v0.3.0 // indirect | 184 | github.com/yeya24/promlinter v0.3.0 // indirect |
| 171 | github.com/ykadowak/zerologlint v0.1.5 // indirect | 185 | github.com/ykadowak/zerologlint v0.1.5 // indirect |
| 172 | gitlab.com/bosi/decorder v0.4.2 // indirect | 186 | gitlab.com/bosi/decorder v0.4.2 // indirect |
| 187 | + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect | ||
| 188 | + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect | ||
| 173 | go-simpler.org/musttag v0.13.0 // indirect | 189 | go-simpler.org/musttag v0.13.0 // indirect |
| 174 | go-simpler.org/sloglint v0.9.0 // indirect | 190 | go-simpler.org/sloglint v0.9.0 // indirect |
| 175 | go.uber.org/automaxprocs v1.6.0 // indirect | 191 | go.uber.org/automaxprocs v1.6.0 // indirect |
| 176 | go.uber.org/multierr v1.11.0 // indirect | 192 | go.uber.org/multierr v1.11.0 // indirect |
| 177 | go.uber.org/zap v1.26.0 // indirect | 193 | go.uber.org/zap v1.26.0 // indirect |
| 194 | + golang.org/x/crypto v0.35.0 // indirect | ||
| 178 | golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect | 195 | golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect |
| 179 | - golang.org/x/mod v0.24.0 // indirect | 196 | + golang.org/x/mod v0.33.0 // indirect |
| 180 | - golang.org/x/sync v0.12.0 // indirect | 197 | + golang.org/x/sync v0.20.0 // indirect |
| 181 | - golang.org/x/sys v0.31.0 // indirect | 198 | + golang.org/x/sys v0.42.0 // indirect |
| 182 | golang.org/x/text v0.22.0 // indirect | 199 | golang.org/x/text v0.22.0 // indirect |
| 183 | - golang.org/x/tools v0.31.0 // indirect | 200 | + golang.org/x/time v0.10.0 // indirect |
| 201 | + golang.org/x/tools v0.42.0 // indirect | ||
| 202 | + golang.org/x/tools/go/expect v0.1.1-deprecated // indirect | ||
| 203 | + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect | ||
| 184 | google.golang.org/protobuf v1.36.5 // indirect | 204 | google.golang.org/protobuf v1.36.5 // indirect |
| 185 | gopkg.in/ini.v1 v1.67.0 // indirect | 205 | gopkg.in/ini.v1 v1.67.0 // indirect |
| 186 | gopkg.in/yaml.v2 v2.4.0 // indirect | 206 | gopkg.in/yaml.v2 v2.4.0 // indirect |
| 187 | gopkg.in/yaml.v3 v3.0.1 // indirect | 207 | gopkg.in/yaml.v3 v3.0.1 // indirect |
| 188 | honnef.co/go/tools v0.6.1 // indirect | 208 | honnef.co/go/tools v0.6.1 // indirect |
| 209 | + modernc.org/libc v1.72.0 // indirect | ||
| 210 | + modernc.org/mathutil v1.7.1 // indirect | ||
| 211 | + modernc.org/memory v1.11.0 // indirect | ||
| 189 | mvdan.cc/gofumpt v0.7.0 // indirect | 212 | mvdan.cc/gofumpt v0.7.0 // indirect |
| 190 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect | 213 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f // indirect |
| 191 | ) | 214 | ) |
modified
go.sum +72 -12 | @@ -48,6 +48,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5 | ||
| 48 | 48 | github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= |
| 49 | 49 | github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= |
| 50 | 50 | github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= |
| 51 | +github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM= | |
| 52 | +github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= | |
| 51 | 53 | github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A= |
| 52 | 54 | github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc= |
| 53 | 55 | github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs= |
| @@ -82,6 +84,10 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 | ||
| 82 | 84 | github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= |
| 83 | 85 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= |
| 84 | 86 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= |
| 87 | +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | |
| 88 | +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | |
| 89 | +github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= | |
| 90 | +github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= | |
| 85 | 91 | github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= |
| 86 | 92 | github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= |
| 87 | 93 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= |
| @@ -98,6 +104,10 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= | ||
| 98 | 104 | github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= |
| 99 | 105 | github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= |
| 100 | 106 | github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= |
| 107 | +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= | |
| 108 | +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= | |
| 109 | +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= | |
| 110 | +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= | |
| 101 | 111 | github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= |
| 102 | 112 | github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= |
| 103 | 113 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= |
| @@ -156,10 +166,14 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ | ||
| 156 | 166 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
| 157 | 167 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 158 | 168 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 159 | -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= | |
| 160 | -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= | |
| 169 | +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= | |
| 170 | +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= | |
| 171 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 172 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 161 | 173 | github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= |
| 162 | 174 | github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= |
| 175 | +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= | |
| 176 | +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | |
| 163 | 177 | github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= |
| 164 | 178 | github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= |
| 165 | 179 | github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= |
| @@ -253,8 +267,12 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua | ||
| 253 | 267 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= |
| 254 | 268 | github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= |
| 255 | 269 | github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= |
| 270 | +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= | |
| 271 | +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= | |
| 256 | 272 | github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= |
| 257 | 273 | github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= |
| 274 | +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= | |
| 275 | +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | |
| 258 | 276 | github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= |
| 259 | 277 | github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= |
| 260 | 278 | github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= |
| @@ -304,6 +322,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l | ||
| 304 | 322 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= |
| 305 | 323 | github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= |
| 306 | 324 | github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= |
| 325 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | |
| 326 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | |
| 307 | 327 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= |
| 308 | 328 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
| 309 | 329 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= |
| @@ -410,6 +430,10 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 | ||
| 410 | 430 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
| 411 | 431 | gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= |
| 412 | 432 | gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= |
| 433 | +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= | |
| 434 | +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= | |
| 435 | +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= | |
| 436 | +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= | |
| 413 | 437 | go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= |
| 414 | 438 | go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= |
| 415 | 439 | go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= |
| @@ -430,6 +454,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh | ||
| 430 | 454 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= |
| 431 | 455 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= |
| 432 | 456 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= |
| 457 | +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= | |
| 458 | +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= | |
| 433 | 459 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= |
| 434 | 460 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= |
| 435 | 461 | golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= |
| @@ -447,8 +473,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||
| 447 | 473 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
| 448 | 474 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
| 449 | 475 | golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
| 450 | -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= | |
| 451 | -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= | |
| 476 | +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= | |
| 477 | +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= | |
| 452 | 478 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
| 453 | 479 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
| 454 | 480 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
| @@ -464,8 +490,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= | ||
| 464 | 490 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= |
| 465 | 491 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= |
| 466 | 492 | golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= |
| 467 | -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= | |
| 468 | -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= | |
| 493 | +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= | |
| 494 | +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= | |
| 469 | 495 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| 470 | 496 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| 471 | 497 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| @@ -475,8 +501,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ | ||
| 475 | 501 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| 476 | 502 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= |
| 477 | 503 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= |
| 478 | -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= | |
| 479 | -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= | |
| 504 | +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | |
| 505 | +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 480 | 506 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
| 481 | 507 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
| 482 | 508 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
| @@ -499,8 +525,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 499 | 525 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 500 | 526 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 501 | 527 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 502 | -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= | |
| 503 | -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | |
| 528 | +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= | |
| 529 | +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 504 | 530 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
| 505 | 531 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
| 506 | 532 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= |
| @@ -521,6 +547,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | ||
| 521 | 547 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= |
| 522 | 548 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= |
| 523 | 549 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= |
| 550 | +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= | |
| 551 | +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= | |
| 524 | 552 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
| 525 | 553 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
| 526 | 554 | golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= |
| @@ -539,8 +567,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | ||
| 539 | 567 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= |
| 540 | 568 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= |
| 541 | 569 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= |
| 542 | -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= | |
| 543 | -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= | |
| 570 | +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= | |
| 571 | +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= | |
| 572 | +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= | |
| 573 | +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= | |
| 574 | +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= | |
| 575 | +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= | |
| 544 | 576 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| 545 | 577 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| 546 | 578 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| @@ -560,6 +592,34 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 560 | 592 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 561 | 593 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= |
| 562 | 594 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= |
| 595 | +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= | |
| 596 | +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= | |
| 597 | +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= | |
| 598 | +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= | |
| 599 | +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= | |
| 600 | +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= | |
| 601 | +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | |
| 602 | +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | |
| 603 | +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= | |
| 604 | +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= | |
| 605 | +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | |
| 606 | +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | |
| 607 | +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= | |
| 608 | +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= | |
| 609 | +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | |
| 610 | +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | |
| 611 | +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | |
| 612 | +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | |
| 613 | +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= | |
| 614 | +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | |
| 615 | +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | |
| 616 | +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | |
| 617 | +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= | |
| 618 | +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= | |
| 619 | +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | |
| 620 | +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | |
| 621 | +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | |
| 622 | +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= | |
| 563 | 623 | mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= |
| 564 | 624 | mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= |
| 565 | 625 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= |
| @@ -48,6 +48,8 @@ github.com/bkielbasa/cyclop v1.2.3 h1:faIVMIGDIANuGPWH031CZJTi2ymOQBULs9H21HSMa5 | |||
| 48 | github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= | 48 | github.com/bkielbasa/cyclop v1.2.3/go.mod h1:kHTwA9Q0uZqOADdupvcFJQtp/ksSnytRMe8ztxG8Fuo= |
| 49 | github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= | 49 | github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ089M= |
| 50 | github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= | 50 | github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k= |
| 51 | +github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d h1:ThKFUrkm2/IZwbvmIKLJYr0wPHibtCkIVmuZCWmdIHM= | ||
| 52 | +github.com/bluesky-social/indigo v0.0.0-20260417172304-7da09df6081d/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= | ||
| 51 | github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A= | 53 | github.com/bombsimon/wsl/v4 v4.5.0 h1:iZRsEvDdyhd2La0FVi5k6tYehpOR/R7qIUjmKk7N74A= |
| 52 | github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc= | 54 | github.com/bombsimon/wsl/v4 v4.5.0/go.mod h1:NOQ3aLF4nD7N5YPXMruR6ZXDOAqLoM0GEpLwTdvmOSc= |
| 53 | github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs= | 55 | github.com/breml/bidichk v0.3.2 h1:xV4flJ9V5xWTqxL+/PMFF6dtJPvZLPsyixAoPe8BGJs= |
| @@ -82,6 +84,10 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 | |||
| 82 | github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= | 84 | github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= |
| 83 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= | 85 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= |
| 84 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= | 86 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= |
| 87 | +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | ||
| 88 | +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | ||
| 89 | +github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= | ||
| 90 | +github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= | ||
| 85 | github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= | 91 | github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= |
| 86 | github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= | 92 | github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= |
| 87 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= | 93 | github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= |
| @@ -98,6 +104,10 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= | |||
| 98 | github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= | 104 | github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= |
| 99 | github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= | 105 | github.com/ghostiam/protogetter v0.3.9 h1:j+zlLLWzqLay22Cz/aYwTHKQ88GE2DQ6GkWSYFOI4lQ= |
| 100 | github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= | 106 | github.com/ghostiam/protogetter v0.3.9/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= |
| 107 | +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= | ||
| 108 | +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= | ||
| 109 | +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= | ||
| 110 | +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= | ||
| 101 | github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= | 111 | github.com/go-critic/go-critic v0.12.0 h1:iLosHZuye812wnkEz1Xu3aBwn5ocCPfc9yqmFG9pa6w= |
| 102 | github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= | 112 | github.com/go-critic/go-critic v0.12.0/go.mod h1:DpE0P6OVc6JzVYzmM5gq5jMU31zLr4am5mB/VfFK64w= |
| 103 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= | 113 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= |
| @@ -156,10 +166,14 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ | |||
| 156 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | 166 | github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
| 157 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= | 167 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= |
| 158 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | 168 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= |
| 159 | -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= | 169 | +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| 160 | -github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= | 170 | +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| 171 | +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | ||
| 172 | +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||
| 161 | github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= | 173 | github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= |
| 162 | github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= | 174 | github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= |
| 175 | +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= | ||
| 176 | +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | ||
| 163 | github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= | 177 | github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk= |
| 164 | github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= | 178 | github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc= |
| 165 | github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= | 179 | github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado= |
| @@ -253,8 +267,12 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua | |||
| 253 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= | 267 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= |
| 254 | github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= | 268 | github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI= |
| 255 | github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= | 269 | github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U= |
| 270 | +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= | ||
| 271 | +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= | ||
| 256 | github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= | 272 | github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= |
| 257 | github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= | 273 | github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= |
| 274 | +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= | ||
| 275 | +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= | ||
| 258 | github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= | 276 | github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= |
| 259 | github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= | 277 | github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= |
| 260 | github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= | 278 | github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= |
| @@ -304,6 +322,8 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l | |||
| 304 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= | 322 | github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= |
| 305 | github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= | 323 | github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= |
| 306 | github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= | 324 | github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= |
| 325 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= | ||
| 326 | +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= | ||
| 307 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= | 327 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= |
| 308 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= | 328 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
| 309 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= | 329 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= |
| @@ -410,6 +430,10 @@ github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1 | |||
| 410 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= | 430 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
| 411 | gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= | 431 | gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= |
| 412 | gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= | 432 | gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= |
| 433 | +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= | ||
| 434 | +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= | ||
| 435 | +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= | ||
| 436 | +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= | ||
| 413 | go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= | 437 | go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= |
| 414 | go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= | 438 | go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28= |
| 415 | go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= | 439 | go-simpler.org/musttag v0.13.0 h1:Q/YAW0AHvaoaIbsPj3bvEI5/QFP7w696IMUpnKXQfCE= |
| @@ -430,6 +454,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh | |||
| 430 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | 454 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= |
| 431 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= | 455 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= |
| 432 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= | 456 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= |
| 457 | +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= | ||
| 458 | +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= | ||
| 433 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= | 459 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= |
| 434 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= | 460 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= |
| 435 | golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= | 461 | golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= |
| @@ -447,8 +473,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | |||
| 447 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | 473 | golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
| 448 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | 474 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= |
| 449 | golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= | 475 | golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
| 450 | -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= | 476 | +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= |
| 451 | -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= | 477 | +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= |
| 452 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | 478 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= |
| 453 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | 479 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
| 454 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | 480 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= |
| @@ -464,8 +490,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= | |||
| 464 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= | 490 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= |
| 465 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= | 491 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= |
| 466 | golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= | 492 | golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= |
| 467 | -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= | 493 | +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= |
| 468 | -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= | 494 | +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= |
| 469 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 495 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| 470 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 496 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| 471 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 497 | golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| @@ -475,8 +501,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ | |||
| 475 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 501 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| 476 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= | 502 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= |
| 477 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= | 503 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= |
| 478 | -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= | 504 | +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= |
| 479 | -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= | 505 | +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| 480 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | 506 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= |
| 481 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | 507 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
| 482 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | 508 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
| @@ -499,8 +525,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |||
| 499 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 525 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 500 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 526 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 501 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 527 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| 502 | -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= | 528 | +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= |
| 503 | -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= | 529 | +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| 504 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | 530 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
| 505 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | 531 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= |
| 506 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= | 532 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= |
| @@ -521,6 +547,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | |||
| 521 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= | 547 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= |
| 522 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= | 548 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= |
| 523 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= | 549 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= |
| 550 | +golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= | ||
| 551 | +golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= | ||
| 524 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | 552 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
| 525 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | 553 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
| 526 | golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= | 554 | golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= |
| @@ -539,8 +567,12 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | |||
| 539 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= | 567 | golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= |
| 540 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= | 568 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= |
| 541 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= | 569 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= |
| 542 | -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= | 570 | +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= |
| 543 | -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= | 571 | +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= |
| 572 | +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= | ||
| 573 | +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= | ||
| 574 | +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= | ||
| 575 | +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= | ||
| 544 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | 576 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| 545 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | 577 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| 546 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | 578 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= |
| @@ -560,6 +592,34 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |||
| 560 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 592 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 561 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= | 593 | honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= |
| 562 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= | 594 | honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= |
| 595 | +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= | ||
| 596 | +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= | ||
| 597 | +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= | ||
| 598 | +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= | ||
| 599 | +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= | ||
| 600 | +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= | ||
| 601 | +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= | ||
| 602 | +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= | ||
| 603 | +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= | ||
| 604 | +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= | ||
| 605 | +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= | ||
| 606 | +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= | ||
| 607 | +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= | ||
| 608 | +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= | ||
| 609 | +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= | ||
| 610 | +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= | ||
| 611 | +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= | ||
| 612 | +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= | ||
| 613 | +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= | ||
| 614 | +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= | ||
| 615 | +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= | ||
| 616 | +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= | ||
| 617 | +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= | ||
| 618 | +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= | ||
| 619 | +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= | ||
| 620 | +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= | ||
| 621 | +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= | ||
| 622 | +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= | ||
| 563 | mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= | 623 | mvdan.cc/gofumpt v0.7.0 h1:bg91ttqXmi9y2xawvkuMXyvAA/1ZGJqYAEGjXuP0JXU= |
| 564 | mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= | 624 | mvdan.cc/gofumpt v0.7.0/go.mod h1:txVFJy/Sc/mvaycET54pV8SW8gWxTlUuGHVEcncmNUo= |
| 565 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= | 625 | mvdan.cc/unparam v0.0.0-20240528143540-8a5130ca722f h1:lMpcwN6GxNbWtbpI1+xzFLSW8XzX0u72NttUGVFjO3U= |
deleted
internal/.gitkeep +0 -0 | deleted file mode 100644 |
| deleted file mode 100644 |
added
internal/atproto/auth.go +85 -0 | new file mode 100644 | ||
| @@ -0,0 +1,85 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + | |
| 7 | + "github.com/bluesky-social/indigo/atproto/identity" | |
| 8 | + "github.com/bluesky-social/indigo/atproto/syntax" | |
| 9 | +) | |
| 10 | + | |
| 11 | +type DIDDocument = identity.DIDDocument | |
| 12 | +type Identity = identity.Identity | |
| 13 | + | |
| 14 | +func ResolveHandle(ctx context.Context, handle string) (string, error) { | |
| 15 | + h, err := syntax.ParseHandle(handle) | |
| 16 | + if err != nil { | |
| 17 | + return "", fmt.Errorf("parsing handle: %w", err) | |
| 18 | + } | |
| 19 | + | |
| 20 | + dir := identity.DefaultDirectory() | |
| 21 | + ident, err := dir.LookupHandle(ctx, h) | |
| 22 | + if err != nil { | |
| 23 | + return "", fmt.Errorf("resolving handle: %w", err) | |
| 24 | + } | |
| 25 | + | |
| 26 | + return ident.DID.String(), nil | |
| 27 | +} | |
| 28 | + | |
| 29 | +func ResolveDID(ctx context.Context, did string) (*DIDDocument, error) { | |
| 30 | + d, err := syntax.ParseDID(did) | |
| 31 | + if err != nil { | |
| 32 | + return nil, fmt.Errorf("parsing DID: %w", err) | |
| 33 | + } | |
| 34 | + | |
| 35 | + dir := identity.DefaultDirectory() | |
| 36 | + ident, err := dir.LookupDID(ctx, d) | |
| 37 | + if err != nil { | |
| 38 | + return nil, fmt.Errorf("resolving DID: %w", err) | |
| 39 | + } | |
| 40 | + | |
| 41 | + doc := ident.DIDDocument() | |
| 42 | + return &doc, nil | |
| 43 | +} | |
| 44 | + | |
| 45 | +func ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { | |
| 46 | + atid, err := syntax.ParseAtIdentifier(identifier) | |
| 47 | + if err != nil { | |
| 48 | + return nil, fmt.Errorf("parsing identifier: %w", err) | |
| 49 | + } | |
| 50 | + | |
| 51 | + dir := identity.DefaultDirectory() | |
| 52 | + ident, err := dir.Lookup(ctx, atid) | |
| 53 | + if err != nil { | |
| 54 | + return nil, fmt.Errorf("resolving identity: %w", err) | |
| 55 | + } | |
| 56 | + | |
| 57 | + return ident, nil | |
| 58 | +} | |
| 59 | + | |
| 60 | +func ResolvePDSEndpoint(ctx context.Context, did string) (string, error) { | |
| 61 | + ident, err := ResolveIdentity(ctx, did) | |
| 62 | + if err != nil { | |
| 63 | + return "", err | |
| 64 | + } | |
| 65 | + | |
| 66 | + pds := ident.PDSEndpoint() | |
| 67 | + if pds == "" { | |
| 68 | + return "", fmt.Errorf("no PDS endpoint found for %s", did) | |
| 69 | + } | |
| 70 | + | |
| 71 | + return pds, nil | |
| 72 | +} | |
| 73 | + | |
| 74 | +type OAuthConfig struct { | |
| 75 | + ClientID string | |
| 76 | + RedirectURL string | |
| 77 | + Scopes []string | |
| 78 | +} | |
| 79 | + | |
| 80 | +type OAuthTokens struct { | |
| 81 | + AccessToken string | |
| 82 | + RefreshToken string | |
| 83 | + DID string | |
| 84 | + Handle string | |
| 85 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,85 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + | ||
| 7 | + "github.com/bluesky-social/indigo/atproto/identity" | ||
| 8 | + "github.com/bluesky-social/indigo/atproto/syntax" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +type DIDDocument = identity.DIDDocument | ||
| 12 | +type Identity = identity.Identity | ||
| 13 | + | ||
| 14 | +func ResolveHandle(ctx context.Context, handle string) (string, error) { | ||
| 15 | + h, err := syntax.ParseHandle(handle) | ||
| 16 | + if err != nil { | ||
| 17 | + return "", fmt.Errorf("parsing handle: %w", err) | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + dir := identity.DefaultDirectory() | ||
| 21 | + ident, err := dir.LookupHandle(ctx, h) | ||
| 22 | + if err != nil { | ||
| 23 | + return "", fmt.Errorf("resolving handle: %w", err) | ||
| 24 | + } | ||
| 25 | + | ||
| 26 | + return ident.DID.String(), nil | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +func ResolveDID(ctx context.Context, did string) (*DIDDocument, error) { | ||
| 30 | + d, err := syntax.ParseDID(did) | ||
| 31 | + if err != nil { | ||
| 32 | + return nil, fmt.Errorf("parsing DID: %w", err) | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + dir := identity.DefaultDirectory() | ||
| 36 | + ident, err := dir.LookupDID(ctx, d) | ||
| 37 | + if err != nil { | ||
| 38 | + return nil, fmt.Errorf("resolving DID: %w", err) | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + doc := ident.DIDDocument() | ||
| 42 | + return &doc, nil | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { | ||
| 46 | + atid, err := syntax.ParseAtIdentifier(identifier) | ||
| 47 | + if err != nil { | ||
| 48 | + return nil, fmt.Errorf("parsing identifier: %w", err) | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + dir := identity.DefaultDirectory() | ||
| 52 | + ident, err := dir.Lookup(ctx, atid) | ||
| 53 | + if err != nil { | ||
| 54 | + return nil, fmt.Errorf("resolving identity: %w", err) | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + return ident, nil | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +func ResolvePDSEndpoint(ctx context.Context, did string) (string, error) { | ||
| 61 | + ident, err := ResolveIdentity(ctx, did) | ||
| 62 | + if err != nil { | ||
| 63 | + return "", err | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + pds := ident.PDSEndpoint() | ||
| 67 | + if pds == "" { | ||
| 68 | + return "", fmt.Errorf("no PDS endpoint found for %s", did) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + return pds, nil | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +type OAuthConfig struct { | ||
| 75 | + ClientID string | ||
| 76 | + RedirectURL string | ||
| 77 | + Scopes []string | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +type OAuthTokens struct { | ||
| 81 | + AccessToken string | ||
| 82 | + RefreshToken string | ||
| 83 | + DID string | ||
| 84 | + Handle string | ||
| 85 | +} | ||
added
internal/atproto/client.go +153 -0 | new file mode 100644 | ||
| @@ -0,0 +1,153 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "context" | |
| 6 | + "encoding/json" | |
| 7 | + "fmt" | |
| 8 | + "net/http" | |
| 9 | + | |
| 10 | + "github.com/bluesky-social/indigo/atproto/syntax" | |
| 11 | +) | |
| 12 | + | |
| 13 | +type Client struct { | |
| 14 | + httpClient *http.Client | |
| 15 | + pdsURL string | |
| 16 | + accessToken string | |
| 17 | +} | |
| 18 | + | |
| 19 | +func NewClient(pdsURL, accessToken string) *Client { | |
| 20 | + return &Client{ | |
| 21 | + httpClient: &http.Client{}, | |
| 22 | + pdsURL: pdsURL, | |
| 23 | + accessToken: accessToken, | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { | |
| 28 | + nsid, err := syntax.ParseNSID(collection) | |
| 29 | + if err != nil { | |
| 30 | + return "", "", fmt.Errorf("parsing collection NSID: %w", err) | |
| 31 | + } | |
| 32 | + | |
| 33 | + body := map[string]any{ | |
| 34 | + "repo": did, | |
| 35 | + "collection": nsid.String(), | |
| 36 | + "record": record, | |
| 37 | + } | |
| 38 | + | |
| 39 | + data, err := json.Marshal(body) | |
| 40 | + if err != nil { | |
| 41 | + return "", "", err | |
| 42 | + } | |
| 43 | + | |
| 44 | + url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord" | |
| 45 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) | |
| 46 | + if err != nil { | |
| 47 | + return "", "", err | |
| 48 | + } | |
| 49 | + req.Header.Set("Content-Type", "application/json") | |
| 50 | + req.Header.Set("Authorization", "Bearer "+c.accessToken) | |
| 51 | + | |
| 52 | + resp, err := c.httpClient.Do(req) | |
| 53 | + if err != nil { | |
| 54 | + return "", "", err | |
| 55 | + } | |
| 56 | + defer resp.Body.Close() | |
| 57 | + | |
| 58 | + if resp.StatusCode != http.StatusOK { | |
| 59 | + return "", "", fmt.Errorf("create record returned %d", resp.StatusCode) | |
| 60 | + } | |
| 61 | + | |
| 62 | + var result struct { | |
| 63 | + URI string `json:"uri"` | |
| 64 | + CID string `json:"cid"` | |
| 65 | + } | |
| 66 | + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
| 67 | + return "", "", err | |
| 68 | + } | |
| 69 | + | |
| 70 | + return result.URI, result.CID, nil | |
| 71 | +} | |
| 72 | + | |
| 73 | +func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error { | |
| 74 | + body := map[string]any{ | |
| 75 | + "repo": did, | |
| 76 | + "collection": collection, | |
| 77 | + "rkey": rkey, | |
| 78 | + } | |
| 79 | + | |
| 80 | + data, err := json.Marshal(body) | |
| 81 | + if err != nil { | |
| 82 | + return err | |
| 83 | + } | |
| 84 | + | |
| 85 | + url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord" | |
| 86 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) | |
| 87 | + if err != nil { | |
| 88 | + return err | |
| 89 | + } | |
| 90 | + req.Header.Set("Content-Type", "application/json") | |
| 91 | + req.Header.Set("Authorization", "Bearer "+c.accessToken) | |
| 92 | + | |
| 93 | + resp, err := c.httpClient.Do(req) | |
| 94 | + if err != nil { | |
| 95 | + return err | |
| 96 | + } | |
| 97 | + defer resp.Body.Close() | |
| 98 | + | |
| 99 | + if resp.StatusCode != http.StatusOK { | |
| 100 | + return fmt.Errorf("delete record returned %d", resp.StatusCode) | |
| 101 | + } | |
| 102 | + | |
| 103 | + return nil | |
| 104 | +} | |
| 105 | + | |
| 106 | +func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) { | |
| 107 | + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection) | |
| 108 | + if limit > 0 { | |
| 109 | + url += fmt.Sprintf("&limit=%d", limit) | |
| 110 | + } | |
| 111 | + if cursor != "" { | |
| 112 | + url += "&cursor=" + cursor | |
| 113 | + } | |
| 114 | + | |
| 115 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 116 | + if err != nil { | |
| 117 | + return nil, "", err | |
| 118 | + } | |
| 119 | + req.Header.Set("Authorization", "Bearer "+c.accessToken) | |
| 120 | + | |
| 121 | + resp, err := c.httpClient.Do(req) | |
| 122 | + if err != nil { | |
| 123 | + return nil, "", err | |
| 124 | + } | |
| 125 | + defer resp.Body.Close() | |
| 126 | + | |
| 127 | + if resp.StatusCode != http.StatusOK { | |
| 128 | + return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode) | |
| 129 | + } | |
| 130 | + | |
| 131 | + var result struct { | |
| 132 | + Records []struct { | |
| 133 | + URI string `json:"uri"` | |
| 134 | + CID string `json:"cid"` | |
| 135 | + Value json.RawMessage `json:"value"` | |
| 136 | + } `json:"records"` | |
| 137 | + Cursor string `json:"cursor"` | |
| 138 | + } | |
| 139 | + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
| 140 | + return nil, "", err | |
| 141 | + } | |
| 142 | + | |
| 143 | + records := make([]Record, len(result.Records)) | |
| 144 | + for i, r := range result.Records { | |
| 145 | + records[i] = Record{ | |
| 146 | + URI: r.URI, | |
| 147 | + CID: r.CID, | |
| 148 | + Value: r.Value, | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + return records, result.Cursor, nil | |
| 153 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,153 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "context" | ||
| 6 | + "encoding/json" | ||
| 7 | + "fmt" | ||
| 8 | + "net/http" | ||
| 9 | + | ||
| 10 | + "github.com/bluesky-social/indigo/atproto/syntax" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +type Client struct { | ||
| 14 | + httpClient *http.Client | ||
| 15 | + pdsURL string | ||
| 16 | + accessToken string | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +func NewClient(pdsURL, accessToken string) *Client { | ||
| 20 | + return &Client{ | ||
| 21 | + httpClient: &http.Client{}, | ||
| 22 | + pdsURL: pdsURL, | ||
| 23 | + accessToken: accessToken, | ||
| 24 | + } | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) { | ||
| 28 | + nsid, err := syntax.ParseNSID(collection) | ||
| 29 | + if err != nil { | ||
| 30 | + return "", "", fmt.Errorf("parsing collection NSID: %w", err) | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + body := map[string]any{ | ||
| 34 | + "repo": did, | ||
| 35 | + "collection": nsid.String(), | ||
| 36 | + "record": record, | ||
| 37 | + } | ||
| 38 | + | ||
| 39 | + data, err := json.Marshal(body) | ||
| 40 | + if err != nil { | ||
| 41 | + return "", "", err | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord" | ||
| 45 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) | ||
| 46 | + if err != nil { | ||
| 47 | + return "", "", err | ||
| 48 | + } | ||
| 49 | + req.Header.Set("Content-Type", "application/json") | ||
| 50 | + req.Header.Set("Authorization", "Bearer "+c.accessToken) | ||
| 51 | + | ||
| 52 | + resp, err := c.httpClient.Do(req) | ||
| 53 | + if err != nil { | ||
| 54 | + return "", "", err | ||
| 55 | + } | ||
| 56 | + defer resp.Body.Close() | ||
| 57 | + | ||
| 58 | + if resp.StatusCode != http.StatusOK { | ||
| 59 | + return "", "", fmt.Errorf("create record returned %d", resp.StatusCode) | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + var result struct { | ||
| 63 | + URI string `json:"uri"` | ||
| 64 | + CID string `json:"cid"` | ||
| 65 | + } | ||
| 66 | + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
| 67 | + return "", "", err | ||
| 68 | + } | ||
| 69 | + | ||
| 70 | + return result.URI, result.CID, nil | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string) error { | ||
| 74 | + body := map[string]any{ | ||
| 75 | + "repo": did, | ||
| 76 | + "collection": collection, | ||
| 77 | + "rkey": rkey, | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + data, err := json.Marshal(body) | ||
| 81 | + if err != nil { | ||
| 82 | + return err | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord" | ||
| 86 | + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) | ||
| 87 | + if err != nil { | ||
| 88 | + return err | ||
| 89 | + } | ||
| 90 | + req.Header.Set("Content-Type", "application/json") | ||
| 91 | + req.Header.Set("Authorization", "Bearer "+c.accessToken) | ||
| 92 | + | ||
| 93 | + resp, err := c.httpClient.Do(req) | ||
| 94 | + if err != nil { | ||
| 95 | + return err | ||
| 96 | + } | ||
| 97 | + defer resp.Body.Close() | ||
| 98 | + | ||
| 99 | + if resp.StatusCode != http.StatusOK { | ||
| 100 | + return fmt.Errorf("delete record returned %d", resp.StatusCode) | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + return nil | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) { | ||
| 107 | + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection) | ||
| 108 | + if limit > 0 { | ||
| 109 | + url += fmt.Sprintf("&limit=%d", limit) | ||
| 110 | + } | ||
| 111 | + if cursor != "" { | ||
| 112 | + url += "&cursor=" + cursor | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | ||
| 116 | + if err != nil { | ||
| 117 | + return nil, "", err | ||
| 118 | + } | ||
| 119 | + req.Header.Set("Authorization", "Bearer "+c.accessToken) | ||
| 120 | + | ||
| 121 | + resp, err := c.httpClient.Do(req) | ||
| 122 | + if err != nil { | ||
| 123 | + return nil, "", err | ||
| 124 | + } | ||
| 125 | + defer resp.Body.Close() | ||
| 126 | + | ||
| 127 | + if resp.StatusCode != http.StatusOK { | ||
| 128 | + return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode) | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + var result struct { | ||
| 132 | + Records []struct { | ||
| 133 | + URI string `json:"uri"` | ||
| 134 | + CID string `json:"cid"` | ||
| 135 | + Value json.RawMessage `json:"value"` | ||
| 136 | + } `json:"records"` | ||
| 137 | + Cursor string `json:"cursor"` | ||
| 138 | + } | ||
| 139 | + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
| 140 | + return nil, "", err | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + records := make([]Record, len(result.Records)) | ||
| 144 | + for i, r := range result.Records { | ||
| 145 | + records[i] = Record{ | ||
| 146 | + URI: r.URI, | ||
| 147 | + CID: r.CID, | ||
| 148 | + Value: r.Value, | ||
| 149 | + } | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + return records, result.Cursor, nil | ||
| 153 | +} | ||
added
internal/atproto/firehose.go +201 -0 | new file mode 100644 | ||
| @@ -0,0 +1,201 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "fmt" | |
| 7 | + "log/slog" | |
| 8 | + "net/http" | |
| 9 | + "net/url" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "github.com/gorilla/websocket" | |
| 13 | +) | |
| 14 | + | |
| 15 | +type FirehoseEvent struct { | |
| 16 | + Type string | |
| 17 | + DID string | |
| 18 | + Collection string | |
| 19 | + RKey string | |
| 20 | + URI string | |
| 21 | + CID string | |
| 22 | + Value json.RawMessage | |
| 23 | +} | |
| 24 | + | |
| 25 | +type FirehoseHandler func(ctx context.Context, event *FirehoseEvent) error | |
| 26 | + | |
| 27 | +type FirehoseConsumer struct { | |
| 28 | + relayURL string | |
| 29 | + handler FirehoseHandler | |
| 30 | + cursor int64 | |
| 31 | + logger *slog.Logger | |
| 32 | + collections map[string]bool | |
| 33 | +} | |
| 34 | + | |
| 35 | +func NewFirehoseConsumer(relayURL string, handler FirehoseHandler, logger *slog.Logger) *FirehoseConsumer { | |
| 36 | + return &FirehoseConsumer{ | |
| 37 | + relayURL: relayURL, | |
| 38 | + handler: handler, | |
| 39 | + logger: logger, | |
| 40 | + collections: map[string]bool{ | |
| 41 | + "at.glean.subscription": true, | |
| 42 | + "at.glean.annotation": true, | |
| 43 | + "at.glean.like": true, | |
| 44 | + }, | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +func (fc *FirehoseConsumer) Start(ctx context.Context) error { | |
| 49 | + for { | |
| 50 | + err := fc.connect(ctx) | |
| 51 | + if ctx.Err() != nil { | |
| 52 | + return ctx.Err() | |
| 53 | + } | |
| 54 | + if err != nil { | |
| 55 | + fc.logger.Error("firehose connection error", "error", err) | |
| 56 | + } | |
| 57 | + | |
| 58 | + select { | |
| 59 | + case <-ctx.Done(): | |
| 60 | + return ctx.Err() | |
| 61 | + case <-time.After(5 * time.Second): | |
| 62 | + } | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +func (fc *FirehoseConsumer) connect(ctx context.Context) error { | |
| 67 | + u, err := url.Parse(fc.relayURL) | |
| 68 | + if err != nil { | |
| 69 | + return fmt.Errorf("parsing relay URL: %w", err) | |
| 70 | + } | |
| 71 | + | |
| 72 | + scheme := "wss" | |
| 73 | + if u.Scheme == "http" || u.Scheme == "ws" { | |
| 74 | + scheme = "ws" | |
| 75 | + } | |
| 76 | + | |
| 77 | + wsURL := fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos", scheme, u.Host) | |
| 78 | + if fc.cursor > 0 { | |
| 79 | + wsURL += fmt.Sprintf("?cursor=%d", fc.cursor) | |
| 80 | + } | |
| 81 | + | |
| 82 | + fc.logger.Info("connecting to firehose", "url", wsURL) | |
| 83 | + | |
| 84 | + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, http.Header{}) | |
| 85 | + if err != nil { | |
| 86 | + return fmt.Errorf("dialing firehose: %w", err) | |
| 87 | + } | |
| 88 | + defer conn.Close() | |
| 89 | + | |
| 90 | + fc.logger.Info("firehose connected") | |
| 91 | + | |
| 92 | + for { | |
| 93 | + select { | |
| 94 | + case <-ctx.Done(): | |
| 95 | + return ctx.Err() | |
| 96 | + default: | |
| 97 | + } | |
| 98 | + | |
| 99 | + _, msg, err := conn.ReadMessage() | |
| 100 | + if err != nil { | |
| 101 | + return fmt.Errorf("reading firehose: %w", err) | |
| 102 | + } | |
| 103 | + | |
| 104 | + fc.handleMessage(ctx, msg) | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +func (fc *FirehoseConsumer) handleMessage(ctx context.Context, msg []byte) { | |
| 109 | + var frame struct { | |
| 110 | + Type string `json:"type"` | |
| 111 | + Commit json.RawMessage `json:"commit"` | |
| 112 | + Seq int64 `json:"seq"` | |
| 113 | + } | |
| 114 | + | |
| 115 | + if err := json.Unmarshal(msg, &frame); err != nil { | |
| 116 | + return | |
| 117 | + } | |
| 118 | + | |
| 119 | + if frame.Seq > 0 { | |
| 120 | + fc.cursor = frame.Seq | |
| 121 | + } | |
| 122 | + | |
| 123 | + if frame.Type == "#commit" { | |
| 124 | + fc.parseCommit(ctx, frame.Commit) | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) { | |
| 129 | + var commit struct { | |
| 130 | + Did string `json:"did"` | |
| 131 | + Ops []struct { | |
| 132 | + Action string `json:"action"` | |
| 133 | + Path string `json:"path"` | |
| 134 | + CID json.RawMessage `json:"cid"` | |
| 135 | + Record json.RawMessage `json:"record"` | |
| 136 | + } `json:"ops"` | |
| 137 | + } | |
| 138 | + | |
| 139 | + if err := json.Unmarshal(raw, &commit); err != nil { | |
| 140 | + return | |
| 141 | + } | |
| 142 | + | |
| 143 | + for _, op := range commit.Ops { | |
| 144 | + parts := splitPath(op.Path) | |
| 145 | + if len(parts) != 2 { | |
| 146 | + continue | |
| 147 | + } | |
| 148 | + | |
| 149 | + collection := parts[0] | |
| 150 | + rkey := parts[1] | |
| 151 | + | |
| 152 | + if !fc.collections[collection] { | |
| 153 | + continue | |
| 154 | + } | |
| 155 | + | |
| 156 | + var action string | |
| 157 | + switch op.Action { | |
| 158 | + case "create": | |
| 159 | + action = "create" | |
| 160 | + case "update": | |
| 161 | + action = "update" | |
| 162 | + case "delete": | |
| 163 | + action = "delete" | |
| 164 | + default: | |
| 165 | + continue | |
| 166 | + } | |
| 167 | + | |
| 168 | + evt := &FirehoseEvent{ | |
| 169 | + Type: action, | |
| 170 | + DID: commit.Did, | |
| 171 | + Collection: collection, | |
| 172 | + RKey: rkey, | |
| 173 | + URI: fmt.Sprintf("at://%s/%s/%s", commit.Did, collection, rkey), | |
| 174 | + Value: op.Record, | |
| 175 | + } | |
| 176 | + | |
| 177 | + if op.CID != nil { | |
| 178 | + evt.CID = string(op.CID) | |
| 179 | + } | |
| 180 | + | |
| 181 | + if err := fc.handler(ctx, evt); err != nil { | |
| 182 | + fc.logger.Error("firehose handler error", "error", err) | |
| 183 | + } | |
| 184 | + } | |
| 185 | +} | |
| 186 | + | |
| 187 | +func splitPath(p string) []string { | |
| 188 | + if p == "" { | |
| 189 | + return nil | |
| 190 | + } | |
| 191 | + var parts []string | |
| 192 | + start := 0 | |
| 193 | + for i := 0; i < len(p); i++ { | |
| 194 | + if p[i] == '/' { | |
| 195 | + parts = append(parts, p[start:i]) | |
| 196 | + start = i + 1 | |
| 197 | + } | |
| 198 | + } | |
| 199 | + parts = append(parts, p[start:]) | |
| 200 | + return parts | |
| 201 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,201 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "fmt" | ||
| 7 | + "log/slog" | ||
| 8 | + "net/http" | ||
| 9 | + "net/url" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "github.com/gorilla/websocket" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +type FirehoseEvent struct { | ||
| 16 | + Type string | ||
| 17 | + DID string | ||
| 18 | + Collection string | ||
| 19 | + RKey string | ||
| 20 | + URI string | ||
| 21 | + CID string | ||
| 22 | + Value json.RawMessage | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +type FirehoseHandler func(ctx context.Context, event *FirehoseEvent) error | ||
| 26 | + | ||
| 27 | +type FirehoseConsumer struct { | ||
| 28 | + relayURL string | ||
| 29 | + handler FirehoseHandler | ||
| 30 | + cursor int64 | ||
| 31 | + logger *slog.Logger | ||
| 32 | + collections map[string]bool | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +func NewFirehoseConsumer(relayURL string, handler FirehoseHandler, logger *slog.Logger) *FirehoseConsumer { | ||
| 36 | + return &FirehoseConsumer{ | ||
| 37 | + relayURL: relayURL, | ||
| 38 | + handler: handler, | ||
| 39 | + logger: logger, | ||
| 40 | + collections: map[string]bool{ | ||
| 41 | + "at.glean.subscription": true, | ||
| 42 | + "at.glean.annotation": true, | ||
| 43 | + "at.glean.like": true, | ||
| 44 | + }, | ||
| 45 | + } | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +func (fc *FirehoseConsumer) Start(ctx context.Context) error { | ||
| 49 | + for { | ||
| 50 | + err := fc.connect(ctx) | ||
| 51 | + if ctx.Err() != nil { | ||
| 52 | + return ctx.Err() | ||
| 53 | + } | ||
| 54 | + if err != nil { | ||
| 55 | + fc.logger.Error("firehose connection error", "error", err) | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + select { | ||
| 59 | + case <-ctx.Done(): | ||
| 60 | + return ctx.Err() | ||
| 61 | + case <-time.After(5 * time.Second): | ||
| 62 | + } | ||
| 63 | + } | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +func (fc *FirehoseConsumer) connect(ctx context.Context) error { | ||
| 67 | + u, err := url.Parse(fc.relayURL) | ||
| 68 | + if err != nil { | ||
| 69 | + return fmt.Errorf("parsing relay URL: %w", err) | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + scheme := "wss" | ||
| 73 | + if u.Scheme == "http" || u.Scheme == "ws" { | ||
| 74 | + scheme = "ws" | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + wsURL := fmt.Sprintf("%s://%s/xrpc/com.atproto.sync.subscribeRepos", scheme, u.Host) | ||
| 78 | + if fc.cursor > 0 { | ||
| 79 | + wsURL += fmt.Sprintf("?cursor=%d", fc.cursor) | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + fc.logger.Info("connecting to firehose", "url", wsURL) | ||
| 83 | + | ||
| 84 | + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, http.Header{}) | ||
| 85 | + if err != nil { | ||
| 86 | + return fmt.Errorf("dialing firehose: %w", err) | ||
| 87 | + } | ||
| 88 | + defer conn.Close() | ||
| 89 | + | ||
| 90 | + fc.logger.Info("firehose connected") | ||
| 91 | + | ||
| 92 | + for { | ||
| 93 | + select { | ||
| 94 | + case <-ctx.Done(): | ||
| 95 | + return ctx.Err() | ||
| 96 | + default: | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + _, msg, err := conn.ReadMessage() | ||
| 100 | + if err != nil { | ||
| 101 | + return fmt.Errorf("reading firehose: %w", err) | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + fc.handleMessage(ctx, msg) | ||
| 105 | + } | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +func (fc *FirehoseConsumer) handleMessage(ctx context.Context, msg []byte) { | ||
| 109 | + var frame struct { | ||
| 110 | + Type string `json:"type"` | ||
| 111 | + Commit json.RawMessage `json:"commit"` | ||
| 112 | + Seq int64 `json:"seq"` | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + if err := json.Unmarshal(msg, &frame); err != nil { | ||
| 116 | + return | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + if frame.Seq > 0 { | ||
| 120 | + fc.cursor = frame.Seq | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + if frame.Type == "#commit" { | ||
| 124 | + fc.parseCommit(ctx, frame.Commit) | ||
| 125 | + } | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +func (fc *FirehoseConsumer) parseCommit(ctx context.Context, raw json.RawMessage) { | ||
| 129 | + var commit struct { | ||
| 130 | + Did string `json:"did"` | ||
| 131 | + Ops []struct { | ||
| 132 | + Action string `json:"action"` | ||
| 133 | + Path string `json:"path"` | ||
| 134 | + CID json.RawMessage `json:"cid"` | ||
| 135 | + Record json.RawMessage `json:"record"` | ||
| 136 | + } `json:"ops"` | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + if err := json.Unmarshal(raw, &commit); err != nil { | ||
| 140 | + return | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + for _, op := range commit.Ops { | ||
| 144 | + parts := splitPath(op.Path) | ||
| 145 | + if len(parts) != 2 { | ||
| 146 | + continue | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + collection := parts[0] | ||
| 150 | + rkey := parts[1] | ||
| 151 | + | ||
| 152 | + if !fc.collections[collection] { | ||
| 153 | + continue | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + var action string | ||
| 157 | + switch op.Action { | ||
| 158 | + case "create": | ||
| 159 | + action = "create" | ||
| 160 | + case "update": | ||
| 161 | + action = "update" | ||
| 162 | + case "delete": | ||
| 163 | + action = "delete" | ||
| 164 | + default: | ||
| 165 | + continue | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + evt := &FirehoseEvent{ | ||
| 169 | + Type: action, | ||
| 170 | + DID: commit.Did, | ||
| 171 | + Collection: collection, | ||
| 172 | + RKey: rkey, | ||
| 173 | + URI: fmt.Sprintf("at://%s/%s/%s", commit.Did, collection, rkey), | ||
| 174 | + Value: op.Record, | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + if op.CID != nil { | ||
| 178 | + evt.CID = string(op.CID) | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + if err := fc.handler(ctx, evt); err != nil { | ||
| 182 | + fc.logger.Error("firehose handler error", "error", err) | ||
| 183 | + } | ||
| 184 | + } | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +func splitPath(p string) []string { | ||
| 188 | + if p == "" { | ||
| 189 | + return nil | ||
| 190 | + } | ||
| 191 | + var parts []string | ||
| 192 | + start := 0 | ||
| 193 | + for i := 0; i < len(p); i++ { | ||
| 194 | + if p[i] == '/' { | ||
| 195 | + parts = append(parts, p[start:i]) | ||
| 196 | + start = i + 1 | ||
| 197 | + } | ||
| 198 | + } | ||
| 199 | + parts = append(parts, p[start:]) | ||
| 200 | + return parts | ||
| 201 | +} | ||
added
internal/atproto/lexicon.go +126 -0 | new file mode 100644 | ||
| @@ -0,0 +1,126 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import "time" | |
| 4 | + | |
| 5 | +type SubscriptionRecord struct { | |
| 6 | + CreatedAt string `json:"createdAt"` | |
| 7 | + FeedURL string `json:"feedUrl"` | |
| 8 | + Title string `json:"title,omitempty"` | |
| 9 | + Category string `json:"category,omitempty"` | |
| 10 | +} | |
| 11 | + | |
| 12 | +type AnnotationRecord struct { | |
| 13 | + CreatedAt string `json:"createdAt"` | |
| 14 | + FeedURL string `json:"feedUrl"` | |
| 15 | + ArticleURL string `json:"articleUrl"` | |
| 16 | + Quote string `json:"quote,omitempty"` | |
| 17 | + Note string `json:"note,omitempty"` | |
| 18 | + Tags []string `json:"tags,omitempty"` | |
| 19 | + Rating int `json:"rating,omitempty"` | |
| 20 | +} | |
| 21 | + | |
| 22 | +type LikeRecord struct { | |
| 23 | + CreatedAt string `json:"createdAt"` | |
| 24 | + FeedURL string `json:"feedUrl"` | |
| 25 | + ArticleURL string `json:"articleUrl"` | |
| 26 | +} | |
| 27 | + | |
| 28 | +type Record struct { | |
| 29 | + URI string | |
| 30 | + CID string | |
| 31 | + DID string | |
| 32 | + Collection string | |
| 33 | + RKey string | |
| 34 | + Value []byte | |
| 35 | + IndexedAt time.Time | |
| 36 | +} | |
| 37 | + | |
| 38 | +type SubscriptionView struct { | |
| 39 | + URI string `json:"uri"` | |
| 40 | + CID string `json:"cid"` | |
| 41 | + Value SubscriptionRecord `json:"value"` | |
| 42 | + IndexedAt string `json:"indexedAt"` | |
| 43 | +} | |
| 44 | + | |
| 45 | +type ListSubscriptionsResponse struct { | |
| 46 | + Cursor string `json:"cursor,omitempty"` | |
| 47 | + Subscriptions []SubscriptionView `json:"subscriptions"` | |
| 48 | +} | |
| 49 | + | |
| 50 | +type FeedListEntry struct { | |
| 51 | + DID string `json:"did"` | |
| 52 | + SubscriptionCount int `json:"subscriptionCount"` | |
| 53 | + Subscriptions []SubscriptionRecord `json:"subscriptions"` | |
| 54 | +} | |
| 55 | + | |
| 56 | +type ListFeedListsResponse struct { | |
| 57 | + Cursor string `json:"cursor,omitempty"` | |
| 58 | + Feeds []FeedListEntry `json:"feeds"` | |
| 59 | +} | |
| 60 | + | |
| 61 | +type ActorView struct { | |
| 62 | + DID string `json:"did"` | |
| 63 | + Handle string `json:"handle"` | |
| 64 | +} | |
| 65 | + | |
| 66 | +type AnnotationView struct { | |
| 67 | + URI string `json:"uri"` | |
| 68 | + CID string `json:"cid"` | |
| 69 | + Author ActorView `json:"author"` | |
| 70 | + Value AnnotationRecord `json:"value"` | |
| 71 | + IndexedAt string `json:"indexedAt"` | |
| 72 | +} | |
| 73 | + | |
| 74 | +type ListAnnotationsResponse struct { | |
| 75 | + Cursor string `json:"cursor,omitempty"` | |
| 76 | + Annotations []AnnotationView `json:"annotations"` | |
| 77 | +} | |
| 78 | + | |
| 79 | +type LikeView struct { | |
| 80 | + URI string `json:"uri"` | |
| 81 | + CID string `json:"cid"` | |
| 82 | + Author ActorView `json:"author"` | |
| 83 | + Value LikeRecord `json:"value"` | |
| 84 | + IndexedAt string `json:"indexedAt"` | |
| 85 | +} | |
| 86 | + | |
| 87 | +type ListLikesResponse struct { | |
| 88 | + Cursor string `json:"cursor,omitempty"` | |
| 89 | + Likes []LikeView `json:"likes"` | |
| 90 | +} | |
| 91 | + | |
| 92 | +type TrendingArticle struct { | |
| 93 | + FeedURL string `json:"feedUrl"` | |
| 94 | + ArticleURL string `json:"articleUrl"` | |
| 95 | + Title string `json:"title"` | |
| 96 | + LikeCount int `json:"likeCount"` | |
| 97 | + Annotations []AnnotationView `json:"annotations"` | |
| 98 | +} | |
| 99 | + | |
| 100 | +type GetTrendingResponse struct { | |
| 101 | + Cursor string `json:"cursor,omitempty"` | |
| 102 | + Articles []TrendingArticle `json:"articles"` | |
| 103 | +} | |
| 104 | + | |
| 105 | +type RecommendedFeed struct { | |
| 106 | + FeedURL string `json:"feedUrl"` | |
| 107 | + Title string `json:"title"` | |
| 108 | + SiteURL string `json:"siteUrl"` | |
| 109 | + Description string `json:"description"` | |
| 110 | + SubscriberCount int `json:"subscriberCount"` | |
| 111 | + Score float64 `json:"score"` | |
| 112 | +} | |
| 113 | + | |
| 114 | +type RecommendedPerson struct { | |
| 115 | + DID string `json:"did"` | |
| 116 | + Handle string `json:"handle"` | |
| 117 | + DisplayName string `json:"displayName"` | |
| 118 | + Avatar string `json:"avatar"` | |
| 119 | + Jaccard float64 `json:"jaccard"` | |
| 120 | + CommonFeeds int `json:"commonFeeds"` | |
| 121 | +} | |
| 122 | + | |
| 123 | +type GetRecommendationsResponse struct { | |
| 124 | + Feeds []RecommendedFeed `json:"feeds"` | |
| 125 | + People []RecommendedPerson `json:"people"` | |
| 126 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,126 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import "time" | ||
| 4 | + | ||
| 5 | +type SubscriptionRecord struct { | ||
| 6 | + CreatedAt string `json:"createdAt"` | ||
| 7 | + FeedURL string `json:"feedUrl"` | ||
| 8 | + Title string `json:"title,omitempty"` | ||
| 9 | + Category string `json:"category,omitempty"` | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +type AnnotationRecord struct { | ||
| 13 | + CreatedAt string `json:"createdAt"` | ||
| 14 | + FeedURL string `json:"feedUrl"` | ||
| 15 | + ArticleURL string `json:"articleUrl"` | ||
| 16 | + Quote string `json:"quote,omitempty"` | ||
| 17 | + Note string `json:"note,omitempty"` | ||
| 18 | + Tags []string `json:"tags,omitempty"` | ||
| 19 | + Rating int `json:"rating,omitempty"` | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +type LikeRecord struct { | ||
| 23 | + CreatedAt string `json:"createdAt"` | ||
| 24 | + FeedURL string `json:"feedUrl"` | ||
| 25 | + ArticleURL string `json:"articleUrl"` | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +type Record struct { | ||
| 29 | + URI string | ||
| 30 | + CID string | ||
| 31 | + DID string | ||
| 32 | + Collection string | ||
| 33 | + RKey string | ||
| 34 | + Value []byte | ||
| 35 | + IndexedAt time.Time | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +type SubscriptionView struct { | ||
| 39 | + URI string `json:"uri"` | ||
| 40 | + CID string `json:"cid"` | ||
| 41 | + Value SubscriptionRecord `json:"value"` | ||
| 42 | + IndexedAt string `json:"indexedAt"` | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +type ListSubscriptionsResponse struct { | ||
| 46 | + Cursor string `json:"cursor,omitempty"` | ||
| 47 | + Subscriptions []SubscriptionView `json:"subscriptions"` | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +type FeedListEntry struct { | ||
| 51 | + DID string `json:"did"` | ||
| 52 | + SubscriptionCount int `json:"subscriptionCount"` | ||
| 53 | + Subscriptions []SubscriptionRecord `json:"subscriptions"` | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +type ListFeedListsResponse struct { | ||
| 57 | + Cursor string `json:"cursor,omitempty"` | ||
| 58 | + Feeds []FeedListEntry `json:"feeds"` | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +type ActorView struct { | ||
| 62 | + DID string `json:"did"` | ||
| 63 | + Handle string `json:"handle"` | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +type AnnotationView struct { | ||
| 67 | + URI string `json:"uri"` | ||
| 68 | + CID string `json:"cid"` | ||
| 69 | + Author ActorView `json:"author"` | ||
| 70 | + Value AnnotationRecord `json:"value"` | ||
| 71 | + IndexedAt string `json:"indexedAt"` | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +type ListAnnotationsResponse struct { | ||
| 75 | + Cursor string `json:"cursor,omitempty"` | ||
| 76 | + Annotations []AnnotationView `json:"annotations"` | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +type LikeView struct { | ||
| 80 | + URI string `json:"uri"` | ||
| 81 | + CID string `json:"cid"` | ||
| 82 | + Author ActorView `json:"author"` | ||
| 83 | + Value LikeRecord `json:"value"` | ||
| 84 | + IndexedAt string `json:"indexedAt"` | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +type ListLikesResponse struct { | ||
| 88 | + Cursor string `json:"cursor,omitempty"` | ||
| 89 | + Likes []LikeView `json:"likes"` | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +type TrendingArticle struct { | ||
| 93 | + FeedURL string `json:"feedUrl"` | ||
| 94 | + ArticleURL string `json:"articleUrl"` | ||
| 95 | + Title string `json:"title"` | ||
| 96 | + LikeCount int `json:"likeCount"` | ||
| 97 | + Annotations []AnnotationView `json:"annotations"` | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +type GetTrendingResponse struct { | ||
| 101 | + Cursor string `json:"cursor,omitempty"` | ||
| 102 | + Articles []TrendingArticle `json:"articles"` | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +type RecommendedFeed struct { | ||
| 106 | + FeedURL string `json:"feedUrl"` | ||
| 107 | + Title string `json:"title"` | ||
| 108 | + SiteURL string `json:"siteUrl"` | ||
| 109 | + Description string `json:"description"` | ||
| 110 | + SubscriberCount int `json:"subscriberCount"` | ||
| 111 | + Score float64 `json:"score"` | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +type RecommendedPerson struct { | ||
| 115 | + DID string `json:"did"` | ||
| 116 | + Handle string `json:"handle"` | ||
| 117 | + DisplayName string `json:"displayName"` | ||
| 118 | + Avatar string `json:"avatar"` | ||
| 119 | + Jaccard float64 `json:"jaccard"` | ||
| 120 | + CommonFeeds int `json:"commonFeeds"` | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +type GetRecommendationsResponse struct { | ||
| 124 | + Feeds []RecommendedFeed `json:"feeds"` | ||
| 125 | + People []RecommendedPerson `json:"people"` | ||
| 126 | +} | ||
added
internal/atproto/xrpc.go +496 -0 | new file mode 100644 | ||
| @@ -0,0 +1,496 @@ | ||
| 1 | +package atproto | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "encoding/json" | |
| 6 | + "net/http" | |
| 7 | + "strconv" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "github.com/go-chi/chi/v5" | |
| 11 | +) | |
| 12 | + | |
| 13 | +type XRPCHandler struct { | |
| 14 | + db *sql.DB | |
| 15 | +} | |
| 16 | + | |
| 17 | +func NewXRPCHandler(db *sql.DB) *XRPCHandler { | |
| 18 | + return &XRPCHandler{db: db} | |
| 19 | +} | |
| 20 | + | |
| 21 | +func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) { | |
| 22 | + repo := chi.URLParam(r, "repo") | |
| 23 | + category := r.URL.Query().Get("category") | |
| 24 | + limit := parseIntParam(r, "limit", 50) | |
| 25 | + cursor := r.URL.Query().Get("cursor") | |
| 26 | + | |
| 27 | + query := ` | |
| 28 | + SELECT s.id, f.feed_url, f.title, s.category, s.added_at | |
| 29 | + FROM subscriptions s | |
| 30 | + JOIN feeds f ON s.feed_url = f.feed_url | |
| 31 | + WHERE s.user_did = ?` | |
| 32 | + args := []any{repo} | |
| 33 | + | |
| 34 | + if category != "" { | |
| 35 | + query += " AND s.category = ?" | |
| 36 | + args = append(args, category) | |
| 37 | + } | |
| 38 | + if cursor != "" { | |
| 39 | + query += " AND s.id > ?" | |
| 40 | + args = append(args, cursor) | |
| 41 | + } | |
| 42 | + | |
| 43 | + query += " ORDER BY s.id ASC LIMIT ?" | |
| 44 | + args = append(args, limit+1) | |
| 45 | + | |
| 46 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | |
| 47 | + if err != nil { | |
| 48 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 49 | + return | |
| 50 | + } | |
| 51 | + defer rows.Close() | |
| 52 | + | |
| 53 | + subs := make([]SubscriptionView, 0) | |
| 54 | + for rows.Next() { | |
| 55 | + var id int | |
| 56 | + var feedURL, title string | |
| 57 | + var cat, addedAt sql.NullString | |
| 58 | + if err := rows.Scan(&id, &feedURL, &title, &cat, &addedAt); err != nil { | |
| 59 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 60 | + return | |
| 61 | + } | |
| 62 | + | |
| 63 | + sv := SubscriptionView{ | |
| 64 | + URI: fmtATURI(repo, "at.glean.subscription", strconv.Itoa(id)), | |
| 65 | + Value: SubscriptionRecord{ | |
| 66 | + CreatedAt: addedAt.String, | |
| 67 | + FeedURL: feedURL, | |
| 68 | + Title: title, | |
| 69 | + Category: cat.String, | |
| 70 | + }, | |
| 71 | + IndexedAt: addedAt.String, | |
| 72 | + } | |
| 73 | + subs = append(subs, sv) | |
| 74 | + } | |
| 75 | + | |
| 76 | + resp := ListSubscriptionsResponse{Subscriptions: subs} | |
| 77 | + if len(subs) > limit { | |
| 78 | + resp.Cursor = strconv.Itoa(limit) | |
| 79 | + resp.Subscriptions = subs[:limit] | |
| 80 | + } | |
| 81 | + | |
| 82 | + writeJSON(w, resp) | |
| 83 | +} | |
| 84 | + | |
| 85 | +func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) { | |
| 86 | + feedURL := r.URL.Query().Get("feedUrl") | |
| 87 | + articleURL := r.URL.Query().Get("articleUrl") | |
| 88 | + author := r.URL.Query().Get("author") | |
| 89 | + limit := parseIntParam(r, "limit", 50) | |
| 90 | + cursor := r.URL.Query().Get("cursor") | |
| 91 | + | |
| 92 | + query := ` | |
| 93 | + SELECT a.uri, a.cid, u.did, u.handle, a.feed_url, a.article_url, | |
| 94 | + a.quote, a.note, a.tags, a.rating, a.created_at | |
| 95 | + FROM annotations a | |
| 96 | + JOIN users u ON a.author_did = u.did | |
| 97 | + WHERE 1=1` | |
| 98 | + args := []any{} | |
| 99 | + | |
| 100 | + if feedURL != "" { | |
| 101 | + query += " AND a.feed_url = ?" | |
| 102 | + args = append(args, feedURL) | |
| 103 | + } | |
| 104 | + if articleURL != "" { | |
| 105 | + query += " AND a.article_url = ?" | |
| 106 | + args = append(args, articleURL) | |
| 107 | + } | |
| 108 | + if author != "" { | |
| 109 | + query += " AND a.author_did = ?" | |
| 110 | + args = append(args, author) | |
| 111 | + } | |
| 112 | + if cursor != "" { | |
| 113 | + query += " AND a.id > ?" | |
| 114 | + args = append(args, cursor) | |
| 115 | + } | |
| 116 | + | |
| 117 | + query += " ORDER BY a.id ASC LIMIT ?" | |
| 118 | + args = append(args, limit+1) | |
| 119 | + | |
| 120 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | |
| 121 | + if err != nil { | |
| 122 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 123 | + return | |
| 124 | + } | |
| 125 | + defer rows.Close() | |
| 126 | + | |
| 127 | + annotations := make([]AnnotationView, 0) | |
| 128 | + for rows.Next() { | |
| 129 | + var uri, did, handle, fURL, artURL, createdAt string | |
| 130 | + var cid, quote, note, tags sql.NullString | |
| 131 | + var rating sql.NullInt64 | |
| 132 | + if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, "e, ¬e, &tags, &rating, &createdAt); err != nil { | |
| 133 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 134 | + return | |
| 135 | + } | |
| 136 | + | |
| 137 | + var tagSlice []string | |
| 138 | + if tags.Valid && tags.String != "" { | |
| 139 | + tagSlice = strings.Split(tags.String, ",") | |
| 140 | + } | |
| 141 | + | |
| 142 | + av := AnnotationView{ | |
| 143 | + URI: uri, | |
| 144 | + CID: cid.String, | |
| 145 | + Author: ActorView{ | |
| 146 | + DID: did, | |
| 147 | + Handle: handle, | |
| 148 | + }, | |
| 149 | + Value: AnnotationRecord{ | |
| 150 | + CreatedAt: createdAt, | |
| 151 | + FeedURL: fURL, | |
| 152 | + ArticleURL: artURL, | |
| 153 | + Quote: quote.String, | |
| 154 | + Note: note.String, | |
| 155 | + Tags: tagSlice, | |
| 156 | + Rating: int(rating.Int64), | |
| 157 | + }, | |
| 158 | + IndexedAt: createdAt, | |
| 159 | + } | |
| 160 | + annotations = append(annotations, av) | |
| 161 | + } | |
| 162 | + | |
| 163 | + resp := ListAnnotationsResponse{Annotations: annotations} | |
| 164 | + if len(annotations) > limit { | |
| 165 | + resp.Cursor = strconv.Itoa(limit) | |
| 166 | + resp.Annotations = annotations[:limit] | |
| 167 | + } | |
| 168 | + | |
| 169 | + writeJSON(w, resp) | |
| 170 | +} | |
| 171 | + | |
| 172 | +func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) { | |
| 173 | + author := r.URL.Query().Get("author") | |
| 174 | + feedURL := r.URL.Query().Get("feedUrl") | |
| 175 | + limit := parseIntParam(r, "limit", 50) | |
| 176 | + cursor := r.URL.Query().Get("cursor") | |
| 177 | + | |
| 178 | + query := ` | |
| 179 | + SELECT l.uri, l.cid, u.did, u.handle, l.feed_url, l.article_url, l.created_at | |
| 180 | + FROM likes l | |
| 181 | + JOIN users u ON l.author_did = u.did | |
| 182 | + WHERE 1=1` | |
| 183 | + args := []any{} | |
| 184 | + | |
| 185 | + if author != "" { | |
| 186 | + query += " AND l.author_did = ?" | |
| 187 | + args = append(args, author) | |
| 188 | + } | |
| 189 | + if feedURL != "" { | |
| 190 | + query += " AND l.feed_url = ?" | |
| 191 | + args = append(args, feedURL) | |
| 192 | + } | |
| 193 | + if cursor != "" { | |
| 194 | + query += " AND l.id > ?" | |
| 195 | + args = append(args, cursor) | |
| 196 | + } | |
| 197 | + | |
| 198 | + query += " ORDER BY l.id ASC LIMIT ?" | |
| 199 | + args = append(args, limit+1) | |
| 200 | + | |
| 201 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | |
| 202 | + if err != nil { | |
| 203 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 204 | + return | |
| 205 | + } | |
| 206 | + defer rows.Close() | |
| 207 | + | |
| 208 | + likes := make([]LikeView, 0) | |
| 209 | + for rows.Next() { | |
| 210 | + var uri, did, handle, fURL, artURL, createdAt string | |
| 211 | + var cid sql.NullString | |
| 212 | + if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &createdAt); err != nil { | |
| 213 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 214 | + return | |
| 215 | + } | |
| 216 | + | |
| 217 | + lv := LikeView{ | |
| 218 | + URI: uri, | |
| 219 | + CID: cid.String, | |
| 220 | + Author: ActorView{ | |
| 221 | + DID: did, | |
| 222 | + Handle: handle, | |
| 223 | + }, | |
| 224 | + Value: LikeRecord{ | |
| 225 | + CreatedAt: createdAt, | |
| 226 | + FeedURL: fURL, | |
| 227 | + ArticleURL: artURL, | |
| 228 | + }, | |
| 229 | + IndexedAt: createdAt, | |
| 230 | + } | |
| 231 | + likes = append(likes, lv) | |
| 232 | + } | |
| 233 | + | |
| 234 | + resp := ListLikesResponse{Likes: likes} | |
| 235 | + if len(likes) > limit { | |
| 236 | + resp.Cursor = strconv.Itoa(limit) | |
| 237 | + resp.Likes = likes[:limit] | |
| 238 | + } | |
| 239 | + | |
| 240 | + writeJSON(w, resp) | |
| 241 | +} | |
| 242 | + | |
| 243 | +func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) { | |
| 244 | + limit := parseIntParam(r, "limit", 25) | |
| 245 | + cursor := r.URL.Query().Get("cursor") | |
| 246 | + since := r.URL.Query().Get("since") | |
| 247 | + | |
| 248 | + query := ` | |
| 249 | + SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count | |
| 250 | + FROM likes l | |
| 251 | + LEFT JOIN articles a ON l.article_url = a.url | |
| 252 | + WHERE 1=1` | |
| 253 | + args := []any{} | |
| 254 | + | |
| 255 | + if since != "" { | |
| 256 | + query += " AND l.created_at >= ?" | |
| 257 | + args = append(args, since) | |
| 258 | + } | |
| 259 | + if cursor != "" { | |
| 260 | + query += " AND l.article_url > ?" | |
| 261 | + args = append(args, cursor) | |
| 262 | + } | |
| 263 | + | |
| 264 | + query += " GROUP BY l.feed_url, l.article_url ORDER BY like_count DESC LIMIT ?" | |
| 265 | + args = append(args, limit+1) | |
| 266 | + | |
| 267 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | |
| 268 | + if err != nil { | |
| 269 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 270 | + return | |
| 271 | + } | |
| 272 | + defer rows.Close() | |
| 273 | + | |
| 274 | + articles := make([]TrendingArticle, 0) | |
| 275 | + for rows.Next() { | |
| 276 | + var feedURL, articleURL string | |
| 277 | + var title sql.NullString | |
| 278 | + var likeCount int | |
| 279 | + if err := rows.Scan(&feedURL, &articleURL, &title, &likeCount); err != nil { | |
| 280 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 281 | + return | |
| 282 | + } | |
| 283 | + | |
| 284 | + ta := TrendingArticle{ | |
| 285 | + FeedURL: feedURL, | |
| 286 | + ArticleURL: articleURL, | |
| 287 | + Title: title.String, | |
| 288 | + LikeCount: likeCount, | |
| 289 | + } | |
| 290 | + articles = append(articles, ta) | |
| 291 | + } | |
| 292 | + | |
| 293 | + resp := GetTrendingResponse{Articles: articles} | |
| 294 | + if len(articles) > limit { | |
| 295 | + resp.Cursor = strconv.Itoa(limit) | |
| 296 | + resp.Articles = articles[:limit] | |
| 297 | + } | |
| 298 | + | |
| 299 | + writeJSON(w, resp) | |
| 300 | +} | |
| 301 | + | |
| 302 | +func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { | |
| 303 | + repo := chi.URLParam(r, "repo") | |
| 304 | + limit := parseIntParam(r, "limit", 10) | |
| 305 | + | |
| 306 | + feedRows, err := h.db.QueryContext(r.Context(), ` | |
| 307 | + SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score | |
| 308 | + FROM user_feed_recommendations r | |
| 309 | + JOIN feeds f ON r.feed_url = f.feed_url | |
| 310 | + WHERE r.user_did = ? | |
| 311 | + ORDER BY r.score DESC | |
| 312 | + LIMIT ? | |
| 313 | + `, repo, limit) | |
| 314 | + if err != nil { | |
| 315 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 316 | + return | |
| 317 | + } | |
| 318 | + defer feedRows.Close() | |
| 319 | + | |
| 320 | + feeds := make([]RecommendedFeed, 0) | |
| 321 | + for feedRows.Next() { | |
| 322 | + var feedURL, title, siteURL, description string | |
| 323 | + var subscriberCount int | |
| 324 | + var score float64 | |
| 325 | + if err := feedRows.Scan(&feedURL, &title, &siteURL, &description, &subscriberCount, &score); err != nil { | |
| 326 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 327 | + return | |
| 328 | + } | |
| 329 | + feeds = append(feeds, RecommendedFeed{ | |
| 330 | + FeedURL: feedURL, | |
| 331 | + Title: title, | |
| 332 | + SiteURL: siteURL, | |
| 333 | + Description: description, | |
| 334 | + SubscriberCount: subscriberCount, | |
| 335 | + Score: score, | |
| 336 | + }) | |
| 337 | + } | |
| 338 | + | |
| 339 | + peopleRows, err := h.db.QueryContext(r.Context(), ` | |
| 340 | + SELECT u.did, u.handle, u.display_name, u.avatar_url, s.jaccard, s.common_feeds | |
| 341 | + FROM user_similarity s | |
| 342 | + JOIN users u ON ( | |
| 343 | + CASE WHEN s.user_a = ? THEN s.user_b ELSE s.user_a END | |
| 344 | + ) = u.did | |
| 345 | + WHERE s.user_a = ? OR s.user_b = ? | |
| 346 | + ORDER BY s.jaccard DESC | |
| 347 | + LIMIT ? | |
| 348 | + `, repo, repo, repo, limit) | |
| 349 | + if err != nil { | |
| 350 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 351 | + return | |
| 352 | + } | |
| 353 | + defer peopleRows.Close() | |
| 354 | + | |
| 355 | + people := make([]RecommendedPerson, 0) | |
| 356 | + for peopleRows.Next() { | |
| 357 | + var did, handle string | |
| 358 | + var displayName, avatar sql.NullString | |
| 359 | + var jaccard float64 | |
| 360 | + var commonFeeds int | |
| 361 | + if err := peopleRows.Scan(&did, &handle, &displayName, &avatar, &jaccard, &commonFeeds); err != nil { | |
| 362 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 363 | + return | |
| 364 | + } | |
| 365 | + people = append(people, RecommendedPerson{ | |
| 366 | + DID: did, | |
| 367 | + Handle: handle, | |
| 368 | + DisplayName: displayName.String, | |
| 369 | + Avatar: avatar.String, | |
| 370 | + Jaccard: jaccard, | |
| 371 | + CommonFeeds: commonFeeds, | |
| 372 | + }) | |
| 373 | + } | |
| 374 | + | |
| 375 | + writeJSON(w, GetRecommendationsResponse{Feeds: feeds, People: people}) | |
| 376 | +} | |
| 377 | + | |
| 378 | +func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | |
| 379 | + actorsParam := r.URL.Query().Get("actors") | |
| 380 | + limit := parseIntParam(r, "limit", 50) | |
| 381 | + cursor := r.URL.Query().Get("cursor") | |
| 382 | + | |
| 383 | + var dids []string | |
| 384 | + if actorsParam != "" { | |
| 385 | + dids = strings.Split(actorsParam, ",") | |
| 386 | + } | |
| 387 | + | |
| 388 | + if len(dids) == 0 { | |
| 389 | + writeJSON(w, ListFeedListsResponse{}) | |
| 390 | + return | |
| 391 | + } | |
| 392 | + | |
| 393 | + placeholders := make([]string, len(dids)) | |
| 394 | + args := make([]any, len(dids)) | |
| 395 | + for i, d := range dids { | |
| 396 | + placeholders[i] = "?" | |
| 397 | + args[i] = d | |
| 398 | + } | |
| 399 | + | |
| 400 | + query := ` | |
| 401 | + SELECT u.did, u.handle, COUNT(s.id) as subscription_count | |
| 402 | + FROM users u | |
| 403 | + LEFT JOIN subscriptions s ON u.did = s.user_did | |
| 404 | + WHERE u.did IN (` + strings.Join(placeholders, ",") + `)` | |
| 405 | + | |
| 406 | + if cursor != "" { | |
| 407 | + query += " AND u.did > ?" | |
| 408 | + args = append(args, cursor) | |
| 409 | + } | |
| 410 | + | |
| 411 | + query += " GROUP BY u.did ORDER BY u.did ASC LIMIT ?" | |
| 412 | + args = append(args, limit+1) | |
| 413 | + | |
| 414 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | |
| 415 | + if err != nil { | |
| 416 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 417 | + return | |
| 418 | + } | |
| 419 | + defer rows.Close() | |
| 420 | + | |
| 421 | + feedLists := make([]FeedListEntry, 0) | |
| 422 | + for rows.Next() { | |
| 423 | + var did, handle string | |
| 424 | + var subCount int | |
| 425 | + if err := rows.Scan(&did, &handle, &subCount); err != nil { | |
| 426 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 427 | + return | |
| 428 | + } | |
| 429 | + | |
| 430 | + subRows, err := h.db.QueryContext(r.Context(), ` | |
| 431 | + SELECT f.feed_url, f.title, s.category | |
| 432 | + FROM subscriptions s | |
| 433 | + JOIN feeds f ON s.feed_url = f.feed_url | |
| 434 | + WHERE s.user_did = ? | |
| 435 | + ORDER BY s.added_at DESC | |
| 436 | + `, did) | |
| 437 | + if err != nil { | |
| 438 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 439 | + return | |
| 440 | + } | |
| 441 | + | |
| 442 | + subs := make([]SubscriptionRecord, 0) | |
| 443 | + for subRows.Next() { | |
| 444 | + var feedURL, title string | |
| 445 | + var cat sql.NullString | |
| 446 | + if err := subRows.Scan(&feedURL, &title, &cat); err != nil { | |
| 447 | + subRows.Close() | |
| 448 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 449 | + return | |
| 450 | + } | |
| 451 | + subs = append(subs, SubscriptionRecord{ | |
| 452 | + FeedURL: feedURL, | |
| 453 | + Title: title, | |
| 454 | + Category: cat.String, | |
| 455 | + }) | |
| 456 | + } | |
| 457 | + subRows.Close() | |
| 458 | + | |
| 459 | + feedLists = append(feedLists, FeedListEntry{ | |
| 460 | + DID: did, | |
| 461 | + SubscriptionCount: subCount, | |
| 462 | + Subscriptions: subs, | |
| 463 | + }) | |
| 464 | + } | |
| 465 | + | |
| 466 | + resp := ListFeedListsResponse{Feeds: feedLists} | |
| 467 | + if len(feedLists) > limit { | |
| 468 | + resp.Cursor = strconv.Itoa(limit) | |
| 469 | + resp.Feeds = feedLists[:limit] | |
| 470 | + } | |
| 471 | + | |
| 472 | + writeJSON(w, resp) | |
| 473 | +} | |
| 474 | + | |
| 475 | +func parseIntParam(r *http.Request, key string, defaultVal int) int { | |
| 476 | + v := r.URL.Query().Get(key) | |
| 477 | + if v == "" { | |
| 478 | + return defaultVal | |
| 479 | + } | |
| 480 | + n, err := strconv.Atoi(v) | |
| 481 | + if err != nil { | |
| 482 | + return defaultVal | |
| 483 | + } | |
| 484 | + return n | |
| 485 | +} | |
| 486 | + | |
| 487 | +func fmtATURI(did, collection, rkey string) string { | |
| 488 | + return "at://" + did + "/" + collection + "/" + rkey | |
| 489 | +} | |
| 490 | + | |
| 491 | +func writeJSON(w http.ResponseWriter, v any) { | |
| 492 | + w.Header().Set("Content-Type", "application/json") | |
| 493 | + if err := json.NewEncoder(w).Encode(v); err != nil { | |
| 494 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 495 | + } | |
| 496 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,496 @@ | |||
| 1 | +package atproto | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "database/sql" | ||
| 5 | + "encoding/json" | ||
| 6 | + "net/http" | ||
| 7 | + "strconv" | ||
| 8 | + "strings" | ||
| 9 | + | ||
| 10 | + "github.com/go-chi/chi/v5" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +type XRPCHandler struct { | ||
| 14 | + db *sql.DB | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +func NewXRPCHandler(db *sql.DB) *XRPCHandler { | ||
| 18 | + return &XRPCHandler{db: db} | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +func (h *XRPCHandler) ListSubscriptions(w http.ResponseWriter, r *http.Request) { | ||
| 22 | + repo := chi.URLParam(r, "repo") | ||
| 23 | + category := r.URL.Query().Get("category") | ||
| 24 | + limit := parseIntParam(r, "limit", 50) | ||
| 25 | + cursor := r.URL.Query().Get("cursor") | ||
| 26 | + | ||
| 27 | + query := ` | ||
| 28 | + SELECT s.id, f.feed_url, f.title, s.category, s.added_at | ||
| 29 | + FROM subscriptions s | ||
| 30 | + JOIN feeds f ON s.feed_url = f.feed_url | ||
| 31 | + WHERE s.user_did = ?` | ||
| 32 | + args := []any{repo} | ||
| 33 | + | ||
| 34 | + if category != "" { | ||
| 35 | + query += " AND s.category = ?" | ||
| 36 | + args = append(args, category) | ||
| 37 | + } | ||
| 38 | + if cursor != "" { | ||
| 39 | + query += " AND s.id > ?" | ||
| 40 | + args = append(args, cursor) | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + query += " ORDER BY s.id ASC LIMIT ?" | ||
| 44 | + args = append(args, limit+1) | ||
| 45 | + | ||
| 46 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | ||
| 47 | + if err != nil { | ||
| 48 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 49 | + return | ||
| 50 | + } | ||
| 51 | + defer rows.Close() | ||
| 52 | + | ||
| 53 | + subs := make([]SubscriptionView, 0) | ||
| 54 | + for rows.Next() { | ||
| 55 | + var id int | ||
| 56 | + var feedURL, title string | ||
| 57 | + var cat, addedAt sql.NullString | ||
| 58 | + if err := rows.Scan(&id, &feedURL, &title, &cat, &addedAt); err != nil { | ||
| 59 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 60 | + return | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + sv := SubscriptionView{ | ||
| 64 | + URI: fmtATURI(repo, "at.glean.subscription", strconv.Itoa(id)), | ||
| 65 | + Value: SubscriptionRecord{ | ||
| 66 | + CreatedAt: addedAt.String, | ||
| 67 | + FeedURL: feedURL, | ||
| 68 | + Title: title, | ||
| 69 | + Category: cat.String, | ||
| 70 | + }, | ||
| 71 | + IndexedAt: addedAt.String, | ||
| 72 | + } | ||
| 73 | + subs = append(subs, sv) | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + resp := ListSubscriptionsResponse{Subscriptions: subs} | ||
| 77 | + if len(subs) > limit { | ||
| 78 | + resp.Cursor = strconv.Itoa(limit) | ||
| 79 | + resp.Subscriptions = subs[:limit] | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + writeJSON(w, resp) | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +func (h *XRPCHandler) ListAnnotations(w http.ResponseWriter, r *http.Request) { | ||
| 86 | + feedURL := r.URL.Query().Get("feedUrl") | ||
| 87 | + articleURL := r.URL.Query().Get("articleUrl") | ||
| 88 | + author := r.URL.Query().Get("author") | ||
| 89 | + limit := parseIntParam(r, "limit", 50) | ||
| 90 | + cursor := r.URL.Query().Get("cursor") | ||
| 91 | + | ||
| 92 | + query := ` | ||
| 93 | + SELECT a.uri, a.cid, u.did, u.handle, a.feed_url, a.article_url, | ||
| 94 | + a.quote, a.note, a.tags, a.rating, a.created_at | ||
| 95 | + FROM annotations a | ||
| 96 | + JOIN users u ON a.author_did = u.did | ||
| 97 | + WHERE 1=1` | ||
| 98 | + args := []any{} | ||
| 99 | + | ||
| 100 | + if feedURL != "" { | ||
| 101 | + query += " AND a.feed_url = ?" | ||
| 102 | + args = append(args, feedURL) | ||
| 103 | + } | ||
| 104 | + if articleURL != "" { | ||
| 105 | + query += " AND a.article_url = ?" | ||
| 106 | + args = append(args, articleURL) | ||
| 107 | + } | ||
| 108 | + if author != "" { | ||
| 109 | + query += " AND a.author_did = ?" | ||
| 110 | + args = append(args, author) | ||
| 111 | + } | ||
| 112 | + if cursor != "" { | ||
| 113 | + query += " AND a.id > ?" | ||
| 114 | + args = append(args, cursor) | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + query += " ORDER BY a.id ASC LIMIT ?" | ||
| 118 | + args = append(args, limit+1) | ||
| 119 | + | ||
| 120 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | ||
| 121 | + if err != nil { | ||
| 122 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 123 | + return | ||
| 124 | + } | ||
| 125 | + defer rows.Close() | ||
| 126 | + | ||
| 127 | + annotations := make([]AnnotationView, 0) | ||
| 128 | + for rows.Next() { | ||
| 129 | + var uri, did, handle, fURL, artURL, createdAt string | ||
| 130 | + var cid, quote, note, tags sql.NullString | ||
| 131 | + var rating sql.NullInt64 | ||
| 132 | + if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, "e, ¬e, &tags, &rating, &createdAt); err != nil { | ||
| 133 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 134 | + return | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + var tagSlice []string | ||
| 138 | + if tags.Valid && tags.String != "" { | ||
| 139 | + tagSlice = strings.Split(tags.String, ",") | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + av := AnnotationView{ | ||
| 143 | + URI: uri, | ||
| 144 | + CID: cid.String, | ||
| 145 | + Author: ActorView{ | ||
| 146 | + DID: did, | ||
| 147 | + Handle: handle, | ||
| 148 | + }, | ||
| 149 | + Value: AnnotationRecord{ | ||
| 150 | + CreatedAt: createdAt, | ||
| 151 | + FeedURL: fURL, | ||
| 152 | + ArticleURL: artURL, | ||
| 153 | + Quote: quote.String, | ||
| 154 | + Note: note.String, | ||
| 155 | + Tags: tagSlice, | ||
| 156 | + Rating: int(rating.Int64), | ||
| 157 | + }, | ||
| 158 | + IndexedAt: createdAt, | ||
| 159 | + } | ||
| 160 | + annotations = append(annotations, av) | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + resp := ListAnnotationsResponse{Annotations: annotations} | ||
| 164 | + if len(annotations) > limit { | ||
| 165 | + resp.Cursor = strconv.Itoa(limit) | ||
| 166 | + resp.Annotations = annotations[:limit] | ||
| 167 | + } | ||
| 168 | + | ||
| 169 | + writeJSON(w, resp) | ||
| 170 | +} | ||
| 171 | + | ||
| 172 | +func (h *XRPCHandler) ListLikes(w http.ResponseWriter, r *http.Request) { | ||
| 173 | + author := r.URL.Query().Get("author") | ||
| 174 | + feedURL := r.URL.Query().Get("feedUrl") | ||
| 175 | + limit := parseIntParam(r, "limit", 50) | ||
| 176 | + cursor := r.URL.Query().Get("cursor") | ||
| 177 | + | ||
| 178 | + query := ` | ||
| 179 | + SELECT l.uri, l.cid, u.did, u.handle, l.feed_url, l.article_url, l.created_at | ||
| 180 | + FROM likes l | ||
| 181 | + JOIN users u ON l.author_did = u.did | ||
| 182 | + WHERE 1=1` | ||
| 183 | + args := []any{} | ||
| 184 | + | ||
| 185 | + if author != "" { | ||
| 186 | + query += " AND l.author_did = ?" | ||
| 187 | + args = append(args, author) | ||
| 188 | + } | ||
| 189 | + if feedURL != "" { | ||
| 190 | + query += " AND l.feed_url = ?" | ||
| 191 | + args = append(args, feedURL) | ||
| 192 | + } | ||
| 193 | + if cursor != "" { | ||
| 194 | + query += " AND l.id > ?" | ||
| 195 | + args = append(args, cursor) | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + query += " ORDER BY l.id ASC LIMIT ?" | ||
| 199 | + args = append(args, limit+1) | ||
| 200 | + | ||
| 201 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | ||
| 202 | + if err != nil { | ||
| 203 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 204 | + return | ||
| 205 | + } | ||
| 206 | + defer rows.Close() | ||
| 207 | + | ||
| 208 | + likes := make([]LikeView, 0) | ||
| 209 | + for rows.Next() { | ||
| 210 | + var uri, did, handle, fURL, artURL, createdAt string | ||
| 211 | + var cid sql.NullString | ||
| 212 | + if err := rows.Scan(&uri, &cid, &did, &handle, &fURL, &artURL, &createdAt); err != nil { | ||
| 213 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 214 | + return | ||
| 215 | + } | ||
| 216 | + | ||
| 217 | + lv := LikeView{ | ||
| 218 | + URI: uri, | ||
| 219 | + CID: cid.String, | ||
| 220 | + Author: ActorView{ | ||
| 221 | + DID: did, | ||
| 222 | + Handle: handle, | ||
| 223 | + }, | ||
| 224 | + Value: LikeRecord{ | ||
| 225 | + CreatedAt: createdAt, | ||
| 226 | + FeedURL: fURL, | ||
| 227 | + ArticleURL: artURL, | ||
| 228 | + }, | ||
| 229 | + IndexedAt: createdAt, | ||
| 230 | + } | ||
| 231 | + likes = append(likes, lv) | ||
| 232 | + } | ||
| 233 | + | ||
| 234 | + resp := ListLikesResponse{Likes: likes} | ||
| 235 | + if len(likes) > limit { | ||
| 236 | + resp.Cursor = strconv.Itoa(limit) | ||
| 237 | + resp.Likes = likes[:limit] | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + writeJSON(w, resp) | ||
| 241 | +} | ||
| 242 | + | ||
| 243 | +func (h *XRPCHandler) GetTrending(w http.ResponseWriter, r *http.Request) { | ||
| 244 | + limit := parseIntParam(r, "limit", 25) | ||
| 245 | + cursor := r.URL.Query().Get("cursor") | ||
| 246 | + since := r.URL.Query().Get("since") | ||
| 247 | + | ||
| 248 | + query := ` | ||
| 249 | + SELECT l.feed_url, l.article_url, a.title, COUNT(*) as like_count | ||
| 250 | + FROM likes l | ||
| 251 | + LEFT JOIN articles a ON l.article_url = a.url | ||
| 252 | + WHERE 1=1` | ||
| 253 | + args := []any{} | ||
| 254 | + | ||
| 255 | + if since != "" { | ||
| 256 | + query += " AND l.created_at >= ?" | ||
| 257 | + args = append(args, since) | ||
| 258 | + } | ||
| 259 | + if cursor != "" { | ||
| 260 | + query += " AND l.article_url > ?" | ||
| 261 | + args = append(args, cursor) | ||
| 262 | + } | ||
| 263 | + | ||
| 264 | + query += " GROUP BY l.feed_url, l.article_url ORDER BY like_count DESC LIMIT ?" | ||
| 265 | + args = append(args, limit+1) | ||
| 266 | + | ||
| 267 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | ||
| 268 | + if err != nil { | ||
| 269 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 270 | + return | ||
| 271 | + } | ||
| 272 | + defer rows.Close() | ||
| 273 | + | ||
| 274 | + articles := make([]TrendingArticle, 0) | ||
| 275 | + for rows.Next() { | ||
| 276 | + var feedURL, articleURL string | ||
| 277 | + var title sql.NullString | ||
| 278 | + var likeCount int | ||
| 279 | + if err := rows.Scan(&feedURL, &articleURL, &title, &likeCount); err != nil { | ||
| 280 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 281 | + return | ||
| 282 | + } | ||
| 283 | + | ||
| 284 | + ta := TrendingArticle{ | ||
| 285 | + FeedURL: feedURL, | ||
| 286 | + ArticleURL: articleURL, | ||
| 287 | + Title: title.String, | ||
| 288 | + LikeCount: likeCount, | ||
| 289 | + } | ||
| 290 | + articles = append(articles, ta) | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + resp := GetTrendingResponse{Articles: articles} | ||
| 294 | + if len(articles) > limit { | ||
| 295 | + resp.Cursor = strconv.Itoa(limit) | ||
| 296 | + resp.Articles = articles[:limit] | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + writeJSON(w, resp) | ||
| 300 | +} | ||
| 301 | + | ||
| 302 | +func (h *XRPCHandler) GetRecommendations(w http.ResponseWriter, r *http.Request) { | ||
| 303 | + repo := chi.URLParam(r, "repo") | ||
| 304 | + limit := parseIntParam(r, "limit", 10) | ||
| 305 | + | ||
| 306 | + feedRows, err := h.db.QueryContext(r.Context(), ` | ||
| 307 | + SELECT r.feed_url, f.title, f.site_url, f.description, f.subscriber_count, r.score | ||
| 308 | + FROM user_feed_recommendations r | ||
| 309 | + JOIN feeds f ON r.feed_url = f.feed_url | ||
| 310 | + WHERE r.user_did = ? | ||
| 311 | + ORDER BY r.score DESC | ||
| 312 | + LIMIT ? | ||
| 313 | + `, repo, limit) | ||
| 314 | + if err != nil { | ||
| 315 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 316 | + return | ||
| 317 | + } | ||
| 318 | + defer feedRows.Close() | ||
| 319 | + | ||
| 320 | + feeds := make([]RecommendedFeed, 0) | ||
| 321 | + for feedRows.Next() { | ||
| 322 | + var feedURL, title, siteURL, description string | ||
| 323 | + var subscriberCount int | ||
| 324 | + var score float64 | ||
| 325 | + if err := feedRows.Scan(&feedURL, &title, &siteURL, &description, &subscriberCount, &score); err != nil { | ||
| 326 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 327 | + return | ||
| 328 | + } | ||
| 329 | + feeds = append(feeds, RecommendedFeed{ | ||
| 330 | + FeedURL: feedURL, | ||
| 331 | + Title: title, | ||
| 332 | + SiteURL: siteURL, | ||
| 333 | + Description: description, | ||
| 334 | + SubscriberCount: subscriberCount, | ||
| 335 | + Score: score, | ||
| 336 | + }) | ||
| 337 | + } | ||
| 338 | + | ||
| 339 | + peopleRows, err := h.db.QueryContext(r.Context(), ` | ||
| 340 | + SELECT u.did, u.handle, u.display_name, u.avatar_url, s.jaccard, s.common_feeds | ||
| 341 | + FROM user_similarity s | ||
| 342 | + JOIN users u ON ( | ||
| 343 | + CASE WHEN s.user_a = ? THEN s.user_b ELSE s.user_a END | ||
| 344 | + ) = u.did | ||
| 345 | + WHERE s.user_a = ? OR s.user_b = ? | ||
| 346 | + ORDER BY s.jaccard DESC | ||
| 347 | + LIMIT ? | ||
| 348 | + `, repo, repo, repo, limit) | ||
| 349 | + if err != nil { | ||
| 350 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 351 | + return | ||
| 352 | + } | ||
| 353 | + defer peopleRows.Close() | ||
| 354 | + | ||
| 355 | + people := make([]RecommendedPerson, 0) | ||
| 356 | + for peopleRows.Next() { | ||
| 357 | + var did, handle string | ||
| 358 | + var displayName, avatar sql.NullString | ||
| 359 | + var jaccard float64 | ||
| 360 | + var commonFeeds int | ||
| 361 | + if err := peopleRows.Scan(&did, &handle, &displayName, &avatar, &jaccard, &commonFeeds); err != nil { | ||
| 362 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 363 | + return | ||
| 364 | + } | ||
| 365 | + people = append(people, RecommendedPerson{ | ||
| 366 | + DID: did, | ||
| 367 | + Handle: handle, | ||
| 368 | + DisplayName: displayName.String, | ||
| 369 | + Avatar: avatar.String, | ||
| 370 | + Jaccard: jaccard, | ||
| 371 | + CommonFeeds: commonFeeds, | ||
| 372 | + }) | ||
| 373 | + } | ||
| 374 | + | ||
| 375 | + writeJSON(w, GetRecommendationsResponse{Feeds: feeds, People: people}) | ||
| 376 | +} | ||
| 377 | + | ||
| 378 | +func (h *XRPCHandler) ListFeedLists(w http.ResponseWriter, r *http.Request) { | ||
| 379 | + actorsParam := r.URL.Query().Get("actors") | ||
| 380 | + limit := parseIntParam(r, "limit", 50) | ||
| 381 | + cursor := r.URL.Query().Get("cursor") | ||
| 382 | + | ||
| 383 | + var dids []string | ||
| 384 | + if actorsParam != "" { | ||
| 385 | + dids = strings.Split(actorsParam, ",") | ||
| 386 | + } | ||
| 387 | + | ||
| 388 | + if len(dids) == 0 { | ||
| 389 | + writeJSON(w, ListFeedListsResponse{}) | ||
| 390 | + return | ||
| 391 | + } | ||
| 392 | + | ||
| 393 | + placeholders := make([]string, len(dids)) | ||
| 394 | + args := make([]any, len(dids)) | ||
| 395 | + for i, d := range dids { | ||
| 396 | + placeholders[i] = "?" | ||
| 397 | + args[i] = d | ||
| 398 | + } | ||
| 399 | + | ||
| 400 | + query := ` | ||
| 401 | + SELECT u.did, u.handle, COUNT(s.id) as subscription_count | ||
| 402 | + FROM users u | ||
| 403 | + LEFT JOIN subscriptions s ON u.did = s.user_did | ||
| 404 | + WHERE u.did IN (` + strings.Join(placeholders, ",") + `)` | ||
| 405 | + | ||
| 406 | + if cursor != "" { | ||
| 407 | + query += " AND u.did > ?" | ||
| 408 | + args = append(args, cursor) | ||
| 409 | + } | ||
| 410 | + | ||
| 411 | + query += " GROUP BY u.did ORDER BY u.did ASC LIMIT ?" | ||
| 412 | + args = append(args, limit+1) | ||
| 413 | + | ||
| 414 | + rows, err := h.db.QueryContext(r.Context(), query, args...) | ||
| 415 | + if err != nil { | ||
| 416 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 417 | + return | ||
| 418 | + } | ||
| 419 | + defer rows.Close() | ||
| 420 | + | ||
| 421 | + feedLists := make([]FeedListEntry, 0) | ||
| 422 | + for rows.Next() { | ||
| 423 | + var did, handle string | ||
| 424 | + var subCount int | ||
| 425 | + if err := rows.Scan(&did, &handle, &subCount); err != nil { | ||
| 426 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 427 | + return | ||
| 428 | + } | ||
| 429 | + | ||
| 430 | + subRows, err := h.db.QueryContext(r.Context(), ` | ||
| 431 | + SELECT f.feed_url, f.title, s.category | ||
| 432 | + FROM subscriptions s | ||
| 433 | + JOIN feeds f ON s.feed_url = f.feed_url | ||
| 434 | + WHERE s.user_did = ? | ||
| 435 | + ORDER BY s.added_at DESC | ||
| 436 | + `, did) | ||
| 437 | + if err != nil { | ||
| 438 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 439 | + return | ||
| 440 | + } | ||
| 441 | + | ||
| 442 | + subs := make([]SubscriptionRecord, 0) | ||
| 443 | + for subRows.Next() { | ||
| 444 | + var feedURL, title string | ||
| 445 | + var cat sql.NullString | ||
| 446 | + if err := subRows.Scan(&feedURL, &title, &cat); err != nil { | ||
| 447 | + subRows.Close() | ||
| 448 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 449 | + return | ||
| 450 | + } | ||
| 451 | + subs = append(subs, SubscriptionRecord{ | ||
| 452 | + FeedURL: feedURL, | ||
| 453 | + Title: title, | ||
| 454 | + Category: cat.String, | ||
| 455 | + }) | ||
| 456 | + } | ||
| 457 | + subRows.Close() | ||
| 458 | + | ||
| 459 | + feedLists = append(feedLists, FeedListEntry{ | ||
| 460 | + DID: did, | ||
| 461 | + SubscriptionCount: subCount, | ||
| 462 | + Subscriptions: subs, | ||
| 463 | + }) | ||
| 464 | + } | ||
| 465 | + | ||
| 466 | + resp := ListFeedListsResponse{Feeds: feedLists} | ||
| 467 | + if len(feedLists) > limit { | ||
| 468 | + resp.Cursor = strconv.Itoa(limit) | ||
| 469 | + resp.Feeds = feedLists[:limit] | ||
| 470 | + } | ||
| 471 | + | ||
| 472 | + writeJSON(w, resp) | ||
| 473 | +} | ||
| 474 | + | ||
| 475 | +func parseIntParam(r *http.Request, key string, defaultVal int) int { | ||
| 476 | + v := r.URL.Query().Get(key) | ||
| 477 | + if v == "" { | ||
| 478 | + return defaultVal | ||
| 479 | + } | ||
| 480 | + n, err := strconv.Atoi(v) | ||
| 481 | + if err != nil { | ||
| 482 | + return defaultVal | ||
| 483 | + } | ||
| 484 | + return n | ||
| 485 | +} | ||
| 486 | + | ||
| 487 | +func fmtATURI(did, collection, rkey string) string { | ||
| 488 | + return "at://" + did + "/" + collection + "/" + rkey | ||
| 489 | +} | ||
| 490 | + | ||
| 491 | +func writeJSON(w http.ResponseWriter, v any) { | ||
| 492 | + w.Header().Set("Content-Type", "application/json") | ||
| 493 | + if err := json.NewEncoder(w).Encode(v); err != nil { | ||
| 494 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 495 | + } | ||
| 496 | +} | ||
added
internal/cluster/cron.go +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +package cluster | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "log/slog" | |
| 6 | + "time" | |
| 7 | +) | |
| 8 | + | |
| 9 | +type Cron struct { | |
| 10 | + engine *Engine | |
| 11 | + interval time.Duration | |
| 12 | + logger *slog.Logger | |
| 13 | +} | |
| 14 | + | |
| 15 | +func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron { | |
| 16 | + return &Cron{engine: engine, interval: interval, logger: logger} | |
| 17 | +} | |
| 18 | + | |
| 19 | +func (c *Cron) Run(ctx context.Context) error { | |
| 20 | + for { | |
| 21 | + c.logger.Info("starting similarity computation") | |
| 22 | + | |
| 23 | + if err := c.engine.ComputeFeedSimilarity(ctx); err != nil { | |
| 24 | + c.logger.Error("feed similarity failed", "error", err) | |
| 25 | + } | |
| 26 | + | |
| 27 | + if err := c.engine.ComputeUserSimilarity(ctx); err != nil { | |
| 28 | + c.logger.Error("user similarity failed", "error", err) | |
| 29 | + } | |
| 30 | + | |
| 31 | + if err := c.engine.ComputeRecommendations(ctx); err != nil { | |
| 32 | + c.logger.Error("recommendations failed", "error", err) | |
| 33 | + } | |
| 34 | + | |
| 35 | + c.logger.Info("similarity computation complete", "next_run", c.interval) | |
| 36 | + | |
| 37 | + select { | |
| 38 | + case <-ctx.Done(): | |
| 39 | + return ctx.Err() | |
| 40 | + case <-time.After(c.interval): | |
| 41 | + } | |
| 42 | + } | |
| 43 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +package cluster | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "log/slog" | ||
| 6 | + "time" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +type Cron struct { | ||
| 10 | + engine *Engine | ||
| 11 | + interval time.Duration | ||
| 12 | + logger *slog.Logger | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +func NewCron(engine *Engine, interval time.Duration, logger *slog.Logger) *Cron { | ||
| 16 | + return &Cron{engine: engine, interval: interval, logger: logger} | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +func (c *Cron) Run(ctx context.Context) error { | ||
| 20 | + for { | ||
| 21 | + c.logger.Info("starting similarity computation") | ||
| 22 | + | ||
| 23 | + if err := c.engine.ComputeFeedSimilarity(ctx); err != nil { | ||
| 24 | + c.logger.Error("feed similarity failed", "error", err) | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + if err := c.engine.ComputeUserSimilarity(ctx); err != nil { | ||
| 28 | + c.logger.Error("user similarity failed", "error", err) | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + if err := c.engine.ComputeRecommendations(ctx); err != nil { | ||
| 32 | + c.logger.Error("recommendations failed", "error", err) | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + c.logger.Info("similarity computation complete", "next_run", c.interval) | ||
| 36 | + | ||
| 37 | + select { | ||
| 38 | + case <-ctx.Done(): | ||
| 39 | + return ctx.Err() | ||
| 40 | + case <-time.After(c.interval): | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | +} | ||
added
internal/cluster/jaccard.go +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +package cluster | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "log/slog" | |
| 7 | +) | |
| 8 | + | |
| 9 | +type Engine struct { | |
| 10 | + db *sql.DB | |
| 11 | + logger *slog.Logger | |
| 12 | +} | |
| 13 | + | |
| 14 | +func NewEngine(db *sql.DB, logger *slog.Logger) *Engine { | |
| 15 | + return &Engine{db: db, logger: logger} | |
| 16 | +} | |
| 17 | + | |
| 18 | +func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error { | |
| 19 | + tx, err := e.db.BeginTx(ctx, nil) | |
| 20 | + if err != nil { | |
| 21 | + return err | |
| 22 | + } | |
| 23 | + defer func() { _ = tx.Rollback() }() | |
| 24 | + | |
| 25 | + if _, err := tx.ExecContext(ctx, `DELETE FROM feed_similarity`); err != nil { | |
| 26 | + return err | |
| 27 | + } | |
| 28 | + | |
| 29 | + _, err = tx.ExecContext(ctx, ` | |
| 30 | + INSERT INTO feed_similarity (feed_a, feed_b, jaccard) | |
| 31 | + SELECT | |
| 32 | + s1.feed_url, | |
| 33 | + s2.feed_url, | |
| 34 | + CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL)) | |
| 35 | + FROM subscriptions s1 | |
| 36 | + JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url | |
| 37 | + JOIN feeds f1 ON f1.feed_url = s1.feed_url | |
| 38 | + JOIN feeds f2 ON f2.feed_url = s2.feed_url | |
| 39 | + GROUP BY s1.feed_url, s2.feed_url | |
| 40 | + HAVING COUNT(*) > 0 | |
| 41 | + `) | |
| 42 | + if err != nil { | |
| 43 | + return err | |
| 44 | + } | |
| 45 | + | |
| 46 | + e.logger.Info("feed similarity computed") | |
| 47 | + return tx.Commit() | |
| 48 | +} | |
| 49 | + | |
| 50 | +func (e *Engine) ComputeUserSimilarity(ctx context.Context) error { | |
| 51 | + tx, err := e.db.BeginTx(ctx, nil) | |
| 52 | + if err != nil { | |
| 53 | + return err | |
| 54 | + } | |
| 55 | + defer func() { _ = tx.Rollback() }() | |
| 56 | + | |
| 57 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity`); err != nil { | |
| 58 | + return err | |
| 59 | + } | |
| 60 | + | |
| 61 | + _, err = tx.ExecContext(ctx, ` | |
| 62 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds) | |
| 63 | + SELECT | |
| 64 | + s1.user_did, | |
| 65 | + s2.user_did, | |
| 66 | + CAST(COUNT(*) AS REAL) / ( | |
| 67 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = s1.user_did) + | |
| 68 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) - | |
| 69 | + CAST(COUNT(*) AS REAL) | |
| 70 | + ), | |
| 71 | + COUNT(*) | |
| 72 | + FROM subscriptions s1 | |
| 73 | + JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did | |
| 74 | + GROUP BY s1.user_did, s2.user_did | |
| 75 | + HAVING COUNT(*) > 0 | |
| 76 | + `) | |
| 77 | + if err != nil { | |
| 78 | + return err | |
| 79 | + } | |
| 80 | + | |
| 81 | + e.logger.Info("user similarity computed") | |
| 82 | + return tx.Commit() | |
| 83 | +} | |
| 84 | + | |
| 85 | +func (e *Engine) ComputeRecommendations(ctx context.Context) error { | |
| 86 | + tx, err := e.db.BeginTx(ctx, nil) | |
| 87 | + if err != nil { | |
| 88 | + return err | |
| 89 | + } | |
| 90 | + defer func() { _ = tx.Rollback() }() | |
| 91 | + | |
| 92 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_feed_recommendations`); err != nil { | |
| 93 | + return err | |
| 94 | + } | |
| 95 | + | |
| 96 | + _, err = tx.ExecContext(ctx, ` | |
| 97 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score) | |
| 98 | + SELECT target, feed_url, SUM(jaccard) AS score | |
| 99 | + FROM ( | |
| 100 | + SELECT us.user_a AS target, s.feed_url, us.jaccard | |
| 101 | + FROM user_similarity us | |
| 102 | + JOIN subscriptions s ON s.user_did = us.user_b | |
| 103 | + WHERE us.jaccard > 0.2 | |
| 104 | + AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a) | |
| 105 | + | |
| 106 | + UNION ALL | |
| 107 | + | |
| 108 | + SELECT us.user_b AS target, s.feed_url, us.jaccard | |
| 109 | + FROM user_similarity us | |
| 110 | + JOIN subscriptions s ON s.user_did = us.user_a | |
| 111 | + WHERE us.jaccard > 0.2 | |
| 112 | + AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b) | |
| 113 | + ) | |
| 114 | + GROUP BY target, feed_url | |
| 115 | + ORDER BY score DESC | |
| 116 | + `) | |
| 117 | + if err != nil { | |
| 118 | + return err | |
| 119 | + } | |
| 120 | + | |
| 121 | + e.logger.Info("feed recommendations computed") | |
| 122 | + return tx.Commit() | |
| 123 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +package cluster | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | + "log/slog" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +type Engine struct { | ||
| 10 | + db *sql.DB | ||
| 11 | + logger *slog.Logger | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +func NewEngine(db *sql.DB, logger *slog.Logger) *Engine { | ||
| 15 | + return &Engine{db: db, logger: logger} | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +func (e *Engine) ComputeFeedSimilarity(ctx context.Context) error { | ||
| 19 | + tx, err := e.db.BeginTx(ctx, nil) | ||
| 20 | + if err != nil { | ||
| 21 | + return err | ||
| 22 | + } | ||
| 23 | + defer func() { _ = tx.Rollback() }() | ||
| 24 | + | ||
| 25 | + if _, err := tx.ExecContext(ctx, `DELETE FROM feed_similarity`); err != nil { | ||
| 26 | + return err | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + _, err = tx.ExecContext(ctx, ` | ||
| 30 | + INSERT INTO feed_similarity (feed_a, feed_b, jaccard) | ||
| 31 | + SELECT | ||
| 32 | + s1.feed_url, | ||
| 33 | + s2.feed_url, | ||
| 34 | + CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL)) | ||
| 35 | + FROM subscriptions s1 | ||
| 36 | + JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url | ||
| 37 | + JOIN feeds f1 ON f1.feed_url = s1.feed_url | ||
| 38 | + JOIN feeds f2 ON f2.feed_url = s2.feed_url | ||
| 39 | + GROUP BY s1.feed_url, s2.feed_url | ||
| 40 | + HAVING COUNT(*) > 0 | ||
| 41 | + `) | ||
| 42 | + if err != nil { | ||
| 43 | + return err | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + e.logger.Info("feed similarity computed") | ||
| 47 | + return tx.Commit() | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +func (e *Engine) ComputeUserSimilarity(ctx context.Context) error { | ||
| 51 | + tx, err := e.db.BeginTx(ctx, nil) | ||
| 52 | + if err != nil { | ||
| 53 | + return err | ||
| 54 | + } | ||
| 55 | + defer func() { _ = tx.Rollback() }() | ||
| 56 | + | ||
| 57 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity`); err != nil { | ||
| 58 | + return err | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + _, err = tx.ExecContext(ctx, ` | ||
| 62 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds) | ||
| 63 | + SELECT | ||
| 64 | + s1.user_did, | ||
| 65 | + s2.user_did, | ||
| 66 | + CAST(COUNT(*) AS REAL) / ( | ||
| 67 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = s1.user_did) + | ||
| 68 | + (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) - | ||
| 69 | + CAST(COUNT(*) AS REAL) | ||
| 70 | + ), | ||
| 71 | + COUNT(*) | ||
| 72 | + FROM subscriptions s1 | ||
| 73 | + JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did | ||
| 74 | + GROUP BY s1.user_did, s2.user_did | ||
| 75 | + HAVING COUNT(*) > 0 | ||
| 76 | + `) | ||
| 77 | + if err != nil { | ||
| 78 | + return err | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + e.logger.Info("user similarity computed") | ||
| 82 | + return tx.Commit() | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +func (e *Engine) ComputeRecommendations(ctx context.Context) error { | ||
| 86 | + tx, err := e.db.BeginTx(ctx, nil) | ||
| 87 | + if err != nil { | ||
| 88 | + return err | ||
| 89 | + } | ||
| 90 | + defer func() { _ = tx.Rollback() }() | ||
| 91 | + | ||
| 92 | + if _, err := tx.ExecContext(ctx, `DELETE FROM user_feed_recommendations`); err != nil { | ||
| 93 | + return err | ||
| 94 | + } | ||
| 95 | + | ||
| 96 | + _, err = tx.ExecContext(ctx, ` | ||
| 97 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score) | ||
| 98 | + SELECT target, feed_url, SUM(jaccard) AS score | ||
| 99 | + FROM ( | ||
| 100 | + SELECT us.user_a AS target, s.feed_url, us.jaccard | ||
| 101 | + FROM user_similarity us | ||
| 102 | + JOIN subscriptions s ON s.user_did = us.user_b | ||
| 103 | + WHERE us.jaccard > 0.2 | ||
| 104 | + AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_a) | ||
| 105 | + | ||
| 106 | + UNION ALL | ||
| 107 | + | ||
| 108 | + SELECT us.user_b AS target, s.feed_url, us.jaccard | ||
| 109 | + FROM user_similarity us | ||
| 110 | + JOIN subscriptions s ON s.user_did = us.user_a | ||
| 111 | + WHERE us.jaccard > 0.2 | ||
| 112 | + AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = us.user_b) | ||
| 113 | + ) | ||
| 114 | + GROUP BY target, feed_url | ||
| 115 | + ORDER BY score DESC | ||
| 116 | + `) | ||
| 117 | + if err != nil { | ||
| 118 | + return err | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + e.logger.Info("feed recommendations computed") | ||
| 122 | + return tx.Commit() | ||
| 123 | +} | ||
added
internal/cluster/recommender.go +111 -0 | new file mode 100644 | ||
| @@ -0,0 +1,111 @@ | ||
| 1 | +package cluster | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | +) | |
| 6 | + | |
| 7 | +func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | |
| 8 | + rows, err := e.db.QueryContext(ctx, ` | |
| 9 | + SELECT r.feed_url, f.title, f.site_url, f.description, f.feed_type, r.score | |
| 10 | + FROM user_feed_recommendations r | |
| 11 | + JOIN feeds f ON f.feed_url = r.feed_url | |
| 12 | + WHERE r.user_did = ? | |
| 13 | + ORDER BY r.score DESC | |
| 14 | + LIMIT ? | |
| 15 | + `, userDID, limit) | |
| 16 | + if err != nil { | |
| 17 | + return nil, err | |
| 18 | + } | |
| 19 | + defer rows.Close() | |
| 20 | + | |
| 21 | + var results []map[string]any | |
| 22 | + for rows.Next() { | |
| 23 | + var feedURL, title, siteURL, description, feedType string | |
| 24 | + var score float64 | |
| 25 | + if err := rows.Scan(&feedURL, &title, &siteURL, &description, &feedType, &score); err != nil { | |
| 26 | + return nil, err | |
| 27 | + } | |
| 28 | + results = append(results, map[string]any{ | |
| 29 | + "feed_url": feedURL, | |
| 30 | + "title": title, | |
| 31 | + "site_url": siteURL, | |
| 32 | + "description": description, | |
| 33 | + "feed_type": feedType, | |
| 34 | + "score": score, | |
| 35 | + }) | |
| 36 | + } | |
| 37 | + return results, rows.Err() | |
| 38 | +} | |
| 39 | + | |
| 40 | +func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | |
| 41 | + rows, err := e.db.QueryContext(ctx, ` | |
| 42 | + SELECT u.did, u.handle, u.display_name, u.avatar_url, sim.jaccard, sim.common_feeds | |
| 43 | + FROM ( | |
| 44 | + SELECT user_b AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_a = ? | |
| 45 | + UNION ALL | |
| 46 | + SELECT user_a AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_b = ? | |
| 47 | + ) sim | |
| 48 | + JOIN users u ON u.did = sim.peer_did | |
| 49 | + ORDER BY sim.jaccard DESC | |
| 50 | + LIMIT ? | |
| 51 | + `, userDID, userDID, limit) | |
| 52 | + if err != nil { | |
| 53 | + return nil, err | |
| 54 | + } | |
| 55 | + defer rows.Close() | |
| 56 | + | |
| 57 | + var results []map[string]any | |
| 58 | + for rows.Next() { | |
| 59 | + var did, handle, displayName, avatarURL string | |
| 60 | + var jaccard float64 | |
| 61 | + var commonFeeds int | |
| 62 | + if err := rows.Scan(&did, &handle, &displayName, &avatarURL, &jaccard, &commonFeeds); err != nil { | |
| 63 | + return nil, err | |
| 64 | + } | |
| 65 | + results = append(results, map[string]any{ | |
| 66 | + "did": did, | |
| 67 | + "handle": handle, | |
| 68 | + "display_name": displayName, | |
| 69 | + "avatar_url": avatarURL, | |
| 70 | + "jaccard": jaccard, | |
| 71 | + "common_feeds": commonFeeds, | |
| 72 | + }) | |
| 73 | + } | |
| 74 | + return results, rows.Err() | |
| 75 | +} | |
| 76 | + | |
| 77 | +func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]map[string]any, error) { | |
| 78 | + rows, err := e.db.QueryContext(ctx, ` | |
| 79 | + SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, sim.jaccard | |
| 80 | + FROM ( | |
| 81 | + SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ? | |
| 82 | + UNION ALL | |
| 83 | + SELECT feed_a AS peer_url, jaccard FROM feed_similarity WHERE feed_b = ? | |
| 84 | + ) sim | |
| 85 | + JOIN feeds f ON f.feed_url = sim.peer_url | |
| 86 | + ORDER BY sim.jaccard DESC | |
| 87 | + LIMIT ? | |
| 88 | + `, feedURL, feedURL, limit) | |
| 89 | + if err != nil { | |
| 90 | + return nil, err | |
| 91 | + } | |
| 92 | + defer rows.Close() | |
| 93 | + | |
| 94 | + var results []map[string]any | |
| 95 | + for rows.Next() { | |
| 96 | + var peerURL, title, siteURL, description, feedType string | |
| 97 | + var jaccard float64 | |
| 98 | + if err := rows.Scan(&peerURL, &title, &siteURL, &description, &feedType, &jaccard); err != nil { | |
| 99 | + return nil, err | |
| 100 | + } | |
| 101 | + results = append(results, map[string]any{ | |
| 102 | + "feed_url": peerURL, | |
| 103 | + "title": title, | |
| 104 | + "site_url": siteURL, | |
| 105 | + "description": description, | |
| 106 | + "feed_type": feedType, | |
| 107 | + "jaccard": jaccard, | |
| 108 | + }) | |
| 109 | + } | |
| 110 | + return results, rows.Err() | |
| 111 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,111 @@ | |||
| 1 | +package cluster | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | +) | ||
| 6 | + | ||
| 7 | +func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | ||
| 8 | + rows, err := e.db.QueryContext(ctx, ` | ||
| 9 | + SELECT r.feed_url, f.title, f.site_url, f.description, f.feed_type, r.score | ||
| 10 | + FROM user_feed_recommendations r | ||
| 11 | + JOIN feeds f ON f.feed_url = r.feed_url | ||
| 12 | + WHERE r.user_did = ? | ||
| 13 | + ORDER BY r.score DESC | ||
| 14 | + LIMIT ? | ||
| 15 | + `, userDID, limit) | ||
| 16 | + if err != nil { | ||
| 17 | + return nil, err | ||
| 18 | + } | ||
| 19 | + defer rows.Close() | ||
| 20 | + | ||
| 21 | + var results []map[string]any | ||
| 22 | + for rows.Next() { | ||
| 23 | + var feedURL, title, siteURL, description, feedType string | ||
| 24 | + var score float64 | ||
| 25 | + if err := rows.Scan(&feedURL, &title, &siteURL, &description, &feedType, &score); err != nil { | ||
| 26 | + return nil, err | ||
| 27 | + } | ||
| 28 | + results = append(results, map[string]any{ | ||
| 29 | + "feed_url": feedURL, | ||
| 30 | + "title": title, | ||
| 31 | + "site_url": siteURL, | ||
| 32 | + "description": description, | ||
| 33 | + "feed_type": feedType, | ||
| 34 | + "score": score, | ||
| 35 | + }) | ||
| 36 | + } | ||
| 37 | + return results, rows.Err() | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | ||
| 41 | + rows, err := e.db.QueryContext(ctx, ` | ||
| 42 | + SELECT u.did, u.handle, u.display_name, u.avatar_url, sim.jaccard, sim.common_feeds | ||
| 43 | + FROM ( | ||
| 44 | + SELECT user_b AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_a = ? | ||
| 45 | + UNION ALL | ||
| 46 | + SELECT user_a AS peer_did, jaccard, common_feeds FROM user_similarity WHERE user_b = ? | ||
| 47 | + ) sim | ||
| 48 | + JOIN users u ON u.did = sim.peer_did | ||
| 49 | + ORDER BY sim.jaccard DESC | ||
| 50 | + LIMIT ? | ||
| 51 | + `, userDID, userDID, limit) | ||
| 52 | + if err != nil { | ||
| 53 | + return nil, err | ||
| 54 | + } | ||
| 55 | + defer rows.Close() | ||
| 56 | + | ||
| 57 | + var results []map[string]any | ||
| 58 | + for rows.Next() { | ||
| 59 | + var did, handle, displayName, avatarURL string | ||
| 60 | + var jaccard float64 | ||
| 61 | + var commonFeeds int | ||
| 62 | + if err := rows.Scan(&did, &handle, &displayName, &avatarURL, &jaccard, &commonFeeds); err != nil { | ||
| 63 | + return nil, err | ||
| 64 | + } | ||
| 65 | + results = append(results, map[string]any{ | ||
| 66 | + "did": did, | ||
| 67 | + "handle": handle, | ||
| 68 | + "display_name": displayName, | ||
| 69 | + "avatar_url": avatarURL, | ||
| 70 | + "jaccard": jaccard, | ||
| 71 | + "common_feeds": commonFeeds, | ||
| 72 | + }) | ||
| 73 | + } | ||
| 74 | + return results, rows.Err() | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +func (e *Engine) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]map[string]any, error) { | ||
| 78 | + rows, err := e.db.QueryContext(ctx, ` | ||
| 79 | + SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, sim.jaccard | ||
| 80 | + FROM ( | ||
| 81 | + SELECT feed_b AS peer_url, jaccard FROM feed_similarity WHERE feed_a = ? | ||
| 82 | + UNION ALL | ||
| 83 | + SELECT feed_a AS peer_url, jaccard FROM feed_similarity WHERE feed_b = ? | ||
| 84 | + ) sim | ||
| 85 | + JOIN feeds f ON f.feed_url = sim.peer_url | ||
| 86 | + ORDER BY sim.jaccard DESC | ||
| 87 | + LIMIT ? | ||
| 88 | + `, feedURL, feedURL, limit) | ||
| 89 | + if err != nil { | ||
| 90 | + return nil, err | ||
| 91 | + } | ||
| 92 | + defer rows.Close() | ||
| 93 | + | ||
| 94 | + var results []map[string]any | ||
| 95 | + for rows.Next() { | ||
| 96 | + var peerURL, title, siteURL, description, feedType string | ||
| 97 | + var jaccard float64 | ||
| 98 | + if err := rows.Scan(&peerURL, &title, &siteURL, &description, &feedType, &jaccard); err != nil { | ||
| 99 | + return nil, err | ||
| 100 | + } | ||
| 101 | + results = append(results, map[string]any{ | ||
| 102 | + "feed_url": peerURL, | ||
| 103 | + "title": title, | ||
| 104 | + "site_url": siteURL, | ||
| 105 | + "description": description, | ||
| 106 | + "feed_type": feedType, | ||
| 107 | + "jaccard": jaccard, | ||
| 108 | + }) | ||
| 109 | + } | ||
| 110 | + return results, rows.Err() | ||
| 111 | +} | ||
added
internal/db/article.go +266 -0 | new file mode 100644 | ||
| @@ -0,0 +1,266 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "fmt" | |
| 7 | +) | |
| 8 | + | |
| 9 | +type Article struct { | |
| 10 | + ID int64 | |
| 11 | + FeedURL string | |
| 12 | + FeedTitle string | |
| 13 | + GUID string | |
| 14 | + Title string | |
| 15 | + URL sql.NullString | |
| 16 | + Author sql.NullString | |
| 17 | + Summary sql.NullString | |
| 18 | + Content sql.NullString | |
| 19 | + Published sql.NullTime | |
| 20 | + Updated sql.NullTime | |
| 21 | + FetchedAt sql.NullTime | |
| 22 | + IsRead sql.NullBool | |
| 23 | + IsStarred sql.NullBool | |
| 24 | +} | |
| 25 | + | |
| 26 | +type ReadState struct { | |
| 27 | + UserDID string | |
| 28 | + ArticleID int64 | |
| 29 | + IsRead bool | |
| 30 | + ReadAt sql.NullTime | |
| 31 | + IsStarred bool | |
| 32 | + StarredAt sql.NullTime | |
| 33 | +} | |
| 34 | + | |
| 35 | +func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { | |
| 36 | + var id int64 | |
| 37 | + err := db.QueryRowContext(ctx, ` | |
| 38 | + INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated) | |
| 39 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 40 | + ON CONFLICT(feed_url, guid) DO NOTHING | |
| 41 | + RETURNING id | |
| 42 | + `, article.FeedURL, article.GUID, article.Title, article.URL, article.Author, | |
| 43 | + article.Summary, article.Content, article.Published, article.Updated).Scan(&id) | |
| 44 | + if err == sql.ErrNoRows { | |
| 45 | + err = db.QueryRowContext(ctx, ` | |
| 46 | + SELECT id FROM articles WHERE feed_url = ? AND guid = ? | |
| 47 | + `, article.FeedURL, article.GUID).Scan(&id) | |
| 48 | + } | |
| 49 | + return id, err | |
| 50 | +} | |
| 51 | + | |
| 52 | +func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) { | |
| 53 | + a := &Article{} | |
| 54 | + err := db.QueryRowContext(ctx, ` | |
| 55 | + SELECT id, feed_url, guid, title, url, author, summary, content, published, updated, fetched_at | |
| 56 | + FROM articles WHERE id = ? | |
| 57 | + `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author, | |
| 58 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt) | |
| 59 | + if err != nil { | |
| 60 | + return nil, err | |
| 61 | + } | |
| 62 | + return a, nil | |
| 63 | +} | |
| 64 | + | |
| 65 | +func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { | |
| 66 | + query := ` | |
| 67 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, | |
| 68 | + a.published, a.updated, a.fetched_at, | |
| 69 | + COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0) | |
| 70 | + FROM articles a | |
| 71 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 72 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | |
| 73 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | |
| 74 | + ` | |
| 75 | + args := []any{userDID, userDID} | |
| 76 | + | |
| 77 | + if feedURL != "" { | |
| 78 | + query += ` AND s.feed_url = ?` | |
| 79 | + args = append(args, feedURL) | |
| 80 | + } | |
| 81 | + | |
| 82 | + query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 83 | + | |
| 84 | + rows, err := db.QueryContext(ctx, query, args...) | |
| 85 | + if err != nil { | |
| 86 | + return nil, err | |
| 87 | + } | |
| 88 | + defer rows.Close() | |
| 89 | + | |
| 90 | + var articles []*Article | |
| 91 | + for rows.Next() { | |
| 92 | + a := &Article{} | |
| 93 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, | |
| 94 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, | |
| 95 | + &a.IsRead, &a.IsStarred); err != nil { | |
| 96 | + return nil, err | |
| 97 | + } | |
| 98 | + articles = append(articles, a) | |
| 99 | + } | |
| 100 | + return articles, rows.Err() | |
| 101 | +} | |
| 102 | + | |
| 103 | +func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { | |
| 104 | + query := ` | |
| 105 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, | |
| 106 | + a.published, a.updated, a.fetched_at | |
| 107 | + FROM articles a | |
| 108 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 109 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | |
| 110 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | |
| 111 | + WHERE r.is_read = 0 OR r.is_read IS NULL | |
| 112 | + ` | |
| 113 | + args := []any{userDID, userDID} | |
| 114 | + | |
| 115 | + if feedURL != "" { | |
| 116 | + query += ` AND a.feed_url = ?` | |
| 117 | + args = append(args, feedURL) | |
| 118 | + } | |
| 119 | + | |
| 120 | + query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 121 | + | |
| 122 | + rows, err := db.QueryContext(ctx, query, args...) | |
| 123 | + if err != nil { | |
| 124 | + return nil, err | |
| 125 | + } | |
| 126 | + defer rows.Close() | |
| 127 | + | |
| 128 | + var articles []*Article | |
| 129 | + for rows.Next() { | |
| 130 | + a := &Article{} | |
| 131 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, | |
| 132 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil { | |
| 133 | + return nil, err | |
| 134 | + } | |
| 135 | + articles = append(articles, a) | |
| 136 | + } | |
| 137 | + return articles, rows.Err() | |
| 138 | +} | |
| 139 | + | |
| 140 | +func (db *DB) ListStarredArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { | |
| 141 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 142 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, | |
| 143 | + a.published, a.updated, a.fetched_at | |
| 144 | + FROM articles a | |
| 145 | + JOIN read_state r ON r.article_id = a.id AND r.user_did = ? | |
| 146 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | |
| 147 | + WHERE r.is_starred = 1 | |
| 148 | + ORDER BY r.starred_at DESC | |
| 149 | + LIMIT %d OFFSET %d | |
| 150 | + `, limit, offset), userDID) | |
| 151 | + if err != nil { | |
| 152 | + return nil, err | |
| 153 | + } | |
| 154 | + defer rows.Close() | |
| 155 | + | |
| 156 | + var articles []*Article | |
| 157 | + for rows.Next() { | |
| 158 | + a := &Article{} | |
| 159 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, | |
| 160 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil { | |
| 161 | + return nil, err | |
| 162 | + } | |
| 163 | + articles = append(articles, a) | |
| 164 | + } | |
| 165 | + return articles, rows.Err() | |
| 166 | +} | |
| 167 | + | |
| 168 | +func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error { | |
| 169 | + _, err := db.ExecContext(ctx, ` | |
| 170 | + INSERT INTO read_state (user_did, article_id, is_read, read_at) | |
| 171 | + VALUES (?, ?, 1, CURRENT_TIMESTAMP) | |
| 172 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | |
| 173 | + is_read = 1, read_at = CURRENT_TIMESTAMP | |
| 174 | + `, userDID, articleID) | |
| 175 | + return err | |
| 176 | +} | |
| 177 | + | |
| 178 | +func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error { | |
| 179 | + _, err := db.ExecContext(ctx, ` | |
| 180 | + INSERT INTO read_state (user_did, article_id, is_read) | |
| 181 | + VALUES (?, ?, 0) | |
| 182 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | |
| 183 | + is_read = 0, read_at = NULL | |
| 184 | + `, userDID, articleID) | |
| 185 | + return err | |
| 186 | +} | |
| 187 | + | |
| 188 | +func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error { | |
| 189 | + _, err := db.ExecContext(ctx, ` | |
| 190 | + INSERT INTO read_state (user_did, article_id, is_read, read_at) | |
| 191 | + SELECT ?, a.id, 1, CURRENT_TIMESTAMP | |
| 192 | + FROM articles a | |
| 193 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 194 | + WHERE a.feed_url = ? | |
| 195 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | |
| 196 | + is_read = 1, read_at = CURRENT_TIMESTAMP | |
| 197 | + `, userDID, userDID, feedURL) | |
| 198 | + return err | |
| 199 | +} | |
| 200 | + | |
| 201 | +func (db *DB) MarkAllSubscribedRead(ctx context.Context, userDID string) error { | |
| 202 | + _, err := db.ExecContext(ctx, ` | |
| 203 | + INSERT INTO read_state (user_did, article_id, is_read, read_at) | |
| 204 | + SELECT ?, a.id, 1, CURRENT_TIMESTAMP | |
| 205 | + FROM articles a | |
| 206 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 207 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | |
| 208 | + is_read = 1, read_at = CURRENT_TIMESTAMP | |
| 209 | + `, userDID, userDID) | |
| 210 | + return err | |
| 211 | +} | |
| 212 | + | |
| 213 | +func (db *DB) StarArticle(ctx context.Context, userDID string, articleID int64) error { | |
| 214 | + _, err := db.ExecContext(ctx, ` | |
| 215 | + INSERT INTO read_state (user_did, article_id, is_starred, starred_at) | |
| 216 | + VALUES (?, ?, 1, CURRENT_TIMESTAMP) | |
| 217 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | |
| 218 | + is_starred = 1, starred_at = CURRENT_TIMESTAMP | |
| 219 | + `, userDID, articleID) | |
| 220 | + return err | |
| 221 | +} | |
| 222 | + | |
| 223 | +func (db *DB) UnstarArticle(ctx context.Context, userDID string, articleID int64) error { | |
| 224 | + _, err := db.ExecContext(ctx, ` | |
| 225 | + UPDATE read_state SET is_starred = 0, starred_at = NULL | |
| 226 | + WHERE user_did = ? AND article_id = ? | |
| 227 | + `, userDID, articleID) | |
| 228 | + return err | |
| 229 | +} | |
| 230 | + | |
| 231 | +func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { | |
| 232 | + rs := &ReadState{} | |
| 233 | + err := db.QueryRowContext(ctx, ` | |
| 234 | + SELECT user_did, article_id, is_read, read_at, is_starred, starred_at | |
| 235 | + FROM read_state WHERE user_did = ? AND article_id = ? | |
| 236 | + `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt, &rs.IsStarred, &rs.StarredAt) | |
| 237 | + if err == sql.ErrNoRows { | |
| 238 | + return &ReadState{UserDID: userDID, ArticleID: articleID}, nil | |
| 239 | + } | |
| 240 | + if err != nil { | |
| 241 | + return nil, err | |
| 242 | + } | |
| 243 | + return rs, nil | |
| 244 | +} | |
| 245 | + | |
| 246 | +func (db *DB) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) { | |
| 247 | + var count int | |
| 248 | + if feedURL != "" { | |
| 249 | + err := db.QueryRowContext(ctx, ` | |
| 250 | + SELECT COUNT(*) | |
| 251 | + FROM articles a | |
| 252 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 253 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | |
| 254 | + WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL) | |
| 255 | + `, userDID, userDID, feedURL).Scan(&count) | |
| 256 | + return count, err | |
| 257 | + } | |
| 258 | + err := db.QueryRowContext(ctx, ` | |
| 259 | + SELECT COUNT(*) | |
| 260 | + FROM articles a | |
| 261 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | |
| 262 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | |
| 263 | + WHERE r.is_read = 0 OR r.is_read IS NULL | |
| 264 | + `, userDID, userDID).Scan(&count) | |
| 265 | + return count, err | |
| 266 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,266 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | + "fmt" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +type Article struct { | ||
| 10 | + ID int64 | ||
| 11 | + FeedURL string | ||
| 12 | + FeedTitle string | ||
| 13 | + GUID string | ||
| 14 | + Title string | ||
| 15 | + URL sql.NullString | ||
| 16 | + Author sql.NullString | ||
| 17 | + Summary sql.NullString | ||
| 18 | + Content sql.NullString | ||
| 19 | + Published sql.NullTime | ||
| 20 | + Updated sql.NullTime | ||
| 21 | + FetchedAt sql.NullTime | ||
| 22 | + IsRead sql.NullBool | ||
| 23 | + IsStarred sql.NullBool | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +type ReadState struct { | ||
| 27 | + UserDID string | ||
| 28 | + ArticleID int64 | ||
| 29 | + IsRead bool | ||
| 30 | + ReadAt sql.NullTime | ||
| 31 | + IsStarred bool | ||
| 32 | + StarredAt sql.NullTime | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +func (db *DB) UpsertArticle(ctx context.Context, article *Article) (int64, error) { | ||
| 36 | + var id int64 | ||
| 37 | + err := db.QueryRowContext(ctx, ` | ||
| 38 | + INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated) | ||
| 39 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 40 | + ON CONFLICT(feed_url, guid) DO NOTHING | ||
| 41 | + RETURNING id | ||
| 42 | + `, article.FeedURL, article.GUID, article.Title, article.URL, article.Author, | ||
| 43 | + article.Summary, article.Content, article.Published, article.Updated).Scan(&id) | ||
| 44 | + if err == sql.ErrNoRows { | ||
| 45 | + err = db.QueryRowContext(ctx, ` | ||
| 46 | + SELECT id FROM articles WHERE feed_url = ? AND guid = ? | ||
| 47 | + `, article.FeedURL, article.GUID).Scan(&id) | ||
| 48 | + } | ||
| 49 | + return id, err | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) { | ||
| 53 | + a := &Article{} | ||
| 54 | + err := db.QueryRowContext(ctx, ` | ||
| 55 | + SELECT id, feed_url, guid, title, url, author, summary, content, published, updated, fetched_at | ||
| 56 | + FROM articles WHERE id = ? | ||
| 57 | + `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author, | ||
| 58 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt) | ||
| 59 | + if err != nil { | ||
| 60 | + return nil, err | ||
| 61 | + } | ||
| 62 | + return a, nil | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { | ||
| 66 | + query := ` | ||
| 67 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, | ||
| 68 | + a.published, a.updated, a.fetched_at, | ||
| 69 | + COALESCE(r.is_read, 0), COALESCE(r.is_starred, 0) | ||
| 70 | + FROM articles a | ||
| 71 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 72 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | ||
| 73 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | ||
| 74 | + ` | ||
| 75 | + args := []any{userDID, userDID} | ||
| 76 | + | ||
| 77 | + if feedURL != "" { | ||
| 78 | + query += ` AND s.feed_url = ?` | ||
| 79 | + args = append(args, feedURL) | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | ||
| 83 | + | ||
| 84 | + rows, err := db.QueryContext(ctx, query, args...) | ||
| 85 | + if err != nil { | ||
| 86 | + return nil, err | ||
| 87 | + } | ||
| 88 | + defer rows.Close() | ||
| 89 | + | ||
| 90 | + var articles []*Article | ||
| 91 | + for rows.Next() { | ||
| 92 | + a := &Article{} | ||
| 93 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, | ||
| 94 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt, | ||
| 95 | + &a.IsRead, &a.IsStarred); err != nil { | ||
| 96 | + return nil, err | ||
| 97 | + } | ||
| 98 | + articles = append(articles, a) | ||
| 99 | + } | ||
| 100 | + return articles, rows.Err() | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { | ||
| 104 | + query := ` | ||
| 105 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, | ||
| 106 | + a.published, a.updated, a.fetched_at | ||
| 107 | + FROM articles a | ||
| 108 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 109 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | ||
| 110 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | ||
| 111 | + WHERE r.is_read = 0 OR r.is_read IS NULL | ||
| 112 | + ` | ||
| 113 | + args := []any{userDID, userDID} | ||
| 114 | + | ||
| 115 | + if feedURL != "" { | ||
| 116 | + query += ` AND a.feed_url = ?` | ||
| 117 | + args = append(args, feedURL) | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + query += fmt.Sprintf(` ORDER BY a.published DESC LIMIT %d OFFSET %d`, limit, offset) | ||
| 121 | + | ||
| 122 | + rows, err := db.QueryContext(ctx, query, args...) | ||
| 123 | + if err != nil { | ||
| 124 | + return nil, err | ||
| 125 | + } | ||
| 126 | + defer rows.Close() | ||
| 127 | + | ||
| 128 | + var articles []*Article | ||
| 129 | + for rows.Next() { | ||
| 130 | + a := &Article{} | ||
| 131 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, | ||
| 132 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil { | ||
| 133 | + return nil, err | ||
| 134 | + } | ||
| 135 | + articles = append(articles, a) | ||
| 136 | + } | ||
| 137 | + return articles, rows.Err() | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +func (db *DB) ListStarredArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { | ||
| 141 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | ||
| 142 | + SELECT a.id, a.feed_url, COALESCE(f.title, ''), a.guid, a.title, a.url, a.author, a.summary, a.content, | ||
| 143 | + a.published, a.updated, a.fetched_at | ||
| 144 | + FROM articles a | ||
| 145 | + JOIN read_state r ON r.article_id = a.id AND r.user_did = ? | ||
| 146 | + LEFT JOIN feeds f ON a.feed_url = f.feed_url | ||
| 147 | + WHERE r.is_starred = 1 | ||
| 148 | + ORDER BY r.starred_at DESC | ||
| 149 | + LIMIT %d OFFSET %d | ||
| 150 | + `, limit, offset), userDID) | ||
| 151 | + if err != nil { | ||
| 152 | + return nil, err | ||
| 153 | + } | ||
| 154 | + defer rows.Close() | ||
| 155 | + | ||
| 156 | + var articles []*Article | ||
| 157 | + for rows.Next() { | ||
| 158 | + a := &Article{} | ||
| 159 | + if err := rows.Scan(&a.ID, &a.FeedURL, &a.FeedTitle, &a.GUID, &a.Title, &a.URL, &a.Author, | ||
| 160 | + &a.Summary, &a.Content, &a.Published, &a.Updated, &a.FetchedAt); err != nil { | ||
| 161 | + return nil, err | ||
| 162 | + } | ||
| 163 | + articles = append(articles, a) | ||
| 164 | + } | ||
| 165 | + return articles, rows.Err() | ||
| 166 | +} | ||
| 167 | + | ||
| 168 | +func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error { | ||
| 169 | + _, err := db.ExecContext(ctx, ` | ||
| 170 | + INSERT INTO read_state (user_did, article_id, is_read, read_at) | ||
| 171 | + VALUES (?, ?, 1, CURRENT_TIMESTAMP) | ||
| 172 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | ||
| 173 | + is_read = 1, read_at = CURRENT_TIMESTAMP | ||
| 174 | + `, userDID, articleID) | ||
| 175 | + return err | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error { | ||
| 179 | + _, err := db.ExecContext(ctx, ` | ||
| 180 | + INSERT INTO read_state (user_did, article_id, is_read) | ||
| 181 | + VALUES (?, ?, 0) | ||
| 182 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | ||
| 183 | + is_read = 0, read_at = NULL | ||
| 184 | + `, userDID, articleID) | ||
| 185 | + return err | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error { | ||
| 189 | + _, err := db.ExecContext(ctx, ` | ||
| 190 | + INSERT INTO read_state (user_did, article_id, is_read, read_at) | ||
| 191 | + SELECT ?, a.id, 1, CURRENT_TIMESTAMP | ||
| 192 | + FROM articles a | ||
| 193 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 194 | + WHERE a.feed_url = ? | ||
| 195 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | ||
| 196 | + is_read = 1, read_at = CURRENT_TIMESTAMP | ||
| 197 | + `, userDID, userDID, feedURL) | ||
| 198 | + return err | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +func (db *DB) MarkAllSubscribedRead(ctx context.Context, userDID string) error { | ||
| 202 | + _, err := db.ExecContext(ctx, ` | ||
| 203 | + INSERT INTO read_state (user_did, article_id, is_read, read_at) | ||
| 204 | + SELECT ?, a.id, 1, CURRENT_TIMESTAMP | ||
| 205 | + FROM articles a | ||
| 206 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 207 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | ||
| 208 | + is_read = 1, read_at = CURRENT_TIMESTAMP | ||
| 209 | + `, userDID, userDID) | ||
| 210 | + return err | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +func (db *DB) StarArticle(ctx context.Context, userDID string, articleID int64) error { | ||
| 214 | + _, err := db.ExecContext(ctx, ` | ||
| 215 | + INSERT INTO read_state (user_did, article_id, is_starred, starred_at) | ||
| 216 | + VALUES (?, ?, 1, CURRENT_TIMESTAMP) | ||
| 217 | + ON CONFLICT(user_did, article_id) DO UPDATE SET | ||
| 218 | + is_starred = 1, starred_at = CURRENT_TIMESTAMP | ||
| 219 | + `, userDID, articleID) | ||
| 220 | + return err | ||
| 221 | +} | ||
| 222 | + | ||
| 223 | +func (db *DB) UnstarArticle(ctx context.Context, userDID string, articleID int64) error { | ||
| 224 | + _, err := db.ExecContext(ctx, ` | ||
| 225 | + UPDATE read_state SET is_starred = 0, starred_at = NULL | ||
| 226 | + WHERE user_did = ? AND article_id = ? | ||
| 227 | + `, userDID, articleID) | ||
| 228 | + return err | ||
| 229 | +} | ||
| 230 | + | ||
| 231 | +func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { | ||
| 232 | + rs := &ReadState{} | ||
| 233 | + err := db.QueryRowContext(ctx, ` | ||
| 234 | + SELECT user_did, article_id, is_read, read_at, is_starred, starred_at | ||
| 235 | + FROM read_state WHERE user_did = ? AND article_id = ? | ||
| 236 | + `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt, &rs.IsStarred, &rs.StarredAt) | ||
| 237 | + if err == sql.ErrNoRows { | ||
| 238 | + return &ReadState{UserDID: userDID, ArticleID: articleID}, nil | ||
| 239 | + } | ||
| 240 | + if err != nil { | ||
| 241 | + return nil, err | ||
| 242 | + } | ||
| 243 | + return rs, nil | ||
| 244 | +} | ||
| 245 | + | ||
| 246 | +func (db *DB) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) { | ||
| 247 | + var count int | ||
| 248 | + if feedURL != "" { | ||
| 249 | + err := db.QueryRowContext(ctx, ` | ||
| 250 | + SELECT COUNT(*) | ||
| 251 | + FROM articles a | ||
| 252 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 253 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | ||
| 254 | + WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL) | ||
| 255 | + `, userDID, userDID, feedURL).Scan(&count) | ||
| 256 | + return count, err | ||
| 257 | + } | ||
| 258 | + err := db.QueryRowContext(ctx, ` | ||
| 259 | + SELECT COUNT(*) | ||
| 260 | + FROM articles a | ||
| 261 | + JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? | ||
| 262 | + LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id | ||
| 263 | + WHERE r.is_read = 0 OR r.is_read IS NULL | ||
| 264 | + `, userDID, userDID).Scan(&count) | ||
| 265 | + return count, err | ||
| 266 | +} | ||
added
internal/db/cluster.go +306 -0 | new file mode 100644 | ||
| @@ -0,0 +1,306 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "fmt" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | |
| 10 | + _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`) | |
| 11 | + if err != nil { | |
| 12 | + return err | |
| 13 | + } | |
| 14 | + | |
| 15 | + rows, err := db.QueryContext(ctx, ` | |
| 16 | + SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap | |
| 17 | + FROM subscriptions s1 | |
| 18 | + JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url | |
| 19 | + GROUP BY s1.feed_url, s2.feed_url | |
| 20 | + HAVING overlap > 0 | |
| 21 | + `) | |
| 22 | + if err != nil { | |
| 23 | + return err | |
| 24 | + } | |
| 25 | + defer rows.Close() | |
| 26 | + | |
| 27 | + type pair struct { | |
| 28 | + feedA string | |
| 29 | + feedB string | |
| 30 | + overlap int | |
| 31 | + } | |
| 32 | + var pairs []pair | |
| 33 | + for rows.Next() { | |
| 34 | + var p pair | |
| 35 | + if err := rows.Scan(&p.feedA, &p.feedB, &p.overlap); err != nil { | |
| 36 | + return err | |
| 37 | + } | |
| 38 | + pairs = append(pairs, p) | |
| 39 | + } | |
| 40 | + if err := rows.Err(); err != nil { | |
| 41 | + return err | |
| 42 | + } | |
| 43 | + | |
| 44 | + subCounts := make(map[string]int) | |
| 45 | + for _, p := range pairs { | |
| 46 | + subCounts[p.feedA] = 0 | |
| 47 | + subCounts[p.feedB] = 0 | |
| 48 | + } | |
| 49 | + | |
| 50 | + if len(subCounts) > 0 { | |
| 51 | + countRows, err := db.QueryContext(ctx, ` | |
| 52 | + SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url | |
| 53 | + `) | |
| 54 | + if err != nil { | |
| 55 | + return err | |
| 56 | + } | |
| 57 | + for countRows.Next() { | |
| 58 | + var feedURL string | |
| 59 | + var count int | |
| 60 | + if err := countRows.Scan(&feedURL, &count); err != nil { | |
| 61 | + countRows.Close() | |
| 62 | + return err | |
| 63 | + } | |
| 64 | + subCounts[feedURL] = count | |
| 65 | + } | |
| 66 | + countRows.Close() | |
| 67 | + } | |
| 68 | + | |
| 69 | + for _, p := range pairs { | |
| 70 | + total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap | |
| 71 | + if total == 0 { | |
| 72 | + continue | |
| 73 | + } | |
| 74 | + jaccard := float64(p.overlap) / float64(total) | |
| 75 | + _, err := db.ExecContext(ctx, ` | |
| 76 | + INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at) | |
| 77 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |
| 78 | + `, p.feedA, p.feedB, jaccard) | |
| 79 | + if err != nil { | |
| 80 | + return err | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + return nil | |
| 85 | +} | |
| 86 | + | |
| 87 | +func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | |
| 88 | + _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`) | |
| 89 | + if err != nil { | |
| 90 | + return err | |
| 91 | + } | |
| 92 | + | |
| 93 | + rows, err := db.QueryContext(ctx, ` | |
| 94 | + SELECT s1.user_did, s2.user_did, COUNT(*) AS common | |
| 95 | + FROM subscriptions s1 | |
| 96 | + JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url | |
| 97 | + GROUP BY s1.user_did, s2.user_did | |
| 98 | + HAVING common > 0 | |
| 99 | + `) | |
| 100 | + if err != nil { | |
| 101 | + return err | |
| 102 | + } | |
| 103 | + defer rows.Close() | |
| 104 | + | |
| 105 | + type pair struct { | |
| 106 | + userA string | |
| 107 | + userB string | |
| 108 | + common int | |
| 109 | + } | |
| 110 | + var pairs []pair | |
| 111 | + for rows.Next() { | |
| 112 | + var p pair | |
| 113 | + if err := rows.Scan(&p.userA, &p.userB, &p.common); err != nil { | |
| 114 | + return err | |
| 115 | + } | |
| 116 | + pairs = append(pairs, p) | |
| 117 | + } | |
| 118 | + if err := rows.Err(); err != nil { | |
| 119 | + return err | |
| 120 | + } | |
| 121 | + | |
| 122 | + subCounts := make(map[string]int) | |
| 123 | + for _, p := range pairs { | |
| 124 | + subCounts[p.userA] = 0 | |
| 125 | + subCounts[p.userB] = 0 | |
| 126 | + } | |
| 127 | + | |
| 128 | + if len(subCounts) > 0 { | |
| 129 | + countRows, err := db.QueryContext(ctx, ` | |
| 130 | + SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did | |
| 131 | + `) | |
| 132 | + if err != nil { | |
| 133 | + return err | |
| 134 | + } | |
| 135 | + for countRows.Next() { | |
| 136 | + var userDID string | |
| 137 | + var count int | |
| 138 | + if err := countRows.Scan(&userDID, &count); err != nil { | |
| 139 | + countRows.Close() | |
| 140 | + return err | |
| 141 | + } | |
| 142 | + subCounts[userDID] = count | |
| 143 | + } | |
| 144 | + countRows.Close() | |
| 145 | + } | |
| 146 | + | |
| 147 | + for _, p := range pairs { | |
| 148 | + total := subCounts[p.userA] + subCounts[p.userB] - p.common | |
| 149 | + if total == 0 { | |
| 150 | + continue | |
| 151 | + } | |
| 152 | + jaccard := float64(p.common) / float64(total) | |
| 153 | + _, err := db.ExecContext(ctx, ` | |
| 154 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at) | |
| 155 | + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | |
| 156 | + `, p.userA, p.userB, jaccard, p.common) | |
| 157 | + if err != nil { | |
| 158 | + return err | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + return nil | |
| 163 | +} | |
| 164 | + | |
| 165 | +func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error { | |
| 166 | + _, err := db.ExecContext(ctx, ` | |
| 167 | + DELETE FROM user_feed_recommendations WHERE user_did = ? | |
| 168 | + `, userDID) | |
| 169 | + if err != nil { | |
| 170 | + return err | |
| 171 | + } | |
| 172 | + | |
| 173 | + rows, err := db.QueryContext(ctx, ` | |
| 174 | + SELECT | |
| 175 | + CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed, | |
| 176 | + SUM(fs.jaccard) AS score | |
| 177 | + FROM feed_similarity fs | |
| 178 | + WHERE fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | |
| 179 | + OR fs.feed_b IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | |
| 180 | + GROUP BY recommended_feed | |
| 181 | + HAVING recommended_feed NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | |
| 182 | + ORDER BY score DESC | |
| 183 | + `, userDID, userDID, userDID, userDID) | |
| 184 | + if err != nil { | |
| 185 | + return err | |
| 186 | + } | |
| 187 | + defer rows.Close() | |
| 188 | + | |
| 189 | + for rows.Next() { | |
| 190 | + var feedURL string | |
| 191 | + var score float64 | |
| 192 | + if err := rows.Scan(&feedURL, &score); err != nil { | |
| 193 | + return err | |
| 194 | + } | |
| 195 | + _, err := db.ExecContext(ctx, ` | |
| 196 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at) | |
| 197 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | |
| 198 | + `, userDID, feedURL, score) | |
| 199 | + if err != nil { | |
| 200 | + return err | |
| 201 | + } | |
| 202 | + } | |
| 203 | + return rows.Err() | |
| 204 | +} | |
| 205 | + | |
| 206 | +func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | |
| 207 | + rows, err := db.QueryContext(ctx, ` | |
| 208 | + SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count | |
| 209 | + FROM user_feed_recommendations r | |
| 210 | + JOIN feeds f ON f.feed_url = r.feed_url | |
| 211 | + WHERE r.user_did = ? | |
| 212 | + ORDER BY r.score DESC | |
| 213 | + LIMIT ? | |
| 214 | + `, userDID, limit) | |
| 215 | + if err != nil { | |
| 216 | + return nil, err | |
| 217 | + } | |
| 218 | + defer rows.Close() | |
| 219 | + | |
| 220 | + var results []map[string]any | |
| 221 | + for rows.Next() { | |
| 222 | + var feedURL string | |
| 223 | + var score float64 | |
| 224 | + var title, siteURL, description sql.NullString | |
| 225 | + var subCount int | |
| 226 | + if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount); err != nil { | |
| 227 | + return nil, err | |
| 228 | + } | |
| 229 | + results = append(results, map[string]any{ | |
| 230 | + "feed_url": feedURL, | |
| 231 | + "score": score, | |
| 232 | + "title": title, | |
| 233 | + "site_url": siteURL, | |
| 234 | + "description": description, | |
| 235 | + "subscriber_count": subCount, | |
| 236 | + }) | |
| 237 | + } | |
| 238 | + return results, rows.Err() | |
| 239 | +} | |
| 240 | + | |
| 241 | +func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | |
| 242 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 243 | + SELECT | |
| 244 | + CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user, | |
| 245 | + us.jaccard, us.common_feeds, | |
| 246 | + u.handle, u.display_name, u.avatar_url | |
| 247 | + FROM user_similarity us | |
| 248 | + JOIN users u ON u.did = CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END | |
| 249 | + WHERE us.user_a = ? OR us.user_b = ? | |
| 250 | + ORDER BY us.jaccard DESC | |
| 251 | + LIMIT %d | |
| 252 | + `, limit), userDID, userDID, userDID, userDID) | |
| 253 | + if err != nil { | |
| 254 | + return nil, err | |
| 255 | + } | |
| 256 | + defer rows.Close() | |
| 257 | + | |
| 258 | + var results []map[string]any | |
| 259 | + for rows.Next() { | |
| 260 | + var recUser, handle string | |
| 261 | + var jaccard float64 | |
| 262 | + var commonFeeds int | |
| 263 | + var displayName, avatarURL sql.NullString | |
| 264 | + if err := rows.Scan(&recUser, &jaccard, &commonFeeds, &handle, &displayName, &avatarURL); err != nil { | |
| 265 | + return nil, err | |
| 266 | + } | |
| 267 | + results = append(results, map[string]any{ | |
| 268 | + "did": recUser, | |
| 269 | + "jaccard": jaccard, | |
| 270 | + "common_feeds": commonFeeds, | |
| 271 | + "handle": handle, | |
| 272 | + "display_name": displayName, | |
| 273 | + "avatar_url": avatarURL, | |
| 274 | + }) | |
| 275 | + } | |
| 276 | + return results, rows.Err() | |
| 277 | +} | |
| 278 | + | |
| 279 | +func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*Feed, error) { | |
| 280 | + rows, err := db.QueryContext(ctx, ` | |
| 281 | + SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, | |
| 282 | + f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified, | |
| 283 | + f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count | |
| 284 | + FROM feed_similarity fs | |
| 285 | + JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END | |
| 286 | + WHERE fs.feed_a = ? OR fs.feed_b = ? | |
| 287 | + ORDER BY fs.jaccard DESC | |
| 288 | + LIMIT ? | |
| 289 | + `, feedURL, feedURL, feedURL, limit) | |
| 290 | + if err != nil { | |
| 291 | + return nil, err | |
| 292 | + } | |
| 293 | + defer rows.Close() | |
| 294 | + | |
| 295 | + var feeds []*Feed | |
| 296 | + for rows.Next() { | |
| 297 | + f := &Feed{} | |
| 298 | + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | |
| 299 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | |
| 300 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { | |
| 301 | + return nil, err | |
| 302 | + } | |
| 303 | + feeds = append(feeds, f) | |
| 304 | + } | |
| 305 | + return feeds, rows.Err() | |
| 306 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,306 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | + "fmt" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +func (db *DB) ComputeFeedSimilarity(ctx context.Context) error { | ||
| 10 | + _, err := db.ExecContext(ctx, `DELETE FROM feed_similarity`) | ||
| 11 | + if err != nil { | ||
| 12 | + return err | ||
| 13 | + } | ||
| 14 | + | ||
| 15 | + rows, err := db.QueryContext(ctx, ` | ||
| 16 | + SELECT s1.feed_url, s2.feed_url, COUNT(*) AS overlap | ||
| 17 | + FROM subscriptions s1 | ||
| 18 | + JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url | ||
| 19 | + GROUP BY s1.feed_url, s2.feed_url | ||
| 20 | + HAVING overlap > 0 | ||
| 21 | + `) | ||
| 22 | + if err != nil { | ||
| 23 | + return err | ||
| 24 | + } | ||
| 25 | + defer rows.Close() | ||
| 26 | + | ||
| 27 | + type pair struct { | ||
| 28 | + feedA string | ||
| 29 | + feedB string | ||
| 30 | + overlap int | ||
| 31 | + } | ||
| 32 | + var pairs []pair | ||
| 33 | + for rows.Next() { | ||
| 34 | + var p pair | ||
| 35 | + if err := rows.Scan(&p.feedA, &p.feedB, &p.overlap); err != nil { | ||
| 36 | + return err | ||
| 37 | + } | ||
| 38 | + pairs = append(pairs, p) | ||
| 39 | + } | ||
| 40 | + if err := rows.Err(); err != nil { | ||
| 41 | + return err | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + subCounts := make(map[string]int) | ||
| 45 | + for _, p := range pairs { | ||
| 46 | + subCounts[p.feedA] = 0 | ||
| 47 | + subCounts[p.feedB] = 0 | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + if len(subCounts) > 0 { | ||
| 51 | + countRows, err := db.QueryContext(ctx, ` | ||
| 52 | + SELECT feed_url, COUNT(*) FROM subscriptions GROUP BY feed_url | ||
| 53 | + `) | ||
| 54 | + if err != nil { | ||
| 55 | + return err | ||
| 56 | + } | ||
| 57 | + for countRows.Next() { | ||
| 58 | + var feedURL string | ||
| 59 | + var count int | ||
| 60 | + if err := countRows.Scan(&feedURL, &count); err != nil { | ||
| 61 | + countRows.Close() | ||
| 62 | + return err | ||
| 63 | + } | ||
| 64 | + subCounts[feedURL] = count | ||
| 65 | + } | ||
| 66 | + countRows.Close() | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + for _, p := range pairs { | ||
| 70 | + total := subCounts[p.feedA] + subCounts[p.feedB] - p.overlap | ||
| 71 | + if total == 0 { | ||
| 72 | + continue | ||
| 73 | + } | ||
| 74 | + jaccard := float64(p.overlap) / float64(total) | ||
| 75 | + _, err := db.ExecContext(ctx, ` | ||
| 76 | + INSERT INTO feed_similarity (feed_a, feed_b, jaccard, computed_at) | ||
| 77 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | ||
| 78 | + `, p.feedA, p.feedB, jaccard) | ||
| 79 | + if err != nil { | ||
| 80 | + return err | ||
| 81 | + } | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + return nil | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +func (db *DB) ComputeUserSimilarity(ctx context.Context) error { | ||
| 88 | + _, err := db.ExecContext(ctx, `DELETE FROM user_similarity`) | ||
| 89 | + if err != nil { | ||
| 90 | + return err | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + rows, err := db.QueryContext(ctx, ` | ||
| 94 | + SELECT s1.user_did, s2.user_did, COUNT(*) AS common | ||
| 95 | + FROM subscriptions s1 | ||
| 96 | + JOIN subscriptions s2 ON s1.user_did < s2.user_did AND s1.feed_url = s2.feed_url | ||
| 97 | + GROUP BY s1.user_did, s2.user_did | ||
| 98 | + HAVING common > 0 | ||
| 99 | + `) | ||
| 100 | + if err != nil { | ||
| 101 | + return err | ||
| 102 | + } | ||
| 103 | + defer rows.Close() | ||
| 104 | + | ||
| 105 | + type pair struct { | ||
| 106 | + userA string | ||
| 107 | + userB string | ||
| 108 | + common int | ||
| 109 | + } | ||
| 110 | + var pairs []pair | ||
| 111 | + for rows.Next() { | ||
| 112 | + var p pair | ||
| 113 | + if err := rows.Scan(&p.userA, &p.userB, &p.common); err != nil { | ||
| 114 | + return err | ||
| 115 | + } | ||
| 116 | + pairs = append(pairs, p) | ||
| 117 | + } | ||
| 118 | + if err := rows.Err(); err != nil { | ||
| 119 | + return err | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + subCounts := make(map[string]int) | ||
| 123 | + for _, p := range pairs { | ||
| 124 | + subCounts[p.userA] = 0 | ||
| 125 | + subCounts[p.userB] = 0 | ||
| 126 | + } | ||
| 127 | + | ||
| 128 | + if len(subCounts) > 0 { | ||
| 129 | + countRows, err := db.QueryContext(ctx, ` | ||
| 130 | + SELECT user_did, COUNT(*) FROM subscriptions GROUP BY user_did | ||
| 131 | + `) | ||
| 132 | + if err != nil { | ||
| 133 | + return err | ||
| 134 | + } | ||
| 135 | + for countRows.Next() { | ||
| 136 | + var userDID string | ||
| 137 | + var count int | ||
| 138 | + if err := countRows.Scan(&userDID, &count); err != nil { | ||
| 139 | + countRows.Close() | ||
| 140 | + return err | ||
| 141 | + } | ||
| 142 | + subCounts[userDID] = count | ||
| 143 | + } | ||
| 144 | + countRows.Close() | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + for _, p := range pairs { | ||
| 148 | + total := subCounts[p.userA] + subCounts[p.userB] - p.common | ||
| 149 | + if total == 0 { | ||
| 150 | + continue | ||
| 151 | + } | ||
| 152 | + jaccard := float64(p.common) / float64(total) | ||
| 153 | + _, err := db.ExecContext(ctx, ` | ||
| 154 | + INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, computed_at) | ||
| 155 | + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | ||
| 156 | + `, p.userA, p.userB, jaccard, p.common) | ||
| 157 | + if err != nil { | ||
| 158 | + return err | ||
| 159 | + } | ||
| 160 | + } | ||
| 161 | + | ||
| 162 | + return nil | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +func (db *DB) ComputeFeedRecommendations(ctx context.Context, userDID string) error { | ||
| 166 | + _, err := db.ExecContext(ctx, ` | ||
| 167 | + DELETE FROM user_feed_recommendations WHERE user_did = ? | ||
| 168 | + `, userDID) | ||
| 169 | + if err != nil { | ||
| 170 | + return err | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + rows, err := db.QueryContext(ctx, ` | ||
| 174 | + SELECT | ||
| 175 | + CASE WHEN fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) THEN fs.feed_b ELSE fs.feed_a END AS recommended_feed, | ||
| 176 | + SUM(fs.jaccard) AS score | ||
| 177 | + FROM feed_similarity fs | ||
| 178 | + WHERE fs.feed_a IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | ||
| 179 | + OR fs.feed_b IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | ||
| 180 | + GROUP BY recommended_feed | ||
| 181 | + HAVING recommended_feed NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) | ||
| 182 | + ORDER BY score DESC | ||
| 183 | + `, userDID, userDID, userDID, userDID) | ||
| 184 | + if err != nil { | ||
| 185 | + return err | ||
| 186 | + } | ||
| 187 | + defer rows.Close() | ||
| 188 | + | ||
| 189 | + for rows.Next() { | ||
| 190 | + var feedURL string | ||
| 191 | + var score float64 | ||
| 192 | + if err := rows.Scan(&feedURL, &score); err != nil { | ||
| 193 | + return err | ||
| 194 | + } | ||
| 195 | + _, err := db.ExecContext(ctx, ` | ||
| 196 | + INSERT INTO user_feed_recommendations (user_did, feed_url, score, computed_at) | ||
| 197 | + VALUES (?, ?, ?, CURRENT_TIMESTAMP) | ||
| 198 | + `, userDID, feedURL, score) | ||
| 199 | + if err != nil { | ||
| 200 | + return err | ||
| 201 | + } | ||
| 202 | + } | ||
| 203 | + return rows.Err() | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +func (db *DB) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | ||
| 207 | + rows, err := db.QueryContext(ctx, ` | ||
| 208 | + SELECT r.feed_url, r.score, f.title, f.site_url, f.description, f.subscriber_count | ||
| 209 | + FROM user_feed_recommendations r | ||
| 210 | + JOIN feeds f ON f.feed_url = r.feed_url | ||
| 211 | + WHERE r.user_did = ? | ||
| 212 | + ORDER BY r.score DESC | ||
| 213 | + LIMIT ? | ||
| 214 | + `, userDID, limit) | ||
| 215 | + if err != nil { | ||
| 216 | + return nil, err | ||
| 217 | + } | ||
| 218 | + defer rows.Close() | ||
| 219 | + | ||
| 220 | + var results []map[string]any | ||
| 221 | + for rows.Next() { | ||
| 222 | + var feedURL string | ||
| 223 | + var score float64 | ||
| 224 | + var title, siteURL, description sql.NullString | ||
| 225 | + var subCount int | ||
| 226 | + if err := rows.Scan(&feedURL, &score, &title, &siteURL, &description, &subCount); err != nil { | ||
| 227 | + return nil, err | ||
| 228 | + } | ||
| 229 | + results = append(results, map[string]any{ | ||
| 230 | + "feed_url": feedURL, | ||
| 231 | + "score": score, | ||
| 232 | + "title": title, | ||
| 233 | + "site_url": siteURL, | ||
| 234 | + "description": description, | ||
| 235 | + "subscriber_count": subCount, | ||
| 236 | + }) | ||
| 237 | + } | ||
| 238 | + return results, rows.Err() | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +func (db *DB) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]map[string]any, error) { | ||
| 242 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | ||
| 243 | + SELECT | ||
| 244 | + CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END AS recommended_user, | ||
| 245 | + us.jaccard, us.common_feeds, | ||
| 246 | + u.handle, u.display_name, u.avatar_url | ||
| 247 | + FROM user_similarity us | ||
| 248 | + JOIN users u ON u.did = CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END | ||
| 249 | + WHERE us.user_a = ? OR us.user_b = ? | ||
| 250 | + ORDER BY us.jaccard DESC | ||
| 251 | + LIMIT %d | ||
| 252 | + `, limit), userDID, userDID, userDID, userDID) | ||
| 253 | + if err != nil { | ||
| 254 | + return nil, err | ||
| 255 | + } | ||
| 256 | + defer rows.Close() | ||
| 257 | + | ||
| 258 | + var results []map[string]any | ||
| 259 | + for rows.Next() { | ||
| 260 | + var recUser, handle string | ||
| 261 | + var jaccard float64 | ||
| 262 | + var commonFeeds int | ||
| 263 | + var displayName, avatarURL sql.NullString | ||
| 264 | + if err := rows.Scan(&recUser, &jaccard, &commonFeeds, &handle, &displayName, &avatarURL); err != nil { | ||
| 265 | + return nil, err | ||
| 266 | + } | ||
| 267 | + results = append(results, map[string]any{ | ||
| 268 | + "did": recUser, | ||
| 269 | + "jaccard": jaccard, | ||
| 270 | + "common_feeds": commonFeeds, | ||
| 271 | + "handle": handle, | ||
| 272 | + "display_name": displayName, | ||
| 273 | + "avatar_url": avatarURL, | ||
| 274 | + }) | ||
| 275 | + } | ||
| 276 | + return results, rows.Err() | ||
| 277 | +} | ||
| 278 | + | ||
| 279 | +func (db *DB) GetSimilarFeeds(ctx context.Context, feedURL string, limit int) ([]*Feed, error) { | ||
| 280 | + rows, err := db.QueryContext(ctx, ` | ||
| 281 | + SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, | ||
| 282 | + f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified, | ||
| 283 | + f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count | ||
| 284 | + FROM feed_similarity fs | ||
| 285 | + JOIN feeds f ON f.feed_url = CASE WHEN fs.feed_a = ? THEN fs.feed_b ELSE fs.feed_a END | ||
| 286 | + WHERE fs.feed_a = ? OR fs.feed_b = ? | ||
| 287 | + ORDER BY fs.jaccard DESC | ||
| 288 | + LIMIT ? | ||
| 289 | + `, feedURL, feedURL, feedURL, limit) | ||
| 290 | + if err != nil { | ||
| 291 | + return nil, err | ||
| 292 | + } | ||
| 293 | + defer rows.Close() | ||
| 294 | + | ||
| 295 | + var feeds []*Feed | ||
| 296 | + for rows.Next() { | ||
| 297 | + f := &Feed{} | ||
| 298 | + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | ||
| 299 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | ||
| 300 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { | ||
| 301 | + return nil, err | ||
| 302 | + } | ||
| 303 | + feeds = append(feeds, f) | ||
| 304 | + } | ||
| 305 | + return feeds, rows.Err() | ||
| 306 | +} | ||
added
internal/db/db.go +164 -0 | new file mode 100644 | ||
| @@ -0,0 +1,164 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + _ "modernc.org/sqlite" | |
| 6 | +) | |
| 7 | + | |
| 8 | +type DB struct { | |
| 9 | + *sql.DB | |
| 10 | +} | |
| 11 | + | |
| 12 | +func Open(path string) (*DB, error) { | |
| 13 | + db, err := sql.Open("sqlite", path) | |
| 14 | + if err != nil { | |
| 15 | + return nil, err | |
| 16 | + } | |
| 17 | + | |
| 18 | + db.SetMaxOpenConns(1) | |
| 19 | + | |
| 20 | + if err := migrate(db); err != nil { | |
| 21 | + db.Close() | |
| 22 | + return nil, err | |
| 23 | + } | |
| 24 | + | |
| 25 | + return &DB{db}, nil | |
| 26 | +} | |
| 27 | + | |
| 28 | +func migrate(db *sql.DB) error { | |
| 29 | + tx, err := db.Begin() | |
| 30 | + if err != nil { | |
| 31 | + return err | |
| 32 | + } | |
| 33 | + defer func() { _ = tx.Rollback() }() | |
| 34 | + | |
| 35 | + stmts := []string{ | |
| 36 | + `CREATE TABLE IF NOT EXISTS users ( | |
| 37 | + did TEXT PRIMARY KEY, | |
| 38 | + handle TEXT NOT NULL, | |
| 39 | + display_name TEXT, | |
| 40 | + avatar_url TEXT, | |
| 41 | + indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 42 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| 43 | + )`, | |
| 44 | + `CREATE TABLE IF NOT EXISTS feeds ( | |
| 45 | + feed_url TEXT PRIMARY KEY, | |
| 46 | + title TEXT, | |
| 47 | + site_url TEXT, | |
| 48 | + description TEXT, | |
| 49 | + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')), | |
| 50 | + last_fetched_at DATETIME, | |
| 51 | + last_error TEXT, | |
| 52 | + subscriber_count INTEGER NOT NULL DEFAULT 0, | |
| 53 | + etag TEXT, | |
| 54 | + last_modified TEXT, | |
| 55 | + fetch_interval_minutes INTEGER NOT NULL DEFAULT 30, | |
| 56 | + next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 57 | + consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, | |
| 58 | + error_count INTEGER NOT NULL DEFAULT 0 | |
| 59 | + )`, | |
| 60 | + `CREATE TABLE IF NOT EXISTS subscriptions ( | |
| 61 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 62 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 63 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 64 | + category TEXT, | |
| 65 | + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 66 | + UNIQUE(user_did, feed_url) | |
| 67 | + )`, | |
| 68 | + `CREATE TABLE IF NOT EXISTS articles ( | |
| 69 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 70 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 71 | + guid TEXT NOT NULL, | |
| 72 | + title TEXT NOT NULL DEFAULT '', | |
| 73 | + url TEXT, | |
| 74 | + author TEXT, | |
| 75 | + summary TEXT, | |
| 76 | + content TEXT, | |
| 77 | + published DATETIME, | |
| 78 | + updated DATETIME, | |
| 79 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 80 | + UNIQUE(feed_url, guid) | |
| 81 | + )`, | |
| 82 | + `CREATE TABLE IF NOT EXISTS read_state ( | |
| 83 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 84 | + article_id INTEGER NOT NULL REFERENCES articles(id), | |
| 85 | + is_read BOOLEAN NOT NULL DEFAULT 0, | |
| 86 | + read_at DATETIME, | |
| 87 | + is_starred BOOLEAN NOT NULL DEFAULT 0, | |
| 88 | + starred_at DATETIME, | |
| 89 | + PRIMARY KEY (user_did, article_id) | |
| 90 | + )`, | |
| 91 | + `CREATE TABLE IF NOT EXISTS annotations ( | |
| 92 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 93 | + uri TEXT NOT NULL UNIQUE, | |
| 94 | + author_did TEXT NOT NULL REFERENCES users(did), | |
| 95 | + feed_url TEXT NOT NULL, | |
| 96 | + article_url TEXT NOT NULL, | |
| 97 | + quote TEXT, | |
| 98 | + note TEXT, | |
| 99 | + tags TEXT, | |
| 100 | + rating INTEGER, | |
| 101 | + created_at DATETIME NOT NULL, | |
| 102 | + cid TEXT | |
| 103 | + )`, | |
| 104 | + `CREATE TABLE IF NOT EXISTS likes ( | |
| 105 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 106 | + uri TEXT NOT NULL UNIQUE, | |
| 107 | + author_did TEXT NOT NULL REFERENCES users(did), | |
| 108 | + feed_url TEXT NOT NULL, | |
| 109 | + article_url TEXT NOT NULL, | |
| 110 | + created_at DATETIME NOT NULL, | |
| 111 | + cid TEXT, | |
| 112 | + UNIQUE(author_did, feed_url, article_url) | |
| 113 | + )`, | |
| 114 | + `CREATE TABLE IF NOT EXISTS feed_similarity ( | |
| 115 | + feed_a TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 116 | + feed_b TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 117 | + jaccard REAL NOT NULL, | |
| 118 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 119 | + PRIMARY KEY (feed_a, feed_b), | |
| 120 | + CHECK(feed_a < feed_b) | |
| 121 | + )`, | |
| 122 | + `CREATE TABLE IF NOT EXISTS user_similarity ( | |
| 123 | + user_a TEXT NOT NULL REFERENCES users(did), | |
| 124 | + user_b TEXT NOT NULL REFERENCES users(did), | |
| 125 | + jaccard REAL NOT NULL, | |
| 126 | + common_feeds INTEGER NOT NULL, | |
| 127 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 128 | + PRIMARY KEY (user_a, user_b), | |
| 129 | + CHECK(user_a < user_b) | |
| 130 | + )`, | |
| 131 | + `CREATE TABLE IF NOT EXISTS user_feed_recommendations ( | |
| 132 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 133 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | |
| 134 | + score REAL NOT NULL, | |
| 135 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 136 | + PRIMARY KEY (user_did, feed_url) | |
| 137 | + )`, | |
| 138 | + `CREATE TABLE IF NOT EXISTS user_article_recommendations ( | |
| 139 | + user_did TEXT NOT NULL REFERENCES users(did), | |
| 140 | + feed_url TEXT NOT NULL, | |
| 141 | + article_url TEXT NOT NULL, | |
| 142 | + score REAL NOT NULL, | |
| 143 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 144 | + PRIMARY KEY (user_did, feed_url, article_url) | |
| 145 | + )`, | |
| 146 | + `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`, | |
| 147 | + `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`, | |
| 148 | + `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`, | |
| 149 | + `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`, | |
| 150 | + `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, | |
| 151 | + `CREATE INDEX IF NOT EXISTS idx_read_state_starred ON read_state(user_did, is_starred) WHERE is_starred = 1`, | |
| 152 | + `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`, | |
| 153 | + `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`, | |
| 154 | + `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`, | |
| 155 | + } | |
| 156 | + | |
| 157 | + for _, s := range stmts { | |
| 158 | + if _, err := tx.Exec(s); err != nil { | |
| 159 | + return err | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + return tx.Commit() | |
| 164 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,164 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "database/sql" | ||
| 5 | + _ "modernc.org/sqlite" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +type DB struct { | ||
| 9 | + *sql.DB | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +func Open(path string) (*DB, error) { | ||
| 13 | + db, err := sql.Open("sqlite", path) | ||
| 14 | + if err != nil { | ||
| 15 | + return nil, err | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | + db.SetMaxOpenConns(1) | ||
| 19 | + | ||
| 20 | + if err := migrate(db); err != nil { | ||
| 21 | + db.Close() | ||
| 22 | + return nil, err | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + return &DB{db}, nil | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func migrate(db *sql.DB) error { | ||
| 29 | + tx, err := db.Begin() | ||
| 30 | + if err != nil { | ||
| 31 | + return err | ||
| 32 | + } | ||
| 33 | + defer func() { _ = tx.Rollback() }() | ||
| 34 | + | ||
| 35 | + stmts := []string{ | ||
| 36 | + `CREATE TABLE IF NOT EXISTS users ( | ||
| 37 | + did TEXT PRIMARY KEY, | ||
| 38 | + handle TEXT NOT NULL, | ||
| 39 | + display_name TEXT, | ||
| 40 | + avatar_url TEXT, | ||
| 41 | + indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 42 | + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP | ||
| 43 | + )`, | ||
| 44 | + `CREATE TABLE IF NOT EXISTS feeds ( | ||
| 45 | + feed_url TEXT PRIMARY KEY, | ||
| 46 | + title TEXT, | ||
| 47 | + site_url TEXT, | ||
| 48 | + description TEXT, | ||
| 49 | + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')), | ||
| 50 | + last_fetched_at DATETIME, | ||
| 51 | + last_error TEXT, | ||
| 52 | + subscriber_count INTEGER NOT NULL DEFAULT 0, | ||
| 53 | + etag TEXT, | ||
| 54 | + last_modified TEXT, | ||
| 55 | + fetch_interval_minutes INTEGER NOT NULL DEFAULT 30, | ||
| 56 | + next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 57 | + consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, | ||
| 58 | + error_count INTEGER NOT NULL DEFAULT 0 | ||
| 59 | + )`, | ||
| 60 | + `CREATE TABLE IF NOT EXISTS subscriptions ( | ||
| 61 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 62 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 63 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 64 | + category TEXT, | ||
| 65 | + added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 66 | + UNIQUE(user_did, feed_url) | ||
| 67 | + )`, | ||
| 68 | + `CREATE TABLE IF NOT EXISTS articles ( | ||
| 69 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 70 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 71 | + guid TEXT NOT NULL, | ||
| 72 | + title TEXT NOT NULL DEFAULT '', | ||
| 73 | + url TEXT, | ||
| 74 | + author TEXT, | ||
| 75 | + summary TEXT, | ||
| 76 | + content TEXT, | ||
| 77 | + published DATETIME, | ||
| 78 | + updated DATETIME, | ||
| 79 | + fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 80 | + UNIQUE(feed_url, guid) | ||
| 81 | + )`, | ||
| 82 | + `CREATE TABLE IF NOT EXISTS read_state ( | ||
| 83 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 84 | + article_id INTEGER NOT NULL REFERENCES articles(id), | ||
| 85 | + is_read BOOLEAN NOT NULL DEFAULT 0, | ||
| 86 | + read_at DATETIME, | ||
| 87 | + is_starred BOOLEAN NOT NULL DEFAULT 0, | ||
| 88 | + starred_at DATETIME, | ||
| 89 | + PRIMARY KEY (user_did, article_id) | ||
| 90 | + )`, | ||
| 91 | + `CREATE TABLE IF NOT EXISTS annotations ( | ||
| 92 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 93 | + uri TEXT NOT NULL UNIQUE, | ||
| 94 | + author_did TEXT NOT NULL REFERENCES users(did), | ||
| 95 | + feed_url TEXT NOT NULL, | ||
| 96 | + article_url TEXT NOT NULL, | ||
| 97 | + quote TEXT, | ||
| 98 | + note TEXT, | ||
| 99 | + tags TEXT, | ||
| 100 | + rating INTEGER, | ||
| 101 | + created_at DATETIME NOT NULL, | ||
| 102 | + cid TEXT | ||
| 103 | + )`, | ||
| 104 | + `CREATE TABLE IF NOT EXISTS likes ( | ||
| 105 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 106 | + uri TEXT NOT NULL UNIQUE, | ||
| 107 | + author_did TEXT NOT NULL REFERENCES users(did), | ||
| 108 | + feed_url TEXT NOT NULL, | ||
| 109 | + article_url TEXT NOT NULL, | ||
| 110 | + created_at DATETIME NOT NULL, | ||
| 111 | + cid TEXT, | ||
| 112 | + UNIQUE(author_did, feed_url, article_url) | ||
| 113 | + )`, | ||
| 114 | + `CREATE TABLE IF NOT EXISTS feed_similarity ( | ||
| 115 | + feed_a TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 116 | + feed_b TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 117 | + jaccard REAL NOT NULL, | ||
| 118 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 119 | + PRIMARY KEY (feed_a, feed_b), | ||
| 120 | + CHECK(feed_a < feed_b) | ||
| 121 | + )`, | ||
| 122 | + `CREATE TABLE IF NOT EXISTS user_similarity ( | ||
| 123 | + user_a TEXT NOT NULL REFERENCES users(did), | ||
| 124 | + user_b TEXT NOT NULL REFERENCES users(did), | ||
| 125 | + jaccard REAL NOT NULL, | ||
| 126 | + common_feeds INTEGER NOT NULL, | ||
| 127 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 128 | + PRIMARY KEY (user_a, user_b), | ||
| 129 | + CHECK(user_a < user_b) | ||
| 130 | + )`, | ||
| 131 | + `CREATE TABLE IF NOT EXISTS user_feed_recommendations ( | ||
| 132 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 133 | + feed_url TEXT NOT NULL REFERENCES feeds(feed_url), | ||
| 134 | + score REAL NOT NULL, | ||
| 135 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 136 | + PRIMARY KEY (user_did, feed_url) | ||
| 137 | + )`, | ||
| 138 | + `CREATE TABLE IF NOT EXISTS user_article_recommendations ( | ||
| 139 | + user_did TEXT NOT NULL REFERENCES users(did), | ||
| 140 | + feed_url TEXT NOT NULL, | ||
| 141 | + article_url TEXT NOT NULL, | ||
| 142 | + score REAL NOT NULL, | ||
| 143 | + computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
| 144 | + PRIMARY KEY (user_did, feed_url, article_url) | ||
| 145 | + )`, | ||
| 146 | + `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`, | ||
| 147 | + `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`, | ||
| 148 | + `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`, | ||
| 149 | + `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`, | ||
| 150 | + `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, | ||
| 151 | + `CREATE INDEX IF NOT EXISTS idx_read_state_starred ON read_state(user_did, is_starred) WHERE is_starred = 1`, | ||
| 152 | + `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`, | ||
| 153 | + `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`, | ||
| 154 | + `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`, | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + for _, s := range stmts { | ||
| 158 | + if _, err := tx.Exec(s); err != nil { | ||
| 159 | + return err | ||
| 160 | + } | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + return tx.Commit() | ||
| 164 | +} | ||
added
internal/db/feed.go +210 -0 | new file mode 100644 | ||
| @@ -0,0 +1,210 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "fmt" | |
| 7 | + "time" | |
| 8 | +) | |
| 9 | + | |
| 10 | +type Feed struct { | |
| 11 | + FeedURL string | |
| 12 | + Title sql.NullString | |
| 13 | + SiteURL sql.NullString | |
| 14 | + Description sql.NullString | |
| 15 | + FeedType sql.NullString | |
| 16 | + LastFetchedAt sql.NullTime | |
| 17 | + LastError sql.NullString | |
| 18 | + SubscriberCount int | |
| 19 | + Etag sql.NullString | |
| 20 | + LastModified sql.NullString | |
| 21 | + FetchIntervalMinutes int | |
| 22 | + NextFetchAt sql.NullTime | |
| 23 | + ConsecutiveEmptyFetches int | |
| 24 | + ErrorCount int | |
| 25 | +} | |
| 26 | + | |
| 27 | +type Subscription struct { | |
| 28 | + ID int64 | |
| 29 | + UserDID string | |
| 30 | + FeedURL string | |
| 31 | + FeedTitle string | |
| 32 | + Category sql.NullString | |
| 33 | + AddedAt sql.NullTime | |
| 34 | + UnreadCount int | |
| 35 | +} | |
| 36 | + | |
| 37 | +func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error { | |
| 38 | + _, err := db.ExecContext(ctx, ` | |
| 39 | + INSERT INTO feeds (feed_url, title, site_url, description, feed_type) | |
| 40 | + VALUES (?, ?, ?, ?, ?) | |
| 41 | + ON CONFLICT(feed_url) DO UPDATE SET | |
| 42 | + title = excluded.title, | |
| 43 | + site_url = excluded.site_url, | |
| 44 | + description = excluded.description, | |
| 45 | + feed_type = excluded.feed_type | |
| 46 | + `, feed.FeedURL, feed.Title, feed.SiteURL, feed.Description, feed.FeedType) | |
| 47 | + return err | |
| 48 | +} | |
| 49 | + | |
| 50 | +func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) { | |
| 51 | + f := &Feed{} | |
| 52 | + err := db.QueryRowContext(ctx, ` | |
| 53 | + SELECT feed_url, title, site_url, description, feed_type, | |
| 54 | + last_fetched_at, last_error, subscriber_count, etag, last_modified, | |
| 55 | + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count | |
| 56 | + FROM feeds WHERE feed_url = ? | |
| 57 | + `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | |
| 58 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | |
| 59 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount) | |
| 60 | + if err != nil { | |
| 61 | + return nil, err | |
| 62 | + } | |
| 63 | + return f, nil | |
| 64 | +} | |
| 65 | + | |
| 66 | +func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) { | |
| 67 | + rows, err := db.QueryContext(ctx, ` | |
| 68 | + SELECT feed_url, title, site_url, description, feed_type, | |
| 69 | + last_fetched_at, last_error, subscriber_count, etag, last_modified, | |
| 70 | + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count | |
| 71 | + FROM feeds | |
| 72 | + WHERE next_fetch_at <= CURRENT_TIMESTAMP | |
| 73 | + ORDER BY next_fetch_at | |
| 74 | + LIMIT ? | |
| 75 | + `, limit) | |
| 76 | + if err != nil { | |
| 77 | + return nil, err | |
| 78 | + } | |
| 79 | + defer rows.Close() | |
| 80 | + | |
| 81 | + var feeds []*Feed | |
| 82 | + for rows.Next() { | |
| 83 | + f := &Feed{} | |
| 84 | + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | |
| 85 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | |
| 86 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { | |
| 87 | + return nil, err | |
| 88 | + } | |
| 89 | + feeds = append(feeds, f) | |
| 90 | + } | |
| 91 | + return feeds, rows.Err() | |
| 92 | +} | |
| 93 | + | |
| 94 | +func (db *DB) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error { | |
| 95 | + nextFetch := time.Now().Add(time.Duration(intervalMinutes) * time.Minute) | |
| 96 | + _, err := db.ExecContext(ctx, ` | |
| 97 | + UPDATE feeds SET | |
| 98 | + etag = ?, | |
| 99 | + last_modified = ?, | |
| 100 | + fetch_interval_minutes = ?, | |
| 101 | + next_fetch_at = ?, | |
| 102 | + consecutive_empty_fetches = ?, | |
| 103 | + error_count = ?, | |
| 104 | + last_error = ?, | |
| 105 | + last_fetched_at = CURRENT_TIMESTAMP | |
| 106 | + WHERE feed_url = ? | |
| 107 | + `, etag, lastModified, intervalMinutes, nextFetch, consecutiveEmpty, errorCount, lastError, feedURL) | |
| 108 | + return err | |
| 109 | +} | |
| 110 | + | |
| 111 | +func (db *DB) IncrementSubscriberCount(ctx context.Context, feedURL string) error { | |
| 112 | + _, err := db.ExecContext(ctx, ` | |
| 113 | + UPDATE feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ? | |
| 114 | + `, feedURL) | |
| 115 | + return err | |
| 116 | +} | |
| 117 | + | |
| 118 | +func (db *DB) DecrementSubscriberCount(ctx context.Context, feedURL string) error { | |
| 119 | + _, err := db.ExecContext(ctx, ` | |
| 120 | + UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ? | |
| 121 | + `, feedURL) | |
| 122 | + return err | |
| 123 | +} | |
| 124 | + | |
| 125 | +func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category string) error { | |
| 126 | + _, err := db.ExecContext(ctx, ` | |
| 127 | + INSERT INTO subscriptions (user_did, feed_url, category) | |
| 128 | + VALUES (?, ?, ?) | |
| 129 | + `, userDID, feedURL, category) | |
| 130 | + if err != nil { | |
| 131 | + return err | |
| 132 | + } | |
| 133 | + return db.IncrementSubscriberCount(ctx, feedURL) | |
| 134 | +} | |
| 135 | + | |
| 136 | +func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error { | |
| 137 | + _, err := db.ExecContext(ctx, ` | |
| 138 | + DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ? | |
| 139 | + `, userDID, feedURL) | |
| 140 | + if err != nil { | |
| 141 | + return err | |
| 142 | + } | |
| 143 | + return db.DecrementSubscriberCount(ctx, feedURL) | |
| 144 | +} | |
| 145 | + | |
| 146 | +func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { | |
| 147 | + query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at | |
| 148 | + FROM subscriptions s | |
| 149 | + LEFT JOIN feeds f ON s.feed_url = f.feed_url | |
| 150 | + WHERE s.user_did = ?` | |
| 151 | + args := []any{userDID} | |
| 152 | + | |
| 153 | + if category != "" { | |
| 154 | + query += ` AND s.category = ?` | |
| 155 | + args = append(args, category) | |
| 156 | + } | |
| 157 | + | |
| 158 | + query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 159 | + | |
| 160 | + rows, err := db.QueryContext(ctx, query, args...) | |
| 161 | + if err != nil { | |
| 162 | + return nil, err | |
| 163 | + } | |
| 164 | + defer rows.Close() | |
| 165 | + | |
| 166 | + var subs []*Subscription | |
| 167 | + for rows.Next() { | |
| 168 | + s := &Subscription{} | |
| 169 | + if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt); err != nil { | |
| 170 | + return nil, err | |
| 171 | + } | |
| 172 | + subs = append(subs, s) | |
| 173 | + } | |
| 174 | + return subs, rows.Err() | |
| 175 | +} | |
| 176 | + | |
| 177 | +func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) { | |
| 178 | + var count int | |
| 179 | + err := db.QueryRowContext(ctx, ` | |
| 180 | + SELECT COUNT(*) FROM subscriptions WHERE user_did = ? | |
| 181 | + `, userDID).Scan(&count) | |
| 182 | + return count, err | |
| 183 | +} | |
| 184 | + | |
| 185 | +func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { | |
| 186 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 187 | + SELECT feed_url, title, site_url, description, feed_type, | |
| 188 | + last_fetched_at, last_error, subscriber_count, etag, last_modified, | |
| 189 | + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count | |
| 190 | + FROM feeds | |
| 191 | + ORDER BY subscriber_count DESC | |
| 192 | + LIMIT %d OFFSET %d | |
| 193 | + `, limit, offset)) | |
| 194 | + if err != nil { | |
| 195 | + return nil, err | |
| 196 | + } | |
| 197 | + defer rows.Close() | |
| 198 | + | |
| 199 | + var feeds []*Feed | |
| 200 | + for rows.Next() { | |
| 201 | + f := &Feed{} | |
| 202 | + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | |
| 203 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | |
| 204 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { | |
| 205 | + return nil, err | |
| 206 | + } | |
| 207 | + feeds = append(feeds, f) | |
| 208 | + } | |
| 209 | + return feeds, rows.Err() | |
| 210 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,210 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | + "fmt" | ||
| 7 | + "time" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +type Feed struct { | ||
| 11 | + FeedURL string | ||
| 12 | + Title sql.NullString | ||
| 13 | + SiteURL sql.NullString | ||
| 14 | + Description sql.NullString | ||
| 15 | + FeedType sql.NullString | ||
| 16 | + LastFetchedAt sql.NullTime | ||
| 17 | + LastError sql.NullString | ||
| 18 | + SubscriberCount int | ||
| 19 | + Etag sql.NullString | ||
| 20 | + LastModified sql.NullString | ||
| 21 | + FetchIntervalMinutes int | ||
| 22 | + NextFetchAt sql.NullTime | ||
| 23 | + ConsecutiveEmptyFetches int | ||
| 24 | + ErrorCount int | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +type Subscription struct { | ||
| 28 | + ID int64 | ||
| 29 | + UserDID string | ||
| 30 | + FeedURL string | ||
| 31 | + FeedTitle string | ||
| 32 | + Category sql.NullString | ||
| 33 | + AddedAt sql.NullTime | ||
| 34 | + UnreadCount int | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error { | ||
| 38 | + _, err := db.ExecContext(ctx, ` | ||
| 39 | + INSERT INTO feeds (feed_url, title, site_url, description, feed_type) | ||
| 40 | + VALUES (?, ?, ?, ?, ?) | ||
| 41 | + ON CONFLICT(feed_url) DO UPDATE SET | ||
| 42 | + title = excluded.title, | ||
| 43 | + site_url = excluded.site_url, | ||
| 44 | + description = excluded.description, | ||
| 45 | + feed_type = excluded.feed_type | ||
| 46 | + `, feed.FeedURL, feed.Title, feed.SiteURL, feed.Description, feed.FeedType) | ||
| 47 | + return err | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) { | ||
| 51 | + f := &Feed{} | ||
| 52 | + err := db.QueryRowContext(ctx, ` | ||
| 53 | + SELECT feed_url, title, site_url, description, feed_type, | ||
| 54 | + last_fetched_at, last_error, subscriber_count, etag, last_modified, | ||
| 55 | + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count | ||
| 56 | + FROM feeds WHERE feed_url = ? | ||
| 57 | + `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | ||
| 58 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | ||
| 59 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount) | ||
| 60 | + if err != nil { | ||
| 61 | + return nil, err | ||
| 62 | + } | ||
| 63 | + return f, nil | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +func (db *DB) GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) { | ||
| 67 | + rows, err := db.QueryContext(ctx, ` | ||
| 68 | + SELECT feed_url, title, site_url, description, feed_type, | ||
| 69 | + last_fetched_at, last_error, subscriber_count, etag, last_modified, | ||
| 70 | + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count | ||
| 71 | + FROM feeds | ||
| 72 | + WHERE next_fetch_at <= CURRENT_TIMESTAMP | ||
| 73 | + ORDER BY next_fetch_at | ||
| 74 | + LIMIT ? | ||
| 75 | + `, limit) | ||
| 76 | + if err != nil { | ||
| 77 | + return nil, err | ||
| 78 | + } | ||
| 79 | + defer rows.Close() | ||
| 80 | + | ||
| 81 | + var feeds []*Feed | ||
| 82 | + for rows.Next() { | ||
| 83 | + f := &Feed{} | ||
| 84 | + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | ||
| 85 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | ||
| 86 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { | ||
| 87 | + return nil, err | ||
| 88 | + } | ||
| 89 | + feeds = append(feeds, f) | ||
| 90 | + } | ||
| 91 | + return feeds, rows.Err() | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func (db *DB) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error { | ||
| 95 | + nextFetch := time.Now().Add(time.Duration(intervalMinutes) * time.Minute) | ||
| 96 | + _, err := db.ExecContext(ctx, ` | ||
| 97 | + UPDATE feeds SET | ||
| 98 | + etag = ?, | ||
| 99 | + last_modified = ?, | ||
| 100 | + fetch_interval_minutes = ?, | ||
| 101 | + next_fetch_at = ?, | ||
| 102 | + consecutive_empty_fetches = ?, | ||
| 103 | + error_count = ?, | ||
| 104 | + last_error = ?, | ||
| 105 | + last_fetched_at = CURRENT_TIMESTAMP | ||
| 106 | + WHERE feed_url = ? | ||
| 107 | + `, etag, lastModified, intervalMinutes, nextFetch, consecutiveEmpty, errorCount, lastError, feedURL) | ||
| 108 | + return err | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +func (db *DB) IncrementSubscriberCount(ctx context.Context, feedURL string) error { | ||
| 112 | + _, err := db.ExecContext(ctx, ` | ||
| 113 | + UPDATE feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ? | ||
| 114 | + `, feedURL) | ||
| 115 | + return err | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +func (db *DB) DecrementSubscriberCount(ctx context.Context, feedURL string) error { | ||
| 119 | + _, err := db.ExecContext(ctx, ` | ||
| 120 | + UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ? | ||
| 121 | + `, feedURL) | ||
| 122 | + return err | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, category string) error { | ||
| 126 | + _, err := db.ExecContext(ctx, ` | ||
| 127 | + INSERT INTO subscriptions (user_did, feed_url, category) | ||
| 128 | + VALUES (?, ?, ?) | ||
| 129 | + `, userDID, feedURL, category) | ||
| 130 | + if err != nil { | ||
| 131 | + return err | ||
| 132 | + } | ||
| 133 | + return db.IncrementSubscriberCount(ctx, feedURL) | ||
| 134 | +} | ||
| 135 | + | ||
| 136 | +func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error { | ||
| 137 | + _, err := db.ExecContext(ctx, ` | ||
| 138 | + DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ? | ||
| 139 | + `, userDID, feedURL) | ||
| 140 | + if err != nil { | ||
| 141 | + return err | ||
| 142 | + } | ||
| 143 | + return db.DecrementSubscriberCount(ctx, feedURL) | ||
| 144 | +} | ||
| 145 | + | ||
| 146 | +func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { | ||
| 147 | + query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(f.title, ''), s.category, s.added_at | ||
| 148 | + FROM subscriptions s | ||
| 149 | + LEFT JOIN feeds f ON s.feed_url = f.feed_url | ||
| 150 | + WHERE s.user_did = ?` | ||
| 151 | + args := []any{userDID} | ||
| 152 | + | ||
| 153 | + if category != "" { | ||
| 154 | + query += ` AND s.category = ?` | ||
| 155 | + args = append(args, category) | ||
| 156 | + } | ||
| 157 | + | ||
| 158 | + query += fmt.Sprintf(` ORDER BY s.added_at DESC LIMIT %d OFFSET %d`, limit, offset) | ||
| 159 | + | ||
| 160 | + rows, err := db.QueryContext(ctx, query, args...) | ||
| 161 | + if err != nil { | ||
| 162 | + return nil, err | ||
| 163 | + } | ||
| 164 | + defer rows.Close() | ||
| 165 | + | ||
| 166 | + var subs []*Subscription | ||
| 167 | + for rows.Next() { | ||
| 168 | + s := &Subscription{} | ||
| 169 | + if err := rows.Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt); err != nil { | ||
| 170 | + return nil, err | ||
| 171 | + } | ||
| 172 | + subs = append(subs, s) | ||
| 173 | + } | ||
| 174 | + return subs, rows.Err() | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) { | ||
| 178 | + var count int | ||
| 179 | + err := db.QueryRowContext(ctx, ` | ||
| 180 | + SELECT COUNT(*) FROM subscriptions WHERE user_did = ? | ||
| 181 | + `, userDID).Scan(&count) | ||
| 182 | + return count, err | ||
| 183 | +} | ||
| 184 | + | ||
| 185 | +func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { | ||
| 186 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | ||
| 187 | + SELECT feed_url, title, site_url, description, feed_type, | ||
| 188 | + last_fetched_at, last_error, subscriber_count, etag, last_modified, | ||
| 189 | + fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count | ||
| 190 | + FROM feeds | ||
| 191 | + ORDER BY subscriber_count DESC | ||
| 192 | + LIMIT %d OFFSET %d | ||
| 193 | + `, limit, offset)) | ||
| 194 | + if err != nil { | ||
| 195 | + return nil, err | ||
| 196 | + } | ||
| 197 | + defer rows.Close() | ||
| 198 | + | ||
| 199 | + var feeds []*Feed | ||
| 200 | + for rows.Next() { | ||
| 201 | + f := &Feed{} | ||
| 202 | + if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, | ||
| 203 | + &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, | ||
| 204 | + &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount); err != nil { | ||
| 205 | + return nil, err | ||
| 206 | + } | ||
| 207 | + feeds = append(feeds, f) | ||
| 208 | + } | ||
| 209 | + return feeds, rows.Err() | ||
| 210 | +} | ||
added
internal/db/social.go +210 -0 | new file mode 100644 | ||
| @@ -0,0 +1,210 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + "fmt" | |
| 7 | + "strings" | |
| 8 | +) | |
| 9 | + | |
| 10 | +type Annotation struct { | |
| 11 | + ID int64 | |
| 12 | + URI string | |
| 13 | + AuthorDID string | |
| 14 | + FeedURL string | |
| 15 | + ArticleURL string | |
| 16 | + Quote sql.NullString | |
| 17 | + Note sql.NullString | |
| 18 | + Tags sql.NullString | |
| 19 | + Rating sql.NullInt64 | |
| 20 | + CreatedAt sql.NullTime | |
| 21 | + CID sql.NullString | |
| 22 | +} | |
| 23 | + | |
| 24 | +type Like struct { | |
| 25 | + ID int64 | |
| 26 | + URI string | |
| 27 | + AuthorDID string | |
| 28 | + FeedURL string | |
| 29 | + ArticleURL string | |
| 30 | + CreatedAt sql.NullTime | |
| 31 | + CID sql.NullString | |
| 32 | +} | |
| 33 | + | |
| 34 | +func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error { | |
| 35 | + _, err := db.ExecContext(ctx, ` | |
| 36 | + INSERT INTO annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid) | |
| 37 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 38 | + `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID) | |
| 39 | + return err | |
| 40 | +} | |
| 41 | + | |
| 42 | +func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { | |
| 43 | + _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri) | |
| 44 | + return err | |
| 45 | +} | |
| 46 | + | |
| 47 | +func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { | |
| 48 | + var conds []string | |
| 49 | + var args []any | |
| 50 | + | |
| 51 | + if feedURL != "" { | |
| 52 | + conds = append(conds, "feed_url = ?") | |
| 53 | + args = append(args, feedURL) | |
| 54 | + } | |
| 55 | + if articleURL != "" { | |
| 56 | + conds = append(conds, "article_url = ?") | |
| 57 | + args = append(args, articleURL) | |
| 58 | + } | |
| 59 | + if authorDID != "" { | |
| 60 | + conds = append(conds, "author_did = ?") | |
| 61 | + args = append(args, authorDID) | |
| 62 | + } | |
| 63 | + | |
| 64 | + query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid | |
| 65 | + FROM annotations` | |
| 66 | + if len(conds) > 0 { | |
| 67 | + query += ` WHERE ` + strings.Join(conds, " AND ") | |
| 68 | + } | |
| 69 | + query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 70 | + | |
| 71 | + rows, err := db.QueryContext(ctx, query, args...) | |
| 72 | + if err != nil { | |
| 73 | + return nil, err | |
| 74 | + } | |
| 75 | + defer rows.Close() | |
| 76 | + | |
| 77 | + var annotations []*Annotation | |
| 78 | + for rows.Next() { | |
| 79 | + a := &Annotation{} | |
| 80 | + if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL, | |
| 81 | + &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil { | |
| 82 | + return nil, err | |
| 83 | + } | |
| 84 | + annotations = append(annotations, a) | |
| 85 | + } | |
| 86 | + return annotations, rows.Err() | |
| 87 | +} | |
| 88 | + | |
| 89 | +func (db *DB) CreateLike(ctx context.Context, l *Like) error { | |
| 90 | + _, err := db.ExecContext(ctx, ` | |
| 91 | + INSERT INTO likes (uri, author_did, feed_url, article_url, created_at, cid) | |
| 92 | + VALUES (?, ?, ?, ?, ?, ?) | |
| 93 | + `, l.URI, l.AuthorDID, l.FeedURL, l.ArticleURL, l.CreatedAt, l.CID) | |
| 94 | + return err | |
| 95 | +} | |
| 96 | + | |
| 97 | +func (db *DB) DeleteLike(ctx context.Context, uri string) error { | |
| 98 | + _, err := db.ExecContext(ctx, `DELETE FROM likes WHERE uri = ?`, uri) | |
| 99 | + return err | |
| 100 | +} | |
| 101 | + | |
| 102 | +func (db *DB) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error { | |
| 103 | + _, err := db.ExecContext(ctx, ` | |
| 104 | + DELETE FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ? | |
| 105 | + `, authorDID, feedURL, articleURL) | |
| 106 | + return err | |
| 107 | +} | |
| 108 | + | |
| 109 | +func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, offset int) ([]*Like, error) { | |
| 110 | + var conds []string | |
| 111 | + var args []any | |
| 112 | + | |
| 113 | + if authorDID != "" { | |
| 114 | + conds = append(conds, "author_did = ?") | |
| 115 | + args = append(args, authorDID) | |
| 116 | + } | |
| 117 | + if feedURL != "" { | |
| 118 | + conds = append(conds, "feed_url = ?") | |
| 119 | + args = append(args, feedURL) | |
| 120 | + } | |
| 121 | + | |
| 122 | + query := `SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes` | |
| 123 | + if len(conds) > 0 { | |
| 124 | + query += ` WHERE ` + strings.Join(conds, " AND ") | |
| 125 | + } | |
| 126 | + query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | |
| 127 | + | |
| 128 | + rows, err := db.QueryContext(ctx, query, args...) | |
| 129 | + if err != nil { | |
| 130 | + return nil, err | |
| 131 | + } | |
| 132 | + defer rows.Close() | |
| 133 | + | |
| 134 | + var likes []*Like | |
| 135 | + for rows.Next() { | |
| 136 | + l := &Like{} | |
| 137 | + if err := rows.Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID); err != nil { | |
| 138 | + return nil, err | |
| 139 | + } | |
| 140 | + likes = append(likes, l) | |
| 141 | + } | |
| 142 | + return likes, rows.Err() | |
| 143 | +} | |
| 144 | + | |
| 145 | +func (db *DB) GetLikeCount(ctx context.Context, feedURL, articleURL string) (int, error) { | |
| 146 | + var count int | |
| 147 | + err := db.QueryRowContext(ctx, ` | |
| 148 | + SELECT COUNT(*) FROM likes WHERE feed_url = ? AND article_url = ? | |
| 149 | + `, feedURL, articleURL).Scan(&count) | |
| 150 | + return count, err | |
| 151 | +} | |
| 152 | + | |
| 153 | +func (db *DB) HasLiked(ctx context.Context, authorDID, feedURL, articleURL string) (bool, error) { | |
| 154 | + var exists int | |
| 155 | + err := db.QueryRowContext(ctx, ` | |
| 156 | + SELECT 1 FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ? | |
| 157 | + `, authorDID, feedURL, articleURL).Scan(&exists) | |
| 158 | + if err == sql.ErrNoRows { | |
| 159 | + return false, nil | |
| 160 | + } | |
| 161 | + if err != nil { | |
| 162 | + return false, err | |
| 163 | + } | |
| 164 | + return true, nil | |
| 165 | +} | |
| 166 | + | |
| 167 | +type TrendingItem struct { | |
| 168 | + ArticleID int64 | |
| 169 | + Title string | |
| 170 | + URL string | |
| 171 | + Author string | |
| 172 | + Summary string | |
| 173 | + FeedURL string | |
| 174 | + FeedTitle string | |
| 175 | + LikeCount int | |
| 176 | + AnnotationCount int | |
| 177 | +} | |
| 178 | + | |
| 179 | +func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) { | |
| 180 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | |
| 181 | + SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), | |
| 182 | + COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), | |
| 183 | + COUNT(DISTINCT l.id) AS like_count, | |
| 184 | + COUNT(DISTINCT a.id) AS annotation_count | |
| 185 | + FROM likes l | |
| 186 | + JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url | |
| 187 | + LEFT JOIN feeds f ON f.feed_url = l.feed_url | |
| 188 | + LEFT JOIN annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ? | |
| 189 | + WHERE l.created_at >= ? | |
| 190 | + GROUP BY ar.id | |
| 191 | + ORDER BY like_count DESC, annotation_count DESC | |
| 192 | + LIMIT %d OFFSET %d | |
| 193 | + `, limit, offset), since, since) | |
| 194 | + if err != nil { | |
| 195 | + return nil, err | |
| 196 | + } | |
| 197 | + defer rows.Close() | |
| 198 | + | |
| 199 | + var results []*TrendingItem | |
| 200 | + for rows.Next() { | |
| 201 | + item := &TrendingItem{} | |
| 202 | + if err := rows.Scan(&item.ArticleID, &item.Title, &item.URL, &item.Author, | |
| 203 | + &item.Summary, &item.FeedURL, &item.FeedTitle, | |
| 204 | + &item.LikeCount, &item.AnnotationCount); err != nil { | |
| 205 | + return nil, err | |
| 206 | + } | |
| 207 | + results = append(results, item) | |
| 208 | + } | |
| 209 | + return results, rows.Err() | |
| 210 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,210 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | + "fmt" | ||
| 7 | + "strings" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +type Annotation struct { | ||
| 11 | + ID int64 | ||
| 12 | + URI string | ||
| 13 | + AuthorDID string | ||
| 14 | + FeedURL string | ||
| 15 | + ArticleURL string | ||
| 16 | + Quote sql.NullString | ||
| 17 | + Note sql.NullString | ||
| 18 | + Tags sql.NullString | ||
| 19 | + Rating sql.NullInt64 | ||
| 20 | + CreatedAt sql.NullTime | ||
| 21 | + CID sql.NullString | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +type Like struct { | ||
| 25 | + ID int64 | ||
| 26 | + URI string | ||
| 27 | + AuthorDID string | ||
| 28 | + FeedURL string | ||
| 29 | + ArticleURL string | ||
| 30 | + CreatedAt sql.NullTime | ||
| 31 | + CID sql.NullString | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error { | ||
| 35 | + _, err := db.ExecContext(ctx, ` | ||
| 36 | + INSERT INTO annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid) | ||
| 37 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| 38 | + `, a.URI, a.AuthorDID, a.FeedURL, a.ArticleURL, a.Quote, a.Note, a.Tags, a.Rating, a.CreatedAt, a.CID) | ||
| 39 | + return err | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { | ||
| 43 | + _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri) | ||
| 44 | + return err | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { | ||
| 48 | + var conds []string | ||
| 49 | + var args []any | ||
| 50 | + | ||
| 51 | + if feedURL != "" { | ||
| 52 | + conds = append(conds, "feed_url = ?") | ||
| 53 | + args = append(args, feedURL) | ||
| 54 | + } | ||
| 55 | + if articleURL != "" { | ||
| 56 | + conds = append(conds, "article_url = ?") | ||
| 57 | + args = append(args, articleURL) | ||
| 58 | + } | ||
| 59 | + if authorDID != "" { | ||
| 60 | + conds = append(conds, "author_did = ?") | ||
| 61 | + args = append(args, authorDID) | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid | ||
| 65 | + FROM annotations` | ||
| 66 | + if len(conds) > 0 { | ||
| 67 | + query += ` WHERE ` + strings.Join(conds, " AND ") | ||
| 68 | + } | ||
| 69 | + query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | ||
| 70 | + | ||
| 71 | + rows, err := db.QueryContext(ctx, query, args...) | ||
| 72 | + if err != nil { | ||
| 73 | + return nil, err | ||
| 74 | + } | ||
| 75 | + defer rows.Close() | ||
| 76 | + | ||
| 77 | + var annotations []*Annotation | ||
| 78 | + for rows.Next() { | ||
| 79 | + a := &Annotation{} | ||
| 80 | + if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL, | ||
| 81 | + &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil { | ||
| 82 | + return nil, err | ||
| 83 | + } | ||
| 84 | + annotations = append(annotations, a) | ||
| 85 | + } | ||
| 86 | + return annotations, rows.Err() | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +func (db *DB) CreateLike(ctx context.Context, l *Like) error { | ||
| 90 | + _, err := db.ExecContext(ctx, ` | ||
| 91 | + INSERT INTO likes (uri, author_did, feed_url, article_url, created_at, cid) | ||
| 92 | + VALUES (?, ?, ?, ?, ?, ?) | ||
| 93 | + `, l.URI, l.AuthorDID, l.FeedURL, l.ArticleURL, l.CreatedAt, l.CID) | ||
| 94 | + return err | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +func (db *DB) DeleteLike(ctx context.Context, uri string) error { | ||
| 98 | + _, err := db.ExecContext(ctx, `DELETE FROM likes WHERE uri = ?`, uri) | ||
| 99 | + return err | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +func (db *DB) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error { | ||
| 103 | + _, err := db.ExecContext(ctx, ` | ||
| 104 | + DELETE FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ? | ||
| 105 | + `, authorDID, feedURL, articleURL) | ||
| 106 | + return err | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, offset int) ([]*Like, error) { | ||
| 110 | + var conds []string | ||
| 111 | + var args []any | ||
| 112 | + | ||
| 113 | + if authorDID != "" { | ||
| 114 | + conds = append(conds, "author_did = ?") | ||
| 115 | + args = append(args, authorDID) | ||
| 116 | + } | ||
| 117 | + if feedURL != "" { | ||
| 118 | + conds = append(conds, "feed_url = ?") | ||
| 119 | + args = append(args, feedURL) | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + query := `SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes` | ||
| 123 | + if len(conds) > 0 { | ||
| 124 | + query += ` WHERE ` + strings.Join(conds, " AND ") | ||
| 125 | + } | ||
| 126 | + query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset) | ||
| 127 | + | ||
| 128 | + rows, err := db.QueryContext(ctx, query, args...) | ||
| 129 | + if err != nil { | ||
| 130 | + return nil, err | ||
| 131 | + } | ||
| 132 | + defer rows.Close() | ||
| 133 | + | ||
| 134 | + var likes []*Like | ||
| 135 | + for rows.Next() { | ||
| 136 | + l := &Like{} | ||
| 137 | + if err := rows.Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID); err != nil { | ||
| 138 | + return nil, err | ||
| 139 | + } | ||
| 140 | + likes = append(likes, l) | ||
| 141 | + } | ||
| 142 | + return likes, rows.Err() | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +func (db *DB) GetLikeCount(ctx context.Context, feedURL, articleURL string) (int, error) { | ||
| 146 | + var count int | ||
| 147 | + err := db.QueryRowContext(ctx, ` | ||
| 148 | + SELECT COUNT(*) FROM likes WHERE feed_url = ? AND article_url = ? | ||
| 149 | + `, feedURL, articleURL).Scan(&count) | ||
| 150 | + return count, err | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +func (db *DB) HasLiked(ctx context.Context, authorDID, feedURL, articleURL string) (bool, error) { | ||
| 154 | + var exists int | ||
| 155 | + err := db.QueryRowContext(ctx, ` | ||
| 156 | + SELECT 1 FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ? | ||
| 157 | + `, authorDID, feedURL, articleURL).Scan(&exists) | ||
| 158 | + if err == sql.ErrNoRows { | ||
| 159 | + return false, nil | ||
| 160 | + } | ||
| 161 | + if err != nil { | ||
| 162 | + return false, err | ||
| 163 | + } | ||
| 164 | + return true, nil | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +type TrendingItem struct { | ||
| 168 | + ArticleID int64 | ||
| 169 | + Title string | ||
| 170 | + URL string | ||
| 171 | + Author string | ||
| 172 | + Summary string | ||
| 173 | + FeedURL string | ||
| 174 | + FeedTitle string | ||
| 175 | + LikeCount int | ||
| 176 | + AnnotationCount int | ||
| 177 | +} | ||
| 178 | + | ||
| 179 | +func (db *DB) ListTrendingArticles(ctx context.Context, since string, limit, offset int) ([]*TrendingItem, error) { | ||
| 180 | + rows, err := db.QueryContext(ctx, fmt.Sprintf(` | ||
| 181 | + SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), | ||
| 182 | + COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), | ||
| 183 | + COUNT(DISTINCT l.id) AS like_count, | ||
| 184 | + COUNT(DISTINCT a.id) AS annotation_count | ||
| 185 | + FROM likes l | ||
| 186 | + JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url | ||
| 187 | + LEFT JOIN feeds f ON f.feed_url = l.feed_url | ||
| 188 | + LEFT JOIN annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ? | ||
| 189 | + WHERE l.created_at >= ? | ||
| 190 | + GROUP BY ar.id | ||
| 191 | + ORDER BY like_count DESC, annotation_count DESC | ||
| 192 | + LIMIT %d OFFSET %d | ||
| 193 | + `, limit, offset), since, since) | ||
| 194 | + if err != nil { | ||
| 195 | + return nil, err | ||
| 196 | + } | ||
| 197 | + defer rows.Close() | ||
| 198 | + | ||
| 199 | + var results []*TrendingItem | ||
| 200 | + for rows.Next() { | ||
| 201 | + item := &TrendingItem{} | ||
| 202 | + if err := rows.Scan(&item.ArticleID, &item.Title, &item.URL, &item.Author, | ||
| 203 | + &item.Summary, &item.FeedURL, &item.FeedTitle, | ||
| 204 | + &item.LikeCount, &item.AnnotationCount); err != nil { | ||
| 205 | + return nil, err | ||
| 206 | + } | ||
| 207 | + results = append(results, item) | ||
| 208 | + } | ||
| 209 | + return results, rows.Err() | ||
| 210 | +} | ||
added
internal/db/store.go +59 -0 | new file mode 100644 | ||
| @@ -0,0 +1,59 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | + | |
| 7 | + "pkg.rbrt.fr/glean/internal/feed" | |
| 8 | +) | |
| 9 | + | |
| 10 | +type FeedStoreAdapter struct { | |
| 11 | + db *DB | |
| 12 | +} | |
| 13 | + | |
| 14 | +func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter { | |
| 15 | + return &FeedStoreAdapter{db: db} | |
| 16 | +} | |
| 17 | + | |
| 18 | +func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, limit int) ([]*feed.Feed, error) { | |
| 19 | + dbFeeds, err := a.db.GetFeedsToFetch(ctx, limit) | |
| 20 | + if err != nil { | |
| 21 | + return nil, err | |
| 22 | + } | |
| 23 | + var feeds []*feed.Feed | |
| 24 | + for _, df := range dbFeeds { | |
| 25 | + feeds = append(feeds, &feed.Feed{ | |
| 26 | + URL: df.FeedURL, | |
| 27 | + Title: df.Title.String, | |
| 28 | + SiteURL: df.SiteURL.String, | |
| 29 | + Description: df.Description.String, | |
| 30 | + Type: df.FeedType.String, | |
| 31 | + ETag: df.Etag.String, | |
| 32 | + LastModified: df.LastModified.String, | |
| 33 | + }) | |
| 34 | + } | |
| 35 | + return feeds, nil | |
| 36 | +} | |
| 37 | + | |
| 38 | +func (a *FeedStoreAdapter) UpsertArticle(ctx context.Context, article *feed.Article) (int64, error) { | |
| 39 | + dbArticle := &Article{ | |
| 40 | + FeedURL: article.FeedURL, | |
| 41 | + GUID: article.GUID, | |
| 42 | + Title: article.Title, | |
| 43 | + Summary: sql.NullString{String: article.Summary, Valid: article.Summary != ""}, | |
| 44 | + Content: sql.NullString{String: article.Content, Valid: article.Content != ""}, | |
| 45 | + Author: sql.NullString{String: article.Author, Valid: article.Author != ""}, | |
| 46 | + URL: sql.NullString{String: article.URL, Valid: article.URL != ""}, | |
| 47 | + } | |
| 48 | + if !article.Published.IsZero() { | |
| 49 | + dbArticle.Published = sql.NullTime{Time: article.Published, Valid: true} | |
| 50 | + } | |
| 51 | + if !article.Updated.IsZero() { | |
| 52 | + dbArticle.Updated = sql.NullTime{Time: article.Updated, Valid: true} | |
| 53 | + } | |
| 54 | + return a.db.UpsertArticle(ctx, dbArticle) | |
| 55 | +} | |
| 56 | + | |
| 57 | +func (a *FeedStoreAdapter) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error { | |
| 58 | + return a.db.UpdateFeedFetchResult(ctx, feedURL, etag, lastModified, intervalMinutes, consecutiveEmpty, errorCount, lastError) | |
| 59 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,59 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | + | ||
| 7 | + "pkg.rbrt.fr/glean/internal/feed" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +type FeedStoreAdapter struct { | ||
| 11 | + db *DB | ||
| 12 | +} | ||
| 13 | + | ||
| 14 | +func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter { | ||
| 15 | + return &FeedStoreAdapter{db: db} | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, limit int) ([]*feed.Feed, error) { | ||
| 19 | + dbFeeds, err := a.db.GetFeedsToFetch(ctx, limit) | ||
| 20 | + if err != nil { | ||
| 21 | + return nil, err | ||
| 22 | + } | ||
| 23 | + var feeds []*feed.Feed | ||
| 24 | + for _, df := range dbFeeds { | ||
| 25 | + feeds = append(feeds, &feed.Feed{ | ||
| 26 | + URL: df.FeedURL, | ||
| 27 | + Title: df.Title.String, | ||
| 28 | + SiteURL: df.SiteURL.String, | ||
| 29 | + Description: df.Description.String, | ||
| 30 | + Type: df.FeedType.String, | ||
| 31 | + ETag: df.Etag.String, | ||
| 32 | + LastModified: df.LastModified.String, | ||
| 33 | + }) | ||
| 34 | + } | ||
| 35 | + return feeds, nil | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +func (a *FeedStoreAdapter) UpsertArticle(ctx context.Context, article *feed.Article) (int64, error) { | ||
| 39 | + dbArticle := &Article{ | ||
| 40 | + FeedURL: article.FeedURL, | ||
| 41 | + GUID: article.GUID, | ||
| 42 | + Title: article.Title, | ||
| 43 | + Summary: sql.NullString{String: article.Summary, Valid: article.Summary != ""}, | ||
| 44 | + Content: sql.NullString{String: article.Content, Valid: article.Content != ""}, | ||
| 45 | + Author: sql.NullString{String: article.Author, Valid: article.Author != ""}, | ||
| 46 | + URL: sql.NullString{String: article.URL, Valid: article.URL != ""}, | ||
| 47 | + } | ||
| 48 | + if !article.Published.IsZero() { | ||
| 49 | + dbArticle.Published = sql.NullTime{Time: article.Published, Valid: true} | ||
| 50 | + } | ||
| 51 | + if !article.Updated.IsZero() { | ||
| 52 | + dbArticle.Updated = sql.NullTime{Time: article.Updated, Valid: true} | ||
| 53 | + } | ||
| 54 | + return a.db.UpsertArticle(ctx, dbArticle) | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +func (a *FeedStoreAdapter) UpdateFeedFetchResult(ctx context.Context, feedURL, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error { | ||
| 58 | + return a.db.UpdateFeedFetchResult(ctx, feedURL, etag, lastModified, intervalMinutes, consecutiveEmpty, errorCount, lastError) | ||
| 59 | +} | ||
added
internal/db/user.go +55 -0 | new file mode 100644 | ||
| @@ -0,0 +1,55 @@ | ||
| 1 | +package db | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "database/sql" | |
| 6 | +) | |
| 7 | + | |
| 8 | +type User struct { | |
| 9 | + DID string | |
| 10 | + Handle string | |
| 11 | + DisplayName sql.NullString | |
| 12 | + AvatarURL sql.NullString | |
| 13 | + IndexedAt sql.NullTime | |
| 14 | + UpdatedAt sql.NullTime | |
| 15 | +} | |
| 16 | + | |
| 17 | +func (db *DB) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) { | |
| 18 | + _, err := db.ExecContext(ctx, ` | |
| 19 | + INSERT INTO users (did, handle, display_name, avatar_url, updated_at) | |
| 20 | + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | |
| 21 | + ON CONFLICT(did) DO UPDATE SET | |
| 22 | + handle = excluded.handle, | |
| 23 | + display_name = excluded.display_name, | |
| 24 | + avatar_url = excluded.avatar_url, | |
| 25 | + updated_at = CURRENT_TIMESTAMP | |
| 26 | + `, did, handle, displayName, avatarURL) | |
| 27 | + if err != nil { | |
| 28 | + return nil, err | |
| 29 | + } | |
| 30 | + return db.GetUser(ctx, did) | |
| 31 | +} | |
| 32 | + | |
| 33 | +func (db *DB) GetUser(ctx context.Context, did string) (*User, error) { | |
| 34 | + u := &User{} | |
| 35 | + err := db.QueryRowContext(ctx, ` | |
| 36 | + SELECT did, handle, display_name, avatar_url, indexed_at, updated_at | |
| 37 | + FROM users WHERE did = ? | |
| 38 | + `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) | |
| 39 | + if err != nil { | |
| 40 | + return nil, err | |
| 41 | + } | |
| 42 | + return u, nil | |
| 43 | +} | |
| 44 | + | |
| 45 | +func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) { | |
| 46 | + u := &User{} | |
| 47 | + err := db.QueryRowContext(ctx, ` | |
| 48 | + SELECT did, handle, display_name, avatar_url, indexed_at, updated_at | |
| 49 | + FROM users WHERE handle = ? | |
| 50 | + `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) | |
| 51 | + if err != nil { | |
| 52 | + return nil, err | |
| 53 | + } | |
| 54 | + return u, nil | |
| 55 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,55 @@ | |||
| 1 | +package db | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "database/sql" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +type User struct { | ||
| 9 | + DID string | ||
| 10 | + Handle string | ||
| 11 | + DisplayName sql.NullString | ||
| 12 | + AvatarURL sql.NullString | ||
| 13 | + IndexedAt sql.NullTime | ||
| 14 | + UpdatedAt sql.NullTime | ||
| 15 | +} | ||
| 16 | + | ||
| 17 | +func (db *DB) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) { | ||
| 18 | + _, err := db.ExecContext(ctx, ` | ||
| 19 | + INSERT INTO users (did, handle, display_name, avatar_url, updated_at) | ||
| 20 | + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) | ||
| 21 | + ON CONFLICT(did) DO UPDATE SET | ||
| 22 | + handle = excluded.handle, | ||
| 23 | + display_name = excluded.display_name, | ||
| 24 | + avatar_url = excluded.avatar_url, | ||
| 25 | + updated_at = CURRENT_TIMESTAMP | ||
| 26 | + `, did, handle, displayName, avatarURL) | ||
| 27 | + if err != nil { | ||
| 28 | + return nil, err | ||
| 29 | + } | ||
| 30 | + return db.GetUser(ctx, did) | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +func (db *DB) GetUser(ctx context.Context, did string) (*User, error) { | ||
| 34 | + u := &User{} | ||
| 35 | + err := db.QueryRowContext(ctx, ` | ||
| 36 | + SELECT did, handle, display_name, avatar_url, indexed_at, updated_at | ||
| 37 | + FROM users WHERE did = ? | ||
| 38 | + `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) | ||
| 39 | + if err != nil { | ||
| 40 | + return nil, err | ||
| 41 | + } | ||
| 42 | + return u, nil | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) { | ||
| 46 | + u := &User{} | ||
| 47 | + err := db.QueryRowContext(ctx, ` | ||
| 48 | + SELECT did, handle, display_name, avatar_url, indexed_at, updated_at | ||
| 49 | + FROM users WHERE handle = ? | ||
| 50 | + `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) | ||
| 51 | + if err != nil { | ||
| 52 | + return nil, err | ||
| 53 | + } | ||
| 54 | + return u, nil | ||
| 55 | +} | ||
added
internal/feed/fetcher.go +187 -0 | new file mode 100644 | ||
| @@ -0,0 +1,187 @@ | ||
| 1 | +package feed | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "fmt" | |
| 6 | + "log/slog" | |
| 7 | + "net/http" | |
| 8 | + "time" | |
| 9 | +) | |
| 10 | + | |
| 11 | +type Fetcher struct { | |
| 12 | + httpClient *http.Client | |
| 13 | +} | |
| 14 | + | |
| 15 | +func NewFetcher() *Fetcher { | |
| 16 | + return &Fetcher{ | |
| 17 | + httpClient: &http.Client{ | |
| 18 | + Timeout: 30 * time.Second, | |
| 19 | + CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 20 | + if len(via) >= 10 { | |
| 21 | + return fmt.Errorf("too many redirects") | |
| 22 | + } | |
| 23 | + return nil | |
| 24 | + }, | |
| 25 | + }, | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) { | |
| 30 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) | |
| 31 | + if err != nil { | |
| 32 | + return nil, "", "", fmt.Errorf("creating request: %w", err) | |
| 33 | + } | |
| 34 | + | |
| 35 | + if etag != "" { | |
| 36 | + req.Header.Set("If-None-Match", etag) | |
| 37 | + } | |
| 38 | + if lastModified != "" { | |
| 39 | + req.Header.Set("If-Modified-Since", lastModified) | |
| 40 | + } | |
| 41 | + | |
| 42 | + resp, err := f.httpClient.Do(req) | |
| 43 | + if err != nil { | |
| 44 | + return nil, "", "", fmt.Errorf("fetching feed: %w", err) | |
| 45 | + } | |
| 46 | + defer resp.Body.Close() | |
| 47 | + | |
| 48 | + if resp.StatusCode == http.StatusNotModified { | |
| 49 | + return nil, "", "", nil | |
| 50 | + } | |
| 51 | + | |
| 52 | + if resp.StatusCode != http.StatusOK { | |
| 53 | + return nil, "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode) | |
| 54 | + } | |
| 55 | + | |
| 56 | + newEtag := resp.Header.Get("ETag") | |
| 57 | + newLastModified := resp.Header.Get("Last-Modified") | |
| 58 | + | |
| 59 | + result, err := Parse(resp.Body, feedURL) | |
| 60 | + if err != nil { | |
| 61 | + return nil, "", "", fmt.Errorf("parsing feed: %w", err) | |
| 62 | + } | |
| 63 | + | |
| 64 | + return result, newEtag, newLastModified, nil | |
| 65 | +} | |
| 66 | + | |
| 67 | +type FeedStore interface { | |
| 68 | + GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) | |
| 69 | + UpsertArticle(ctx context.Context, article *Article) (int64, error) | |
| 70 | + UpdateFeedFetchResult(ctx context.Context, feedURL string, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error | |
| 71 | +} | |
| 72 | + | |
| 73 | +type feedState struct { | |
| 74 | + interval time.Duration | |
| 75 | + consecutiveEmpty int | |
| 76 | + errorCount int | |
| 77 | +} | |
| 78 | + | |
| 79 | +type Scheduler struct { | |
| 80 | + fetcher *Fetcher | |
| 81 | + store FeedStore | |
| 82 | + logger *slog.Logger | |
| 83 | + interval time.Duration | |
| 84 | + states map[string]*feedState | |
| 85 | +} | |
| 86 | + | |
| 87 | +func NewScheduler(store FeedStore, logger *slog.Logger) *Scheduler { | |
| 88 | + return &Scheduler{ | |
| 89 | + fetcher: NewFetcher(), | |
| 90 | + store: store, | |
| 91 | + logger: logger, | |
| 92 | + interval: 5 * time.Minute, | |
| 93 | + states: make(map[string]*feedState), | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +func (s *Scheduler) Run(ctx context.Context) error { | |
| 98 | + ticker := time.NewTicker(s.interval) | |
| 99 | + defer ticker.Stop() | |
| 100 | + | |
| 101 | + for { | |
| 102 | + select { | |
| 103 | + case <-ctx.Done(): | |
| 104 | + return ctx.Err() | |
| 105 | + case <-ticker.C: | |
| 106 | + feeds, err := s.store.GetFeedsToFetch(ctx, 100) | |
| 107 | + if err != nil { | |
| 108 | + s.logger.Error("failed to get feeds", "error", err) | |
| 109 | + continue | |
| 110 | + } | |
| 111 | + for _, feed := range feeds { | |
| 112 | + s.FetchFeed(ctx, feed) | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | +} | |
| 117 | + | |
| 118 | +func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { | |
| 119 | + state, ok := s.states[feed.URL] | |
| 120 | + if !ok { | |
| 121 | + state = &feedState{ | |
| 122 | + interval: 30 * time.Minute, | |
| 123 | + } | |
| 124 | + s.states[feed.URL] = state | |
| 125 | + } | |
| 126 | + | |
| 127 | + result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified) | |
| 128 | + if err != nil { | |
| 129 | + state.errorCount++ | |
| 130 | + state.interval *= 2 | |
| 131 | + if state.interval > 24*time.Hour { | |
| 132 | + state.interval = 24 * time.Hour | |
| 133 | + } | |
| 134 | + | |
| 135 | + updErr := s.store.UpdateFeedFetchResult( | |
| 136 | + ctx, feed.URL, | |
| 137 | + feed.ETag, feed.LastModified, | |
| 138 | + int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount, | |
| 139 | + err.Error(), | |
| 140 | + ) | |
| 141 | + if updErr != nil { | |
| 142 | + s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL) | |
| 143 | + } | |
| 144 | + return | |
| 145 | + } | |
| 146 | + | |
| 147 | + if result == nil { | |
| 148 | + return | |
| 149 | + } | |
| 150 | + | |
| 151 | + newCount := 0 | |
| 152 | + for i := range result.Articles { | |
| 153 | + result.Articles[i].FeedURL = feed.URL | |
| 154 | + id, upsertErr := s.store.UpsertArticle(ctx, &result.Articles[i]) | |
| 155 | + if upsertErr != nil { | |
| 156 | + s.logger.Error("failed to upsert article", "error", upsertErr, "url", result.Articles[i].URL) | |
| 157 | + continue | |
| 158 | + } | |
| 159 | + if id > 0 { | |
| 160 | + newCount++ | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + if newCount > 0 { | |
| 165 | + state.errorCount = 0 | |
| 166 | + state.consecutiveEmpty = 0 | |
| 167 | + state.interval = 30 * time.Minute | |
| 168 | + } else { | |
| 169 | + state.consecutiveEmpty++ | |
| 170 | + if state.consecutiveEmpty > 3 { | |
| 171 | + state.interval *= 2 | |
| 172 | + if state.interval > 6*time.Hour { | |
| 173 | + state.interval = 6 * time.Hour | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + | |
| 178 | + updErr := s.store.UpdateFeedFetchResult( | |
| 179 | + ctx, feed.URL, | |
| 180 | + newEtag, newLastModified, | |
| 181 | + int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount, | |
| 182 | + "", | |
| 183 | + ) | |
| 184 | + if updErr != nil { | |
| 185 | + s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL) | |
| 186 | + } | |
| 187 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,187 @@ | |||
| 1 | +package feed | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "fmt" | ||
| 6 | + "log/slog" | ||
| 7 | + "net/http" | ||
| 8 | + "time" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +type Fetcher struct { | ||
| 12 | + httpClient *http.Client | ||
| 13 | +} | ||
| 14 | + | ||
| 15 | +func NewFetcher() *Fetcher { | ||
| 16 | + return &Fetcher{ | ||
| 17 | + httpClient: &http.Client{ | ||
| 18 | + Timeout: 30 * time.Second, | ||
| 19 | + CheckRedirect: func(req *http.Request, via []*http.Request) error { | ||
| 20 | + if len(via) >= 10 { | ||
| 21 | + return fmt.Errorf("too many redirects") | ||
| 22 | + } | ||
| 23 | + return nil | ||
| 24 | + }, | ||
| 25 | + }, | ||
| 26 | + } | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +func (f *Fetcher) Fetch(ctx context.Context, feedURL, etag, lastModified string) (*ParseResult, string, string, error) { | ||
| 30 | + req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil) | ||
| 31 | + if err != nil { | ||
| 32 | + return nil, "", "", fmt.Errorf("creating request: %w", err) | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + if etag != "" { | ||
| 36 | + req.Header.Set("If-None-Match", etag) | ||
| 37 | + } | ||
| 38 | + if lastModified != "" { | ||
| 39 | + req.Header.Set("If-Modified-Since", lastModified) | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + resp, err := f.httpClient.Do(req) | ||
| 43 | + if err != nil { | ||
| 44 | + return nil, "", "", fmt.Errorf("fetching feed: %w", err) | ||
| 45 | + } | ||
| 46 | + defer resp.Body.Close() | ||
| 47 | + | ||
| 48 | + if resp.StatusCode == http.StatusNotModified { | ||
| 49 | + return nil, "", "", nil | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + if resp.StatusCode != http.StatusOK { | ||
| 53 | + return nil, "", "", fmt.Errorf("unexpected status: %d", resp.StatusCode) | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + newEtag := resp.Header.Get("ETag") | ||
| 57 | + newLastModified := resp.Header.Get("Last-Modified") | ||
| 58 | + | ||
| 59 | + result, err := Parse(resp.Body, feedURL) | ||
| 60 | + if err != nil { | ||
| 61 | + return nil, "", "", fmt.Errorf("parsing feed: %w", err) | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + return result, newEtag, newLastModified, nil | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +type FeedStore interface { | ||
| 68 | + GetFeedsToFetch(ctx context.Context, limit int) ([]*Feed, error) | ||
| 69 | + UpsertArticle(ctx context.Context, article *Article) (int64, error) | ||
| 70 | + UpdateFeedFetchResult(ctx context.Context, feedURL string, etag, lastModified string, intervalMinutes, consecutiveEmpty, errorCount int, lastError string) error | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +type feedState struct { | ||
| 74 | + interval time.Duration | ||
| 75 | + consecutiveEmpty int | ||
| 76 | + errorCount int | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +type Scheduler struct { | ||
| 80 | + fetcher *Fetcher | ||
| 81 | + store FeedStore | ||
| 82 | + logger *slog.Logger | ||
| 83 | + interval time.Duration | ||
| 84 | + states map[string]*feedState | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +func NewScheduler(store FeedStore, logger *slog.Logger) *Scheduler { | ||
| 88 | + return &Scheduler{ | ||
| 89 | + fetcher: NewFetcher(), | ||
| 90 | + store: store, | ||
| 91 | + logger: logger, | ||
| 92 | + interval: 5 * time.Minute, | ||
| 93 | + states: make(map[string]*feedState), | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +func (s *Scheduler) Run(ctx context.Context) error { | ||
| 98 | + ticker := time.NewTicker(s.interval) | ||
| 99 | + defer ticker.Stop() | ||
| 100 | + | ||
| 101 | + for { | ||
| 102 | + select { | ||
| 103 | + case <-ctx.Done(): | ||
| 104 | + return ctx.Err() | ||
| 105 | + case <-ticker.C: | ||
| 106 | + feeds, err := s.store.GetFeedsToFetch(ctx, 100) | ||
| 107 | + if err != nil { | ||
| 108 | + s.logger.Error("failed to get feeds", "error", err) | ||
| 109 | + continue | ||
| 110 | + } | ||
| 111 | + for _, feed := range feeds { | ||
| 112 | + s.FetchFeed(ctx, feed) | ||
| 113 | + } | ||
| 114 | + } | ||
| 115 | + } | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +func (s *Scheduler) FetchFeed(ctx context.Context, feed *Feed) { | ||
| 119 | + state, ok := s.states[feed.URL] | ||
| 120 | + if !ok { | ||
| 121 | + state = &feedState{ | ||
| 122 | + interval: 30 * time.Minute, | ||
| 123 | + } | ||
| 124 | + s.states[feed.URL] = state | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + result, newEtag, newLastModified, err := s.fetcher.Fetch(ctx, feed.URL, feed.ETag, feed.LastModified) | ||
| 128 | + if err != nil { | ||
| 129 | + state.errorCount++ | ||
| 130 | + state.interval *= 2 | ||
| 131 | + if state.interval > 24*time.Hour { | ||
| 132 | + state.interval = 24 * time.Hour | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + updErr := s.store.UpdateFeedFetchResult( | ||
| 136 | + ctx, feed.URL, | ||
| 137 | + feed.ETag, feed.LastModified, | ||
| 138 | + int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount, | ||
| 139 | + err.Error(), | ||
| 140 | + ) | ||
| 141 | + if updErr != nil { | ||
| 142 | + s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL) | ||
| 143 | + } | ||
| 144 | + return | ||
| 145 | + } | ||
| 146 | + | ||
| 147 | + if result == nil { | ||
| 148 | + return | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + newCount := 0 | ||
| 152 | + for i := range result.Articles { | ||
| 153 | + result.Articles[i].FeedURL = feed.URL | ||
| 154 | + id, upsertErr := s.store.UpsertArticle(ctx, &result.Articles[i]) | ||
| 155 | + if upsertErr != nil { | ||
| 156 | + s.logger.Error("failed to upsert article", "error", upsertErr, "url", result.Articles[i].URL) | ||
| 157 | + continue | ||
| 158 | + } | ||
| 159 | + if id > 0 { | ||
| 160 | + newCount++ | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + if newCount > 0 { | ||
| 165 | + state.errorCount = 0 | ||
| 166 | + state.consecutiveEmpty = 0 | ||
| 167 | + state.interval = 30 * time.Minute | ||
| 168 | + } else { | ||
| 169 | + state.consecutiveEmpty++ | ||
| 170 | + if state.consecutiveEmpty > 3 { | ||
| 171 | + state.interval *= 2 | ||
| 172 | + if state.interval > 6*time.Hour { | ||
| 173 | + state.interval = 6 * time.Hour | ||
| 174 | + } | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + updErr := s.store.UpdateFeedFetchResult( | ||
| 179 | + ctx, feed.URL, | ||
| 180 | + newEtag, newLastModified, | ||
| 181 | + int(state.interval.Minutes()), state.consecutiveEmpty, state.errorCount, | ||
| 182 | + "", | ||
| 183 | + ) | ||
| 184 | + if updErr != nil { | ||
| 185 | + s.logger.Error("failed to update feed fetch result", "error", updErr, "feed", feed.URL) | ||
| 186 | + } | ||
| 187 | +} | ||
added
internal/feed/opml.go +95 -0 | new file mode 100644 | ||
| @@ -0,0 +1,95 @@ | ||
| 1 | +package feed | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "encoding/xml" | |
| 6 | + "io" | |
| 7 | +) | |
| 8 | + | |
| 9 | +type OPML struct { | |
| 10 | + XMLName xml.Name `xml:"opml"` | |
| 11 | + Version string `xml:"version,attr"` | |
| 12 | + Head struct { | |
| 13 | + Title string `xml:"title"` | |
| 14 | + } `xml:"head"` | |
| 15 | + Body struct { | |
| 16 | + Outlines []Outline `xml:"outline"` | |
| 17 | + } `xml:"body"` | |
| 18 | +} | |
| 19 | + | |
| 20 | +type Outline struct { | |
| 21 | + Text string `xml:"text,attr"` | |
| 22 | + Title string `xml:"title,attr"` | |
| 23 | + XMLURL string `xml:"xmlUrl,attr"` | |
| 24 | + HTMLURL string `xml:"htmlUrl,attr"` | |
| 25 | + Outlines []Outline `xml:"outline"` | |
| 26 | +} | |
| 27 | + | |
| 28 | +type FeedURL struct { | |
| 29 | + URL string | |
| 30 | + Title string | |
| 31 | + Category string | |
| 32 | +} | |
| 33 | + | |
| 34 | +func ParseOPML(r io.Reader) (*OPML, error) { | |
| 35 | + data, err := io.ReadAll(r) | |
| 36 | + if err != nil { | |
| 37 | + return nil, err | |
| 38 | + } | |
| 39 | + | |
| 40 | + var opml OPML | |
| 41 | + if err := xml.Unmarshal(data, &opml); err != nil { | |
| 42 | + return nil, err | |
| 43 | + } | |
| 44 | + | |
| 45 | + return &opml, nil | |
| 46 | +} | |
| 47 | + | |
| 48 | +func ExtractFeedURLs(opml *OPML) []FeedURL { | |
| 49 | + var urls []FeedURL | |
| 50 | + extractOutlines(opml.Body.Outlines, "", &urls) | |
| 51 | + return urls | |
| 52 | +} | |
| 53 | + | |
| 54 | +func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) { | |
| 55 | + for _, o := range outlines { | |
| 56 | + if o.XMLURL != "" { | |
| 57 | + *urls = append(*urls, FeedURL{ | |
| 58 | + URL: o.XMLURL, | |
| 59 | + Title: o.Title, | |
| 60 | + Category: category, | |
| 61 | + }) | |
| 62 | + } | |
| 63 | + childCategory := o.Text | |
| 64 | + if childCategory == "" { | |
| 65 | + childCategory = category | |
| 66 | + } | |
| 67 | + extractOutlines(o.Outlines, childCategory, urls) | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) { | |
| 72 | + opml := OPML{ | |
| 73 | + Version: "2.0", | |
| 74 | + } | |
| 75 | + opml.Head.Title = title | |
| 76 | + | |
| 77 | + for _, f := range feeds { | |
| 78 | + opml.Body.Outlines = append(opml.Body.Outlines, Outline{ | |
| 79 | + Text: f.Title, | |
| 80 | + Title: f.Title, | |
| 81 | + XMLURL: f.URL, | |
| 82 | + }) | |
| 83 | + } | |
| 84 | + | |
| 85 | + var buf bytes.Buffer | |
| 86 | + buf.WriteString(xml.Header) | |
| 87 | + | |
| 88 | + enc := xml.NewEncoder(&buf) | |
| 89 | + enc.Indent("", " ") | |
| 90 | + if err := enc.Encode(opml); err != nil { | |
| 91 | + return nil, err | |
| 92 | + } | |
| 93 | + | |
| 94 | + return buf.Bytes(), nil | |
| 95 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,95 @@ | |||
| 1 | +package feed | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "encoding/xml" | ||
| 6 | + "io" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +type OPML struct { | ||
| 10 | + XMLName xml.Name `xml:"opml"` | ||
| 11 | + Version string `xml:"version,attr"` | ||
| 12 | + Head struct { | ||
| 13 | + Title string `xml:"title"` | ||
| 14 | + } `xml:"head"` | ||
| 15 | + Body struct { | ||
| 16 | + Outlines []Outline `xml:"outline"` | ||
| 17 | + } `xml:"body"` | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +type Outline struct { | ||
| 21 | + Text string `xml:"text,attr"` | ||
| 22 | + Title string `xml:"title,attr"` | ||
| 23 | + XMLURL string `xml:"xmlUrl,attr"` | ||
| 24 | + HTMLURL string `xml:"htmlUrl,attr"` | ||
| 25 | + Outlines []Outline `xml:"outline"` | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +type FeedURL struct { | ||
| 29 | + URL string | ||
| 30 | + Title string | ||
| 31 | + Category string | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +func ParseOPML(r io.Reader) (*OPML, error) { | ||
| 35 | + data, err := io.ReadAll(r) | ||
| 36 | + if err != nil { | ||
| 37 | + return nil, err | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + var opml OPML | ||
| 41 | + if err := xml.Unmarshal(data, &opml); err != nil { | ||
| 42 | + return nil, err | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + return &opml, nil | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +func ExtractFeedURLs(opml *OPML) []FeedURL { | ||
| 49 | + var urls []FeedURL | ||
| 50 | + extractOutlines(opml.Body.Outlines, "", &urls) | ||
| 51 | + return urls | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +func extractOutlines(outlines []Outline, category string, urls *[]FeedURL) { | ||
| 55 | + for _, o := range outlines { | ||
| 56 | + if o.XMLURL != "" { | ||
| 57 | + *urls = append(*urls, FeedURL{ | ||
| 58 | + URL: o.XMLURL, | ||
| 59 | + Title: o.Title, | ||
| 60 | + Category: category, | ||
| 61 | + }) | ||
| 62 | + } | ||
| 63 | + childCategory := o.Text | ||
| 64 | + if childCategory == "" { | ||
| 65 | + childCategory = category | ||
| 66 | + } | ||
| 67 | + extractOutlines(o.Outlines, childCategory, urls) | ||
| 68 | + } | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +func GenerateOPML(feeds []FeedURL, title string) ([]byte, error) { | ||
| 72 | + opml := OPML{ | ||
| 73 | + Version: "2.0", | ||
| 74 | + } | ||
| 75 | + opml.Head.Title = title | ||
| 76 | + | ||
| 77 | + for _, f := range feeds { | ||
| 78 | + opml.Body.Outlines = append(opml.Body.Outlines, Outline{ | ||
| 79 | + Text: f.Title, | ||
| 80 | + Title: f.Title, | ||
| 81 | + XMLURL: f.URL, | ||
| 82 | + }) | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + var buf bytes.Buffer | ||
| 86 | + buf.WriteString(xml.Header) | ||
| 87 | + | ||
| 88 | + enc := xml.NewEncoder(&buf) | ||
| 89 | + enc.Indent("", " ") | ||
| 90 | + if err := enc.Encode(opml); err != nil { | ||
| 91 | + return nil, err | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + return buf.Bytes(), nil | ||
| 95 | +} | ||
added
internal/feed/parser.go +270 -0 | new file mode 100644 | ||
| @@ -0,0 +1,270 @@ | ||
| 1 | +package feed | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "encoding/xml" | |
| 6 | + "fmt" | |
| 7 | + "io" | |
| 8 | + "strings" | |
| 9 | + "time" | |
| 10 | +) | |
| 11 | + | |
| 12 | +type Feed struct { | |
| 13 | + URL string | |
| 14 | + Title string | |
| 15 | + SiteURL string | |
| 16 | + Description string | |
| 17 | + Type string | |
| 18 | + ETag string | |
| 19 | + LastModified string | |
| 20 | +} | |
| 21 | + | |
| 22 | +type Article struct { | |
| 23 | + FeedURL string | |
| 24 | + GUID string | |
| 25 | + Title string | |
| 26 | + URL string | |
| 27 | + Author string | |
| 28 | + Content string | |
| 29 | + Summary string | |
| 30 | + Published time.Time | |
| 31 | + Updated time.Time | |
| 32 | +} | |
| 33 | + | |
| 34 | +type ParseResult struct { | |
| 35 | + Feed Feed | |
| 36 | + Articles []Article | |
| 37 | +} | |
| 38 | + | |
| 39 | +type rssFeed struct { | |
| 40 | + XMLName xml.Name `xml:"rss"` | |
| 41 | + Channel struct { | |
| 42 | + Title string `xml:"title"` | |
| 43 | + Link string `xml:"link"` | |
| 44 | + Description string `xml:"description"` | |
| 45 | + Items []struct { | |
| 46 | + Title string `xml:"title"` | |
| 47 | + Link string `xml:"link"` | |
| 48 | + GUID string `xml:"guid"` | |
| 49 | + Description string `xml:"description"` | |
| 50 | + Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"` | |
| 51 | + Author string `xml:"author"` | |
| 52 | + PubDate string `xml:"pubDate"` | |
| 53 | + } `xml:"item"` | |
| 54 | + } `xml:"channel"` | |
| 55 | +} | |
| 56 | + | |
| 57 | +type atomLink struct { | |
| 58 | + Href string `xml:"href,attr"` | |
| 59 | + Rel string `xml:"rel,attr"` | |
| 60 | +} | |
| 61 | + | |
| 62 | +type atomFeed struct { | |
| 63 | + XMLName xml.Name `xml:"feed"` | |
| 64 | + Title string `xml:"title"` | |
| 65 | + Link []atomLink `xml:"link"` | |
| 66 | + Subtitle string `xml:"subtitle"` | |
| 67 | + Entry []struct { | |
| 68 | + Title string `xml:"title"` | |
| 69 | + Link []atomLink `xml:"link"` | |
| 70 | + ID string `xml:"id"` | |
| 71 | + Summary string `xml:"summary"` | |
| 72 | + Content string `xml:"content"` | |
| 73 | + Author struct { | |
| 74 | + Name string `xml:"name"` | |
| 75 | + } `xml:"author"` | |
| 76 | + Published string `xml:"published"` | |
| 77 | + Updated string `xml:"updated"` | |
| 78 | + } `xml:"entry"` | |
| 79 | +} | |
| 80 | + | |
| 81 | +type jsonFeed struct { | |
| 82 | + Version string `json:"version"` | |
| 83 | + Title string `json:"title"` | |
| 84 | + HomePageURL string `json:"home_page_url"` | |
| 85 | + Description string `json:"description"` | |
| 86 | + Items []struct { | |
| 87 | + ID string `json:"id"` | |
| 88 | + URL string `json:"url"` | |
| 89 | + Title string `json:"title"` | |
| 90 | + ContentHTML string `json:"content_html"` | |
| 91 | + ContentText string `json:"content_text"` | |
| 92 | + Summary string `json:"summary"` | |
| 93 | + Author struct { | |
| 94 | + Name string `json:"name"` | |
| 95 | + } `json:"author"` | |
| 96 | + DatePublished string `json:"date_published"` | |
| 97 | + DateModified string `json:"date_modified"` | |
| 98 | + } `json:"items"` | |
| 99 | +} | |
| 100 | + | |
| 101 | +func Parse(r io.Reader, feedURL string) (*ParseResult, error) { | |
| 102 | + data, err := io.ReadAll(r) | |
| 103 | + if err != nil { | |
| 104 | + return nil, fmt.Errorf("reading feed data: %w", err) | |
| 105 | + } | |
| 106 | + | |
| 107 | + trimmed := strings.TrimLeft(string(data), " \t\r\n") | |
| 108 | + if len(trimmed) > 0 && trimmed[0] == '{' { | |
| 109 | + return parseJSONFeed(data, feedURL) | |
| 110 | + } | |
| 111 | + | |
| 112 | + return parseXMLFeed(data, feedURL) | |
| 113 | +} | |
| 114 | + | |
| 115 | +func parseJSONFeed(data []byte, feedURL string) (*ParseResult, error) { | |
| 116 | + var jf jsonFeed | |
| 117 | + if err := json.Unmarshal(data, &jf); err != nil { | |
| 118 | + return nil, fmt.Errorf("parsing JSON feed: %w", err) | |
| 119 | + } | |
| 120 | + | |
| 121 | + result := &ParseResult{ | |
| 122 | + Feed: Feed{ | |
| 123 | + URL: feedURL, | |
| 124 | + Title: jf.Title, | |
| 125 | + SiteURL: jf.HomePageURL, | |
| 126 | + Description: jf.Description, | |
| 127 | + Type: "json", | |
| 128 | + }, | |
| 129 | + } | |
| 130 | + | |
| 131 | + for _, item := range jf.Items { | |
| 132 | + content := item.ContentHTML | |
| 133 | + if content == "" { | |
| 134 | + content = item.ContentText | |
| 135 | + } | |
| 136 | + | |
| 137 | + article := Article{ | |
| 138 | + GUID: item.ID, | |
| 139 | + Title: item.Title, | |
| 140 | + URL: item.URL, | |
| 141 | + Content: content, | |
| 142 | + Summary: item.Summary, | |
| 143 | + Author: item.Author.Name, | |
| 144 | + Published: parseTime(item.DatePublished), | |
| 145 | + Updated: parseTime(item.DateModified), | |
| 146 | + } | |
| 147 | + if article.GUID == "" { | |
| 148 | + article.GUID = article.URL | |
| 149 | + } | |
| 150 | + result.Articles = append(result.Articles, article) | |
| 151 | + } | |
| 152 | + | |
| 153 | + return result, nil | |
| 154 | +} | |
| 155 | + | |
| 156 | +func parseXMLFeed(data []byte, feedURL string) (*ParseResult, error) { | |
| 157 | + var rss rssFeed | |
| 158 | + if err := xml.Unmarshal(data, &rss); err == nil { | |
| 159 | + if rss.XMLName.Local == "rss" { | |
| 160 | + return convertRSS(&rss, feedURL), nil | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + var atom atomFeed | |
| 165 | + if err := xml.Unmarshal(data, &atom); err == nil { | |
| 166 | + if atom.XMLName.Local == "feed" { | |
| 167 | + return convertAtom(&atom, feedURL), nil | |
| 168 | + } | |
| 169 | + } | |
| 170 | + | |
| 171 | + return nil, fmt.Errorf("unable to detect feed format") | |
| 172 | +} | |
| 173 | + | |
| 174 | +func convertRSS(rss *rssFeed, feedURL string) *ParseResult { | |
| 175 | + result := &ParseResult{ | |
| 176 | + Feed: Feed{ | |
| 177 | + URL: feedURL, | |
| 178 | + Title: rss.Channel.Title, | |
| 179 | + SiteURL: rss.Channel.Link, | |
| 180 | + Description: rss.Channel.Description, | |
| 181 | + Type: "rss", | |
| 182 | + }, | |
| 183 | + } | |
| 184 | + | |
| 185 | + for _, item := range rss.Channel.Items { | |
| 186 | + article := Article{ | |
| 187 | + GUID: item.GUID, | |
| 188 | + Title: item.Title, | |
| 189 | + URL: item.Link, | |
| 190 | + Content: item.Content, | |
| 191 | + Summary: item.Description, | |
| 192 | + Author: item.Author, | |
| 193 | + Published: parseTime(item.PubDate), | |
| 194 | + } | |
| 195 | + if article.GUID == "" { | |
| 196 | + article.GUID = article.URL | |
| 197 | + } | |
| 198 | + result.Articles = append(result.Articles, article) | |
| 199 | + } | |
| 200 | + | |
| 201 | + return result | |
| 202 | +} | |
| 203 | + | |
| 204 | +func convertAtom(atom *atomFeed, feedURL string) *ParseResult { | |
| 205 | + result := &ParseResult{ | |
| 206 | + Feed: Feed{ | |
| 207 | + URL: feedURL, | |
| 208 | + Title: atom.Title, | |
| 209 | + SiteURL: pickAtomLink(atom.Link), | |
| 210 | + Description: atom.Subtitle, | |
| 211 | + Type: "atom", | |
| 212 | + }, | |
| 213 | + } | |
| 214 | + | |
| 215 | + for _, entry := range atom.Entry { | |
| 216 | + article := Article{ | |
| 217 | + GUID: entry.ID, | |
| 218 | + Title: entry.Title, | |
| 219 | + URL: pickAtomLink(entry.Link), | |
| 220 | + Content: entry.Content, | |
| 221 | + Summary: entry.Summary, | |
| 222 | + Author: entry.Author.Name, | |
| 223 | + Published: parseTime(entry.Published), | |
| 224 | + Updated: parseTime(entry.Updated), | |
| 225 | + } | |
| 226 | + if article.GUID == "" { | |
| 227 | + article.GUID = article.URL | |
| 228 | + } | |
| 229 | + result.Articles = append(result.Articles, article) | |
| 230 | + } | |
| 231 | + | |
| 232 | + return result | |
| 233 | +} | |
| 234 | + | |
| 235 | +func pickAtomLink(links []atomLink) string { | |
| 236 | + for _, l := range links { | |
| 237 | + if l.Rel == "alternate" || l.Rel == "" { | |
| 238 | + return l.Href | |
| 239 | + } | |
| 240 | + } | |
| 241 | + if len(links) > 0 { | |
| 242 | + return links[0].Href | |
| 243 | + } | |
| 244 | + return "" | |
| 245 | +} | |
| 246 | + | |
| 247 | +func parseTime(s string) time.Time { | |
| 248 | + if s == "" { | |
| 249 | + return time.Time{} | |
| 250 | + } | |
| 251 | + | |
| 252 | + formats := []string{ | |
| 253 | + time.RFC3339, | |
| 254 | + "Mon, 02 Jan 2006 15:04:05 -0700", | |
| 255 | + "Mon, 02 Jan 2006 15:04:05 MST", | |
| 256 | + "2006-01-02T15:04:05Z", | |
| 257 | + "2006-01-02T15:04:05-07:00", | |
| 258 | + "2006-01-02 15:04:05", | |
| 259 | + time.RFC1123, | |
| 260 | + time.RFC1123Z, | |
| 261 | + } | |
| 262 | + | |
| 263 | + for _, format := range formats { | |
| 264 | + if t, err := time.Parse(format, s); err == nil { | |
| 265 | + return t | |
| 266 | + } | |
| 267 | + } | |
| 268 | + | |
| 269 | + return time.Time{} | |
| 270 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,270 @@ | |||
| 1 | +package feed | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "encoding/xml" | ||
| 6 | + "fmt" | ||
| 7 | + "io" | ||
| 8 | + "strings" | ||
| 9 | + "time" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +type Feed struct { | ||
| 13 | + URL string | ||
| 14 | + Title string | ||
| 15 | + SiteURL string | ||
| 16 | + Description string | ||
| 17 | + Type string | ||
| 18 | + ETag string | ||
| 19 | + LastModified string | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +type Article struct { | ||
| 23 | + FeedURL string | ||
| 24 | + GUID string | ||
| 25 | + Title string | ||
| 26 | + URL string | ||
| 27 | + Author string | ||
| 28 | + Content string | ||
| 29 | + Summary string | ||
| 30 | + Published time.Time | ||
| 31 | + Updated time.Time | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | +type ParseResult struct { | ||
| 35 | + Feed Feed | ||
| 36 | + Articles []Article | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +type rssFeed struct { | ||
| 40 | + XMLName xml.Name `xml:"rss"` | ||
| 41 | + Channel struct { | ||
| 42 | + Title string `xml:"title"` | ||
| 43 | + Link string `xml:"link"` | ||
| 44 | + Description string `xml:"description"` | ||
| 45 | + Items []struct { | ||
| 46 | + Title string `xml:"title"` | ||
| 47 | + Link string `xml:"link"` | ||
| 48 | + GUID string `xml:"guid"` | ||
| 49 | + Description string `xml:"description"` | ||
| 50 | + Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"` | ||
| 51 | + Author string `xml:"author"` | ||
| 52 | + PubDate string `xml:"pubDate"` | ||
| 53 | + } `xml:"item"` | ||
| 54 | + } `xml:"channel"` | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +type atomLink struct { | ||
| 58 | + Href string `xml:"href,attr"` | ||
| 59 | + Rel string `xml:"rel,attr"` | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +type atomFeed struct { | ||
| 63 | + XMLName xml.Name `xml:"feed"` | ||
| 64 | + Title string `xml:"title"` | ||
| 65 | + Link []atomLink `xml:"link"` | ||
| 66 | + Subtitle string `xml:"subtitle"` | ||
| 67 | + Entry []struct { | ||
| 68 | + Title string `xml:"title"` | ||
| 69 | + Link []atomLink `xml:"link"` | ||
| 70 | + ID string `xml:"id"` | ||
| 71 | + Summary string `xml:"summary"` | ||
| 72 | + Content string `xml:"content"` | ||
| 73 | + Author struct { | ||
| 74 | + Name string `xml:"name"` | ||
| 75 | + } `xml:"author"` | ||
| 76 | + Published string `xml:"published"` | ||
| 77 | + Updated string `xml:"updated"` | ||
| 78 | + } `xml:"entry"` | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +type jsonFeed struct { | ||
| 82 | + Version string `json:"version"` | ||
| 83 | + Title string `json:"title"` | ||
| 84 | + HomePageURL string `json:"home_page_url"` | ||
| 85 | + Description string `json:"description"` | ||
| 86 | + Items []struct { | ||
| 87 | + ID string `json:"id"` | ||
| 88 | + URL string `json:"url"` | ||
| 89 | + Title string `json:"title"` | ||
| 90 | + ContentHTML string `json:"content_html"` | ||
| 91 | + ContentText string `json:"content_text"` | ||
| 92 | + Summary string `json:"summary"` | ||
| 93 | + Author struct { | ||
| 94 | + Name string `json:"name"` | ||
| 95 | + } `json:"author"` | ||
| 96 | + DatePublished string `json:"date_published"` | ||
| 97 | + DateModified string `json:"date_modified"` | ||
| 98 | + } `json:"items"` | ||
| 99 | +} | ||
| 100 | + | ||
| 101 | +func Parse(r io.Reader, feedURL string) (*ParseResult, error) { | ||
| 102 | + data, err := io.ReadAll(r) | ||
| 103 | + if err != nil { | ||
| 104 | + return nil, fmt.Errorf("reading feed data: %w", err) | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + trimmed := strings.TrimLeft(string(data), " \t\r\n") | ||
| 108 | + if len(trimmed) > 0 && trimmed[0] == '{' { | ||
| 109 | + return parseJSONFeed(data, feedURL) | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + return parseXMLFeed(data, feedURL) | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +func parseJSONFeed(data []byte, feedURL string) (*ParseResult, error) { | ||
| 116 | + var jf jsonFeed | ||
| 117 | + if err := json.Unmarshal(data, &jf); err != nil { | ||
| 118 | + return nil, fmt.Errorf("parsing JSON feed: %w", err) | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + result := &ParseResult{ | ||
| 122 | + Feed: Feed{ | ||
| 123 | + URL: feedURL, | ||
| 124 | + Title: jf.Title, | ||
| 125 | + SiteURL: jf.HomePageURL, | ||
| 126 | + Description: jf.Description, | ||
| 127 | + Type: "json", | ||
| 128 | + }, | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + for _, item := range jf.Items { | ||
| 132 | + content := item.ContentHTML | ||
| 133 | + if content == "" { | ||
| 134 | + content = item.ContentText | ||
| 135 | + } | ||
| 136 | + | ||
| 137 | + article := Article{ | ||
| 138 | + GUID: item.ID, | ||
| 139 | + Title: item.Title, | ||
| 140 | + URL: item.URL, | ||
| 141 | + Content: content, | ||
| 142 | + Summary: item.Summary, | ||
| 143 | + Author: item.Author.Name, | ||
| 144 | + Published: parseTime(item.DatePublished), | ||
| 145 | + Updated: parseTime(item.DateModified), | ||
| 146 | + } | ||
| 147 | + if article.GUID == "" { | ||
| 148 | + article.GUID = article.URL | ||
| 149 | + } | ||
| 150 | + result.Articles = append(result.Articles, article) | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + return result, nil | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +func parseXMLFeed(data []byte, feedURL string) (*ParseResult, error) { | ||
| 157 | + var rss rssFeed | ||
| 158 | + if err := xml.Unmarshal(data, &rss); err == nil { | ||
| 159 | + if rss.XMLName.Local == "rss" { | ||
| 160 | + return convertRSS(&rss, feedURL), nil | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + var atom atomFeed | ||
| 165 | + if err := xml.Unmarshal(data, &atom); err == nil { | ||
| 166 | + if atom.XMLName.Local == "feed" { | ||
| 167 | + return convertAtom(&atom, feedURL), nil | ||
| 168 | + } | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + return nil, fmt.Errorf("unable to detect feed format") | ||
| 172 | +} | ||
| 173 | + | ||
| 174 | +func convertRSS(rss *rssFeed, feedURL string) *ParseResult { | ||
| 175 | + result := &ParseResult{ | ||
| 176 | + Feed: Feed{ | ||
| 177 | + URL: feedURL, | ||
| 178 | + Title: rss.Channel.Title, | ||
| 179 | + SiteURL: rss.Channel.Link, | ||
| 180 | + Description: rss.Channel.Description, | ||
| 181 | + Type: "rss", | ||
| 182 | + }, | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + for _, item := range rss.Channel.Items { | ||
| 186 | + article := Article{ | ||
| 187 | + GUID: item.GUID, | ||
| 188 | + Title: item.Title, | ||
| 189 | + URL: item.Link, | ||
| 190 | + Content: item.Content, | ||
| 191 | + Summary: item.Description, | ||
| 192 | + Author: item.Author, | ||
| 193 | + Published: parseTime(item.PubDate), | ||
| 194 | + } | ||
| 195 | + if article.GUID == "" { | ||
| 196 | + article.GUID = article.URL | ||
| 197 | + } | ||
| 198 | + result.Articles = append(result.Articles, article) | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + return result | ||
| 202 | +} | ||
| 203 | + | ||
| 204 | +func convertAtom(atom *atomFeed, feedURL string) *ParseResult { | ||
| 205 | + result := &ParseResult{ | ||
| 206 | + Feed: Feed{ | ||
| 207 | + URL: feedURL, | ||
| 208 | + Title: atom.Title, | ||
| 209 | + SiteURL: pickAtomLink(atom.Link), | ||
| 210 | + Description: atom.Subtitle, | ||
| 211 | + Type: "atom", | ||
| 212 | + }, | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + for _, entry := range atom.Entry { | ||
| 216 | + article := Article{ | ||
| 217 | + GUID: entry.ID, | ||
| 218 | + Title: entry.Title, | ||
| 219 | + URL: pickAtomLink(entry.Link), | ||
| 220 | + Content: entry.Content, | ||
| 221 | + Summary: entry.Summary, | ||
| 222 | + Author: entry.Author.Name, | ||
| 223 | + Published: parseTime(entry.Published), | ||
| 224 | + Updated: parseTime(entry.Updated), | ||
| 225 | + } | ||
| 226 | + if article.GUID == "" { | ||
| 227 | + article.GUID = article.URL | ||
| 228 | + } | ||
| 229 | + result.Articles = append(result.Articles, article) | ||
| 230 | + } | ||
| 231 | + | ||
| 232 | + return result | ||
| 233 | +} | ||
| 234 | + | ||
| 235 | +func pickAtomLink(links []atomLink) string { | ||
| 236 | + for _, l := range links { | ||
| 237 | + if l.Rel == "alternate" || l.Rel == "" { | ||
| 238 | + return l.Href | ||
| 239 | + } | ||
| 240 | + } | ||
| 241 | + if len(links) > 0 { | ||
| 242 | + return links[0].Href | ||
| 243 | + } | ||
| 244 | + return "" | ||
| 245 | +} | ||
| 246 | + | ||
| 247 | +func parseTime(s string) time.Time { | ||
| 248 | + if s == "" { | ||
| 249 | + return time.Time{} | ||
| 250 | + } | ||
| 251 | + | ||
| 252 | + formats := []string{ | ||
| 253 | + time.RFC3339, | ||
| 254 | + "Mon, 02 Jan 2006 15:04:05 -0700", | ||
| 255 | + "Mon, 02 Jan 2006 15:04:05 MST", | ||
| 256 | + "2006-01-02T15:04:05Z", | ||
| 257 | + "2006-01-02T15:04:05-07:00", | ||
| 258 | + "2006-01-02 15:04:05", | ||
| 259 | + time.RFC1123, | ||
| 260 | + time.RFC1123Z, | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + for _, format := range formats { | ||
| 264 | + if t, err := time.Parse(format, s); err == nil { | ||
| 265 | + return t | ||
| 266 | + } | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + return time.Time{} | ||
| 270 | +} | ||
added
internal/sanitize/sanitize.go +113 -0 | new file mode 100644 | ||
| @@ -0,0 +1,113 @@ | ||
| 1 | +package sanitize | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "regexp" | |
| 5 | + "strings" | |
| 6 | +) | |
| 7 | + | |
| 8 | +var ( | |
| 9 | + tagRe = regexp.MustCompile(`<[^>]+>`) | |
| 10 | + entityRe = regexp.MustCompile(`&[^;]+;`) | |
| 11 | + scriptRe = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`) | |
| 12 | + objectRe = regexp.MustCompile(`(?is)<object[^>]*>.*?</object>`) | |
| 13 | + appletRe = regexp.MustCompile(`(?is)<applet[^>]*>.*?</applet>`) | |
| 14 | + formRe = regexp.MustCompile(`(?is)<form[^>]*>.*?</form>`) | |
| 15 | + metaRe = regexp.MustCompile(`(?is)<meta[^>]*/?>`) | |
| 16 | + baseRe = regexp.MustCompile(`(?is)<base[^>]*/?>`) | |
| 17 | + linkRe = regexp.MustCompile(`(?is)<link[^>]*/?>`) | |
| 18 | + onAttrRe = regexp.MustCompile(`(?i)\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)`) | |
| 19 | + jsHrefRe = regexp.MustCompile(`(?i)href\s*=\s*"\s*javascript:[^"]*"`) | |
| 20 | + jsHrefSRe = regexp.MustCompile(`(?i)href\s*=\s*'\s*javascript:[^']*'`) | |
| 21 | + styleExprRe = regexp.MustCompile(`(?i)expression\s*\(`) | |
| 22 | + styleUrlRe = regexp.MustCompile(`(?i)url\s*\(\s*javascript:`) | |
| 23 | + iframeSrcRe = regexp.MustCompile(`(?is)<iframe[^>]*>.*?</iframe>`) | |
| 24 | +) | |
| 25 | + | |
| 26 | +var ( | |
| 27 | + youtubeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/|m\.youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`) | |
| 28 | + youtuBeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://youtu\.be/([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`) | |
| 29 | + vimeoLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?vimeo\.com/(\d+)"[^>]*>.*?</a>`) | |
| 30 | +) | |
| 31 | + | |
| 32 | +var allowedIframeHosts = []string{ | |
| 33 | + "www.youtube.com", | |
| 34 | + "youtube.com", | |
| 35 | + "youtu.be", | |
| 36 | + "www.youtube-nocookie.com", | |
| 37 | + "player.vimeo.com", | |
| 38 | + "vimeo.com", | |
| 39 | + "open.spotify.com", | |
| 40 | + "embed.spotify.com", | |
| 41 | + "w.soundcloud.com", | |
| 42 | + "bandcamp.com", | |
| 43 | +} | |
| 44 | + | |
| 45 | +func isAllowedIframe(tag string) bool { | |
| 46 | + lower := strings.ToLower(tag) | |
| 47 | + for _, host := range allowedIframeHosts { | |
| 48 | + if strings.Contains(lower, host) { | |
| 49 | + return true | |
| 50 | + } | |
| 51 | + } | |
| 52 | + return false | |
| 53 | +} | |
| 54 | + | |
| 55 | +func HTML(input string) string { | |
| 56 | + s := input | |
| 57 | + s = scriptRe.ReplaceAllString(s, "") | |
| 58 | + | |
| 59 | + s = convertMediaLinks(s) | |
| 60 | + | |
| 61 | + s = filterIframes(s) | |
| 62 | + | |
| 63 | + s = objectRe.ReplaceAllString(s, "") | |
| 64 | + s = appletRe.ReplaceAllString(s, "") | |
| 65 | + s = formRe.ReplaceAllString(s, "") | |
| 66 | + s = metaRe.ReplaceAllString(s, "") | |
| 67 | + s = baseRe.ReplaceAllString(s, "") | |
| 68 | + s = linkRe.ReplaceAllString(s, "") | |
| 69 | + s = onAttrRe.ReplaceAllString(s, "") | |
| 70 | + s = jsHrefRe.ReplaceAllString(s, `href="#"`) | |
| 71 | + s = jsHrefSRe.ReplaceAllString(s, `href="#"`) | |
| 72 | + s = styleExprRe.ReplaceAllString(s, "") | |
| 73 | + s = styleUrlRe.ReplaceAllString(s, "") | |
| 74 | + return strings.TrimSpace(s) | |
| 75 | +} | |
| 76 | + | |
| 77 | +func convertMediaLinks(s string) string { | |
| 78 | + s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`) | |
| 79 | + s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`) | |
| 80 | + s = vimeoLinkRe.ReplaceAllString(s, `<iframe src="https://player.vimeo.com/video/$1" allowfullscreen loading="lazy"></iframe>`) | |
| 81 | + return s | |
| 82 | +} | |
| 83 | + | |
| 84 | +func filterIframes(s string) string { | |
| 85 | + return iframeSrcRe.ReplaceAllStringFunc(s, func(match string) string { | |
| 86 | + if isAllowedIframe(match) { | |
| 87 | + return match | |
| 88 | + } | |
| 89 | + return "" | |
| 90 | + }) | |
| 91 | +} | |
| 92 | + | |
| 93 | +var htmlEntities = map[string]string{ | |
| 94 | + "&": "&", "<": "<", ">": ">", """: `"`, "'": "'", | |
| 95 | + "'": "'", " ": " ", "…": "...", "—": "—", | |
| 96 | + "–": "–", "«": "«", "»": "»", "’": "'", | |
| 97 | + "‘": "'", "”": "\"", "“": "\"", | |
| 98 | +} | |
| 99 | + | |
| 100 | +func PlainText(input string) string { | |
| 101 | + s := tagRe.ReplaceAllString(input, " ") | |
| 102 | + for entity, replacement := range htmlEntities { | |
| 103 | + s = strings.ReplaceAll(s, entity, replacement) | |
| 104 | + } | |
| 105 | + s = entityRe.ReplaceAllStringFunc(s, func(e string) string { | |
| 106 | + if strings.HasPrefix(e, "&#") { | |
| 107 | + return "" | |
| 108 | + } | |
| 109 | + return e | |
| 110 | + }) | |
| 111 | + s = strings.Join(strings.Fields(s), " ") | |
| 112 | + return strings.TrimSpace(s) | |
| 113 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,113 @@ | |||
| 1 | +package sanitize | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "regexp" | ||
| 5 | + "strings" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +var ( | ||
| 9 | + tagRe = regexp.MustCompile(`<[^>]+>`) | ||
| 10 | + entityRe = regexp.MustCompile(`&[^;]+;`) | ||
| 11 | + scriptRe = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`) | ||
| 12 | + objectRe = regexp.MustCompile(`(?is)<object[^>]*>.*?</object>`) | ||
| 13 | + appletRe = regexp.MustCompile(`(?is)<applet[^>]*>.*?</applet>`) | ||
| 14 | + formRe = regexp.MustCompile(`(?is)<form[^>]*>.*?</form>`) | ||
| 15 | + metaRe = regexp.MustCompile(`(?is)<meta[^>]*/?>`) | ||
| 16 | + baseRe = regexp.MustCompile(`(?is)<base[^>]*/?>`) | ||
| 17 | + linkRe = regexp.MustCompile(`(?is)<link[^>]*/?>`) | ||
| 18 | + onAttrRe = regexp.MustCompile(`(?i)\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)`) | ||
| 19 | + jsHrefRe = regexp.MustCompile(`(?i)href\s*=\s*"\s*javascript:[^"]*"`) | ||
| 20 | + jsHrefSRe = regexp.MustCompile(`(?i)href\s*=\s*'\s*javascript:[^']*'`) | ||
| 21 | + styleExprRe = regexp.MustCompile(`(?i)expression\s*\(`) | ||
| 22 | + styleUrlRe = regexp.MustCompile(`(?i)url\s*\(\s*javascript:`) | ||
| 23 | + iframeSrcRe = regexp.MustCompile(`(?is)<iframe[^>]*>.*?</iframe>`) | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | +var ( | ||
| 27 | + youtubeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/|m\.youtube\.com/watch\?v=)([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`) | ||
| 28 | + youtuBeLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://youtu\.be/([a-zA-Z0-9_-]{11})"[^>]*>.*?</a>`) | ||
| 29 | + vimeoLinkRe = regexp.MustCompile(`(?i)<a[^>]+href="https?://(?:www\.)?vimeo\.com/(\d+)"[^>]*>.*?</a>`) | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | +var allowedIframeHosts = []string{ | ||
| 33 | + "www.youtube.com", | ||
| 34 | + "youtube.com", | ||
| 35 | + "youtu.be", | ||
| 36 | + "www.youtube-nocookie.com", | ||
| 37 | + "player.vimeo.com", | ||
| 38 | + "vimeo.com", | ||
| 39 | + "open.spotify.com", | ||
| 40 | + "embed.spotify.com", | ||
| 41 | + "w.soundcloud.com", | ||
| 42 | + "bandcamp.com", | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func isAllowedIframe(tag string) bool { | ||
| 46 | + lower := strings.ToLower(tag) | ||
| 47 | + for _, host := range allowedIframeHosts { | ||
| 48 | + if strings.Contains(lower, host) { | ||
| 49 | + return true | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + return false | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +func HTML(input string) string { | ||
| 56 | + s := input | ||
| 57 | + s = scriptRe.ReplaceAllString(s, "") | ||
| 58 | + | ||
| 59 | + s = convertMediaLinks(s) | ||
| 60 | + | ||
| 61 | + s = filterIframes(s) | ||
| 62 | + | ||
| 63 | + s = objectRe.ReplaceAllString(s, "") | ||
| 64 | + s = appletRe.ReplaceAllString(s, "") | ||
| 65 | + s = formRe.ReplaceAllString(s, "") | ||
| 66 | + s = metaRe.ReplaceAllString(s, "") | ||
| 67 | + s = baseRe.ReplaceAllString(s, "") | ||
| 68 | + s = linkRe.ReplaceAllString(s, "") | ||
| 69 | + s = onAttrRe.ReplaceAllString(s, "") | ||
| 70 | + s = jsHrefRe.ReplaceAllString(s, `href="#"`) | ||
| 71 | + s = jsHrefSRe.ReplaceAllString(s, `href="#"`) | ||
| 72 | + s = styleExprRe.ReplaceAllString(s, "") | ||
| 73 | + s = styleUrlRe.ReplaceAllString(s, "") | ||
| 74 | + return strings.TrimSpace(s) | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +func convertMediaLinks(s string) string { | ||
| 78 | + s = youtubeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`) | ||
| 79 | + s = youtuBeLinkRe.ReplaceAllString(s, `<iframe src="https://www.youtube-nocookie.com/embed/$1" allowfullscreen loading="lazy"></iframe>`) | ||
| 80 | + s = vimeoLinkRe.ReplaceAllString(s, `<iframe src="https://player.vimeo.com/video/$1" allowfullscreen loading="lazy"></iframe>`) | ||
| 81 | + return s | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +func filterIframes(s string) string { | ||
| 85 | + return iframeSrcRe.ReplaceAllStringFunc(s, func(match string) string { | ||
| 86 | + if isAllowedIframe(match) { | ||
| 87 | + return match | ||
| 88 | + } | ||
| 89 | + return "" | ||
| 90 | + }) | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +var htmlEntities = map[string]string{ | ||
| 94 | + "&": "&", "<": "<", ">": ">", """: `"`, "'": "'", | ||
| 95 | + "'": "'", " ": " ", "…": "...", "—": "—", | ||
| 96 | + "–": "–", "«": "«", "»": "»", "’": "'", | ||
| 97 | + "‘": "'", "”": "\"", "“": "\"", | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +func PlainText(input string) string { | ||
| 101 | + s := tagRe.ReplaceAllString(input, " ") | ||
| 102 | + for entity, replacement := range htmlEntities { | ||
| 103 | + s = strings.ReplaceAll(s, entity, replacement) | ||
| 104 | + } | ||
| 105 | + s = entityRe.ReplaceAllStringFunc(s, func(e string) string { | ||
| 106 | + if strings.HasPrefix(e, "&#") { | ||
| 107 | + return "" | ||
| 108 | + } | ||
| 109 | + return e | ||
| 110 | + }) | ||
| 111 | + s = strings.Join(strings.Fields(s), " ") | ||
| 112 | + return strings.TrimSpace(s) | ||
| 113 | +} | ||
added
internal/sanitize/sanitize_test.go +208 -0 | new file mode 100644 | ||
| @@ -0,0 +1,208 @@ | ||
| 1 | +package sanitize | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func TestHTML_RemovesScriptTags(t *testing.T) { | |
| 9 | + input := `<p>Hello</p><script>alert('xss')</script><p>World</p>` | |
| 10 | + got := HTML(input) | |
| 11 | + if strings.Contains(got, "<script") { | |
| 12 | + t.Fatalf("script tag not removed: %s", got) | |
| 13 | + } | |
| 14 | + if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") { | |
| 15 | + t.Fatalf("content removed: %s", got) | |
| 16 | + } | |
| 17 | +} | |
| 18 | + | |
| 19 | +func TestHTML_RemovesEvilIframeTags(t *testing.T) { | |
| 20 | + input := `<p>Hello</p><iframe src="evil.com"></iframe>` | |
| 21 | + got := HTML(input) | |
| 22 | + if strings.Contains(got, "<iframe") { | |
| 23 | + t.Fatalf("iframe tag not removed: %s", got) | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +func TestHTML_PreservesYouTubeIframe(t *testing.T) { | |
| 28 | + input := `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>` | |
| 29 | + got := HTML(input) | |
| 30 | + if !strings.Contains(got, "<iframe") { | |
| 31 | + t.Fatalf("youtube iframe removed: %s", got) | |
| 32 | + } | |
| 33 | + if !strings.Contains(got, "youtube.com") { | |
| 34 | + t.Fatalf("youtube iframe src lost: %s", got) | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +func TestHTML_PreservesVimeoIframe(t *testing.T) { | |
| 39 | + input := `<iframe src="https://player.vimeo.com/video/12345" width="640" height="360"></iframe>` | |
| 40 | + got := HTML(input) | |
| 41 | + if !strings.Contains(got, "<iframe") { | |
| 42 | + t.Fatalf("vimeo iframe removed: %s", got) | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +func TestHTML_PreservesSpotifyIframe(t *testing.T) { | |
| 47 | + input := `<iframe src="https://open.spotify.com/embed/track/abc123" width="300" height="80"></iframe>` | |
| 48 | + got := HTML(input) | |
| 49 | + if !strings.Contains(got, "<iframe") { | |
| 50 | + t.Fatalf("spotify iframe removed: %s", got) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +func TestHTML_RemovesEventHandler(t *testing.T) { | |
| 55 | + input := `<div onclick="alert('xss')">Hello</div>` | |
| 56 | + got := HTML(input) | |
| 57 | + if strings.Contains(got, "onclick") { | |
| 58 | + t.Fatalf("onclick handler not removed: %s", got) | |
| 59 | + } | |
| 60 | + if !strings.Contains(got, "Hello") { | |
| 61 | + t.Fatalf("content removed: %s", got) | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +func TestHTML_RemovesJavascriptHref(t *testing.T) { | |
| 66 | + input := `<a href="javascript:alert('xss')">click</a>` | |
| 67 | + got := HTML(input) | |
| 68 | + if strings.Contains(got, "javascript:") { | |
| 69 | + t.Fatalf("javascript: href not removed: %s", got) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +func TestHTML_RemovesObjectTags(t *testing.T) { | |
| 74 | + input := `<object data="evil.swf"></object>` | |
| 75 | + got := HTML(input) | |
| 76 | + if strings.Contains(got, "<object") { | |
| 77 | + t.Fatalf("object tag not removed: %s", got) | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +func TestHTML_RemovesFormTags(t *testing.T) { | |
| 82 | + input := `<form action="evil.com"><input type="submit"></form>` | |
| 83 | + got := HTML(input) | |
| 84 | + if strings.Contains(got, "<form") { | |
| 85 | + t.Fatalf("form tag not removed: %s", got) | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +func TestHTML_RemovesMetaTags(t *testing.T) { | |
| 90 | + input := `<meta http-equiv="refresh" content="0;url=evil.com">` | |
| 91 | + got := HTML(input) | |
| 92 | + if strings.Contains(got, "<meta") { | |
| 93 | + t.Fatalf("meta tag not removed: %s", got) | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +func TestHTML_RemovesBaseTags(t *testing.T) { | |
| 98 | + input := `<base href="evil.com">` | |
| 99 | + got := HTML(input) | |
| 100 | + if strings.Contains(got, "<base") { | |
| 101 | + t.Fatalf("base tag not removed: %s", got) | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +func TestHTML_RemovesStyleExpression(t *testing.T) { | |
| 106 | + input := `<div style="background: expression(alert('xss'))">Hello</div>` | |
| 107 | + got := HTML(input) | |
| 108 | + if strings.Contains(got, "expression") { | |
| 109 | + t.Fatalf("expression not removed: %s", got) | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +func TestHTML_PreservesSafeContent(t *testing.T) { | |
| 114 | + input := `<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p><ul><li>item</li></ul>` | |
| 115 | + got := HTML(input) | |
| 116 | + if got != input { | |
| 117 | + t.Fatalf("safe content modified:\ngot: %s\nwant: %s", got, input) | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 121 | +func TestHTML_PreservesImages(t *testing.T) { | |
| 122 | + input := `<img src="photo.jpg" alt="photo">` | |
| 123 | + got := HTML(input) | |
| 124 | + if got != input { | |
| 125 | + t.Fatalf("img tag modified: %s", got) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +func TestHTML_PreservesLinks(t *testing.T) { | |
| 130 | + input := `<a href="https://example.com">link</a>` | |
| 131 | + got := HTML(input) | |
| 132 | + if got != input { | |
| 133 | + t.Fatalf("link modified: %s", got) | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +func TestHTML_HandlesCaseInsensitiveScript(t *testing.T) { | |
| 138 | + input := `<SCRIPT>alert('xss')</SCRIPT>` | |
| 139 | + got := HTML(input) | |
| 140 | + if strings.Contains(got, "<SCRIPT") { | |
| 141 | + t.Fatalf("case-insensitive script not removed: %s", got) | |
| 142 | + } | |
| 143 | +} | |
| 144 | + | |
| 145 | +func TestHTML_HandlesMultilineScript(t *testing.T) { | |
| 146 | + input := "<script>\nalert('xss');\n</script>" | |
| 147 | + got := HTML(input) | |
| 148 | + if strings.Contains(got, "<script") { | |
| 149 | + t.Fatalf("multiline script not removed: %s", got) | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +func TestHTML_RemovesOnEventHandlers(t *testing.T) { | |
| 154 | + cases := []string{ | |
| 155 | + `<div onmouseover="alert(1)">`, | |
| 156 | + `<img onerror="alert(1)" src="x">`, | |
| 157 | + `<body onload="alert(1)">`, | |
| 158 | + } | |
| 159 | + for _, input := range cases { | |
| 160 | + got := HTML(input) | |
| 161 | + if strings.Contains(got, " on") { | |
| 162 | + t.Fatalf("event handler not removed from %q: %s", input, got) | |
| 163 | + } | |
| 164 | + } | |
| 165 | +} | |
| 166 | + | |
| 167 | +func TestHTML_ConvertsYouTubeLink(t *testing.T) { | |
| 168 | + input := `<p>Check this out:</p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch on YouTube</a>` | |
| 169 | + got := HTML(input) | |
| 170 | + if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) { | |
| 171 | + t.Fatalf("youtube link not converted to iframe: %s", got) | |
| 172 | + } | |
| 173 | + if strings.Contains(got, "<a href") && strings.Contains(got, "youtube.com/watch") { | |
| 174 | + t.Fatalf("original youtube link not replaced: %s", got) | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +func TestHTML_ConvertsYoutuBeLink(t *testing.T) { | |
| 179 | + input := `<a href="https://youtu.be/dQw4w9WgXcQ">Watch</a>` | |
| 180 | + got := HTML(input) | |
| 181 | + if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) { | |
| 182 | + t.Fatalf("youtu.be link not converted to iframe: %s", got) | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +func TestHTML_ConvertsYouTubeShortsLink(t *testing.T) { | |
| 187 | + input := `<a href="https://www.youtube.com/shorts/abc12345678">Short</a>` | |
| 188 | + got := HTML(input) | |
| 189 | + if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/abc12345678"`) { | |
| 190 | + t.Fatalf("youtube shorts link not converted to iframe: %s", got) | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 194 | +func TestHTML_ConvertsVimeoLink(t *testing.T) { | |
| 195 | + input := `<a href="https://vimeo.com/123456789">Watch on Vimeo</a>` | |
| 196 | + got := HTML(input) | |
| 197 | + if !strings.Contains(got, `<iframe src="https://player.vimeo.com/video/123456789"`) { | |
| 198 | + t.Fatalf("vimeo link not converted to iframe: %s", got) | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +func TestHTML_PreservesNonMediaLinks(t *testing.T) { | |
| 203 | + input := `<a href="https://example.com/article">Read more</a>` | |
| 204 | + got := HTML(input) | |
| 205 | + if !strings.Contains(got, `<a href="https://example.com/article">Read more</a>`) { | |
| 206 | + t.Fatalf("non-media link was modified: %s", got) | |
| 207 | + } | |
| 208 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,208 @@ | |||
| 1 | +package sanitize | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +func TestHTML_RemovesScriptTags(t *testing.T) { | ||
| 9 | + input := `<p>Hello</p><script>alert('xss')</script><p>World</p>` | ||
| 10 | + got := HTML(input) | ||
| 11 | + if strings.Contains(got, "<script") { | ||
| 12 | + t.Fatalf("script tag not removed: %s", got) | ||
| 13 | + } | ||
| 14 | + if !strings.Contains(got, "Hello") || !strings.Contains(got, "World") { | ||
| 15 | + t.Fatalf("content removed: %s", got) | ||
| 16 | + } | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +func TestHTML_RemovesEvilIframeTags(t *testing.T) { | ||
| 20 | + input := `<p>Hello</p><iframe src="evil.com"></iframe>` | ||
| 21 | + got := HTML(input) | ||
| 22 | + if strings.Contains(got, "<iframe") { | ||
| 23 | + t.Fatalf("iframe tag not removed: %s", got) | ||
| 24 | + } | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +func TestHTML_PreservesYouTubeIframe(t *testing.T) { | ||
| 28 | + input := `<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315"></iframe>` | ||
| 29 | + got := HTML(input) | ||
| 30 | + if !strings.Contains(got, "<iframe") { | ||
| 31 | + t.Fatalf("youtube iframe removed: %s", got) | ||
| 32 | + } | ||
| 33 | + if !strings.Contains(got, "youtube.com") { | ||
| 34 | + t.Fatalf("youtube iframe src lost: %s", got) | ||
| 35 | + } | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +func TestHTML_PreservesVimeoIframe(t *testing.T) { | ||
| 39 | + input := `<iframe src="https://player.vimeo.com/video/12345" width="640" height="360"></iframe>` | ||
| 40 | + got := HTML(input) | ||
| 41 | + if !strings.Contains(got, "<iframe") { | ||
| 42 | + t.Fatalf("vimeo iframe removed: %s", got) | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +func TestHTML_PreservesSpotifyIframe(t *testing.T) { | ||
| 47 | + input := `<iframe src="https://open.spotify.com/embed/track/abc123" width="300" height="80"></iframe>` | ||
| 48 | + got := HTML(input) | ||
| 49 | + if !strings.Contains(got, "<iframe") { | ||
| 50 | + t.Fatalf("spotify iframe removed: %s", got) | ||
| 51 | + } | ||
| 52 | +} | ||
| 53 | + | ||
| 54 | +func TestHTML_RemovesEventHandler(t *testing.T) { | ||
| 55 | + input := `<div onclick="alert('xss')">Hello</div>` | ||
| 56 | + got := HTML(input) | ||
| 57 | + if strings.Contains(got, "onclick") { | ||
| 58 | + t.Fatalf("onclick handler not removed: %s", got) | ||
| 59 | + } | ||
| 60 | + if !strings.Contains(got, "Hello") { | ||
| 61 | + t.Fatalf("content removed: %s", got) | ||
| 62 | + } | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func TestHTML_RemovesJavascriptHref(t *testing.T) { | ||
| 66 | + input := `<a href="javascript:alert('xss')">click</a>` | ||
| 67 | + got := HTML(input) | ||
| 68 | + if strings.Contains(got, "javascript:") { | ||
| 69 | + t.Fatalf("javascript: href not removed: %s", got) | ||
| 70 | + } | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +func TestHTML_RemovesObjectTags(t *testing.T) { | ||
| 74 | + input := `<object data="evil.swf"></object>` | ||
| 75 | + got := HTML(input) | ||
| 76 | + if strings.Contains(got, "<object") { | ||
| 77 | + t.Fatalf("object tag not removed: %s", got) | ||
| 78 | + } | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +func TestHTML_RemovesFormTags(t *testing.T) { | ||
| 82 | + input := `<form action="evil.com"><input type="submit"></form>` | ||
| 83 | + got := HTML(input) | ||
| 84 | + if strings.Contains(got, "<form") { | ||
| 85 | + t.Fatalf("form tag not removed: %s", got) | ||
| 86 | + } | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +func TestHTML_RemovesMetaTags(t *testing.T) { | ||
| 90 | + input := `<meta http-equiv="refresh" content="0;url=evil.com">` | ||
| 91 | + got := HTML(input) | ||
| 92 | + if strings.Contains(got, "<meta") { | ||
| 93 | + t.Fatalf("meta tag not removed: %s", got) | ||
| 94 | + } | ||
| 95 | +} | ||
| 96 | + | ||
| 97 | +func TestHTML_RemovesBaseTags(t *testing.T) { | ||
| 98 | + input := `<base href="evil.com">` | ||
| 99 | + got := HTML(input) | ||
| 100 | + if strings.Contains(got, "<base") { | ||
| 101 | + t.Fatalf("base tag not removed: %s", got) | ||
| 102 | + } | ||
| 103 | +} | ||
| 104 | + | ||
| 105 | +func TestHTML_RemovesStyleExpression(t *testing.T) { | ||
| 106 | + input := `<div style="background: expression(alert('xss'))">Hello</div>` | ||
| 107 | + got := HTML(input) | ||
| 108 | + if strings.Contains(got, "expression") { | ||
| 109 | + t.Fatalf("expression not removed: %s", got) | ||
| 110 | + } | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +func TestHTML_PreservesSafeContent(t *testing.T) { | ||
| 114 | + input := `<h1>Title</h1><p>Paragraph with <strong>bold</strong> and <em>italic</em>.</p><ul><li>item</li></ul>` | ||
| 115 | + got := HTML(input) | ||
| 116 | + if got != input { | ||
| 117 | + t.Fatalf("safe content modified:\ngot: %s\nwant: %s", got, input) | ||
| 118 | + } | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +func TestHTML_PreservesImages(t *testing.T) { | ||
| 122 | + input := `<img src="photo.jpg" alt="photo">` | ||
| 123 | + got := HTML(input) | ||
| 124 | + if got != input { | ||
| 125 | + t.Fatalf("img tag modified: %s", got) | ||
| 126 | + } | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +func TestHTML_PreservesLinks(t *testing.T) { | ||
| 130 | + input := `<a href="https://example.com">link</a>` | ||
| 131 | + got := HTML(input) | ||
| 132 | + if got != input { | ||
| 133 | + t.Fatalf("link modified: %s", got) | ||
| 134 | + } | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +func TestHTML_HandlesCaseInsensitiveScript(t *testing.T) { | ||
| 138 | + input := `<SCRIPT>alert('xss')</SCRIPT>` | ||
| 139 | + got := HTML(input) | ||
| 140 | + if strings.Contains(got, "<SCRIPT") { | ||
| 141 | + t.Fatalf("case-insensitive script not removed: %s", got) | ||
| 142 | + } | ||
| 143 | +} | ||
| 144 | + | ||
| 145 | +func TestHTML_HandlesMultilineScript(t *testing.T) { | ||
| 146 | + input := "<script>\nalert('xss');\n</script>" | ||
| 147 | + got := HTML(input) | ||
| 148 | + if strings.Contains(got, "<script") { | ||
| 149 | + t.Fatalf("multiline script not removed: %s", got) | ||
| 150 | + } | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +func TestHTML_RemovesOnEventHandlers(t *testing.T) { | ||
| 154 | + cases := []string{ | ||
| 155 | + `<div onmouseover="alert(1)">`, | ||
| 156 | + `<img onerror="alert(1)" src="x">`, | ||
| 157 | + `<body onload="alert(1)">`, | ||
| 158 | + } | ||
| 159 | + for _, input := range cases { | ||
| 160 | + got := HTML(input) | ||
| 161 | + if strings.Contains(got, " on") { | ||
| 162 | + t.Fatalf("event handler not removed from %q: %s", input, got) | ||
| 163 | + } | ||
| 164 | + } | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +func TestHTML_ConvertsYouTubeLink(t *testing.T) { | ||
| 168 | + input := `<p>Check this out:</p><a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">Watch on YouTube</a>` | ||
| 169 | + got := HTML(input) | ||
| 170 | + if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) { | ||
| 171 | + t.Fatalf("youtube link not converted to iframe: %s", got) | ||
| 172 | + } | ||
| 173 | + if strings.Contains(got, "<a href") && strings.Contains(got, "youtube.com/watch") { | ||
| 174 | + t.Fatalf("original youtube link not replaced: %s", got) | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +func TestHTML_ConvertsYoutuBeLink(t *testing.T) { | ||
| 179 | + input := `<a href="https://youtu.be/dQw4w9WgXcQ">Watch</a>` | ||
| 180 | + got := HTML(input) | ||
| 181 | + if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"`) { | ||
| 182 | + t.Fatalf("youtu.be link not converted to iframe: %s", got) | ||
| 183 | + } | ||
| 184 | +} | ||
| 185 | + | ||
| 186 | +func TestHTML_ConvertsYouTubeShortsLink(t *testing.T) { | ||
| 187 | + input := `<a href="https://www.youtube.com/shorts/abc12345678">Short</a>` | ||
| 188 | + got := HTML(input) | ||
| 189 | + if !strings.Contains(got, `<iframe src="https://www.youtube-nocookie.com/embed/abc12345678"`) { | ||
| 190 | + t.Fatalf("youtube shorts link not converted to iframe: %s", got) | ||
| 191 | + } | ||
| 192 | +} | ||
| 193 | + | ||
| 194 | +func TestHTML_ConvertsVimeoLink(t *testing.T) { | ||
| 195 | + input := `<a href="https://vimeo.com/123456789">Watch on Vimeo</a>` | ||
| 196 | + got := HTML(input) | ||
| 197 | + if !strings.Contains(got, `<iframe src="https://player.vimeo.com/video/123456789"`) { | ||
| 198 | + t.Fatalf("vimeo link not converted to iframe: %s", got) | ||
| 199 | + } | ||
| 200 | +} | ||
| 201 | + | ||
| 202 | +func TestHTML_PreservesNonMediaLinks(t *testing.T) { | ||
| 203 | + input := `<a href="https://example.com/article">Read more</a>` | ||
| 204 | + got := HTML(input) | ||
| 205 | + if !strings.Contains(got, `<a href="https://example.com/article">Read more</a>`) { | ||
| 206 | + t.Fatalf("non-media link was modified: %s", got) | ||
| 207 | + } | ||
| 208 | +} | ||
added
internal/server/annotations_handler.go +63 -0 | new file mode 100644 | ||
| @@ -0,0 +1,63 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 10 | + "pkg.rbrt.fr/glean/internal/db" | |
| 11 | +) | |
| 12 | + | |
| 13 | +func (s *Server) handleAnnotations(w http.ResponseWriter, r *http.Request) { | |
| 14 | + user := currentUser(r) | |
| 15 | + articleURL := r.URL.Query().Get("article") | |
| 16 | + annotations, _ := s.db.ListAnnotations(r.Context(), "", articleURL, "", 50, 0) | |
| 17 | + s.render(w, r, "annotations.html", map[string]any{ | |
| 18 | + "User": user, | |
| 19 | + "Annotations": annotations, | |
| 20 | + "ArticleURL": articleURL, | |
| 21 | + }) | |
| 22 | +} | |
| 23 | + | |
| 24 | +func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) { | |
| 25 | + user := currentUser(r) | |
| 26 | + a := &db.Annotation{ | |
| 27 | + URI: fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()), | |
| 28 | + AuthorDID: user.DID, | |
| 29 | + FeedURL: r.FormValue("feed_url"), | |
| 30 | + ArticleURL: r.FormValue("article_url"), | |
| 31 | + Quote: sql.NullString{String: r.FormValue("quote"), Valid: r.FormValue("quote") != ""}, | |
| 32 | + Note: sql.NullString{String: r.FormValue("note"), Valid: r.FormValue("note") != ""}, | |
| 33 | + Tags: sql.NullString{String: r.FormValue("tags"), Valid: r.FormValue("tags") != ""}, | |
| 34 | + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, | |
| 35 | + } | |
| 36 | + if rv := r.FormValue("rating"); rv != "" { | |
| 37 | + var n int64 | |
| 38 | + if _, err := fmt.Sscanf(rv, "%d", &n); err == nil { | |
| 39 | + a.Rating = sql.NullInt64{Int64: n, Valid: true} | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + if err := s.db.CreateAnnotation(r.Context(), a); err != nil { | |
| 44 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 45 | + return | |
| 46 | + } | |
| 47 | + | |
| 48 | + if client := s.pdsClientForUser(r); client != nil { | |
| 49 | + record := atproto.AnnotationRecord{ | |
| 50 | + CreatedAt: time.Now().Format(time.RFC3339), | |
| 51 | + FeedURL: a.FeedURL, | |
| 52 | + ArticleURL: a.ArticleURL, | |
| 53 | + Quote: a.Quote.String, | |
| 54 | + Note: a.Note.String, | |
| 55 | + Rating: int(a.Rating.Int64), | |
| 56 | + } | |
| 57 | + if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.annotation", record); err != nil { | |
| 58 | + s.logger.Warn("failed to write annotation to PDS", "error", err) | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + w.WriteHeader(http.StatusNoContent) | |
| 63 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "database/sql" | ||
| 5 | + "fmt" | ||
| 6 | + "net/http" | ||
| 7 | + "time" | ||
| 8 | + | ||
| 9 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 10 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +func (s *Server) handleAnnotations(w http.ResponseWriter, r *http.Request) { | ||
| 14 | + user := currentUser(r) | ||
| 15 | + articleURL := r.URL.Query().Get("article") | ||
| 16 | + annotations, _ := s.db.ListAnnotations(r.Context(), "", articleURL, "", 50, 0) | ||
| 17 | + s.render(w, r, "annotations.html", map[string]any{ | ||
| 18 | + "User": user, | ||
| 19 | + "Annotations": annotations, | ||
| 20 | + "ArticleURL": articleURL, | ||
| 21 | + }) | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +func (s *Server) handleCreateAnnotation(w http.ResponseWriter, r *http.Request) { | ||
| 25 | + user := currentUser(r) | ||
| 26 | + a := &db.Annotation{ | ||
| 27 | + URI: fmt.Sprintf("glean:annotation:%d", time.Now().UnixNano()), | ||
| 28 | + AuthorDID: user.DID, | ||
| 29 | + FeedURL: r.FormValue("feed_url"), | ||
| 30 | + ArticleURL: r.FormValue("article_url"), | ||
| 31 | + Quote: sql.NullString{String: r.FormValue("quote"), Valid: r.FormValue("quote") != ""}, | ||
| 32 | + Note: sql.NullString{String: r.FormValue("note"), Valid: r.FormValue("note") != ""}, | ||
| 33 | + Tags: sql.NullString{String: r.FormValue("tags"), Valid: r.FormValue("tags") != ""}, | ||
| 34 | + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, | ||
| 35 | + } | ||
| 36 | + if rv := r.FormValue("rating"); rv != "" { | ||
| 37 | + var n int64 | ||
| 38 | + if _, err := fmt.Sscanf(rv, "%d", &n); err == nil { | ||
| 39 | + a.Rating = sql.NullInt64{Int64: n, Valid: true} | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + if err := s.db.CreateAnnotation(r.Context(), a); err != nil { | ||
| 44 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 45 | + return | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + if client := s.pdsClientForUser(r); client != nil { | ||
| 49 | + record := atproto.AnnotationRecord{ | ||
| 50 | + CreatedAt: time.Now().Format(time.RFC3339), | ||
| 51 | + FeedURL: a.FeedURL, | ||
| 52 | + ArticleURL: a.ArticleURL, | ||
| 53 | + Quote: a.Quote.String, | ||
| 54 | + Note: a.Note.String, | ||
| 55 | + Rating: int(a.Rating.Int64), | ||
| 56 | + } | ||
| 57 | + if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.annotation", record); err != nil { | ||
| 58 | + s.logger.Warn("failed to write annotation to PDS", "error", err) | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + w.WriteHeader(http.StatusNoContent) | ||
| 63 | +} | ||
added
internal/server/articles_handler.go +250 -0 | new file mode 100644 | ||
| @@ -0,0 +1,250 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "fmt" | |
| 6 | + "net/http" | |
| 7 | + "strconv" | |
| 8 | + "time" | |
| 9 | + | |
| 10 | + "github.com/go-chi/chi/v5" | |
| 11 | + | |
| 12 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 13 | + "pkg.rbrt.fr/glean/internal/db" | |
| 14 | +) | |
| 15 | + | |
| 16 | +func writeStarButton(w http.ResponseWriter, articleID int64, starred bool) { | |
| 17 | + cls := "text-gray-300 hover:text-yellow-500" | |
| 18 | + ch := "☆" | |
| 19 | + if starred { | |
| 20 | + cls = "text-yellow-500" | |
| 21 | + ch = "★" | |
| 22 | + } | |
| 23 | + w.Header().Set("Content-Type", "text/html") | |
| 24 | + _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/star" hx-target="#star-btn" hx-swap="outerHTML" id="star-btn" class="text-lg %s">%s</button>`, articleID, cls, ch) | |
| 25 | +} | |
| 26 | + | |
| 27 | +func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) { | |
| 28 | + cls := "text-gray-300 hover:text-red-500" | |
| 29 | + if liked { | |
| 30 | + cls = "text-red-500" | |
| 31 | + } | |
| 32 | + w.Header().Set("Content-Type", "text/html") | |
| 33 | + _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">♥ <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count) | |
| 34 | +} | |
| 35 | + | |
| 36 | +func writeReadButton(w http.ResponseWriter, articleID int64, isRead bool) { | |
| 37 | + label := "Mark read" | |
| 38 | + action := "read" | |
| 39 | + if isRead { | |
| 40 | + label = "Mark unread" | |
| 41 | + action = "unread" | |
| 42 | + } | |
| 43 | + w.Header().Set("Content-Type", "text/html") | |
| 44 | + _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/%s" hx-target="#read-btn" hx-swap="outerHTML" id="read-btn" class="text-xs border border-gray-300 rounded px-2 py-1 hover:bg-gray-50">%s</button>`, articleID, action, label) | |
| 45 | +} | |
| 46 | + | |
| 47 | +func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | |
| 48 | + user := currentUser(r) | |
| 49 | + feedURL := r.URL.Query().Get("feed") | |
| 50 | + | |
| 51 | + articles, err := s.db.ListArticles(r.Context(), user.DID, feedURL, 50, 0) | |
| 52 | + if err != nil { | |
| 53 | + s.logger.Error("failed to list articles", "error", err) | |
| 54 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 55 | + return | |
| 56 | + } | |
| 57 | + | |
| 58 | + s.render(w, r, "articles.html", map[string]any{ | |
| 59 | + "User": user, | |
| 60 | + "Articles": articles, | |
| 61 | + "FeedURL": feedURL, | |
| 62 | + }) | |
| 63 | +} | |
| 64 | + | |
| 65 | +func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | |
| 66 | + user := currentUser(r) | |
| 67 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 68 | + if err != nil { | |
| 69 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 70 | + return | |
| 71 | + } | |
| 72 | + | |
| 73 | + article, err := s.db.GetArticle(r.Context(), id) | |
| 74 | + if err != nil { | |
| 75 | + http.Error(w, "article not found", http.StatusNotFound) | |
| 76 | + return | |
| 77 | + } | |
| 78 | + | |
| 79 | + _ = s.db.MarkArticleRead(r.Context(), user.DID, id) | |
| 80 | + | |
| 81 | + readState, _ := s.db.GetReadState(r.Context(), user.DID, id) | |
| 82 | + | |
| 83 | + likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String) | |
| 84 | + liked := false | |
| 85 | + if article.URL.Valid { | |
| 86 | + liked, _ = s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String) | |
| 87 | + } | |
| 88 | + annotations, _ := s.db.ListAnnotations(r.Context(), "", article.URL.String, "", 20, 0) | |
| 89 | + feed, _ := s.db.GetFeed(r.Context(), article.FeedURL) | |
| 90 | + | |
| 91 | + s.render(w, r, "article_detail.html", map[string]any{ | |
| 92 | + "User": user, | |
| 93 | + "Article": article, | |
| 94 | + "Feed": feed, | |
| 95 | + "ReadState": readState, | |
| 96 | + "LikeCount": likeCount, | |
| 97 | + "HasLiked": liked, | |
| 98 | + "Annotations": annotations, | |
| 99 | + }) | |
| 100 | +} | |
| 101 | + | |
| 102 | +func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { | |
| 103 | + user := currentUser(r) | |
| 104 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 105 | + if err != nil { | |
| 106 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 107 | + return | |
| 108 | + } | |
| 109 | + if err := s.db.MarkArticleRead(r.Context(), user.DID, id); err != nil { | |
| 110 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 111 | + return | |
| 112 | + } | |
| 113 | + writeReadButton(w, id, true) | |
| 114 | +} | |
| 115 | + | |
| 116 | +func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { | |
| 117 | + user := currentUser(r) | |
| 118 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 119 | + if err != nil { | |
| 120 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 121 | + return | |
| 122 | + } | |
| 123 | + if err := s.db.MarkArticleUnread(r.Context(), user.DID, id); err != nil { | |
| 124 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 125 | + return | |
| 126 | + } | |
| 127 | + writeReadButton(w, id, false) | |
| 128 | +} | |
| 129 | + | |
| 130 | +func (s *Server) handleStar(w http.ResponseWriter, r *http.Request) { | |
| 131 | + user := currentUser(r) | |
| 132 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 133 | + if err != nil { | |
| 134 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 135 | + return | |
| 136 | + } | |
| 137 | + rs, _ := s.db.GetReadState(r.Context(), user.DID, id) | |
| 138 | + if rs.IsStarred { | |
| 139 | + if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil { | |
| 140 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 141 | + return | |
| 142 | + } | |
| 143 | + writeStarButton(w, id, false) | |
| 144 | + } else { | |
| 145 | + if err := s.db.StarArticle(r.Context(), user.DID, id); err != nil { | |
| 146 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 147 | + return | |
| 148 | + } | |
| 149 | + writeStarButton(w, id, true) | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +func (s *Server) handleUnstar(w http.ResponseWriter, r *http.Request) { | |
| 154 | + user := currentUser(r) | |
| 155 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 156 | + if err != nil { | |
| 157 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 158 | + return | |
| 159 | + } | |
| 160 | + if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil { | |
| 161 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 162 | + return | |
| 163 | + } | |
| 164 | + writeStarButton(w, id, false) | |
| 165 | +} | |
| 166 | + | |
| 167 | +func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | |
| 168 | + user := currentUser(r) | |
| 169 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | |
| 170 | + if err != nil { | |
| 171 | + http.Error(w, "invalid id", http.StatusBadRequest) | |
| 172 | + return | |
| 173 | + } | |
| 174 | + | |
| 175 | + article, err := s.db.GetArticle(r.Context(), id) | |
| 176 | + if err != nil { | |
| 177 | + http.Error(w, err.Error(), http.StatusNotFound) | |
| 178 | + return | |
| 179 | + } | |
| 180 | + | |
| 181 | + liked, err := s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String) | |
| 182 | + if err != nil { | |
| 183 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 184 | + return | |
| 185 | + } | |
| 186 | + | |
| 187 | + if liked { | |
| 188 | + if err := s.db.DeleteLikeByUserArticle(r.Context(), user.DID, article.FeedURL, article.URL.String); err != nil { | |
| 189 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 190 | + return | |
| 191 | + } | |
| 192 | + } else { | |
| 193 | + likeRecord := atproto.LikeRecord{ | |
| 194 | + CreatedAt: time.Now().Format(time.RFC3339), | |
| 195 | + FeedURL: article.FeedURL, | |
| 196 | + ArticleURL: article.URL.String, | |
| 197 | + } | |
| 198 | + | |
| 199 | + if client := s.pdsClientForUser(r); client != nil { | |
| 200 | + uri, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.like", likeRecord) | |
| 201 | + if err != nil { | |
| 202 | + s.logger.Warn("failed to write like to PDS", "error", err) | |
| 203 | + } | |
| 204 | + | |
| 205 | + like := &db.Like{ | |
| 206 | + URI: uri, | |
| 207 | + AuthorDID: user.DID, | |
| 208 | + FeedURL: article.FeedURL, | |
| 209 | + ArticleURL: article.URL.String, | |
| 210 | + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, | |
| 211 | + } | |
| 212 | + if err := s.db.CreateLike(r.Context(), like); err != nil { | |
| 213 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 214 | + return | |
| 215 | + } | |
| 216 | + } else { | |
| 217 | + like := &db.Like{ | |
| 218 | + URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()), | |
| 219 | + AuthorDID: user.DID, | |
| 220 | + FeedURL: article.FeedURL, | |
| 221 | + ArticleURL: article.URL.String, | |
| 222 | + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, | |
| 223 | + } | |
| 224 | + if err := s.db.CreateLike(r.Context(), like); err != nil { | |
| 225 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 226 | + return | |
| 227 | + } | |
| 228 | + } | |
| 229 | + } | |
| 230 | + | |
| 231 | + likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String) | |
| 232 | + writeLikeButton(w, id, !liked, likeCount) | |
| 233 | +} | |
| 234 | + | |
| 235 | +func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) { | |
| 236 | + user := currentUser(r) | |
| 237 | + feedURL := r.FormValue("feed") | |
| 238 | + var err error | |
| 239 | + if feedURL != "" { | |
| 240 | + err = s.db.MarkAllRead(r.Context(), user.DID, feedURL) | |
| 241 | + } else { | |
| 242 | + err = s.db.MarkAllSubscribedRead(r.Context(), user.DID) | |
| 243 | + } | |
| 244 | + if err != nil { | |
| 245 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 246 | + return | |
| 247 | + } | |
| 248 | + w.Header().Set("HX-Refresh", "true") | |
| 249 | + w.WriteHeader(http.StatusNoContent) | |
| 250 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,250 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "database/sql" | ||
| 5 | + "fmt" | ||
| 6 | + "net/http" | ||
| 7 | + "strconv" | ||
| 8 | + "time" | ||
| 9 | + | ||
| 10 | + "github.com/go-chi/chi/v5" | ||
| 11 | + | ||
| 12 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 13 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +func writeStarButton(w http.ResponseWriter, articleID int64, starred bool) { | ||
| 17 | + cls := "text-gray-300 hover:text-yellow-500" | ||
| 18 | + ch := "☆" | ||
| 19 | + if starred { | ||
| 20 | + cls = "text-yellow-500" | ||
| 21 | + ch = "★" | ||
| 22 | + } | ||
| 23 | + w.Header().Set("Content-Type", "text/html") | ||
| 24 | + _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/star" hx-target="#star-btn" hx-swap="outerHTML" id="star-btn" class="text-lg %s">%s</button>`, articleID, cls, ch) | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +func writeLikeButton(w http.ResponseWriter, articleID int64, liked bool, count int) { | ||
| 28 | + cls := "text-gray-300 hover:text-red-500" | ||
| 29 | + if liked { | ||
| 30 | + cls = "text-red-500" | ||
| 31 | + } | ||
| 32 | + w.Header().Set("Content-Type", "text/html") | ||
| 33 | + _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/like" hx-target="#like-btn" hx-swap="outerHTML" id="like-btn" class="text-lg %s">♥ <span class="text-sm text-gray-600">%d</span></button>`, articleID, cls, count) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +func writeReadButton(w http.ResponseWriter, articleID int64, isRead bool) { | ||
| 37 | + label := "Mark read" | ||
| 38 | + action := "read" | ||
| 39 | + if isRead { | ||
| 40 | + label = "Mark unread" | ||
| 41 | + action = "unread" | ||
| 42 | + } | ||
| 43 | + w.Header().Set("Content-Type", "text/html") | ||
| 44 | + _, _ = fmt.Fprintf(w, `<button hx-post="/articles/%d/%s" hx-target="#read-btn" hx-swap="outerHTML" id="read-btn" class="text-xs border border-gray-300 rounded px-2 py-1 hover:bg-gray-50">%s</button>`, articleID, action, label) | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { | ||
| 48 | + user := currentUser(r) | ||
| 49 | + feedURL := r.URL.Query().Get("feed") | ||
| 50 | + | ||
| 51 | + articles, err := s.db.ListArticles(r.Context(), user.DID, feedURL, 50, 0) | ||
| 52 | + if err != nil { | ||
| 53 | + s.logger.Error("failed to list articles", "error", err) | ||
| 54 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 55 | + return | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + s.render(w, r, "articles.html", map[string]any{ | ||
| 59 | + "User": user, | ||
| 60 | + "Articles": articles, | ||
| 61 | + "FeedURL": feedURL, | ||
| 62 | + }) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func (s *Server) handleArticleDetail(w http.ResponseWriter, r *http.Request) { | ||
| 66 | + user := currentUser(r) | ||
| 67 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 68 | + if err != nil { | ||
| 69 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 70 | + return | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + article, err := s.db.GetArticle(r.Context(), id) | ||
| 74 | + if err != nil { | ||
| 75 | + http.Error(w, "article not found", http.StatusNotFound) | ||
| 76 | + return | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + _ = s.db.MarkArticleRead(r.Context(), user.DID, id) | ||
| 80 | + | ||
| 81 | + readState, _ := s.db.GetReadState(r.Context(), user.DID, id) | ||
| 82 | + | ||
| 83 | + likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String) | ||
| 84 | + liked := false | ||
| 85 | + if article.URL.Valid { | ||
| 86 | + liked, _ = s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String) | ||
| 87 | + } | ||
| 88 | + annotations, _ := s.db.ListAnnotations(r.Context(), "", article.URL.String, "", 20, 0) | ||
| 89 | + feed, _ := s.db.GetFeed(r.Context(), article.FeedURL) | ||
| 90 | + | ||
| 91 | + s.render(w, r, "article_detail.html", map[string]any{ | ||
| 92 | + "User": user, | ||
| 93 | + "Article": article, | ||
| 94 | + "Feed": feed, | ||
| 95 | + "ReadState": readState, | ||
| 96 | + "LikeCount": likeCount, | ||
| 97 | + "HasLiked": liked, | ||
| 98 | + "Annotations": annotations, | ||
| 99 | + }) | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { | ||
| 103 | + user := currentUser(r) | ||
| 104 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 105 | + if err != nil { | ||
| 106 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 107 | + return | ||
| 108 | + } | ||
| 109 | + if err := s.db.MarkArticleRead(r.Context(), user.DID, id); err != nil { | ||
| 110 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 111 | + return | ||
| 112 | + } | ||
| 113 | + writeReadButton(w, id, true) | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) { | ||
| 117 | + user := currentUser(r) | ||
| 118 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 119 | + if err != nil { | ||
| 120 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 121 | + return | ||
| 122 | + } | ||
| 123 | + if err := s.db.MarkArticleUnread(r.Context(), user.DID, id); err != nil { | ||
| 124 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 125 | + return | ||
| 126 | + } | ||
| 127 | + writeReadButton(w, id, false) | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +func (s *Server) handleStar(w http.ResponseWriter, r *http.Request) { | ||
| 131 | + user := currentUser(r) | ||
| 132 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 133 | + if err != nil { | ||
| 134 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 135 | + return | ||
| 136 | + } | ||
| 137 | + rs, _ := s.db.GetReadState(r.Context(), user.DID, id) | ||
| 138 | + if rs.IsStarred { | ||
| 139 | + if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil { | ||
| 140 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 141 | + return | ||
| 142 | + } | ||
| 143 | + writeStarButton(w, id, false) | ||
| 144 | + } else { | ||
| 145 | + if err := s.db.StarArticle(r.Context(), user.DID, id); err != nil { | ||
| 146 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 147 | + return | ||
| 148 | + } | ||
| 149 | + writeStarButton(w, id, true) | ||
| 150 | + } | ||
| 151 | +} | ||
| 152 | + | ||
| 153 | +func (s *Server) handleUnstar(w http.ResponseWriter, r *http.Request) { | ||
| 154 | + user := currentUser(r) | ||
| 155 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 156 | + if err != nil { | ||
| 157 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 158 | + return | ||
| 159 | + } | ||
| 160 | + if err := s.db.UnstarArticle(r.Context(), user.DID, id); err != nil { | ||
| 161 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 162 | + return | ||
| 163 | + } | ||
| 164 | + writeStarButton(w, id, false) | ||
| 165 | +} | ||
| 166 | + | ||
| 167 | +func (s *Server) handleLikeArticle(w http.ResponseWriter, r *http.Request) { | ||
| 168 | + user := currentUser(r) | ||
| 169 | + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) | ||
| 170 | + if err != nil { | ||
| 171 | + http.Error(w, "invalid id", http.StatusBadRequest) | ||
| 172 | + return | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + article, err := s.db.GetArticle(r.Context(), id) | ||
| 176 | + if err != nil { | ||
| 177 | + http.Error(w, err.Error(), http.StatusNotFound) | ||
| 178 | + return | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + liked, err := s.db.HasLiked(r.Context(), user.DID, article.FeedURL, article.URL.String) | ||
| 182 | + if err != nil { | ||
| 183 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 184 | + return | ||
| 185 | + } | ||
| 186 | + | ||
| 187 | + if liked { | ||
| 188 | + if err := s.db.DeleteLikeByUserArticle(r.Context(), user.DID, article.FeedURL, article.URL.String); err != nil { | ||
| 189 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 190 | + return | ||
| 191 | + } | ||
| 192 | + } else { | ||
| 193 | + likeRecord := atproto.LikeRecord{ | ||
| 194 | + CreatedAt: time.Now().Format(time.RFC3339), | ||
| 195 | + FeedURL: article.FeedURL, | ||
| 196 | + ArticleURL: article.URL.String, | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + if client := s.pdsClientForUser(r); client != nil { | ||
| 200 | + uri, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.like", likeRecord) | ||
| 201 | + if err != nil { | ||
| 202 | + s.logger.Warn("failed to write like to PDS", "error", err) | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + like := &db.Like{ | ||
| 206 | + URI: uri, | ||
| 207 | + AuthorDID: user.DID, | ||
| 208 | + FeedURL: article.FeedURL, | ||
| 209 | + ArticleURL: article.URL.String, | ||
| 210 | + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, | ||
| 211 | + } | ||
| 212 | + if err := s.db.CreateLike(r.Context(), like); err != nil { | ||
| 213 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 214 | + return | ||
| 215 | + } | ||
| 216 | + } else { | ||
| 217 | + like := &db.Like{ | ||
| 218 | + URI: fmt.Sprintf("glean:like:%d", time.Now().UnixNano()), | ||
| 219 | + AuthorDID: user.DID, | ||
| 220 | + FeedURL: article.FeedURL, | ||
| 221 | + ArticleURL: article.URL.String, | ||
| 222 | + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, | ||
| 223 | + } | ||
| 224 | + if err := s.db.CreateLike(r.Context(), like); err != nil { | ||
| 225 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 226 | + return | ||
| 227 | + } | ||
| 228 | + } | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + likeCount, _ := s.db.GetLikeCount(r.Context(), article.FeedURL, article.URL.String) | ||
| 232 | + writeLikeButton(w, id, !liked, likeCount) | ||
| 233 | +} | ||
| 234 | + | ||
| 235 | +func (s *Server) handleMarkAllRead(w http.ResponseWriter, r *http.Request) { | ||
| 236 | + user := currentUser(r) | ||
| 237 | + feedURL := r.FormValue("feed") | ||
| 238 | + var err error | ||
| 239 | + if feedURL != "" { | ||
| 240 | + err = s.db.MarkAllRead(r.Context(), user.DID, feedURL) | ||
| 241 | + } else { | ||
| 242 | + err = s.db.MarkAllSubscribedRead(r.Context(), user.DID) | ||
| 243 | + } | ||
| 244 | + if err != nil { | ||
| 245 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 246 | + return | ||
| 247 | + } | ||
| 248 | + w.Header().Set("HX-Refresh", "true") | ||
| 249 | + w.WriteHeader(http.StatusNoContent) | ||
| 250 | +} | ||
added
internal/server/auth_handler.go +41 -0 | new file mode 100644 | ||
| @@ -0,0 +1,41 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + | |
| 6 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { | |
| 10 | + s.render(w, r, "login.html", map[string]any{}) | |
| 11 | +} | |
| 12 | + | |
| 13 | +func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { | |
| 14 | + handle := r.URL.Query().Get("handle") | |
| 15 | + if handle == "" { | |
| 16 | + http.Error(w, "handle required", http.StatusBadRequest) | |
| 17 | + return | |
| 18 | + } | |
| 19 | + | |
| 20 | + did, err := atproto.ResolveHandle(r.Context(), handle) | |
| 21 | + if err != nil { | |
| 22 | + s.logger.Error("failed to resolve handle", "error", err) | |
| 23 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 24 | + return | |
| 25 | + } | |
| 26 | + | |
| 27 | + user, err := s.db.CreateUser(r.Context(), did, handle, "", "") | |
| 28 | + if err != nil { | |
| 29 | + s.logger.Error("failed to create user", "error", err) | |
| 30 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 31 | + return | |
| 32 | + } | |
| 33 | + | |
| 34 | + s.setUserSession(w, user) | |
| 35 | + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | |
| 36 | +} | |
| 37 | + | |
| 38 | +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { | |
| 39 | + s.clearUserSession(w) | |
| 40 | + http.Redirect(w, r, "/", http.StatusSeeOther) | |
| 41 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,41 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + | ||
| 6 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { | ||
| 10 | + s.render(w, r, "login.html", map[string]any{}) | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 14 | + handle := r.URL.Query().Get("handle") | ||
| 15 | + if handle == "" { | ||
| 16 | + http.Error(w, "handle required", http.StatusBadRequest) | ||
| 17 | + return | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + did, err := atproto.ResolveHandle(r.Context(), handle) | ||
| 21 | + if err != nil { | ||
| 22 | + s.logger.Error("failed to resolve handle", "error", err) | ||
| 23 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 24 | + return | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + user, err := s.db.CreateUser(r.Context(), did, handle, "", "") | ||
| 28 | + if err != nil { | ||
| 29 | + s.logger.Error("failed to create user", "error", err) | ||
| 30 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 31 | + return | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + s.setUserSession(w, user) | ||
| 35 | + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | ||
| 36 | +} | ||
| 37 | + | ||
| 38 | +func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { | ||
| 39 | + s.clearUserSession(w) | ||
| 40 | + http.Redirect(w, r, "/", http.StatusSeeOther) | ||
| 41 | +} | ||
added
internal/server/dashboard_handler.go +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "time" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | |
| 9 | + user := currentUser(r) | |
| 10 | + | |
| 11 | + unreadCount, _ := s.db.GetUnreadCount(r.Context(), user.DID, "") | |
| 12 | + subCount, _ := s.db.GetSubscriptionCount(r.Context(), user.DID) | |
| 13 | + articles, _ := s.db.ListUnreadArticles(r.Context(), user.DID, "", 25, 0) | |
| 14 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 5) | |
| 15 | + peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) | |
| 16 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | |
| 17 | + trending, _ := s.db.ListTrendingArticles(r.Context(), since, 5, 0) | |
| 18 | + | |
| 19 | + s.render(w, r, "dashboard.html", map[string]any{ | |
| 20 | + "User": user, | |
| 21 | + "UnreadCount": unreadCount, | |
| 22 | + "SubscriptionCount": subCount, | |
| 23 | + "Articles": articles, | |
| 24 | + "FeedRecommendations": feedRecs, | |
| 25 | + "PeopleRecommendations": peopleRecs, | |
| 26 | + "Trending": trending, | |
| 27 | + }) | |
| 28 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + "time" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { | ||
| 9 | + user := currentUser(r) | ||
| 10 | + | ||
| 11 | + unreadCount, _ := s.db.GetUnreadCount(r.Context(), user.DID, "") | ||
| 12 | + subCount, _ := s.db.GetSubscriptionCount(r.Context(), user.DID) | ||
| 13 | + articles, _ := s.db.ListUnreadArticles(r.Context(), user.DID, "", 25, 0) | ||
| 14 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 5) | ||
| 15 | + peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) | ||
| 16 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | ||
| 17 | + trending, _ := s.db.ListTrendingArticles(r.Context(), since, 5, 0) | ||
| 18 | + | ||
| 19 | + s.render(w, r, "dashboard.html", map[string]any{ | ||
| 20 | + "User": user, | ||
| 21 | + "UnreadCount": unreadCount, | ||
| 22 | + "SubscriptionCount": subCount, | ||
| 23 | + "Articles": articles, | ||
| 24 | + "FeedRecommendations": feedRecs, | ||
| 25 | + "PeopleRecommendations": peopleRecs, | ||
| 26 | + "Trending": trending, | ||
| 27 | + }) | ||
| 28 | +} | ||
added
internal/server/discover_handler.go +34 -0 | new file mode 100644 | ||
| @@ -0,0 +1,34 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import "net/http" | |
| 4 | + | |
| 5 | +func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) { | |
| 6 | + user := currentUser(r) | |
| 7 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20) | |
| 8 | + people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20) | |
| 9 | + popular, _ := s.db.ListAllFeeds(r.Context(), 20, 0) | |
| 10 | + s.render(w, r, "discover.html", map[string]any{ | |
| 11 | + "User": user, | |
| 12 | + "FeedRecommendations": feedRecs, | |
| 13 | + "PeopleRecommendations": people, | |
| 14 | + "PopularFeeds": popular, | |
| 15 | + }) | |
| 16 | +} | |
| 17 | + | |
| 18 | +func (s *Server) handleDiscoverFeeds(w http.ResponseWriter, r *http.Request) { | |
| 19 | + user := currentUser(r) | |
| 20 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20) | |
| 21 | + s.render(w, r, "discover.html", map[string]any{ | |
| 22 | + "User": user, | |
| 23 | + "FeedRecommendations": feedRecs, | |
| 24 | + }) | |
| 25 | +} | |
| 26 | + | |
| 27 | +func (s *Server) handleDiscoverPeople(w http.ResponseWriter, r *http.Request) { | |
| 28 | + user := currentUser(r) | |
| 29 | + people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20) | |
| 30 | + s.render(w, r, "discover.html", map[string]any{ | |
| 31 | + "User": user, | |
| 32 | + "PeopleRecommendations": people, | |
| 33 | + }) | |
| 34 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,34 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import "net/http" | ||
| 4 | + | ||
| 5 | +func (s *Server) handleDiscover(w http.ResponseWriter, r *http.Request) { | ||
| 6 | + user := currentUser(r) | ||
| 7 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20) | ||
| 8 | + people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20) | ||
| 9 | + popular, _ := s.db.ListAllFeeds(r.Context(), 20, 0) | ||
| 10 | + s.render(w, r, "discover.html", map[string]any{ | ||
| 11 | + "User": user, | ||
| 12 | + "FeedRecommendations": feedRecs, | ||
| 13 | + "PeopleRecommendations": people, | ||
| 14 | + "PopularFeeds": popular, | ||
| 15 | + }) | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +func (s *Server) handleDiscoverFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 19 | + user := currentUser(r) | ||
| 20 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 20) | ||
| 21 | + s.render(w, r, "discover.html", map[string]any{ | ||
| 22 | + "User": user, | ||
| 23 | + "FeedRecommendations": feedRecs, | ||
| 24 | + }) | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +func (s *Server) handleDiscoverPeople(w http.ResponseWriter, r *http.Request) { | ||
| 28 | + user := currentUser(r) | ||
| 29 | + people, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 20) | ||
| 30 | + s.render(w, r, "discover.html", map[string]any{ | ||
| 31 | + "User": user, | ||
| 32 | + "PeopleRecommendations": people, | ||
| 33 | + }) | ||
| 34 | +} | ||
added
internal/server/feeds_handler.go +238 -0 | new file mode 100644 | ||
| @@ -0,0 +1,238 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "database/sql" | |
| 5 | + "log/slog" | |
| 6 | + "net/http" | |
| 7 | + "time" | |
| 8 | + | |
| 9 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 10 | + "pkg.rbrt.fr/glean/internal/db" | |
| 11 | + "pkg.rbrt.fr/glean/internal/feed" | |
| 12 | +) | |
| 13 | + | |
| 14 | +func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | |
| 15 | + user := currentUser(r) | |
| 16 | + category := r.URL.Query().Get("category") | |
| 17 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, category, 100, 0) | |
| 18 | + allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 19 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10) | |
| 20 | + peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) | |
| 21 | + | |
| 22 | + seen := make(map[string]bool) | |
| 23 | + var categories []string | |
| 24 | + for _, sub := range allSubs { | |
| 25 | + if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] { | |
| 26 | + seen[sub.Category.String] = true | |
| 27 | + categories = append(categories, sub.Category.String) | |
| 28 | + } | |
| 29 | + } | |
| 30 | + | |
| 31 | + s.render(w, r, "feeds.html", map[string]any{ | |
| 32 | + "User": user, | |
| 33 | + "Subscriptions": subs, | |
| 34 | + "Categories": categories, | |
| 35 | + "Category": category, | |
| 36 | + "FeedRecommendations": feedRecs, | |
| 37 | + "PeopleRecommendations": peopleRecs, | |
| 38 | + }) | |
| 39 | +} | |
| 40 | + | |
| 41 | +func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | |
| 42 | + user := currentUser(r) | |
| 43 | + feedURL := r.FormValue("feed_url") | |
| 44 | + category := r.FormValue("category") | |
| 45 | + | |
| 46 | + if feedURL == "" { | |
| 47 | + http.Error(w, "url required", http.StatusBadRequest) | |
| 48 | + return | |
| 49 | + } | |
| 50 | + | |
| 51 | + result, _, _, err := s.fetcher.Fetch(r.Context(), feedURL, "", "") | |
| 52 | + if err != nil { | |
| 53 | + s.logger.Error("failed to fetch feed", "error", err, "url", feedURL) | |
| 54 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 55 | + return | |
| 56 | + } | |
| 57 | + | |
| 58 | + if result != nil { | |
| 59 | + f := &db.Feed{ | |
| 60 | + FeedURL: feedURL, | |
| 61 | + Title: nullString(result.Feed.Title), | |
| 62 | + SiteURL: nullString(result.Feed.SiteURL), | |
| 63 | + Description: nullString(result.Feed.Description), | |
| 64 | + FeedType: nullString(result.Feed.Type), | |
| 65 | + } | |
| 66 | + if err := s.db.UpsertFeed(r.Context(), f); err != nil { | |
| 67 | + s.logger.Error("failed to upsert feed", "error", err) | |
| 68 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 69 | + return | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil { | |
| 74 | + s.logger.Error("failed to create subscription", "error", err) | |
| 75 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 76 | + return | |
| 77 | + } | |
| 78 | + | |
| 79 | + if client := s.pdsClientForUser(r); client != nil { | |
| 80 | + record := atproto.SubscriptionRecord{ | |
| 81 | + CreatedAt: time.Now().Format(time.RFC3339), | |
| 82 | + FeedURL: feedURL, | |
| 83 | + Title: result.Feed.Title, | |
| 84 | + Category: category, | |
| 85 | + } | |
| 86 | + if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil { | |
| 87 | + s.logger.Warn("failed to write subscription to PDS", "error", err) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 92 | + s.render(w, r, "feed_list.html", map[string]any{ | |
| 93 | + "User": user, | |
| 94 | + "Subscriptions": subs, | |
| 95 | + }) | |
| 96 | +} | |
| 97 | + | |
| 98 | +func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | |
| 99 | + user := currentUser(r) | |
| 100 | + feedURL := r.FormValue("url") | |
| 101 | + | |
| 102 | + if feedURL == "" { | |
| 103 | + http.Error(w, "url required", http.StatusBadRequest) | |
| 104 | + return | |
| 105 | + } | |
| 106 | + | |
| 107 | + if err := s.db.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil { | |
| 108 | + s.logger.Error("failed to delete subscription", "error", err) | |
| 109 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 110 | + return | |
| 111 | + } | |
| 112 | + | |
| 113 | + w.WriteHeader(http.StatusNoContent) | |
| 114 | +} | |
| 115 | + | |
| 116 | +func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | |
| 117 | + user := currentUser(r) | |
| 118 | + file, _, err := r.FormFile("opml") | |
| 119 | + if err != nil { | |
| 120 | + http.Error(w, err.Error(), http.StatusBadRequest) | |
| 121 | + return | |
| 122 | + } | |
| 123 | + defer file.Close() | |
| 124 | + | |
| 125 | + opml, err := feed.ParseOPML(file) | |
| 126 | + if err != nil { | |
| 127 | + http.Error(w, err.Error(), http.StatusBadRequest) | |
| 128 | + return | |
| 129 | + } | |
| 130 | + | |
| 131 | + feedURLs := feed.ExtractFeedURLs(opml) | |
| 132 | + var added int | |
| 133 | + client := s.pdsClientForUser(r) | |
| 134 | + for _, fu := range feedURLs { | |
| 135 | + f := &db.Feed{ | |
| 136 | + FeedURL: fu.URL, | |
| 137 | + Title: nullString(fu.Title), | |
| 138 | + } | |
| 139 | + if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil { | |
| 140 | + s.logger.Error("failed to upsert feed", "error", upsertErr) | |
| 141 | + continue | |
| 142 | + } | |
| 143 | + if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category); subErr != nil { | |
| 144 | + s.logger.Error("failed to create subscription", "error", subErr) | |
| 145 | + continue | |
| 146 | + } | |
| 147 | + if client != nil { | |
| 148 | + record := atproto.SubscriptionRecord{ | |
| 149 | + CreatedAt: time.Now().Format(time.RFC3339), | |
| 150 | + FeedURL: fu.URL, | |
| 151 | + Title: fu.Title, | |
| 152 | + Category: fu.Category, | |
| 153 | + } | |
| 154 | + if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil { | |
| 155 | + s.logger.Warn("failed to write subscription to PDS", "error", err, "url", fu.URL) | |
| 156 | + } | |
| 157 | + } | |
| 158 | + added++ | |
| 159 | + } | |
| 160 | + | |
| 161 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 162 | + s.render(w, r, "feeds.html", map[string]any{ | |
| 163 | + "User": user, | |
| 164 | + "Subscriptions": subs, | |
| 165 | + "AddedCount": added, | |
| 166 | + }) | |
| 167 | +} | |
| 168 | + | |
| 169 | +func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) { | |
| 170 | + user := currentUser(r) | |
| 171 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0) | |
| 172 | + | |
| 173 | + var feedURLs []feed.FeedURL | |
| 174 | + for _, sub := range subs { | |
| 175 | + f, err := s.db.GetFeed(r.Context(), sub.FeedURL) | |
| 176 | + title := "" | |
| 177 | + if err == nil { | |
| 178 | + title = f.Title.String | |
| 179 | + } | |
| 180 | + feedURLs = append(feedURLs, feed.FeedURL{ | |
| 181 | + URL: sub.FeedURL, | |
| 182 | + Title: title, | |
| 183 | + }) | |
| 184 | + } | |
| 185 | + | |
| 186 | + data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions") | |
| 187 | + if err != nil { | |
| 188 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 189 | + return | |
| 190 | + } | |
| 191 | + | |
| 192 | + w.Header().Set("Content-Type", "text/xml") | |
| 193 | + w.Header().Set("Content-Disposition", "attachment; filename=glean-subscriptions.xml") | |
| 194 | + _, _ = w.Write(data) | |
| 195 | +} | |
| 196 | + | |
| 197 | +func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { | |
| 198 | + user := currentUser(r) | |
| 199 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 200 | + s.render(w, r, "feeds.html", map[string]any{ | |
| 201 | + "User": user, | |
| 202 | + "Subscriptions": subs, | |
| 203 | + }) | |
| 204 | +} | |
| 205 | + | |
| 206 | +func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | |
| 207 | + user := currentUser(r) | |
| 208 | + store := db.NewFeedStoreAdapter(s.db) | |
| 209 | + scheduler := feed.NewScheduler(store, slog.Default()) | |
| 210 | + | |
| 211 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 212 | + for _, sub := range subs { | |
| 213 | + f, err := s.db.GetFeed(r.Context(), sub.FeedURL) | |
| 214 | + if err != nil { | |
| 215 | + continue | |
| 216 | + } | |
| 217 | + ff := &feed.Feed{ | |
| 218 | + URL: f.FeedURL, | |
| 219 | + Title: f.Title.String, | |
| 220 | + SiteURL: f.SiteURL.String, | |
| 221 | + Description: f.Description.String, | |
| 222 | + Type: f.FeedType.String, | |
| 223 | + ETag: f.Etag.String, | |
| 224 | + LastModified: f.LastModified.String, | |
| 225 | + } | |
| 226 | + scheduler.FetchFeed(r.Context(), ff) | |
| 227 | + } | |
| 228 | + | |
| 229 | + subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | |
| 230 | + s.render(w, r, "feeds.html", map[string]any{ | |
| 231 | + "User": user, | |
| 232 | + "Subscriptions": subs, | |
| 233 | + }) | |
| 234 | +} | |
| 235 | + | |
| 236 | +func nullString(s string) sql.NullString { | |
| 237 | + return sql.NullString{String: s, Valid: s != ""} | |
| 238 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,238 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "database/sql" | ||
| 5 | + "log/slog" | ||
| 6 | + "net/http" | ||
| 7 | + "time" | ||
| 8 | + | ||
| 9 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 10 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 11 | + "pkg.rbrt.fr/glean/internal/feed" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +func (s *Server) handleFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 15 | + user := currentUser(r) | ||
| 16 | + category := r.URL.Query().Get("category") | ||
| 17 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, category, 100, 0) | ||
| 18 | + allSubs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 19 | + feedRecs, _ := s.db.GetFeedRecommendations(r.Context(), user.DID, 10) | ||
| 20 | + peopleRecs, _ := s.db.GetPeopleRecommendations(r.Context(), user.DID, 5) | ||
| 21 | + | ||
| 22 | + seen := make(map[string]bool) | ||
| 23 | + var categories []string | ||
| 24 | + for _, sub := range allSubs { | ||
| 25 | + if sub.Category.Valid && sub.Category.String != "" && !seen[sub.Category.String] { | ||
| 26 | + seen[sub.Category.String] = true | ||
| 27 | + categories = append(categories, sub.Category.String) | ||
| 28 | + } | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + s.render(w, r, "feeds.html", map[string]any{ | ||
| 32 | + "User": user, | ||
| 33 | + "Subscriptions": subs, | ||
| 34 | + "Categories": categories, | ||
| 35 | + "Category": category, | ||
| 36 | + "FeedRecommendations": feedRecs, | ||
| 37 | + "PeopleRecommendations": peopleRecs, | ||
| 38 | + }) | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +func (s *Server) handleAddFeed(w http.ResponseWriter, r *http.Request) { | ||
| 42 | + user := currentUser(r) | ||
| 43 | + feedURL := r.FormValue("feed_url") | ||
| 44 | + category := r.FormValue("category") | ||
| 45 | + | ||
| 46 | + if feedURL == "" { | ||
| 47 | + http.Error(w, "url required", http.StatusBadRequest) | ||
| 48 | + return | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + result, _, _, err := s.fetcher.Fetch(r.Context(), feedURL, "", "") | ||
| 52 | + if err != nil { | ||
| 53 | + s.logger.Error("failed to fetch feed", "error", err, "url", feedURL) | ||
| 54 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 55 | + return | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + if result != nil { | ||
| 59 | + f := &db.Feed{ | ||
| 60 | + FeedURL: feedURL, | ||
| 61 | + Title: nullString(result.Feed.Title), | ||
| 62 | + SiteURL: nullString(result.Feed.SiteURL), | ||
| 63 | + Description: nullString(result.Feed.Description), | ||
| 64 | + FeedType: nullString(result.Feed.Type), | ||
| 65 | + } | ||
| 66 | + if err := s.db.UpsertFeed(r.Context(), f); err != nil { | ||
| 67 | + s.logger.Error("failed to upsert feed", "error", err) | ||
| 68 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 69 | + return | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + if err := s.db.CreateSubscription(r.Context(), user.DID, feedURL, category); err != nil { | ||
| 74 | + s.logger.Error("failed to create subscription", "error", err) | ||
| 75 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 76 | + return | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + if client := s.pdsClientForUser(r); client != nil { | ||
| 80 | + record := atproto.SubscriptionRecord{ | ||
| 81 | + CreatedAt: time.Now().Format(time.RFC3339), | ||
| 82 | + FeedURL: feedURL, | ||
| 83 | + Title: result.Feed.Title, | ||
| 84 | + Category: category, | ||
| 85 | + } | ||
| 86 | + if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil { | ||
| 87 | + s.logger.Warn("failed to write subscription to PDS", "error", err) | ||
| 88 | + } | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 92 | + s.render(w, r, "feed_list.html", map[string]any{ | ||
| 93 | + "User": user, | ||
| 94 | + "Subscriptions": subs, | ||
| 95 | + }) | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +func (s *Server) handleRemoveFeed(w http.ResponseWriter, r *http.Request) { | ||
| 99 | + user := currentUser(r) | ||
| 100 | + feedURL := r.FormValue("url") | ||
| 101 | + | ||
| 102 | + if feedURL == "" { | ||
| 103 | + http.Error(w, "url required", http.StatusBadRequest) | ||
| 104 | + return | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + if err := s.db.DeleteSubscription(r.Context(), user.DID, feedURL); err != nil { | ||
| 108 | + s.logger.Error("failed to delete subscription", "error", err) | ||
| 109 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 110 | + return | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + w.WriteHeader(http.StatusNoContent) | ||
| 114 | +} | ||
| 115 | + | ||
| 116 | +func (s *Server) handleOPMLUpload(w http.ResponseWriter, r *http.Request) { | ||
| 117 | + user := currentUser(r) | ||
| 118 | + file, _, err := r.FormFile("opml") | ||
| 119 | + if err != nil { | ||
| 120 | + http.Error(w, err.Error(), http.StatusBadRequest) | ||
| 121 | + return | ||
| 122 | + } | ||
| 123 | + defer file.Close() | ||
| 124 | + | ||
| 125 | + opml, err := feed.ParseOPML(file) | ||
| 126 | + if err != nil { | ||
| 127 | + http.Error(w, err.Error(), http.StatusBadRequest) | ||
| 128 | + return | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + feedURLs := feed.ExtractFeedURLs(opml) | ||
| 132 | + var added int | ||
| 133 | + client := s.pdsClientForUser(r) | ||
| 134 | + for _, fu := range feedURLs { | ||
| 135 | + f := &db.Feed{ | ||
| 136 | + FeedURL: fu.URL, | ||
| 137 | + Title: nullString(fu.Title), | ||
| 138 | + } | ||
| 139 | + if upsertErr := s.db.UpsertFeed(r.Context(), f); upsertErr != nil { | ||
| 140 | + s.logger.Error("failed to upsert feed", "error", upsertErr) | ||
| 141 | + continue | ||
| 142 | + } | ||
| 143 | + if subErr := s.db.CreateSubscription(r.Context(), user.DID, fu.URL, fu.Category); subErr != nil { | ||
| 144 | + s.logger.Error("failed to create subscription", "error", subErr) | ||
| 145 | + continue | ||
| 146 | + } | ||
| 147 | + if client != nil { | ||
| 148 | + record := atproto.SubscriptionRecord{ | ||
| 149 | + CreatedAt: time.Now().Format(time.RFC3339), | ||
| 150 | + FeedURL: fu.URL, | ||
| 151 | + Title: fu.Title, | ||
| 152 | + Category: fu.Category, | ||
| 153 | + } | ||
| 154 | + if _, _, err := client.CreateRecord(r.Context(), user.DID, "at.glean.subscription", record); err != nil { | ||
| 155 | + s.logger.Warn("failed to write subscription to PDS", "error", err, "url", fu.URL) | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + added++ | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 162 | + s.render(w, r, "feeds.html", map[string]any{ | ||
| 163 | + "User": user, | ||
| 164 | + "Subscriptions": subs, | ||
| 165 | + "AddedCount": added, | ||
| 166 | + }) | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +func (s *Server) handleOPMLDownload(w http.ResponseWriter, r *http.Request) { | ||
| 170 | + user := currentUser(r) | ||
| 171 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 1000, 0) | ||
| 172 | + | ||
| 173 | + var feedURLs []feed.FeedURL | ||
| 174 | + for _, sub := range subs { | ||
| 175 | + f, err := s.db.GetFeed(r.Context(), sub.FeedURL) | ||
| 176 | + title := "" | ||
| 177 | + if err == nil { | ||
| 178 | + title = f.Title.String | ||
| 179 | + } | ||
| 180 | + feedURLs = append(feedURLs, feed.FeedURL{ | ||
| 181 | + URL: sub.FeedURL, | ||
| 182 | + Title: title, | ||
| 183 | + }) | ||
| 184 | + } | ||
| 185 | + | ||
| 186 | + data, err := feed.GenerateOPML(feedURLs, "Glean Subscriptions") | ||
| 187 | + if err != nil { | ||
| 188 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 189 | + return | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + w.Header().Set("Content-Type", "text/xml") | ||
| 193 | + w.Header().Set("Content-Disposition", "attachment; filename=glean-subscriptions.xml") | ||
| 194 | + _, _ = w.Write(data) | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +func (s *Server) handleFeedList(w http.ResponseWriter, r *http.Request) { | ||
| 198 | + user := currentUser(r) | ||
| 199 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 200 | + s.render(w, r, "feeds.html", map[string]any{ | ||
| 201 | + "User": user, | ||
| 202 | + "Subscriptions": subs, | ||
| 203 | + }) | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +func (s *Server) handleRefreshFeeds(w http.ResponseWriter, r *http.Request) { | ||
| 207 | + user := currentUser(r) | ||
| 208 | + store := db.NewFeedStoreAdapter(s.db) | ||
| 209 | + scheduler := feed.NewScheduler(store, slog.Default()) | ||
| 210 | + | ||
| 211 | + subs, _ := s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 212 | + for _, sub := range subs { | ||
| 213 | + f, err := s.db.GetFeed(r.Context(), sub.FeedURL) | ||
| 214 | + if err != nil { | ||
| 215 | + continue | ||
| 216 | + } | ||
| 217 | + ff := &feed.Feed{ | ||
| 218 | + URL: f.FeedURL, | ||
| 219 | + Title: f.Title.String, | ||
| 220 | + SiteURL: f.SiteURL.String, | ||
| 221 | + Description: f.Description.String, | ||
| 222 | + Type: f.FeedType.String, | ||
| 223 | + ETag: f.Etag.String, | ||
| 224 | + LastModified: f.LastModified.String, | ||
| 225 | + } | ||
| 226 | + scheduler.FetchFeed(r.Context(), ff) | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + subs, _ = s.db.ListSubscriptions(r.Context(), user.DID, "", 100, 0) | ||
| 230 | + s.render(w, r, "feeds.html", map[string]any{ | ||
| 231 | + "User": user, | ||
| 232 | + "Subscriptions": subs, | ||
| 233 | + }) | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +func nullString(s string) sql.NullString { | ||
| 237 | + return sql.NullString{String: s, Valid: s != ""} | ||
| 238 | +} | ||
added
internal/server/index_handler.go +12 -0 | new file mode 100644 | ||
| @@ -0,0 +1,12 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import "net/http" | |
| 4 | + | |
| 5 | +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { | |
| 6 | + user := s.getUserFromSession(r) | |
| 7 | + if user != nil { | |
| 8 | + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | |
| 9 | + return | |
| 10 | + } | |
| 11 | + s.render(w, r, "index.html", map[string]any{}) | |
| 12 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,12 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import "net/http" | ||
| 4 | + | ||
| 5 | +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { | ||
| 6 | + user := s.getUserFromSession(r) | ||
| 7 | + if user != nil { | ||
| 8 | + http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | ||
| 9 | + return | ||
| 10 | + } | ||
| 11 | + s.render(w, r, "index.html", map[string]any{}) | ||
| 12 | +} | ||
added
internal/server/middleware.go +83 -0 | new file mode 100644 | ||
| @@ -0,0 +1,83 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "crypto/rand" | |
| 5 | + "encoding/hex" | |
| 6 | + "net/http" | |
| 7 | + "strings" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func (s *Server) sessionMiddleware(next http.Handler) http.Handler { | |
| 11 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 12 | + user := s.getUserFromSession(r) | |
| 13 | + if user != nil { | |
| 14 | + ctx := contextWithUser(r.Context(), user) | |
| 15 | + r = r.WithContext(ctx) | |
| 16 | + } | |
| 17 | + next.ServeHTTP(w, r) | |
| 18 | + }) | |
| 19 | +} | |
| 20 | + | |
| 21 | +func (s *Server) requireAuth(next http.Handler) http.Handler { | |
| 22 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 23 | + if currentUser(r) == nil { | |
| 24 | + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) | |
| 25 | + return | |
| 26 | + } | |
| 27 | + next.ServeHTTP(w, r) | |
| 28 | + }) | |
| 29 | +} | |
| 30 | + | |
| 31 | +func csrfToken() string { | |
| 32 | + b := make([]byte, 32) | |
| 33 | + rand.Read(b) | |
| 34 | + return hex.EncodeToString(b) | |
| 35 | +} | |
| 36 | + | |
| 37 | +func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | |
| 38 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 39 | + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { | |
| 40 | + if _, err := r.Cookie("glean_csrf"); err != nil { | |
| 41 | + http.SetCookie(w, &http.Cookie{ | |
| 42 | + Name: "glean_csrf", | |
| 43 | + Value: csrfToken(), | |
| 44 | + Path: "/", | |
| 45 | + MaxAge: 86400, | |
| 46 | + HttpOnly: false, | |
| 47 | + SameSite: http.SameSiteLaxMode, | |
| 48 | + }) | |
| 49 | + } | |
| 50 | + next.ServeHTTP(w, r) | |
| 51 | + return | |
| 52 | + } | |
| 53 | + | |
| 54 | + if r.Header.Get("HX-Request") == "true" { | |
| 55 | + origin := r.Header.Get("Origin") | |
| 56 | + if origin != "" && !sameOrigin(origin, r.Host) { | |
| 57 | + http.Error(w, "forbidden", http.StatusForbidden) | |
| 58 | + return | |
| 59 | + } | |
| 60 | + next.ServeHTTP(w, r) | |
| 61 | + return | |
| 62 | + } | |
| 63 | + | |
| 64 | + cookie, err := r.Cookie("glean_csrf") | |
| 65 | + if err != nil { | |
| 66 | + http.Error(w, "missing csrf token", http.StatusForbidden) | |
| 67 | + return | |
| 68 | + } | |
| 69 | + formToken := r.FormValue("csrf_token") | |
| 70 | + if formToken == "" { | |
| 71 | + formToken = r.Header.Get("X-CSRF-Token") | |
| 72 | + } | |
| 73 | + if formToken == "" || formToken != cookie.Value { | |
| 74 | + http.Error(w, "csrf mismatch", http.StatusForbidden) | |
| 75 | + return | |
| 76 | + } | |
| 77 | + next.ServeHTTP(w, r) | |
| 78 | + }) | |
| 79 | +} | |
| 80 | + | |
| 81 | +func sameOrigin(origin, host string) bool { | |
| 82 | + return strings.HasPrefix(origin, "http://"+host) || strings.HasPrefix(origin, "https://"+host) | |
| 83 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,83 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "crypto/rand" | ||
| 5 | + "encoding/hex" | ||
| 6 | + "net/http" | ||
| 7 | + "strings" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +func (s *Server) sessionMiddleware(next http.Handler) http.Handler { | ||
| 11 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 12 | + user := s.getUserFromSession(r) | ||
| 13 | + if user != nil { | ||
| 14 | + ctx := contextWithUser(r.Context(), user) | ||
| 15 | + r = r.WithContext(ctx) | ||
| 16 | + } | ||
| 17 | + next.ServeHTTP(w, r) | ||
| 18 | + }) | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +func (s *Server) requireAuth(next http.Handler) http.Handler { | ||
| 22 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 23 | + if currentUser(r) == nil { | ||
| 24 | + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) | ||
| 25 | + return | ||
| 26 | + } | ||
| 27 | + next.ServeHTTP(w, r) | ||
| 28 | + }) | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +func csrfToken() string { | ||
| 32 | + b := make([]byte, 32) | ||
| 33 | + rand.Read(b) | ||
| 34 | + return hex.EncodeToString(b) | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | ||
| 38 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 39 | + if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { | ||
| 40 | + if _, err := r.Cookie("glean_csrf"); err != nil { | ||
| 41 | + http.SetCookie(w, &http.Cookie{ | ||
| 42 | + Name: "glean_csrf", | ||
| 43 | + Value: csrfToken(), | ||
| 44 | + Path: "/", | ||
| 45 | + MaxAge: 86400, | ||
| 46 | + HttpOnly: false, | ||
| 47 | + SameSite: http.SameSiteLaxMode, | ||
| 48 | + }) | ||
| 49 | + } | ||
| 50 | + next.ServeHTTP(w, r) | ||
| 51 | + return | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + if r.Header.Get("HX-Request") == "true" { | ||
| 55 | + origin := r.Header.Get("Origin") | ||
| 56 | + if origin != "" && !sameOrigin(origin, r.Host) { | ||
| 57 | + http.Error(w, "forbidden", http.StatusForbidden) | ||
| 58 | + return | ||
| 59 | + } | ||
| 60 | + next.ServeHTTP(w, r) | ||
| 61 | + return | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + cookie, err := r.Cookie("glean_csrf") | ||
| 65 | + if err != nil { | ||
| 66 | + http.Error(w, "missing csrf token", http.StatusForbidden) | ||
| 67 | + return | ||
| 68 | + } | ||
| 69 | + formToken := r.FormValue("csrf_token") | ||
| 70 | + if formToken == "" { | ||
| 71 | + formToken = r.Header.Get("X-CSRF-Token") | ||
| 72 | + } | ||
| 73 | + if formToken == "" || formToken != cookie.Value { | ||
| 74 | + http.Error(w, "csrf mismatch", http.StatusForbidden) | ||
| 75 | + return | ||
| 76 | + } | ||
| 77 | + next.ServeHTTP(w, r) | ||
| 78 | + }) | ||
| 79 | +} | ||
| 80 | + | ||
| 81 | +func sameOrigin(origin, host string) bool { | ||
| 82 | + return strings.HasPrefix(origin, "http://"+host) || strings.HasPrefix(origin, "https://"+host) | ||
| 83 | +} | ||
added
internal/server/profile_handler.go +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + | |
| 6 | + "github.com/go-chi/chi/v5" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | |
| 10 | + did := chi.URLParam(r, "did") | |
| 11 | + profileUser, err := s.db.GetUser(r.Context(), did) | |
| 12 | + if err != nil { | |
| 13 | + http.Error(w, err.Error(), http.StatusNotFound) | |
| 14 | + return | |
| 15 | + } | |
| 16 | + | |
| 17 | + subs, _ := s.db.ListSubscriptions(r.Context(), did, "", 50, 0) | |
| 18 | + annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0) | |
| 19 | + subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) | |
| 20 | + | |
| 21 | + s.render(w, r, "profile.html", map[string]any{ | |
| 22 | + "User": s.getUserFromSession(r), | |
| 23 | + "ProfileUser": profileUser, | |
| 24 | + "Subscriptions": subs, | |
| 25 | + "Annotations": annotations, | |
| 26 | + "SubscriptionCount": subCount, | |
| 27 | + "AnnotationCount": len(annotations), | |
| 28 | + }) | |
| 29 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + | ||
| 6 | + "github.com/go-chi/chi/v5" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) { | ||
| 10 | + did := chi.URLParam(r, "did") | ||
| 11 | + profileUser, err := s.db.GetUser(r.Context(), did) | ||
| 12 | + if err != nil { | ||
| 13 | + http.Error(w, err.Error(), http.StatusNotFound) | ||
| 14 | + return | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + subs, _ := s.db.ListSubscriptions(r.Context(), did, "", 50, 0) | ||
| 18 | + annotations, _ := s.db.ListAnnotations(r.Context(), "", "", did, 50, 0) | ||
| 19 | + subCount, _ := s.db.GetSubscriptionCount(r.Context(), did) | ||
| 20 | + | ||
| 21 | + s.render(w, r, "profile.html", map[string]any{ | ||
| 22 | + "User": s.getUserFromSession(r), | ||
| 23 | + "ProfileUser": profileUser, | ||
| 24 | + "Subscriptions": subs, | ||
| 25 | + "Annotations": annotations, | ||
| 26 | + "SubscriptionCount": subCount, | ||
| 27 | + "AnnotationCount": len(annotations), | ||
| 28 | + }) | ||
| 29 | +} | ||
added
internal/server/server.go +258 -0 | new file mode 100644 | ||
| @@ -0,0 +1,258 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "html/template" | |
| 6 | + "log/slog" | |
| 7 | + "net/http" | |
| 8 | + "path/filepath" | |
| 9 | + "strings" | |
| 10 | + "time" | |
| 11 | + | |
| 12 | + "github.com/go-chi/chi/v5" | |
| 13 | + "github.com/go-chi/chi/v5/middleware" | |
| 14 | + "github.com/go-chi/cors" | |
| 15 | + | |
| 16 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 17 | + "pkg.rbrt.fr/glean/internal/db" | |
| 18 | + "pkg.rbrt.fr/glean/internal/feed" | |
| 19 | + "pkg.rbrt.fr/glean/internal/sanitize" | |
| 20 | +) | |
| 21 | + | |
| 22 | +func splitString(s, sep string) []string { | |
| 23 | + return strings.Split(s, sep) | |
| 24 | +} | |
| 25 | + | |
| 26 | +type Server struct { | |
| 27 | + db *db.DB | |
| 28 | + router *chi.Mux | |
| 29 | + templates *template.Template | |
| 30 | + logger *slog.Logger | |
| 31 | + oauth *atproto.OAuthConfig | |
| 32 | + fetcher *feed.Fetcher | |
| 33 | +} | |
| 34 | + | |
| 35 | +func New(database *db.DB, oauth *atproto.OAuthConfig, logger *slog.Logger) *Server { | |
| 36 | + s := &Server{ | |
| 37 | + db: database, | |
| 38 | + router: chi.NewRouter(), | |
| 39 | + logger: logger, | |
| 40 | + oauth: oauth, | |
| 41 | + fetcher: feed.NewFetcher(), | |
| 42 | + } | |
| 43 | + | |
| 44 | + s.setupMiddleware() | |
| 45 | + s.setupRoutes() | |
| 46 | + s.loadTemplates() | |
| 47 | + | |
| 48 | + return s | |
| 49 | +} | |
| 50 | + | |
| 51 | +func (s *Server) setupMiddleware() { | |
| 52 | + s.router.Use(middleware.Logger) | |
| 53 | + s.router.Use(middleware.Recoverer) | |
| 54 | + s.router.Use(middleware.Compress(5)) | |
| 55 | + s.router.Use(cors.Handler(cors.Options{ | |
| 56 | + AllowedOrigins: []string{"*"}, | |
| 57 | + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, | |
| 58 | + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, | |
| 59 | + MaxAge: 300, | |
| 60 | + })) | |
| 61 | + s.router.Use(s.sessionMiddleware) | |
| 62 | + s.router.Use(s.csrfMiddleware) | |
| 63 | +} | |
| 64 | + | |
| 65 | +func (s *Server) setupRoutes() { | |
| 66 | + s.router.Get("/", s.handleIndex) | |
| 67 | + | |
| 68 | + s.router.Route("/dashboard", func(r chi.Router) { | |
| 69 | + r.Use(s.requireAuth) | |
| 70 | + r.Get("/", s.handleDashboard) | |
| 71 | + }) | |
| 72 | + | |
| 73 | + s.router.Route("/feeds", func(r chi.Router) { | |
| 74 | + r.Use(s.requireAuth) | |
| 75 | + r.Get("/", s.handleFeeds) | |
| 76 | + r.Post("/add", s.handleAddFeed) | |
| 77 | + r.Delete("/remove", s.handleRemoveFeed) | |
| 78 | + r.Post("/opml/upload", s.handleOPMLUpload) | |
| 79 | + r.Get("/opml/download", s.handleOPMLDownload) | |
| 80 | + r.Post("/refresh", s.handleRefreshFeeds) | |
| 81 | + r.Get("/list", s.handleFeedList) | |
| 82 | + }) | |
| 83 | + | |
| 84 | + s.router.Route("/articles", func(r chi.Router) { | |
| 85 | + r.Use(s.requireAuth) | |
| 86 | + r.Get("/", s.handleArticles) | |
| 87 | + r.Get("/{id}", s.handleArticleDetail) | |
| 88 | + r.Post("/{id}/read", s.handleMarkRead) | |
| 89 | + r.Post("/{id}/unread", s.handleMarkUnread) | |
| 90 | + r.Post("/{id}/star", s.handleStar) | |
| 91 | + r.Post("/{id}/unstar", s.handleUnstar) | |
| 92 | + r.Post("/{id}/like", s.handleLikeArticle) | |
| 93 | + r.Post("/mark-all-read", s.handleMarkAllRead) | |
| 94 | + }) | |
| 95 | + | |
| 96 | + s.router.Route("/trending", func(r chi.Router) { | |
| 97 | + r.Use(s.requireAuth) | |
| 98 | + r.Get("/", s.handleTrending) | |
| 99 | + }) | |
| 100 | + | |
| 101 | + s.router.Route("/discover", func(r chi.Router) { | |
| 102 | + r.Use(s.requireAuth) | |
| 103 | + r.Get("/", s.handleDiscover) | |
| 104 | + r.Get("/feeds", s.handleDiscoverFeeds) | |
| 105 | + r.Get("/people", s.handleDiscoverPeople) | |
| 106 | + }) | |
| 107 | + | |
| 108 | + s.router.Get("/profile/{did}", s.handleProfile) | |
| 109 | + | |
| 110 | + s.router.Route("/annotations", func(r chi.Router) { | |
| 111 | + r.Use(s.requireAuth) | |
| 112 | + r.Get("/", s.handleAnnotations) | |
| 113 | + r.Post("/create", s.handleCreateAnnotation) | |
| 114 | + }) | |
| 115 | + | |
| 116 | + s.router.Get("/auth/login", s.handleAuthLogin) | |
| 117 | + s.router.Get("/auth/callback", s.handleAuthCallback) | |
| 118 | + s.router.Post("/auth/logout", s.handleAuthLogout) | |
| 119 | + | |
| 120 | + xrpc := atproto.NewXRPCHandler(s.db.DB) | |
| 121 | + s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions) | |
| 122 | + s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations) | |
| 123 | + s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes) | |
| 124 | + s.router.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending) | |
| 125 | + s.router.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations) | |
| 126 | + s.router.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists) | |
| 127 | + | |
| 128 | + s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) | |
| 129 | +} | |
| 130 | + | |
| 131 | +func (s *Server) loadTemplates() { | |
| 132 | + fm := template.FuncMap{ | |
| 133 | + "formatDate": func(t time.Time) string { | |
| 134 | + return t.Format("Jan 02, 2006") | |
| 135 | + }, | |
| 136 | + "formatDateTime": func(t time.Time) string { | |
| 137 | + return t.Format("Jan 02, 2006 15:04") | |
| 138 | + }, | |
| 139 | + "split": func(sv, sep string) []string { | |
| 140 | + if sv == "" { | |
| 141 | + return nil | |
| 142 | + } | |
| 143 | + var result []string | |
| 144 | + for _, p := range splitString(sv, sep) { | |
| 145 | + if p != "" { | |
| 146 | + result = append(result, p) | |
| 147 | + } | |
| 148 | + } | |
| 149 | + return result | |
| 150 | + }, | |
| 151 | + "repeat": func(str string, n int) string { | |
| 152 | + b := strings.Builder{} | |
| 153 | + for range n { | |
| 154 | + b.WriteString(str) | |
| 155 | + } | |
| 156 | + return b.String() | |
| 157 | + }, | |
| 158 | + "int": func(n int64) int { | |
| 159 | + return int(n) | |
| 160 | + }, | |
| 161 | + "add": func(a, b int) int { | |
| 162 | + return a + b | |
| 163 | + }, | |
| 164 | + "sanitizeHTML": func(input string) template.HTML { | |
| 165 | + return template.HTML(sanitize.HTML(input)) | |
| 166 | + }, | |
| 167 | + "plainText": func(input string) string { | |
| 168 | + return sanitize.PlainText(input) | |
| 169 | + }, | |
| 170 | + "now": func() time.Time { | |
| 171 | + return time.Now() | |
| 172 | + }, | |
| 173 | + "activeClass": func(activePath, linkPath string) string { | |
| 174 | + if activePath == linkPath || (len(activePath) > len(linkPath) && activePath[:len(linkPath)+1] == linkPath+"/") { | |
| 175 | + return "bg-spot-hover text-spot-text font-bold" | |
| 176 | + } | |
| 177 | + return "text-spot-secondary" | |
| 178 | + }, | |
| 179 | + "csrfInput": func(token any) template.HTML { | |
| 180 | + s, ok := token.(string) | |
| 181 | + if !ok || s == "" { | |
| 182 | + return "" | |
| 183 | + } | |
| 184 | + return template.HTML(`<input type="hidden" name="csrf_token" value="` + s + `">`) | |
| 185 | + }, | |
| 186 | + } | |
| 187 | + | |
| 188 | + var allFiles []string | |
| 189 | + matches, _ := filepath.Glob("internal/tmpl/*.html") | |
| 190 | + allFiles = append(allFiles, matches...) | |
| 191 | + partials, _ := filepath.Glob("internal/tmpl/partials/*.html") | |
| 192 | + allFiles = append(allFiles, partials...) | |
| 193 | + | |
| 194 | + var err error | |
| 195 | + s.templates, err = template.New("").Funcs(fm).ParseFiles(allFiles...) | |
| 196 | + if err != nil { | |
| 197 | + s.logger.Error("failed to load templates", "error", err) | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client { | |
| 202 | + session := s.getSessionData(r) | |
| 203 | + if session == nil || session.AccessToken == "" || session.PDSURL == "" { | |
| 204 | + return nil | |
| 205 | + } | |
| 206 | + return atproto.NewClient(session.PDSURL, session.AccessToken) | |
| 207 | +} | |
| 208 | + | |
| 209 | +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 210 | + s.router.ServeHTTP(w, r) | |
| 211 | +} | |
| 212 | + | |
| 213 | +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { | |
| 214 | + if data == nil { | |
| 215 | + data = map[string]any{} | |
| 216 | + } | |
| 217 | + | |
| 218 | + if cookie, err := r.Cookie("glean_csrf"); err == nil { | |
| 219 | + data["CSRFToken"] = cookie.Value | |
| 220 | + } | |
| 221 | + | |
| 222 | + if r.Header.Get("HX-Request") == "true" { | |
| 223 | + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { | |
| 224 | + s.logger.Error("template error", "error", err, "template", name) | |
| 225 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 226 | + } | |
| 227 | + return | |
| 228 | + } | |
| 229 | + | |
| 230 | + var buf bytes.Buffer | |
| 231 | + if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil { | |
| 232 | + s.logger.Error("template error", "error", err, "template", name) | |
| 233 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 234 | + return | |
| 235 | + } | |
| 236 | + | |
| 237 | + path := r.URL.Path | |
| 238 | + if len(path) > 1 { | |
| 239 | + path = strings.TrimRight(path, "/") | |
| 240 | + } | |
| 241 | + baseData := map[string]any{ | |
| 242 | + "Content": template.HTML(buf.String()), | |
| 243 | + "ActivePath": path, | |
| 244 | + } | |
| 245 | + if data != nil { | |
| 246 | + if u, ok := data["User"]; ok { | |
| 247 | + baseData["User"] = u | |
| 248 | + } | |
| 249 | + if csrf, ok := data["CSRFToken"]; ok { | |
| 250 | + baseData["CSRFToken"] = csrf | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + if err := s.templates.ExecuteTemplate(w, "base.html", baseData); err != nil { | |
| 255 | + s.logger.Error("template error", "error", err, "template", "base.html") | |
| 256 | + http.Error(w, err.Error(), http.StatusInternalServerError) | |
| 257 | + } | |
| 258 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,258 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "bytes" | ||
| 5 | + "html/template" | ||
| 6 | + "log/slog" | ||
| 7 | + "net/http" | ||
| 8 | + "path/filepath" | ||
| 9 | + "strings" | ||
| 10 | + "time" | ||
| 11 | + | ||
| 12 | + "github.com/go-chi/chi/v5" | ||
| 13 | + "github.com/go-chi/chi/v5/middleware" | ||
| 14 | + "github.com/go-chi/cors" | ||
| 15 | + | ||
| 16 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 17 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 18 | + "pkg.rbrt.fr/glean/internal/feed" | ||
| 19 | + "pkg.rbrt.fr/glean/internal/sanitize" | ||
| 20 | +) | ||
| 21 | + | ||
| 22 | +func splitString(s, sep string) []string { | ||
| 23 | + return strings.Split(s, sep) | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +type Server struct { | ||
| 27 | + db *db.DB | ||
| 28 | + router *chi.Mux | ||
| 29 | + templates *template.Template | ||
| 30 | + logger *slog.Logger | ||
| 31 | + oauth *atproto.OAuthConfig | ||
| 32 | + fetcher *feed.Fetcher | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +func New(database *db.DB, oauth *atproto.OAuthConfig, logger *slog.Logger) *Server { | ||
| 36 | + s := &Server{ | ||
| 37 | + db: database, | ||
| 38 | + router: chi.NewRouter(), | ||
| 39 | + logger: logger, | ||
| 40 | + oauth: oauth, | ||
| 41 | + fetcher: feed.NewFetcher(), | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + s.setupMiddleware() | ||
| 45 | + s.setupRoutes() | ||
| 46 | + s.loadTemplates() | ||
| 47 | + | ||
| 48 | + return s | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +func (s *Server) setupMiddleware() { | ||
| 52 | + s.router.Use(middleware.Logger) | ||
| 53 | + s.router.Use(middleware.Recoverer) | ||
| 54 | + s.router.Use(middleware.Compress(5)) | ||
| 55 | + s.router.Use(cors.Handler(cors.Options{ | ||
| 56 | + AllowedOrigins: []string{"*"}, | ||
| 57 | + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, | ||
| 58 | + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, | ||
| 59 | + MaxAge: 300, | ||
| 60 | + })) | ||
| 61 | + s.router.Use(s.sessionMiddleware) | ||
| 62 | + s.router.Use(s.csrfMiddleware) | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +func (s *Server) setupRoutes() { | ||
| 66 | + s.router.Get("/", s.handleIndex) | ||
| 67 | + | ||
| 68 | + s.router.Route("/dashboard", func(r chi.Router) { | ||
| 69 | + r.Use(s.requireAuth) | ||
| 70 | + r.Get("/", s.handleDashboard) | ||
| 71 | + }) | ||
| 72 | + | ||
| 73 | + s.router.Route("/feeds", func(r chi.Router) { | ||
| 74 | + r.Use(s.requireAuth) | ||
| 75 | + r.Get("/", s.handleFeeds) | ||
| 76 | + r.Post("/add", s.handleAddFeed) | ||
| 77 | + r.Delete("/remove", s.handleRemoveFeed) | ||
| 78 | + r.Post("/opml/upload", s.handleOPMLUpload) | ||
| 79 | + r.Get("/opml/download", s.handleOPMLDownload) | ||
| 80 | + r.Post("/refresh", s.handleRefreshFeeds) | ||
| 81 | + r.Get("/list", s.handleFeedList) | ||
| 82 | + }) | ||
| 83 | + | ||
| 84 | + s.router.Route("/articles", func(r chi.Router) { | ||
| 85 | + r.Use(s.requireAuth) | ||
| 86 | + r.Get("/", s.handleArticles) | ||
| 87 | + r.Get("/{id}", s.handleArticleDetail) | ||
| 88 | + r.Post("/{id}/read", s.handleMarkRead) | ||
| 89 | + r.Post("/{id}/unread", s.handleMarkUnread) | ||
| 90 | + r.Post("/{id}/star", s.handleStar) | ||
| 91 | + r.Post("/{id}/unstar", s.handleUnstar) | ||
| 92 | + r.Post("/{id}/like", s.handleLikeArticle) | ||
| 93 | + r.Post("/mark-all-read", s.handleMarkAllRead) | ||
| 94 | + }) | ||
| 95 | + | ||
| 96 | + s.router.Route("/trending", func(r chi.Router) { | ||
| 97 | + r.Use(s.requireAuth) | ||
| 98 | + r.Get("/", s.handleTrending) | ||
| 99 | + }) | ||
| 100 | + | ||
| 101 | + s.router.Route("/discover", func(r chi.Router) { | ||
| 102 | + r.Use(s.requireAuth) | ||
| 103 | + r.Get("/", s.handleDiscover) | ||
| 104 | + r.Get("/feeds", s.handleDiscoverFeeds) | ||
| 105 | + r.Get("/people", s.handleDiscoverPeople) | ||
| 106 | + }) | ||
| 107 | + | ||
| 108 | + s.router.Get("/profile/{did}", s.handleProfile) | ||
| 109 | + | ||
| 110 | + s.router.Route("/annotations", func(r chi.Router) { | ||
| 111 | + r.Use(s.requireAuth) | ||
| 112 | + r.Get("/", s.handleAnnotations) | ||
| 113 | + r.Post("/create", s.handleCreateAnnotation) | ||
| 114 | + }) | ||
| 115 | + | ||
| 116 | + s.router.Get("/auth/login", s.handleAuthLogin) | ||
| 117 | + s.router.Get("/auth/callback", s.handleAuthCallback) | ||
| 118 | + s.router.Post("/auth/logout", s.handleAuthLogout) | ||
| 119 | + | ||
| 120 | + xrpc := atproto.NewXRPCHandler(s.db.DB) | ||
| 121 | + s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions) | ||
| 122 | + s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations) | ||
| 123 | + s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes) | ||
| 124 | + s.router.Get("/xrpc/at.glean.getTrending", xrpc.GetTrending) | ||
| 125 | + s.router.Get("/xrpc/at.glean.getRecommendations", xrpc.GetRecommendations) | ||
| 126 | + s.router.Get("/xrpc/at.glean.listFeedLists", xrpc.ListFeedLists) | ||
| 127 | + | ||
| 128 | + s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +func (s *Server) loadTemplates() { | ||
| 132 | + fm := template.FuncMap{ | ||
| 133 | + "formatDate": func(t time.Time) string { | ||
| 134 | + return t.Format("Jan 02, 2006") | ||
| 135 | + }, | ||
| 136 | + "formatDateTime": func(t time.Time) string { | ||
| 137 | + return t.Format("Jan 02, 2006 15:04") | ||
| 138 | + }, | ||
| 139 | + "split": func(sv, sep string) []string { | ||
| 140 | + if sv == "" { | ||
| 141 | + return nil | ||
| 142 | + } | ||
| 143 | + var result []string | ||
| 144 | + for _, p := range splitString(sv, sep) { | ||
| 145 | + if p != "" { | ||
| 146 | + result = append(result, p) | ||
| 147 | + } | ||
| 148 | + } | ||
| 149 | + return result | ||
| 150 | + }, | ||
| 151 | + "repeat": func(str string, n int) string { | ||
| 152 | + b := strings.Builder{} | ||
| 153 | + for range n { | ||
| 154 | + b.WriteString(str) | ||
| 155 | + } | ||
| 156 | + return b.String() | ||
| 157 | + }, | ||
| 158 | + "int": func(n int64) int { | ||
| 159 | + return int(n) | ||
| 160 | + }, | ||
| 161 | + "add": func(a, b int) int { | ||
| 162 | + return a + b | ||
| 163 | + }, | ||
| 164 | + "sanitizeHTML": func(input string) template.HTML { | ||
| 165 | + return template.HTML(sanitize.HTML(input)) | ||
| 166 | + }, | ||
| 167 | + "plainText": func(input string) string { | ||
| 168 | + return sanitize.PlainText(input) | ||
| 169 | + }, | ||
| 170 | + "now": func() time.Time { | ||
| 171 | + return time.Now() | ||
| 172 | + }, | ||
| 173 | + "activeClass": func(activePath, linkPath string) string { | ||
| 174 | + if activePath == linkPath || (len(activePath) > len(linkPath) && activePath[:len(linkPath)+1] == linkPath+"/") { | ||
| 175 | + return "bg-spot-hover text-spot-text font-bold" | ||
| 176 | + } | ||
| 177 | + return "text-spot-secondary" | ||
| 178 | + }, | ||
| 179 | + "csrfInput": func(token any) template.HTML { | ||
| 180 | + s, ok := token.(string) | ||
| 181 | + if !ok || s == "" { | ||
| 182 | + return "" | ||
| 183 | + } | ||
| 184 | + return template.HTML(`<input type="hidden" name="csrf_token" value="` + s + `">`) | ||
| 185 | + }, | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + var allFiles []string | ||
| 189 | + matches, _ := filepath.Glob("internal/tmpl/*.html") | ||
| 190 | + allFiles = append(allFiles, matches...) | ||
| 191 | + partials, _ := filepath.Glob("internal/tmpl/partials/*.html") | ||
| 192 | + allFiles = append(allFiles, partials...) | ||
| 193 | + | ||
| 194 | + var err error | ||
| 195 | + s.templates, err = template.New("").Funcs(fm).ParseFiles(allFiles...) | ||
| 196 | + if err != nil { | ||
| 197 | + s.logger.Error("failed to load templates", "error", err) | ||
| 198 | + } | ||
| 199 | +} | ||
| 200 | + | ||
| 201 | +func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client { | ||
| 202 | + session := s.getSessionData(r) | ||
| 203 | + if session == nil || session.AccessToken == "" || session.PDSURL == "" { | ||
| 204 | + return nil | ||
| 205 | + } | ||
| 206 | + return atproto.NewClient(session.PDSURL, session.AccessToken) | ||
| 207 | +} | ||
| 208 | + | ||
| 209 | +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 210 | + s.router.ServeHTTP(w, r) | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) { | ||
| 214 | + if data == nil { | ||
| 215 | + data = map[string]any{} | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + if cookie, err := r.Cookie("glean_csrf"); err == nil { | ||
| 219 | + data["CSRFToken"] = cookie.Value | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + if r.Header.Get("HX-Request") == "true" { | ||
| 223 | + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { | ||
| 224 | + s.logger.Error("template error", "error", err, "template", name) | ||
| 225 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 226 | + } | ||
| 227 | + return | ||
| 228 | + } | ||
| 229 | + | ||
| 230 | + var buf bytes.Buffer | ||
| 231 | + if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil { | ||
| 232 | + s.logger.Error("template error", "error", err, "template", name) | ||
| 233 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 234 | + return | ||
| 235 | + } | ||
| 236 | + | ||
| 237 | + path := r.URL.Path | ||
| 238 | + if len(path) > 1 { | ||
| 239 | + path = strings.TrimRight(path, "/") | ||
| 240 | + } | ||
| 241 | + baseData := map[string]any{ | ||
| 242 | + "Content": template.HTML(buf.String()), | ||
| 243 | + "ActivePath": path, | ||
| 244 | + } | ||
| 245 | + if data != nil { | ||
| 246 | + if u, ok := data["User"]; ok { | ||
| 247 | + baseData["User"] = u | ||
| 248 | + } | ||
| 249 | + if csrf, ok := data["CSRFToken"]; ok { | ||
| 250 | + baseData["CSRFToken"] = csrf | ||
| 251 | + } | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + if err := s.templates.ExecuteTemplate(w, "base.html", baseData); err != nil { | ||
| 255 | + s.logger.Error("template error", "error", err, "template", "base.html") | ||
| 256 | + http.Error(w, err.Error(), http.StatusInternalServerError) | ||
| 257 | + } | ||
| 258 | +} | ||
added
internal/server/session.go +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "crypto/hmac" | |
| 6 | + "crypto/sha256" | |
| 7 | + "encoding/base64" | |
| 8 | + "encoding/json" | |
| 9 | + "net/http" | |
| 10 | + "os" | |
| 11 | + | |
| 12 | + "pkg.rbrt.fr/glean/internal/db" | |
| 13 | +) | |
| 14 | + | |
| 15 | +type ctxKey struct{} | |
| 16 | + | |
| 17 | +func contextWithUser(ctx context.Context, user *db.User) context.Context { | |
| 18 | + return context.WithValue(ctx, ctxKey{}, user) | |
| 19 | +} | |
| 20 | + | |
| 21 | +func currentUser(r *http.Request) *db.User { | |
| 22 | + user, _ := r.Context().Value(ctxKey{}).(*db.User) | |
| 23 | + return user | |
| 24 | +} | |
| 25 | + | |
| 26 | +func (s *Server) getUserFromSession(r *http.Request) *db.User { | |
| 27 | + cookie, err := r.Cookie("glean_session") | |
| 28 | + if err != nil { | |
| 29 | + return nil | |
| 30 | + } | |
| 31 | + | |
| 32 | + data, err := decodeSession(cookie.Value) | |
| 33 | + if err != nil { | |
| 34 | + return nil | |
| 35 | + } | |
| 36 | + | |
| 37 | + user, err := s.db.GetUser(r.Context(), data.DID) | |
| 38 | + if err != nil { | |
| 39 | + return nil | |
| 40 | + } | |
| 41 | + | |
| 42 | + return user | |
| 43 | +} | |
| 44 | + | |
| 45 | +func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) { | |
| 46 | + data := sessionData{DID: user.DID} | |
| 47 | + encoded, err := encodeSession(data) | |
| 48 | + if err != nil { | |
| 49 | + s.logger.Error("failed to encode session", "error", err) | |
| 50 | + return | |
| 51 | + } | |
| 52 | + | |
| 53 | + http.SetCookie(w, &http.Cookie{ | |
| 54 | + Name: "glean_session", | |
| 55 | + Value: encoded, | |
| 56 | + Path: "/", | |
| 57 | + MaxAge: 86400 * 30, | |
| 58 | + HttpOnly: true, | |
| 59 | + SameSite: http.SameSiteLaxMode, | |
| 60 | + }) | |
| 61 | +} | |
| 62 | + | |
| 63 | +func (s *Server) clearUserSession(w http.ResponseWriter) { | |
| 64 | + http.SetCookie(w, &http.Cookie{ | |
| 65 | + Name: "glean_session", | |
| 66 | + Value: "", | |
| 67 | + Path: "/", | |
| 68 | + MaxAge: -1, | |
| 69 | + HttpOnly: true, | |
| 70 | + SameSite: http.SameSiteLaxMode, | |
| 71 | + }) | |
| 72 | +} | |
| 73 | + | |
| 74 | +type sessionData struct { | |
| 75 | + DID string `json:"did"` | |
| 76 | + PDSURL string `json:"pds_url,omitempty"` | |
| 77 | + AccessToken string `json:"access_token,omitempty"` | |
| 78 | +} | |
| 79 | + | |
| 80 | +func (s *Server) getSessionData(r *http.Request) *sessionData { | |
| 81 | + cookie, err := r.Cookie("glean_session") | |
| 82 | + if err != nil { | |
| 83 | + return nil | |
| 84 | + } | |
| 85 | + data, err := decodeSession(cookie.Value) | |
| 86 | + if err != nil { | |
| 87 | + return nil | |
| 88 | + } | |
| 89 | + return data | |
| 90 | +} | |
| 91 | + | |
| 92 | +func sessionKey() []byte { | |
| 93 | + key := os.Getenv("GLEAN_SESSION_KEY") | |
| 94 | + if key == "" { | |
| 95 | + key = "default-dev-key-change-in-production" | |
| 96 | + } | |
| 97 | + return []byte(key) | |
| 98 | +} | |
| 99 | + | |
| 100 | +func encodeSession(data sessionData) (string, error) { | |
| 101 | + payload, err := json.Marshal(data) | |
| 102 | + if err != nil { | |
| 103 | + return "", err | |
| 104 | + } | |
| 105 | + | |
| 106 | + mac := hmac.New(sha256.New, sessionKey()) | |
| 107 | + mac.Write(payload) | |
| 108 | + sig := mac.Sum(nil) | |
| 109 | + | |
| 110 | + raw := append(payload, sig...) | |
| 111 | + return base64.URLEncoding.EncodeToString(raw), nil | |
| 112 | +} | |
| 113 | + | |
| 114 | +func decodeSession(encoded string) (*sessionData, error) { | |
| 115 | + raw, err := base64.URLEncoding.DecodeString(encoded) | |
| 116 | + if err != nil { | |
| 117 | + return nil, errInvalidSession | |
| 118 | + } | |
| 119 | + | |
| 120 | + if len(raw) < sha256.Size { | |
| 121 | + return nil, errInvalidSession | |
| 122 | + } | |
| 123 | + | |
| 124 | + payload := raw[:len(raw)-sha256.Size] | |
| 125 | + sig := raw[len(raw)-sha256.Size:] | |
| 126 | + | |
| 127 | + mac := hmac.New(sha256.New, sessionKey()) | |
| 128 | + mac.Write(payload) | |
| 129 | + expectedSig := mac.Sum(nil) | |
| 130 | + | |
| 131 | + if !hmac.Equal(sig, expectedSig) { | |
| 132 | + return nil, errInvalidSession | |
| 133 | + } | |
| 134 | + | |
| 135 | + var data sessionData | |
| 136 | + if err := json.Unmarshal(payload, &data); err != nil { | |
| 137 | + return nil, errInvalidSession | |
| 138 | + } | |
| 139 | + | |
| 140 | + return &data, nil | |
| 141 | +} | |
| 142 | + | |
| 143 | +var errInvalidSession = &invalidSessionError{} | |
| 144 | + | |
| 145 | +type invalidSessionError struct{} | |
| 146 | + | |
| 147 | +func (e *invalidSessionError) Error() string { return "invalid session" } | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "crypto/hmac" | ||
| 6 | + "crypto/sha256" | ||
| 7 | + "encoding/base64" | ||
| 8 | + "encoding/json" | ||
| 9 | + "net/http" | ||
| 10 | + "os" | ||
| 11 | + | ||
| 12 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +type ctxKey struct{} | ||
| 16 | + | ||
| 17 | +func contextWithUser(ctx context.Context, user *db.User) context.Context { | ||
| 18 | + return context.WithValue(ctx, ctxKey{}, user) | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +func currentUser(r *http.Request) *db.User { | ||
| 22 | + user, _ := r.Context().Value(ctxKey{}).(*db.User) | ||
| 23 | + return user | ||
| 24 | +} | ||
| 25 | + | ||
| 26 | +func (s *Server) getUserFromSession(r *http.Request) *db.User { | ||
| 27 | + cookie, err := r.Cookie("glean_session") | ||
| 28 | + if err != nil { | ||
| 29 | + return nil | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + data, err := decodeSession(cookie.Value) | ||
| 33 | + if err != nil { | ||
| 34 | + return nil | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + user, err := s.db.GetUser(r.Context(), data.DID) | ||
| 38 | + if err != nil { | ||
| 39 | + return nil | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + return user | ||
| 43 | +} | ||
| 44 | + | ||
| 45 | +func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) { | ||
| 46 | + data := sessionData{DID: user.DID} | ||
| 47 | + encoded, err := encodeSession(data) | ||
| 48 | + if err != nil { | ||
| 49 | + s.logger.Error("failed to encode session", "error", err) | ||
| 50 | + return | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + http.SetCookie(w, &http.Cookie{ | ||
| 54 | + Name: "glean_session", | ||
| 55 | + Value: encoded, | ||
| 56 | + Path: "/", | ||
| 57 | + MaxAge: 86400 * 30, | ||
| 58 | + HttpOnly: true, | ||
| 59 | + SameSite: http.SameSiteLaxMode, | ||
| 60 | + }) | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +func (s *Server) clearUserSession(w http.ResponseWriter) { | ||
| 64 | + http.SetCookie(w, &http.Cookie{ | ||
| 65 | + Name: "glean_session", | ||
| 66 | + Value: "", | ||
| 67 | + Path: "/", | ||
| 68 | + MaxAge: -1, | ||
| 69 | + HttpOnly: true, | ||
| 70 | + SameSite: http.SameSiteLaxMode, | ||
| 71 | + }) | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +type sessionData struct { | ||
| 75 | + DID string `json:"did"` | ||
| 76 | + PDSURL string `json:"pds_url,omitempty"` | ||
| 77 | + AccessToken string `json:"access_token,omitempty"` | ||
| 78 | +} | ||
| 79 | + | ||
| 80 | +func (s *Server) getSessionData(r *http.Request) *sessionData { | ||
| 81 | + cookie, err := r.Cookie("glean_session") | ||
| 82 | + if err != nil { | ||
| 83 | + return nil | ||
| 84 | + } | ||
| 85 | + data, err := decodeSession(cookie.Value) | ||
| 86 | + if err != nil { | ||
| 87 | + return nil | ||
| 88 | + } | ||
| 89 | + return data | ||
| 90 | +} | ||
| 91 | + | ||
| 92 | +func sessionKey() []byte { | ||
| 93 | + key := os.Getenv("GLEAN_SESSION_KEY") | ||
| 94 | + if key == "" { | ||
| 95 | + key = "default-dev-key-change-in-production" | ||
| 96 | + } | ||
| 97 | + return []byte(key) | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +func encodeSession(data sessionData) (string, error) { | ||
| 101 | + payload, err := json.Marshal(data) | ||
| 102 | + if err != nil { | ||
| 103 | + return "", err | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + mac := hmac.New(sha256.New, sessionKey()) | ||
| 107 | + mac.Write(payload) | ||
| 108 | + sig := mac.Sum(nil) | ||
| 109 | + | ||
| 110 | + raw := append(payload, sig...) | ||
| 111 | + return base64.URLEncoding.EncodeToString(raw), nil | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +func decodeSession(encoded string) (*sessionData, error) { | ||
| 115 | + raw, err := base64.URLEncoding.DecodeString(encoded) | ||
| 116 | + if err != nil { | ||
| 117 | + return nil, errInvalidSession | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + if len(raw) < sha256.Size { | ||
| 121 | + return nil, errInvalidSession | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + payload := raw[:len(raw)-sha256.Size] | ||
| 125 | + sig := raw[len(raw)-sha256.Size:] | ||
| 126 | + | ||
| 127 | + mac := hmac.New(sha256.New, sessionKey()) | ||
| 128 | + mac.Write(payload) | ||
| 129 | + expectedSig := mac.Sum(nil) | ||
| 130 | + | ||
| 131 | + if !hmac.Equal(sig, expectedSig) { | ||
| 132 | + return nil, errInvalidSession | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + var data sessionData | ||
| 136 | + if err := json.Unmarshal(payload, &data); err != nil { | ||
| 137 | + return nil, errInvalidSession | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + return &data, nil | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +var errInvalidSession = &invalidSessionError{} | ||
| 144 | + | ||
| 145 | +type invalidSessionError struct{} | ||
| 146 | + | ||
| 147 | +func (e *invalidSessionError) Error() string { return "invalid session" } | ||
added
internal/server/trending_handler.go +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +package server | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "net/http" | |
| 5 | + "time" | |
| 6 | +) | |
| 7 | + | |
| 8 | +func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | |
| 9 | + user := currentUser(r) | |
| 10 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | |
| 11 | + articles, _ := s.db.ListTrendingArticles(r.Context(), since, 25, 0) | |
| 12 | + s.render(w, r, "trending.html", map[string]any{ | |
| 13 | + "User": user, | |
| 14 | + "Trending": articles, | |
| 15 | + }) | |
| 16 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +package server | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "net/http" | ||
| 5 | + "time" | ||
| 6 | +) | ||
| 7 | + | ||
| 8 | +func (s *Server) handleTrending(w http.ResponseWriter, r *http.Request) { | ||
| 9 | + user := currentUser(r) | ||
| 10 | + since := time.Now().AddDate(0, 0, -7).Format(time.RFC3339) | ||
| 11 | + articles, _ := s.db.ListTrendingArticles(r.Context(), since, 25, 0) | ||
| 12 | + s.render(w, r, "trending.html", map[string]any{ | ||
| 13 | + "User": user, | ||
| 14 | + "Trending": articles, | ||
| 15 | + }) | ||
| 16 | +} | ||
added
internal/tmpl/annotations.html +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +{{define "annotations.html"}} | |
| 2 | +<div class="mb-6"> | |
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Annotations</h1> | |
| 4 | + <p class="text-spot-secondary text-sm mt-1">Your notes and highlights on articles.</p> | |
| 5 | +</div> | |
| 6 | + | |
| 7 | +{{if .ArticleURL}} | |
| 8 | +<div class="mb-4 text-sm text-spot-secondary"> | |
| 9 | + Filtering by: <strong class="text-spot-text">{{.ArticleURL}}</strong> | |
| 10 | + <a href="/annotations" class="text-spot-purple ml-2 hover:brightness-110">Clear</a> | |
| 11 | +</div> | |
| 12 | +{{end}} | |
| 13 | + | |
| 14 | +<div class="space-y-4" id="annotations-list"> | |
| 15 | + {{range .Annotations}} | |
| 16 | + {{template "annotation-card.html" .}} | |
| 17 | + {{else}} | |
| 18 | + <div class="text-center text-spot-secondary py-12"> | |
| 19 | + <p>No annotations yet.</p> | |
| 20 | + <p class="text-sm mt-1">Highlight and annotate articles as you read.</p> | |
| 21 | + </div> | |
| 22 | + {{end}} | |
| 23 | +</div> | |
| 24 | + | |
| 25 | +{{if .HasMore}} | |
| 26 | +<div class="text-center py-4"> | |
| 27 | + <button hx-get="/annotations?offset={{.NextOffset}}{{if .ArticleURL}}&article={{.ArticleURL}}{{end}}" | |
| 28 | + hx-target="#annotations-list" hx-swap="beforeend" | |
| 29 | + hx-select=".annotation-card" | |
| 30 | + class="text-sm text-spot-secondary hover:text-spot-purple transition">Load more</button> | |
| 31 | +</div> | |
| 32 | +{{end}} | |
| 33 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +{{define "annotations.html"}} | ||
| 2 | +<div class="mb-6"> | ||
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Annotations</h1> | ||
| 4 | + <p class="text-spot-secondary text-sm mt-1">Your notes and highlights on articles.</p> | ||
| 5 | +</div> | ||
| 6 | + | ||
| 7 | +{{if .ArticleURL}} | ||
| 8 | +<div class="mb-4 text-sm text-spot-secondary"> | ||
| 9 | + Filtering by: <strong class="text-spot-text">{{.ArticleURL}}</strong> | ||
| 10 | + <a href="/annotations" class="text-spot-purple ml-2 hover:brightness-110">Clear</a> | ||
| 11 | +</div> | ||
| 12 | +{{end}} | ||
| 13 | + | ||
| 14 | +<div class="space-y-4" id="annotations-list"> | ||
| 15 | + {{range .Annotations}} | ||
| 16 | + {{template "annotation-card.html" .}} | ||
| 17 | + {{else}} | ||
| 18 | + <div class="text-center text-spot-secondary py-12"> | ||
| 19 | + <p>No annotations yet.</p> | ||
| 20 | + <p class="text-sm mt-1">Highlight and annotate articles as you read.</p> | ||
| 21 | + </div> | ||
| 22 | + {{end}} | ||
| 23 | +</div> | ||
| 24 | + | ||
| 25 | +{{if .HasMore}} | ||
| 26 | +<div class="text-center py-4"> | ||
| 27 | + <button hx-get="/annotations?offset={{.NextOffset}}{{if .ArticleURL}}&article={{.ArticleURL}}{{end}}" | ||
| 28 | + hx-target="#annotations-list" hx-swap="beforeend" | ||
| 29 | + hx-select=".annotation-card" | ||
| 30 | + class="text-sm text-spot-secondary hover:text-spot-purple transition">Load more</button> | ||
| 31 | +</div> | ||
| 32 | +{{end}} | ||
| 33 | +{{end}} | ||
added
internal/tmpl/article_detail.html +102 -0 | new file mode 100644 | ||
| @@ -0,0 +1,102 @@ | ||
| 1 | +{{define "article_detail.html"}} | |
| 2 | +<div class="max-w-3xl mx-auto"> | |
| 3 | + <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">← Back to articles</a> | |
| 4 | + | |
| 5 | + <article> | |
| 6 | + <h1 class="text-3xl font-bold font-title leading-tight"> | |
| 7 | + {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-purple transition">{{.Article.Title}}</a> | |
| 8 | + {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}} | |
| 9 | + </h1> | |
| 10 | + | |
| 11 | + <div class="flex items-center gap-3 mt-3 text-sm text-spot-secondary"> | |
| 12 | + {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}} | |
| 13 | + {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 14 | + {{if .Feed}} | |
| 15 | + <a href="/articles?feed={{.Feed.FeedURL}}" class="hover:text-spot-purple transition">{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}</a> | |
| 16 | + {{end}} | |
| 17 | + </div> | |
| 18 | + | |
| 19 | + <div class="flex items-center gap-3 mt-4"> | |
| 20 | + <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML" | |
| 21 | + id="like-btn" | |
| 22 | + class="text-lg {{if .HasLiked}}text-spot-red{{else}}text-spot-muted hover:text-spot-red{{end}} transition"> | |
| 23 | + ♥ <span class="text-sm text-spot-secondary">{{.LikeCount}}</span> | |
| 24 | + </button> | |
| 25 | + | |
| 26 | + <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}" | |
| 27 | + target="_blank" rel="noopener noreferrer" | |
| 28 | + class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1"> | |
| 29 | + 🦋 | |
| 30 | + Share | |
| 31 | + </a> | |
| 32 | + | |
| 33 | + <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}" | |
| 34 | + hx-target="#read-btn" hx-swap="outerHTML" | |
| 35 | + id="read-btn" | |
| 36 | + class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition"> | |
| 37 | + {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}} | |
| 38 | + </button> | |
| 39 | + </div> | |
| 40 | + | |
| 41 | + <hr class="my-6 border-spot-divider-30"> | |
| 42 | + | |
| 43 | + {{if .Article.Content.Valid}} | |
| 44 | + <div class="article-body"> | |
| 45 | + {{sanitizeHTML .Article.Content.String}} | |
| 46 | + </div> | |
| 47 | + {{else if .Article.Summary.Valid}} | |
| 48 | + <div class="article-body"> | |
| 49 | + {{sanitizeHTML .Article.Summary.String}} | |
| 50 | + </div> | |
| 51 | + {{else}} | |
| 52 | + <p class="text-spot-secondary">No content available. <a href="{{.Article.URL.String}}" class="text-spot-purple hover:underline">Read the original article.</a></p> | |
| 53 | + {{end}} | |
| 54 | + </article> | |
| 55 | + | |
| 56 | + <hr class="my-8 border-spot-divider-30"> | |
| 57 | + | |
| 58 | + <section> | |
| 59 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> | |
| 60 | + | |
| 61 | + <form hx-post="/annotations/create" hx-target="#annotations-list" hx-swap="beforeend" | |
| 62 | + hx-on::after-request="this.reset()" | |
| 63 | + class="bg-spot-surface rounded-lg shadow-spot p-4 mb-4 space-y-3"> | |
| 64 | + {{csrfInput .CSRFToken}} | |
| 65 | + <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}"> | |
| 66 | + <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"> | |
| 67 | + <div> | |
| 68 | + <textarea name="note" rows="2" placeholder="Add a note..." | |
| 69 | + class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder resize-none"></textarea> | |
| 70 | + </div> | |
| 71 | + <div class="flex gap-2"> | |
| 72 | + <input type="text" name="quote" placeholder="Quote (optional)" | |
| 73 | + class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"> | |
| 74 | + <input type="text" name="tags" placeholder="Tags (comma separated)" | |
| 75 | + class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"> | |
| 76 | + </div> | |
| 77 | + <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Annotate</button> | |
| 78 | + </form> | |
| 79 | + | |
| 80 | + <div id="annotations-list" class="space-y-3"> | |
| 81 | + {{range .Annotations}} | |
| 82 | + {{template "annotation-card.html" .}} | |
| 83 | + {{else}} | |
| 84 | + <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p> | |
| 85 | + {{end}} | |
| 86 | + </div> | |
| 87 | + </section> | |
| 88 | +</div> | |
| 89 | + | |
| 90 | +<script> | |
| 91 | +document.addEventListener('keydown', function(e) { | |
| 92 | + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | |
| 93 | + if (e.key === 'l') { | |
| 94 | + document.getElementById('like-btn') && document.getElementById('like-btn').click(); | |
| 95 | + } else if (e.key === 'm') { | |
| 96 | + document.getElementById('read-btn') && document.getElementById('read-btn').click(); | |
| 97 | + } else if (e.key === 'Escape') { | |
| 98 | + window.location.href = '/articles'; | |
| 99 | + } | |
| 100 | +}); | |
| 101 | +</script> | |
| 102 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,102 @@ | |||
| 1 | +{{define "article_detail.html"}} | ||
| 2 | +<div class="max-w-3xl mx-auto"> | ||
| 3 | + <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-text mb-4 inline-block transition">← Back to articles</a> | ||
| 4 | + | ||
| 5 | + <article> | ||
| 6 | + <h1 class="text-3xl font-bold font-title leading-tight"> | ||
| 7 | + {{if .Article.URL.Valid}}<a href="{{.Article.URL.String}}" target="_blank" rel="noopener noreferrer" class="text-spot-text hover:text-spot-purple transition">{{.Article.Title}}</a> | ||
| 8 | + {{else}}<span class="text-spot-text">{{.Article.Title}}</span>{{end}} | ||
| 9 | + </h1> | ||
| 10 | + | ||
| 11 | + <div class="flex items-center gap-3 mt-3 text-sm text-spot-secondary"> | ||
| 12 | + {{if .Article.Author.Valid}}<span>{{.Article.Author.String}}</span>{{end}} | ||
| 13 | + {{if .Article.Published.Valid}}<span>{{.Article.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 14 | + {{if .Feed}} | ||
| 15 | + <a href="/articles?feed={{.Feed.FeedURL}}" class="hover:text-spot-purple transition">{{if .Feed.Title.Valid}}{{.Feed.Title.String}}{{else}}{{.Feed.FeedURL}}{{end}}</a> | ||
| 16 | + {{end}} | ||
| 17 | + </div> | ||
| 18 | + | ||
| 19 | + <div class="flex items-center gap-3 mt-4"> | ||
| 20 | + <button hx-post="/articles/{{.Article.ID}}/like" hx-target="#like-btn" hx-swap="outerHTML" | ||
| 21 | + id="like-btn" | ||
| 22 | + class="text-lg {{if .HasLiked}}text-spot-red{{else}}text-spot-muted hover:text-spot-red{{end}} transition"> | ||
| 23 | + ♥ <span class="text-sm text-spot-secondary">{{.LikeCount}}</span> | ||
| 24 | + </button> | ||
| 25 | + | ||
| 26 | + <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}" | ||
| 27 | + target="_blank" rel="noopener noreferrer" | ||
| 28 | + class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1"> | ||
| 29 | + 🦋 | ||
| 30 | + Share | ||
| 31 | + </a> | ||
| 32 | + | ||
| 33 | + <button hx-post="/articles/{{.Article.ID}}/{{if .ReadState.IsRead}}unread{{else}}read{{end}}" | ||
| 34 | + hx-target="#read-btn" hx-swap="outerHTML" | ||
| 35 | + id="read-btn" | ||
| 36 | + class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition"> | ||
| 37 | + {{if .ReadState.IsRead}}Mark unread{{else}}Mark read{{end}} | ||
| 38 | + </button> | ||
| 39 | + </div> | ||
| 40 | + | ||
| 41 | + <hr class="my-6 border-spot-divider-30"> | ||
| 42 | + | ||
| 43 | + {{if .Article.Content.Valid}} | ||
| 44 | + <div class="article-body"> | ||
| 45 | + {{sanitizeHTML .Article.Content.String}} | ||
| 46 | + </div> | ||
| 47 | + {{else if .Article.Summary.Valid}} | ||
| 48 | + <div class="article-body"> | ||
| 49 | + {{sanitizeHTML .Article.Summary.String}} | ||
| 50 | + </div> | ||
| 51 | + {{else}} | ||
| 52 | + <p class="text-spot-secondary">No content available. <a href="{{.Article.URL.String}}" class="text-spot-purple hover:underline">Read the original article.</a></p> | ||
| 53 | + {{end}} | ||
| 54 | + </article> | ||
| 55 | + | ||
| 56 | + <hr class="my-8 border-spot-divider-30"> | ||
| 57 | + | ||
| 58 | + <section> | ||
| 59 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Annotations</h2> | ||
| 60 | + | ||
| 61 | + <form hx-post="/annotations/create" hx-target="#annotations-list" hx-swap="beforeend" | ||
| 62 | + hx-on::after-request="this.reset()" | ||
| 63 | + class="bg-spot-surface rounded-lg shadow-spot p-4 mb-4 space-y-3"> | ||
| 64 | + {{csrfInput .CSRFToken}} | ||
| 65 | + <input type="hidden" name="feed_url" value="{{.Article.FeedURL}}"> | ||
| 66 | + <input type="hidden" name="article_url" value="{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"> | ||
| 67 | + <div> | ||
| 68 | + <textarea name="note" rows="2" placeholder="Add a note..." | ||
| 69 | + class="w-full bg-spot-hover text-spot-text rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder resize-none"></textarea> | ||
| 70 | + </div> | ||
| 71 | + <div class="flex gap-2"> | ||
| 72 | + <input type="text" name="quote" placeholder="Quote (optional)" | ||
| 73 | + class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"> | ||
| 74 | + <input type="text" name="tags" placeholder="Tags (comma separated)" | ||
| 75 | + class="flex-1 bg-spot-hover text-spot-text rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"> | ||
| 76 | + </div> | ||
| 77 | + <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Annotate</button> | ||
| 78 | + </form> | ||
| 79 | + | ||
| 80 | + <div id="annotations-list" class="space-y-3"> | ||
| 81 | + {{range .Annotations}} | ||
| 82 | + {{template "annotation-card.html" .}} | ||
| 83 | + {{else}} | ||
| 84 | + <p class="text-sm text-spot-secondary">No annotations yet. Be the first to add one.</p> | ||
| 85 | + {{end}} | ||
| 86 | + </div> | ||
| 87 | + </section> | ||
| 88 | +</div> | ||
| 89 | + | ||
| 90 | +<script> | ||
| 91 | +document.addEventListener('keydown', function(e) { | ||
| 92 | + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | ||
| 93 | + if (e.key === 'l') { | ||
| 94 | + document.getElementById('like-btn') && document.getElementById('like-btn').click(); | ||
| 95 | + } else if (e.key === 'm') { | ||
| 96 | + document.getElementById('read-btn') && document.getElementById('read-btn').click(); | ||
| 97 | + } else if (e.key === 'Escape') { | ||
| 98 | + window.location.href = '/articles'; | ||
| 99 | + } | ||
| 100 | +}); | ||
| 101 | +</script> | ||
| 102 | +{{end}} | ||
added
internal/tmpl/articles.html +29 -0 | new file mode 100644 | ||
| @@ -0,0 +1,29 @@ | ||
| 1 | +{{define "articles.html"}} | |
| 2 | + <div class="flex items-center justify-between mb-6"> | |
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Articles</h1> | |
| 4 | + <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?"> | |
| 5 | + {{csrfInput .CSRFToken}} | |
| 6 | + {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}} | |
| 7 | + <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button> | |
| 8 | + </form> | |
| 9 | + </div> | |
| 10 | + | |
| 11 | +<div id="article-list" class="space-y-3"> | |
| 12 | + {{range .Articles}} | |
| 13 | + {{template "article-card.html" .}} | |
| 14 | + {{else}} | |
| 15 | + <div class="text-center text-spot-secondary py-12">No articles found.</div> | |
| 16 | + {{end}} | |
| 17 | +</div> | |
| 18 | + | |
| 19 | +{{if .HasMore}} | |
| 20 | +<div class="text-center py-4"> | |
| 21 | + <button hx-get="/articles?offset={{.NextOffset}}{{if .FeedURL}}&feed={{.FeedURL}}{{end}}{{if .Starred}}&starred=1{{end}}" | |
| 22 | + hx-target="#article-list" hx-swap="beforeend" | |
| 23 | + hx-select="article" | |
| 24 | + class="text-sm text-spot-secondary hover:text-spot-purple transition"> | |
| 25 | + Load more | |
| 26 | + </button> | |
| 27 | +</div> | |
| 28 | +{{end}} | |
| 29 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | +{{define "articles.html"}} | ||
| 2 | + <div class="flex items-center justify-between mb-6"> | ||
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Articles</h1> | ||
| 4 | + <form hx-post="/articles/mark-all-read" hx-confirm="Mark all articles as read?"> | ||
| 5 | + {{csrfInput .CSRFToken}} | ||
| 6 | + {{if .FeedURL}}<input type="hidden" name="feed" value="{{.FeedURL}}">{{end}} | ||
| 7 | + <button type="submit" class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition">Mark all read</button> | ||
| 8 | + </form> | ||
| 9 | + </div> | ||
| 10 | + | ||
| 11 | +<div id="article-list" class="space-y-3"> | ||
| 12 | + {{range .Articles}} | ||
| 13 | + {{template "article-card.html" .}} | ||
| 14 | + {{else}} | ||
| 15 | + <div class="text-center text-spot-secondary py-12">No articles found.</div> | ||
| 16 | + {{end}} | ||
| 17 | +</div> | ||
| 18 | + | ||
| 19 | +{{if .HasMore}} | ||
| 20 | +<div class="text-center py-4"> | ||
| 21 | + <button hx-get="/articles?offset={{.NextOffset}}{{if .FeedURL}}&feed={{.FeedURL}}{{end}}{{if .Starred}}&starred=1{{end}}" | ||
| 22 | + hx-target="#article-list" hx-swap="beforeend" | ||
| 23 | + hx-select="article" | ||
| 24 | + class="text-sm text-spot-secondary hover:text-spot-purple transition"> | ||
| 25 | + Load more | ||
| 26 | + </button> | ||
| 27 | +</div> | ||
| 28 | +{{end}} | ||
| 29 | +{{end}} | ||
added
internal/tmpl/base.html +341 -0 | new file mode 100644 | ||
| @@ -0,0 +1,341 @@ | ||
| 1 | +{{define "base.html"}} | |
| 2 | +<!DOCTYPE html> | |
| 3 | +<html lang="en"> | |
| 4 | +<head> | |
| 5 | + <meta charset="utf-8"> | |
| 6 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 7 | + <title>Glean</title> | |
| 8 | + <script> | |
| 9 | + (function(){var t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t)})(); | |
| 10 | + </script> | |
| 11 | + <script src="https://cdn.tailwindcss.com"></script> | |
| 12 | + <script> | |
| 13 | + tailwind.config = { | |
| 14 | + theme: { | |
| 15 | + extend: { | |
| 16 | + colors: { | |
| 17 | + spot: { | |
| 18 | + purple: '#a855f7', | |
| 19 | + 'purple-border': '#9333ea', | |
| 20 | + bg: 'var(--spot-bg)', | |
| 21 | + surface: 'var(--spot-surface)', | |
| 22 | + hover: 'var(--spot-hover)', | |
| 23 | + 'hover-50': 'var(--spot-hover-50)', | |
| 24 | + text: 'var(--spot-text)', | |
| 25 | + secondary: 'var(--spot-secondary)', | |
| 26 | + body: 'var(--spot-body)', | |
| 27 | + muted: 'var(--spot-muted)', | |
| 28 | + divider: 'var(--spot-divider)', | |
| 29 | + 'divider-30': 'var(--spot-divider-30)', | |
| 30 | + outline: 'var(--spot-outline)', | |
| 31 | + placeholder: 'var(--spot-placeholder)', | |
| 32 | + 'active-pill-bg': 'var(--spot-active-bg)', | |
| 33 | + 'active-pill-text': 'var(--spot-active-text)', | |
| 34 | + red: '#f3727f', | |
| 35 | + orange: '#ffa42b', | |
| 36 | + blue: '#539df5', | |
| 37 | + } | |
| 38 | + }, | |
| 39 | + borderRadius: { | |
| 40 | + pill: '9999px', | |
| 41 | + 'pill-lg': '500px', | |
| 42 | + }, | |
| 43 | + boxShadow: { | |
| 44 | + 'spot': 'var(--spot-shadow)', | |
| 45 | + 'spot-heavy': 'var(--spot-shadow-heavy)', | |
| 46 | + }, | |
| 47 | + letterSpacing: { | |
| 48 | + button: '1.4px', | |
| 49 | + }, | |
| 50 | + } | |
| 51 | + } | |
| 52 | + } | |
| 53 | + </script> | |
| 54 | + <script src="https://unpkg.com/htmx.org@2"></script> | |
| 55 | + <style> | |
| 56 | + :root { | |
| 57 | + --spot-bg: #121212; | |
| 58 | + --spot-surface: #181818; | |
| 59 | + --spot-hover: #1f1f1f; | |
| 60 | + --spot-hover-50: rgba(31,31,31,0.5); | |
| 61 | + --spot-text: #ffffff; | |
| 62 | + --spot-secondary: #b3b3b3; | |
| 63 | + --spot-body: #cbcbcb; | |
| 64 | + --spot-muted: #4d4d4d; | |
| 65 | + --spot-divider: rgba(77,77,77,0.2); | |
| 66 | + --spot-divider-30: rgba(77,77,77,0.3); | |
| 67 | + --spot-outline: #7c7c7c; | |
| 68 | + --spot-placeholder: #4d4d4d; | |
| 69 | + --spot-active-bg: #ffffff; | |
| 70 | + --spot-active-text: #121212; | |
| 71 | + --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px; | |
| 72 | + --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px; | |
| 73 | + } | |
| 74 | + [data-theme="light"] { | |
| 75 | + --spot-bg: #f5f5f5; | |
| 76 | + --spot-surface: #ffffff; | |
| 77 | + --spot-hover: #f3f4f6; | |
| 78 | + --spot-hover-50: rgba(243,244,246,0.5); | |
| 79 | + --spot-text: #111827; | |
| 80 | + --spot-secondary: #6b7280; | |
| 81 | + --spot-body: #374151; | |
| 82 | + --spot-muted: #9ca3af; | |
| 83 | + --spot-divider: #e5e7eb; | |
| 84 | + --spot-divider-30: #d1d5db; | |
| 85 | + --spot-outline: #d1d5db; | |
| 86 | + --spot-placeholder: #9ca3af; | |
| 87 | + --spot-active-bg: #111827; | |
| 88 | + --spot-active-text: #ffffff; | |
| 89 | + --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px; | |
| 90 | + --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px; | |
| 91 | + } | |
| 92 | + </style> | |
| 93 | + <style type="text/tailwindcss"> | |
| 94 | + @layer components { | |
| 95 | + .article-body h1 { @apply text-2xl font-bold mt-8 mb-3 text-spot-text } | |
| 96 | + .article-body h2 { @apply text-xl font-semibold mt-6 mb-2 text-spot-text } | |
| 97 | + .article-body h3 { @apply text-lg font-semibold mt-5 mb-2 text-spot-text } | |
| 98 | + .article-body h4 { @apply text-base font-semibold mt-4 mb-1 text-spot-text } | |
| 99 | + .article-body p { @apply my-3 leading-7 text-spot-body } | |
| 100 | + .article-body ul { @apply list-disc pl-6 my-3 text-spot-body } | |
| 101 | + .article-body ol { @apply list-decimal pl-6 my-3 text-spot-body } | |
| 102 | + .article-body li { @apply my-1 leading-7 } | |
| 103 | + .article-body blockquote { @apply border-l-4 border-spot-divider pl-4 italic text-spot-secondary my-4 } | |
| 104 | + .article-body pre { @apply bg-spot-bg text-spot-body rounded-lg p-4 overflow-x-auto my-4 text-sm leading-6 } | |
| 105 | + .article-body code { @apply bg-spot-surface text-spot-body px-1.5 py-0.5 rounded text-sm font-mono } | |
| 106 | + .article-body pre code { @apply bg-transparent text-spot-body p-0 } | |
| 107 | + .article-body img { @apply rounded-lg max-w-full h-auto my-4 } | |
| 108 | + .article-body figure { @apply my-4 } | |
| 109 | + .article-body figcaption { @apply text-sm text-spot-secondary text-center mt-2 } | |
| 110 | + .article-body a { @apply text-spot-purple underline hover:brightness-110 } | |
| 111 | + .article-body table { @apply w-full border-collapse my-4 } | |
| 112 | + .article-body th { @apply border border-spot-divider px-3 py-2 bg-spot-surface font-semibold text-left text-spot-text } | |
| 113 | + .article-body td { @apply border border-spot-divider px-3 py-2 text-spot-body } | |
| 114 | + .article-body hr { @apply border-spot-divider my-6 } | |
| 115 | + .article-body iframe, .article-body video { @apply rounded-lg my-4 max-w-full } | |
| 116 | + .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video } | |
| 117 | + .article-body del { @apply line-through text-spot-secondary } | |
| 118 | + .article-body mark { @apply bg-spot-orange/30 px-1 rounded } | |
| 119 | + } | |
| 120 | + </style> | |
| 121 | + <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| 122 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| 123 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | |
| 124 | + <style> | |
| 125 | + body { font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif; } | |
| 126 | + .sidebar-link { transition: color 0.15s; } | |
| 127 | + .sidebar-link:hover { color: var(--spot-text); } | |
| 128 | + .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } | |
| 129 | + ::-webkit-scrollbar { width: 8px; } | |
| 130 | + ::-webkit-scrollbar-track { background: var(--spot-bg); } | |
| 131 | + ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; } | |
| 132 | + ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); } | |
| 133 | + </style> | |
| 134 | +</head> | |
| 135 | +<body class="bg-spot-bg text-spot-text min-h-screen flex"> | |
| 136 | + {{if .User}} | |
| 137 | + <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20"> | |
| 138 | + <a href="/" class="text-spot-purple font-bold text-xl font-title tracking-tight mb-8 px-3">Glean</a> | |
| 139 | + <nav class="flex flex-col gap-1 text-sm"> | |
| 140 | + <a href="/dashboard" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/dashboard"}}"> | |
| 141 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg> | |
| 142 | + Dashboard | |
| 143 | + </a> | |
| 144 | + <a href="/articles" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/articles"}}"> | |
| 145 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | |
| 146 | + Articles | |
| 147 | + </a> | |
| 148 | + <a href="/feeds" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/feeds"}}"> | |
| 149 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | |
| 150 | + Feeds | |
| 151 | + </a> | |
| 152 | + <a href="/trending" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/trending"}}"> | |
| 153 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | |
| 154 | + Trending | |
| 155 | + </a> | |
| 156 | + <a href="/discover" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/discover"}}"> | |
| 157 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | |
| 158 | + Discover | |
| 159 | + </a> | |
| 160 | + <a href="/annotations" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/annotations"}}"> | |
| 161 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg> | |
| 162 | + Annotations | |
| 163 | + </a> | |
| 164 | + </nav> | |
| 165 | + <div class="mt-auto pt-4 border-t border-spot-divider-30"> | |
| 166 | + <div class="flex items-center gap-3 px-3 py-2"> | |
| 167 | + {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}} | |
| 168 | + <div class="min-w-0 flex-1"> | |
| 169 | + <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div> | |
| 170 | + </div> | |
| 171 | + <form method="POST" action="/auth/logout"> | |
| 172 | + {{csrfInput .CSRFToken}} | |
| 173 | + <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout"> | |
| 174 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg> | |
| 175 | + </button> | |
| 176 | + </form> | |
| 177 | + </div> | |
| 178 | + </div> | |
| 179 | + </aside> | |
| 180 | + | |
| 181 | + <nav class="lg:hidden fixed bottom-0 left-0 right-0 bg-spot-surface border-t border-spot-divider z-30 px-2 py-2"> | |
| 182 | + <div class="flex items-center justify-around"> | |
| 183 | + <a href="/dashboard" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/dashboard"}} text-[10px] px-2 py-1"> | |
| 184 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg> | |
| 185 | + Home | |
| 186 | + </a> | |
| 187 | + <a href="/articles" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/articles"}} text-[10px] px-2 py-1"> | |
| 188 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | |
| 189 | + Articles | |
| 190 | + </a> | |
| 191 | + <a href="/feeds" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/feeds"}} text-[10px] px-2 py-1"> | |
| 192 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | |
| 193 | + Feeds | |
| 194 | + </a> | |
| 195 | + <a href="/trending" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/trending"}} text-[10px] px-2 py-1"> | |
| 196 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | |
| 197 | + Trending | |
| 198 | + </a> | |
| 199 | + <a href="/discover" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/discover"}} text-[10px] px-2 py-1"> | |
| 200 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | |
| 201 | + Discover | |
| 202 | + </a> | |
| 203 | + </div> | |
| 204 | + </nav> | |
| 205 | + {{end}} | |
| 206 | + | |
| 207 | + <main id="main-content" class="{{if .User}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col"> | |
| 208 | + {{if .User}} | |
| 209 | + <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20"> | |
| 210 | + <a href="/" class="text-spot-purple font-bold text-lg font-title">Glean</a> | |
| 211 | + <div class="flex items-center gap-2"> | |
| 212 | + {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}} | |
| 213 | + <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span> | |
| 214 | + </div> | |
| 215 | + </div> | |
| 216 | + {{end}} | |
| 217 | + <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1"> | |
| 218 | + {{.Content}} | |
| 219 | + </div> | |
| 220 | + <footer class="mt-auto"> | |
| 221 | + <div class="w-full bg-spot-surface border-t border-spot-divider"> | |
| 222 | + <div class="max-w-6xl mx-auto px-4 lg:px-8 py-8"> | |
| 223 | + <div class="hidden md:grid md:grid-cols-[auto_1fr_auto] md:gap-12 md:items-start"> | |
| 224 | + <div> | |
| 225 | + <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a> | |
| 226 | + <p class="text-xs text-spot-secondary mt-1 max-w-[200px]">A social RSS reader<br>on the AT Protocol.</p> | |
| 227 | + </div> | |
| 228 | + <div class="grid grid-cols-3 gap-8"> | |
| 229 | + <div class="flex flex-col gap-1.5"> | |
| 230 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div> | |
| 231 | + <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | |
| 232 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg> | |
| 233 | + Dashboard | |
| 234 | + </a> | |
| 235 | + <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | |
| 236 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | |
| 237 | + Trending | |
| 238 | + </a> | |
| 239 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | |
| 240 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | |
| 241 | + Discover | |
| 242 | + </a> | |
| 243 | + </div> | |
| 244 | + <div class="flex flex-col gap-1.5"> | |
| 245 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div> | |
| 246 | + <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | |
| 247 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | |
| 248 | + Articles | |
| 249 | + </a> | |
| 250 | + <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | |
| 251 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | |
| 252 | + Feeds | |
| 253 | + </a> | |
| 254 | + <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | |
| 255 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg> | |
| 256 | + Annotations | |
| 257 | + </a> | |
| 258 | + </div> | |
| 259 | + <div class="flex flex-col gap-1.5"> | |
| 260 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Settings</div> | |
| 261 | + <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left"> | |
| 262 | + <svg class="w-3.5 h-3.5 shrink-0 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg> | |
| 263 | + <svg class="w-3.5 h-3.5 shrink-0 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg> | |
| 264 | + <span class="theme-text-dark">Light mode</span> | |
| 265 | + <span class="theme-text-light hidden">Dark mode</span> | |
| 266 | + </button> | |
| 267 | + </div> | |
| 268 | + </div> | |
| 269 | + <div class="text-right"> | |
| 270 | + <div class="text-xs text-spot-secondary">© {{now.Format "2006"}} Glean</div> | |
| 271 | + <div class="text-[10px] text-spot-muted mt-0.5">Built on the AT Protocol</div> | |
| 272 | + </div> | |
| 273 | + </div> | |
| 274 | + | |
| 275 | + <div class="md:hidden flex flex-col gap-6"> | |
| 276 | + <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a> | |
| 277 | + <div class="grid grid-cols-2 gap-6"> | |
| 278 | + <div class="flex flex-col gap-1.5"> | |
| 279 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div> | |
| 280 | + <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text transition">Dashboard</a> | |
| 281 | + <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text transition">Trending</a> | |
| 282 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text transition">Discover</a> | |
| 283 | + </div> | |
| 284 | + <div class="flex flex-col gap-1.5"> | |
| 285 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div> | |
| 286 | + <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">Articles</a> | |
| 287 | + <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text transition">Feeds</a> | |
| 288 | + <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text transition">Annotations</a> | |
| 289 | + </div> | |
| 290 | + </div> | |
| 291 | + <div class="flex items-center justify-between"> | |
| 292 | + <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition"> | |
| 293 | + <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg> | |
| 294 | + <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg> | |
| 295 | + <span class="theme-text-dark">Light mode</span> | |
| 296 | + <span class="theme-text-light hidden">Dark mode</span> | |
| 297 | + </button> | |
| 298 | + <span class="text-xs text-spot-secondary">© {{now.Format "2006"}} Glean</span> | |
| 299 | + </div> | |
| 300 | + </div> | |
| 301 | + </div> | |
| 302 | + </div> | |
| 303 | + </footer> | |
| 304 | + </main> | |
| 305 | + | |
| 306 | + <script> | |
| 307 | + function toggleTheme() { | |
| 308 | + var current = document.documentElement.getAttribute('data-theme'); | |
| 309 | + var next = current === 'dark' ? 'light' : 'dark'; | |
| 310 | + document.documentElement.setAttribute('data-theme', next); | |
| 311 | + localStorage.setItem('theme', next); | |
| 312 | + updateThemeIcons(next); | |
| 313 | + } | |
| 314 | + function updateThemeIcons(theme) { | |
| 315 | + document.querySelectorAll('.theme-icon-dark').forEach(function(el) { | |
| 316 | + el.classList.toggle('hidden', theme === 'light'); | |
| 317 | + }); | |
| 318 | + document.querySelectorAll('.theme-icon-light').forEach(function(el) { | |
| 319 | + el.classList.toggle('hidden', theme === 'dark'); | |
| 320 | + }); | |
| 321 | + document.querySelectorAll('.theme-text-dark').forEach(function(el) { | |
| 322 | + el.classList.toggle('hidden', theme === 'light'); | |
| 323 | + }); | |
| 324 | + document.querySelectorAll('.theme-text-light').forEach(function(el) { | |
| 325 | + el.classList.toggle('hidden', theme === 'dark'); | |
| 326 | + }); | |
| 327 | + } | |
| 328 | + updateThemeIcons(document.documentElement.getAttribute('data-theme') || 'dark'); | |
| 329 | + | |
| 330 | + document.addEventListener('keydown', function(e) { | |
| 331 | + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | |
| 332 | + if (e.key === 'g') window.location.href = '/dashboard'; | |
| 333 | + if (e.key === 'a') window.location.href = '/articles'; | |
| 334 | + if (e.key === 'f') window.location.href = '/feeds'; | |
| 335 | + if (e.key === 't') window.location.href = '/trending'; | |
| 336 | + if (e.key === 'd') window.location.href = '/discover'; | |
| 337 | + }); | |
| 338 | + </script> | |
| 339 | +</body> | |
| 340 | +</html> | |
| 341 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,341 @@ | |||
| 1 | +{{define "base.html"}} | ||
| 2 | +<!DOCTYPE html> | ||
| 3 | +<html lang="en"> | ||
| 4 | +<head> | ||
| 5 | + <meta charset="utf-8"> | ||
| 6 | + <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 7 | + <title>Glean</title> | ||
| 8 | + <script> | ||
| 9 | + (function(){var t=localStorage.getItem('theme')||'dark';document.documentElement.setAttribute('data-theme',t)})(); | ||
| 10 | + </script> | ||
| 11 | + <script src="https://cdn.tailwindcss.com"></script> | ||
| 12 | + <script> | ||
| 13 | + tailwind.config = { | ||
| 14 | + theme: { | ||
| 15 | + extend: { | ||
| 16 | + colors: { | ||
| 17 | + spot: { | ||
| 18 | + purple: '#a855f7', | ||
| 19 | + 'purple-border': '#9333ea', | ||
| 20 | + bg: 'var(--spot-bg)', | ||
| 21 | + surface: 'var(--spot-surface)', | ||
| 22 | + hover: 'var(--spot-hover)', | ||
| 23 | + 'hover-50': 'var(--spot-hover-50)', | ||
| 24 | + text: 'var(--spot-text)', | ||
| 25 | + secondary: 'var(--spot-secondary)', | ||
| 26 | + body: 'var(--spot-body)', | ||
| 27 | + muted: 'var(--spot-muted)', | ||
| 28 | + divider: 'var(--spot-divider)', | ||
| 29 | + 'divider-30': 'var(--spot-divider-30)', | ||
| 30 | + outline: 'var(--spot-outline)', | ||
| 31 | + placeholder: 'var(--spot-placeholder)', | ||
| 32 | + 'active-pill-bg': 'var(--spot-active-bg)', | ||
| 33 | + 'active-pill-text': 'var(--spot-active-text)', | ||
| 34 | + red: '#f3727f', | ||
| 35 | + orange: '#ffa42b', | ||
| 36 | + blue: '#539df5', | ||
| 37 | + } | ||
| 38 | + }, | ||
| 39 | + borderRadius: { | ||
| 40 | + pill: '9999px', | ||
| 41 | + 'pill-lg': '500px', | ||
| 42 | + }, | ||
| 43 | + boxShadow: { | ||
| 44 | + 'spot': 'var(--spot-shadow)', | ||
| 45 | + 'spot-heavy': 'var(--spot-shadow-heavy)', | ||
| 46 | + }, | ||
| 47 | + letterSpacing: { | ||
| 48 | + button: '1.4px', | ||
| 49 | + }, | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + } | ||
| 53 | + </script> | ||
| 54 | + <script src="https://unpkg.com/htmx.org@2"></script> | ||
| 55 | + <style> | ||
| 56 | + :root { | ||
| 57 | + --spot-bg: #121212; | ||
| 58 | + --spot-surface: #181818; | ||
| 59 | + --spot-hover: #1f1f1f; | ||
| 60 | + --spot-hover-50: rgba(31,31,31,0.5); | ||
| 61 | + --spot-text: #ffffff; | ||
| 62 | + --spot-secondary: #b3b3b3; | ||
| 63 | + --spot-body: #cbcbcb; | ||
| 64 | + --spot-muted: #4d4d4d; | ||
| 65 | + --spot-divider: rgba(77,77,77,0.2); | ||
| 66 | + --spot-divider-30: rgba(77,77,77,0.3); | ||
| 67 | + --spot-outline: #7c7c7c; | ||
| 68 | + --spot-placeholder: #4d4d4d; | ||
| 69 | + --spot-active-bg: #ffffff; | ||
| 70 | + --spot-active-text: #121212; | ||
| 71 | + --spot-shadow: rgba(0,0,0,0.3) 0px 8px 8px; | ||
| 72 | + --spot-shadow-heavy: rgba(0,0,0,0.5) 0px 8px 24px; | ||
| 73 | + } | ||
| 74 | + [data-theme="light"] { | ||
| 75 | + --spot-bg: #f5f5f5; | ||
| 76 | + --spot-surface: #ffffff; | ||
| 77 | + --spot-hover: #f3f4f6; | ||
| 78 | + --spot-hover-50: rgba(243,244,246,0.5); | ||
| 79 | + --spot-text: #111827; | ||
| 80 | + --spot-secondary: #6b7280; | ||
| 81 | + --spot-body: #374151; | ||
| 82 | + --spot-muted: #9ca3af; | ||
| 83 | + --spot-divider: #e5e7eb; | ||
| 84 | + --spot-divider-30: #d1d5db; | ||
| 85 | + --spot-outline: #d1d5db; | ||
| 86 | + --spot-placeholder: #9ca3af; | ||
| 87 | + --spot-active-bg: #111827; | ||
| 88 | + --spot-active-text: #ffffff; | ||
| 89 | + --spot-shadow: rgba(0,0,0,0.08) 0px 2px 8px; | ||
| 90 | + --spot-shadow-heavy: rgba(0,0,0,0.1) 0px 4px 16px; | ||
| 91 | + } | ||
| 92 | + </style> | ||
| 93 | + <style type="text/tailwindcss"> | ||
| 94 | + @layer components { | ||
| 95 | + .article-body h1 { @apply text-2xl font-bold mt-8 mb-3 text-spot-text } | ||
| 96 | + .article-body h2 { @apply text-xl font-semibold mt-6 mb-2 text-spot-text } | ||
| 97 | + .article-body h3 { @apply text-lg font-semibold mt-5 mb-2 text-spot-text } | ||
| 98 | + .article-body h4 { @apply text-base font-semibold mt-4 mb-1 text-spot-text } | ||
| 99 | + .article-body p { @apply my-3 leading-7 text-spot-body } | ||
| 100 | + .article-body ul { @apply list-disc pl-6 my-3 text-spot-body } | ||
| 101 | + .article-body ol { @apply list-decimal pl-6 my-3 text-spot-body } | ||
| 102 | + .article-body li { @apply my-1 leading-7 } | ||
| 103 | + .article-body blockquote { @apply border-l-4 border-spot-divider pl-4 italic text-spot-secondary my-4 } | ||
| 104 | + .article-body pre { @apply bg-spot-bg text-spot-body rounded-lg p-4 overflow-x-auto my-4 text-sm leading-6 } | ||
| 105 | + .article-body code { @apply bg-spot-surface text-spot-body px-1.5 py-0.5 rounded text-sm font-mono } | ||
| 106 | + .article-body pre code { @apply bg-transparent text-spot-body p-0 } | ||
| 107 | + .article-body img { @apply rounded-lg max-w-full h-auto my-4 } | ||
| 108 | + .article-body figure { @apply my-4 } | ||
| 109 | + .article-body figcaption { @apply text-sm text-spot-secondary text-center mt-2 } | ||
| 110 | + .article-body a { @apply text-spot-purple underline hover:brightness-110 } | ||
| 111 | + .article-body table { @apply w-full border-collapse my-4 } | ||
| 112 | + .article-body th { @apply border border-spot-divider px-3 py-2 bg-spot-surface font-semibold text-left text-spot-text } | ||
| 113 | + .article-body td { @apply border border-spot-divider px-3 py-2 text-spot-body } | ||
| 114 | + .article-body hr { @apply border-spot-divider my-6 } | ||
| 115 | + .article-body iframe, .article-body video { @apply rounded-lg my-4 max-w-full } | ||
| 116 | + .article-body iframe[src*="youtube.com"], .article-body iframe[src*="youtube-nocookie.com"], .article-body iframe[src*="vimeo.com"], .article-body iframe[src*="spotify.com"], .article-body iframe[src*="soundcloud.com"], .article-body iframe[src*="bandcamp.com"] { @apply w-full aspect-video } | ||
| 117 | + .article-body del { @apply line-through text-spot-secondary } | ||
| 118 | + .article-body mark { @apply bg-spot-orange/30 px-1 rounded } | ||
| 119 | + } | ||
| 120 | + </style> | ||
| 121 | + <link rel="preconnect" href="https://fonts.googleapis.com"> | ||
| 122 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||
| 123 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | ||
| 124 | + <style> | ||
| 125 | + body { font-family: 'Inter', 'Helvetica Neue', helvetica, arial, sans-serif; } | ||
| 126 | + .sidebar-link { transition: color 0.15s; } | ||
| 127 | + .sidebar-link:hover { color: var(--spot-text); } | ||
| 128 | + .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } | ||
| 129 | + ::-webkit-scrollbar { width: 8px; } | ||
| 130 | + ::-webkit-scrollbar-track { background: var(--spot-bg); } | ||
| 131 | + ::-webkit-scrollbar-thumb { background: var(--spot-muted); border-radius: 4px; } | ||
| 132 | + ::-webkit-scrollbar-thumb:hover { background: var(--spot-outline); } | ||
| 133 | + </style> | ||
| 134 | +</head> | ||
| 135 | +<body class="bg-spot-bg text-spot-text min-h-screen flex"> | ||
| 136 | + {{if .User}} | ||
| 137 | + <aside class="hidden lg:flex flex-col w-60 bg-spot-bg h-screen fixed left-0 top-0 px-3 py-4 z-20"> | ||
| 138 | + <a href="/" class="text-spot-purple font-bold text-xl font-title tracking-tight mb-8 px-3">Glean</a> | ||
| 139 | + <nav class="flex flex-col gap-1 text-sm"> | ||
| 140 | + <a href="/dashboard" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/dashboard"}}"> | ||
| 141 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg> | ||
| 142 | + Dashboard | ||
| 143 | + </a> | ||
| 144 | + <a href="/articles" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/articles"}}"> | ||
| 145 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | ||
| 146 | + Articles | ||
| 147 | + </a> | ||
| 148 | + <a href="/feeds" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/feeds"}}"> | ||
| 149 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | ||
| 150 | + Feeds | ||
| 151 | + </a> | ||
| 152 | + <a href="/trending" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/trending"}}"> | ||
| 153 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | ||
| 154 | + Trending | ||
| 155 | + </a> | ||
| 156 | + <a href="/discover" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/discover"}}"> | ||
| 157 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | ||
| 158 | + Discover | ||
| 159 | + </a> | ||
| 160 | + <a href="/annotations" class="sidebar-link flex items-center gap-3 px-3 py-2 rounded-md {{activeClass .ActivePath "/annotations"}}"> | ||
| 161 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg> | ||
| 162 | + Annotations | ||
| 163 | + </a> | ||
| 164 | + </nav> | ||
| 165 | + <div class="mt-auto pt-4 border-t border-spot-divider-30"> | ||
| 166 | + <div class="flex items-center gap-3 px-3 py-2"> | ||
| 167 | + {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-8 h-8 rounded-full">{{end}} | ||
| 168 | + <div class="min-w-0 flex-1"> | ||
| 169 | + <div class="text-sm font-bold truncate text-spot-text">@{{.User.Handle}}</div> | ||
| 170 | + </div> | ||
| 171 | + <form method="POST" action="/auth/logout"> | ||
| 172 | + {{csrfInput .CSRFToken}} | ||
| 173 | + <button type="submit" class="text-spot-secondary hover:text-spot-red transition" title="Logout"> | ||
| 174 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg> | ||
| 175 | + </button> | ||
| 176 | + </form> | ||
| 177 | + </div> | ||
| 178 | + </div> | ||
| 179 | + </aside> | ||
| 180 | + | ||
| 181 | + <nav class="lg:hidden fixed bottom-0 left-0 right-0 bg-spot-surface border-t border-spot-divider z-30 px-2 py-2"> | ||
| 182 | + <div class="flex items-center justify-around"> | ||
| 183 | + <a href="/dashboard" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/dashboard"}} text-[10px] px-2 py-1"> | ||
| 184 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg> | ||
| 185 | + Home | ||
| 186 | + </a> | ||
| 187 | + <a href="/articles" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/articles"}} text-[10px] px-2 py-1"> | ||
| 188 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | ||
| 189 | + Articles | ||
| 190 | + </a> | ||
| 191 | + <a href="/feeds" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/feeds"}} text-[10px] px-2 py-1"> | ||
| 192 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | ||
| 193 | + Feeds | ||
| 194 | + </a> | ||
| 195 | + <a href="/trending" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/trending"}} text-[10px] px-2 py-1"> | ||
| 196 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | ||
| 197 | + Trending | ||
| 198 | + </a> | ||
| 199 | + <a href="/discover" class="flex flex-col items-center gap-0.5 {{activeClass .ActivePath "/discover"}} text-[10px] px-2 py-1"> | ||
| 200 | + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | ||
| 201 | + Discover | ||
| 202 | + </a> | ||
| 203 | + </div> | ||
| 204 | + </nav> | ||
| 205 | + {{end}} | ||
| 206 | + | ||
| 207 | + <main id="main-content" class="{{if .User}}lg:ml-60 pb-20 lg:pb-0{{end}} flex-1 min-h-screen flex flex-col"> | ||
| 208 | + {{if .User}} | ||
| 209 | + <div class="lg:hidden bg-spot-surface border-b border-spot-divider px-4 py-3 flex items-center justify-between sticky top-0 z-20"> | ||
| 210 | + <a href="/" class="text-spot-purple font-bold text-lg font-title">Glean</a> | ||
| 211 | + <div class="flex items-center gap-2"> | ||
| 212 | + {{if .User.AvatarURL.Valid}}<img src="{{.User.AvatarURL.String}}" class="w-7 h-7 rounded-full">{{end}} | ||
| 213 | + <span class="text-xs text-spot-secondary">@{{.User.Handle}}</span> | ||
| 214 | + </div> | ||
| 215 | + </div> | ||
| 216 | + {{end}} | ||
| 217 | + <div class="max-w-6xl mx-auto px-4 lg:px-8 py-6 flex-1"> | ||
| 218 | + {{.Content}} | ||
| 219 | + </div> | ||
| 220 | + <footer class="mt-auto"> | ||
| 221 | + <div class="w-full bg-spot-surface border-t border-spot-divider"> | ||
| 222 | + <div class="max-w-6xl mx-auto px-4 lg:px-8 py-8"> | ||
| 223 | + <div class="hidden md:grid md:grid-cols-[auto_1fr_auto] md:gap-12 md:items-start"> | ||
| 224 | + <div> | ||
| 225 | + <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a> | ||
| 226 | + <p class="text-xs text-spot-secondary mt-1 max-w-[200px]">A social RSS reader<br>on the AT Protocol.</p> | ||
| 227 | + </div> | ||
| 228 | + <div class="grid grid-cols-3 gap-8"> | ||
| 229 | + <div class="flex flex-col gap-1.5"> | ||
| 230 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div> | ||
| 231 | + <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | ||
| 232 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1"/></svg> | ||
| 233 | + Dashboard | ||
| 234 | + </a> | ||
| 235 | + <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | ||
| 236 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/></svg> | ||
| 237 | + Trending | ||
| 238 | + </a> | ||
| 239 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | ||
| 240 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | ||
| 241 | + Discover | ||
| 242 | + </a> | ||
| 243 | + </div> | ||
| 244 | + <div class="flex flex-col gap-1.5"> | ||
| 245 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div> | ||
| 246 | + <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | ||
| 247 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2"/></svg> | ||
| 248 | + Articles | ||
| 249 | + </a> | ||
| 250 | + <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | ||
| 251 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | ||
| 252 | + Feeds | ||
| 253 | + </a> | ||
| 254 | + <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition"> | ||
| 255 | + <svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/></svg> | ||
| 256 | + Annotations | ||
| 257 | + </a> | ||
| 258 | + </div> | ||
| 259 | + <div class="flex flex-col gap-1.5"> | ||
| 260 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Settings</div> | ||
| 261 | + <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text hover:underline inline-flex gap-1.5 items-center transition text-left"> | ||
| 262 | + <svg class="w-3.5 h-3.5 shrink-0 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg> | ||
| 263 | + <svg class="w-3.5 h-3.5 shrink-0 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg> | ||
| 264 | + <span class="theme-text-dark">Light mode</span> | ||
| 265 | + <span class="theme-text-light hidden">Dark mode</span> | ||
| 266 | + </button> | ||
| 267 | + </div> | ||
| 268 | + </div> | ||
| 269 | + <div class="text-right"> | ||
| 270 | + <div class="text-xs text-spot-secondary">© {{now.Format "2006"}} Glean</div> | ||
| 271 | + <div class="text-[10px] text-spot-muted mt-0.5">Built on the AT Protocol</div> | ||
| 272 | + </div> | ||
| 273 | + </div> | ||
| 274 | + | ||
| 275 | + <div class="md:hidden flex flex-col gap-6"> | ||
| 276 | + <a href="/" class="text-spot-purple font-bold text-lg font-title tracking-tight">Glean</a> | ||
| 277 | + <div class="grid grid-cols-2 gap-6"> | ||
| 278 | + <div class="flex flex-col gap-1.5"> | ||
| 279 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Browse</div> | ||
| 280 | + <a href="/dashboard" class="text-xs text-spot-secondary hover:text-spot-text transition">Dashboard</a> | ||
| 281 | + <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-text transition">Trending</a> | ||
| 282 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-text transition">Discover</a> | ||
| 283 | + </div> | ||
| 284 | + <div class="flex flex-col gap-1.5"> | ||
| 285 | + <div class="text-spot-text font-bold text-xs uppercase tracking-wide mb-1">Library</div> | ||
| 286 | + <a href="/articles" class="text-xs text-spot-secondary hover:text-spot-text transition">Articles</a> | ||
| 287 | + <a href="/feeds" class="text-xs text-spot-secondary hover:text-spot-text transition">Feeds</a> | ||
| 288 | + <a href="/annotations" class="text-xs text-spot-secondary hover:text-spot-text transition">Annotations</a> | ||
| 289 | + </div> | ||
| 290 | + </div> | ||
| 291 | + <div class="flex items-center justify-between"> | ||
| 292 | + <button onclick="toggleTheme()" class="text-xs text-spot-secondary hover:text-spot-text inline-flex gap-1.5 items-center transition"> | ||
| 293 | + <svg class="w-3.5 h-3.5 theme-icon-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg> | ||
| 294 | + <svg class="w-3.5 h-3.5 theme-icon-light hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg> | ||
| 295 | + <span class="theme-text-dark">Light mode</span> | ||
| 296 | + <span class="theme-text-light hidden">Dark mode</span> | ||
| 297 | + </button> | ||
| 298 | + <span class="text-xs text-spot-secondary">© {{now.Format "2006"}} Glean</span> | ||
| 299 | + </div> | ||
| 300 | + </div> | ||
| 301 | + </div> | ||
| 302 | + </div> | ||
| 303 | + </footer> | ||
| 304 | + </main> | ||
| 305 | + | ||
| 306 | + <script> | ||
| 307 | + function toggleTheme() { | ||
| 308 | + var current = document.documentElement.getAttribute('data-theme'); | ||
| 309 | + var next = current === 'dark' ? 'light' : 'dark'; | ||
| 310 | + document.documentElement.setAttribute('data-theme', next); | ||
| 311 | + localStorage.setItem('theme', next); | ||
| 312 | + updateThemeIcons(next); | ||
| 313 | + } | ||
| 314 | + function updateThemeIcons(theme) { | ||
| 315 | + document.querySelectorAll('.theme-icon-dark').forEach(function(el) { | ||
| 316 | + el.classList.toggle('hidden', theme === 'light'); | ||
| 317 | + }); | ||
| 318 | + document.querySelectorAll('.theme-icon-light').forEach(function(el) { | ||
| 319 | + el.classList.toggle('hidden', theme === 'dark'); | ||
| 320 | + }); | ||
| 321 | + document.querySelectorAll('.theme-text-dark').forEach(function(el) { | ||
| 322 | + el.classList.toggle('hidden', theme === 'light'); | ||
| 323 | + }); | ||
| 324 | + document.querySelectorAll('.theme-text-light').forEach(function(el) { | ||
| 325 | + el.classList.toggle('hidden', theme === 'dark'); | ||
| 326 | + }); | ||
| 327 | + } | ||
| 328 | + updateThemeIcons(document.documentElement.getAttribute('data-theme') || 'dark'); | ||
| 329 | + | ||
| 330 | + document.addEventListener('keydown', function(e) { | ||
| 331 | + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | ||
| 332 | + if (e.key === 'g') window.location.href = '/dashboard'; | ||
| 333 | + if (e.key === 'a') window.location.href = '/articles'; | ||
| 334 | + if (e.key === 'f') window.location.href = '/feeds'; | ||
| 335 | + if (e.key === 't') window.location.href = '/trending'; | ||
| 336 | + if (e.key === 'd') window.location.href = '/discover'; | ||
| 337 | + }); | ||
| 338 | + </script> | ||
| 339 | +</body> | ||
| 340 | +</html> | ||
| 341 | +{{end}} | ||
added
internal/tmpl/dashboard.html +99 -0 | new file mode 100644 | ||
| @@ -0,0 +1,99 @@ | ||
| 1 | +{{define "dashboard.html"}} | |
| 2 | +<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| 3 | + <div class="lg:col-span-2"> | |
| 4 | + <div class="flex items-center justify-between mb-6"> | |
| 5 | + <h1 class="text-2xl font-bold font-title text-spot-text">Dashboard</h1> | |
| 6 | + <div class="flex gap-4 text-sm"> | |
| 7 | + <span class="text-spot-secondary"><strong class="text-spot-text">{{.UnreadCount}}</strong> unread</span> | |
| 8 | + <span class="text-spot-secondary"><strong class="text-spot-text">{{.SubscriptionCount}}</strong> feeds</span> | |
| 9 | + </div> | |
| 10 | + </div> | |
| 11 | + | |
| 12 | + <div class="space-y-3"> | |
| 13 | + {{range .Articles}} | |
| 14 | + {{template "article-card.html" .}} | |
| 15 | + {{else}} | |
| 16 | + <div class="text-center text-spot-secondary py-12">No unread articles. You're all caught up!</div> | |
| 17 | + {{end}} | |
| 18 | + </div> | |
| 19 | + | |
| 20 | + {{if .Articles}} | |
| 21 | + <div class="mt-4 text-center"> | |
| 22 | + <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-purple transition">View all articles →</a> | |
| 23 | + </div> | |
| 24 | + {{end}} | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + <div class="space-y-8"> | |
| 28 | + {{if .Trending}} | |
| 29 | + <div> | |
| 30 | + <div class="flex items-center justify-between mb-4"> | |
| 31 | + <h2 class="text-lg font-semibold text-spot-text">Trending</h2> | |
| 32 | + <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | |
| 33 | + </div> | |
| 34 | + <div class="space-y-3"> | |
| 35 | + {{range .Trending}} | |
| 36 | + <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-lg p-4 hover:bg-spot-hover-50 transition"> | |
| 37 | + <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div> | |
| 38 | + <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary"> | |
| 39 | + {{if .Author}}<span>{{.Author}}</span>{{end}} | |
| 40 | + <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{end}}</span> | |
| 41 | + </div> | |
| 42 | + <div class="flex items-center gap-3 mt-2 text-xs text-spot-secondary"> | |
| 43 | + <span>♥ {{.LikeCount}}</span> | |
| 44 | + <span class="text-spot-muted">{{.AnnotationCount}} notes</span> | |
| 45 | + </div> | |
| 46 | + </a> | |
| 47 | + {{end}} | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + {{end}} | |
| 51 | + | |
| 52 | + {{if .PeopleRecommendations}} | |
| 53 | + <div> | |
| 54 | + <div class="flex items-center justify-between mb-4"> | |
| 55 | + <h2 class="text-lg font-semibold text-spot-text">Similar readers</h2> | |
| 56 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | |
| 57 | + </div> | |
| 58 | + <div class="space-y-3"> | |
| 59 | + {{range .PeopleRecommendations}} | |
| 60 | + <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | |
| 61 | + {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}} | |
| 62 | + <div class="min-w-0 flex-1"> | |
| 63 | + <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | |
| 64 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | |
| 65 | + </div> | |
| 66 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span> | |
| 67 | + </div> | |
| 68 | + {{end}} | |
| 69 | + </div> | |
| 70 | + </div> | |
| 71 | + {{end}} | |
| 72 | + | |
| 73 | + {{if .FeedRecommendations}} | |
| 74 | + <div> | |
| 75 | + <div class="flex items-center justify-between mb-4"> | |
| 76 | + <h2 class="text-lg font-semibold text-spot-text">Recommended feeds</h2> | |
| 77 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | |
| 78 | + </div> | |
| 79 | + <div class="space-y-3"> | |
| 80 | + {{range .FeedRecommendations}} | |
| 81 | + {{template "recommendation-card.html" .}} | |
| 82 | + {{end}} | |
| 83 | + </div> | |
| 84 | + </div> | |
| 85 | + {{end}} | |
| 86 | + | |
| 87 | + <div> | |
| 88 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2> | |
| 89 | + <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2"> | |
| 90 | + {{csrfInput .CSRFToken}} | |
| 91 | + <input type="url" name="feed_url" placeholder="Feed URL" | |
| 92 | + class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | |
| 93 | + required> | |
| 94 | + <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button> | |
| 95 | + </form> | |
| 96 | + </div> | |
| 97 | + </div> | |
| 98 | +</div> | |
| 99 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,99 @@ | |||
| 1 | +{{define "dashboard.html"}} | ||
| 2 | +<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | ||
| 3 | + <div class="lg:col-span-2"> | ||
| 4 | + <div class="flex items-center justify-between mb-6"> | ||
| 5 | + <h1 class="text-2xl font-bold font-title text-spot-text">Dashboard</h1> | ||
| 6 | + <div class="flex gap-4 text-sm"> | ||
| 7 | + <span class="text-spot-secondary"><strong class="text-spot-text">{{.UnreadCount}}</strong> unread</span> | ||
| 8 | + <span class="text-spot-secondary"><strong class="text-spot-text">{{.SubscriptionCount}}</strong> feeds</span> | ||
| 9 | + </div> | ||
| 10 | + </div> | ||
| 11 | + | ||
| 12 | + <div class="space-y-3"> | ||
| 13 | + {{range .Articles}} | ||
| 14 | + {{template "article-card.html" .}} | ||
| 15 | + {{else}} | ||
| 16 | + <div class="text-center text-spot-secondary py-12">No unread articles. You're all caught up!</div> | ||
| 17 | + {{end}} | ||
| 18 | + </div> | ||
| 19 | + | ||
| 20 | + {{if .Articles}} | ||
| 21 | + <div class="mt-4 text-center"> | ||
| 22 | + <a href="/articles" class="text-sm text-spot-secondary hover:text-spot-purple transition">View all articles →</a> | ||
| 23 | + </div> | ||
| 24 | + {{end}} | ||
| 25 | + </div> | ||
| 26 | + | ||
| 27 | + <div class="space-y-8"> | ||
| 28 | + {{if .Trending}} | ||
| 29 | + <div> | ||
| 30 | + <div class="flex items-center justify-between mb-4"> | ||
| 31 | + <h2 class="text-lg font-semibold text-spot-text">Trending</h2> | ||
| 32 | + <a href="/trending" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | ||
| 33 | + </div> | ||
| 34 | + <div class="space-y-3"> | ||
| 35 | + {{range .Trending}} | ||
| 36 | + <a href="/articles/{{.ArticleID}}" class="block bg-spot-surface rounded-lg p-4 hover:bg-spot-hover-50 transition"> | ||
| 37 | + <div class="font-bold text-sm text-spot-text leading-tight">{{.Title}}</div> | ||
| 38 | + <div class="flex items-center gap-2 mt-1 text-xs text-spot-secondary"> | ||
| 39 | + {{if .Author}}<span>{{.Author}}</span>{{end}} | ||
| 40 | + <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{end}}</span> | ||
| 41 | + </div> | ||
| 42 | + <div class="flex items-center gap-3 mt-2 text-xs text-spot-secondary"> | ||
| 43 | + <span>♥ {{.LikeCount}}</span> | ||
| 44 | + <span class="text-spot-muted">{{.AnnotationCount}} notes</span> | ||
| 45 | + </div> | ||
| 46 | + </a> | ||
| 47 | + {{end}} | ||
| 48 | + </div> | ||
| 49 | + </div> | ||
| 50 | + {{end}} | ||
| 51 | + | ||
| 52 | + {{if .PeopleRecommendations}} | ||
| 53 | + <div> | ||
| 54 | + <div class="flex items-center justify-between mb-4"> | ||
| 55 | + <h2 class="text-lg font-semibold text-spot-text">Similar readers</h2> | ||
| 56 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | ||
| 57 | + </div> | ||
| 58 | + <div class="space-y-3"> | ||
| 59 | + {{range .PeopleRecommendations}} | ||
| 60 | + <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | ||
| 61 | + {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}} | ||
| 62 | + <div class="min-w-0 flex-1"> | ||
| 63 | + <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | ||
| 64 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | ||
| 65 | + </div> | ||
| 66 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span> | ||
| 67 | + </div> | ||
| 68 | + {{end}} | ||
| 69 | + </div> | ||
| 70 | + </div> | ||
| 71 | + {{end}} | ||
| 72 | + | ||
| 73 | + {{if .FeedRecommendations}} | ||
| 74 | + <div> | ||
| 75 | + <div class="flex items-center justify-between mb-4"> | ||
| 76 | + <h2 class="text-lg font-semibold text-spot-text">Recommended feeds</h2> | ||
| 77 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | ||
| 78 | + </div> | ||
| 79 | + <div class="space-y-3"> | ||
| 80 | + {{range .FeedRecommendations}} | ||
| 81 | + {{template "recommendation-card.html" .}} | ||
| 82 | + {{end}} | ||
| 83 | + </div> | ||
| 84 | + </div> | ||
| 85 | + {{end}} | ||
| 86 | + | ||
| 87 | + <div> | ||
| 88 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Quick add</h2> | ||
| 89 | + <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="flex gap-2"> | ||
| 90 | + {{csrfInput .CSRFToken}} | ||
| 91 | + <input type="url" name="feed_url" placeholder="Feed URL" | ||
| 92 | + class="flex-1 bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | ||
| 93 | + required> | ||
| 94 | + <button type="submit" class="bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button> | ||
| 95 | + </form> | ||
| 96 | + </div> | ||
| 97 | + </div> | ||
| 98 | +</div> | ||
| 99 | +{{end}} | ||
added
internal/tmpl/discover.html +58 -0 | new file mode 100644 | ||
| @@ -0,0 +1,58 @@ | ||
| 1 | +{{define "discover.html"}} | |
| 2 | +<div class="mb-6"> | |
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Discover</h1> | |
| 4 | + <p class="text-spot-secondary text-sm mt-1">Feeds and readers similar to you.</p> | |
| 5 | +</div> | |
| 6 | + | |
| 7 | +<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> | |
| 8 | + <div> | |
| 9 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> | |
| 10 | + <div class="space-y-3"> | |
| 11 | + {{range .FeedRecommendations}} | |
| 12 | + {{template "recommendation-card.html" .}} | |
| 13 | + {{else}} | |
| 14 | + <p class="text-sm text-spot-secondary">Subscribe to some feeds first to get recommendations.</p> | |
| 15 | + {{end}} | |
| 16 | + </div> | |
| 17 | + </div> | |
| 18 | + | |
| 19 | + <div> | |
| 20 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2> | |
| 21 | + <div class="space-y-3"> | |
| 22 | + {{range .PeopleRecommendations}} | |
| 23 | + <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | |
| 24 | + {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}} | |
| 25 | + <div class="min-w-0 flex-1"> | |
| 26 | + <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | |
| 27 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | |
| 28 | + </div> | |
| 29 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span> | |
| 30 | + </div> | |
| 31 | + {{else}} | |
| 32 | + <p class="text-sm text-spot-secondary">Not enough data yet. Keep reading!</p> | |
| 33 | + {{end}} | |
| 34 | + </div> | |
| 35 | + </div> | |
| 36 | +</div> | |
| 37 | + | |
| 38 | +<div class="mt-8"> | |
| 39 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Browse all feeds</h2> | |
| 40 | + <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider"> | |
| 41 | + {{range .PopularFeeds}} | |
| 42 | + <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg"> | |
| 43 | + <div class="min-w-0"> | |
| 44 | + <div class="font-bold text-spot-text">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</div> | |
| 45 | + {{if .Description.Valid}}<p class="text-sm text-spot-secondary truncate">{{.Description.String}}</p>{{end}} | |
| 46 | + </div> | |
| 47 | + <div class="flex items-center gap-3 shrink-0"> | |
| 48 | + <span class="text-xs text-spot-secondary">{{.SubscriberCount}} subscribers</span> | |
| 49 | + <button hx-post="/feeds" hx-vals='{"feed_url": "{{.FeedURL}}"}' hx-target="this" hx-swap="outerHTML" | |
| 50 | + class="text-xs bg-spot-purple/20 text-spot-purple px-4 py-1.5 rounded-pill font-bold uppercase tracking-button hover:bg-spot-purple hover:text-spot-bg transition">Subscribe</button> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + {{else}} | |
| 54 | + <div class="px-4 py-8 text-center text-spot-secondary">No feeds in the directory yet.</div> | |
| 55 | + {{end}} | |
| 56 | + </div> | |
| 57 | +</div> | |
| 58 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,58 @@ | |||
| 1 | +{{define "discover.html"}} | ||
| 2 | +<div class="mb-6"> | ||
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Discover</h1> | ||
| 4 | + <p class="text-spot-secondary text-sm mt-1">Feeds and readers similar to you.</p> | ||
| 5 | +</div> | ||
| 6 | + | ||
| 7 | +<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> | ||
| 8 | + <div> | ||
| 9 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Recommended feeds</h2> | ||
| 10 | + <div class="space-y-3"> | ||
| 11 | + {{range .FeedRecommendations}} | ||
| 12 | + {{template "recommendation-card.html" .}} | ||
| 13 | + {{else}} | ||
| 14 | + <p class="text-sm text-spot-secondary">Subscribe to some feeds first to get recommendations.</p> | ||
| 15 | + {{end}} | ||
| 16 | + </div> | ||
| 17 | + </div> | ||
| 18 | + | ||
| 19 | + <div> | ||
| 20 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Similar readers</h2> | ||
| 21 | + <div class="space-y-3"> | ||
| 22 | + {{range .PeopleRecommendations}} | ||
| 23 | + <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | ||
| 24 | + {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}} | ||
| 25 | + <div class="min-w-0 flex-1"> | ||
| 26 | + <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | ||
| 27 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | ||
| 28 | + </div> | ||
| 29 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span> | ||
| 30 | + </div> | ||
| 31 | + {{else}} | ||
| 32 | + <p class="text-sm text-spot-secondary">Not enough data yet. Keep reading!</p> | ||
| 33 | + {{end}} | ||
| 34 | + </div> | ||
| 35 | + </div> | ||
| 36 | +</div> | ||
| 37 | + | ||
| 38 | +<div class="mt-8"> | ||
| 39 | + <h2 class="text-lg font-semibold text-spot-text mb-4">Browse all feeds</h2> | ||
| 40 | + <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider"> | ||
| 41 | + {{range .PopularFeeds}} | ||
| 42 | + <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg"> | ||
| 43 | + <div class="min-w-0"> | ||
| 44 | + <div class="font-bold text-spot-text">{{if .Title.Valid}}{{.Title.String}}{{else}}{{.FeedURL}}{{end}}</div> | ||
| 45 | + {{if .Description.Valid}}<p class="text-sm text-spot-secondary truncate">{{.Description.String}}</p>{{end}} | ||
| 46 | + </div> | ||
| 47 | + <div class="flex items-center gap-3 shrink-0"> | ||
| 48 | + <span class="text-xs text-spot-secondary">{{.SubscriberCount}} subscribers</span> | ||
| 49 | + <button hx-post="/feeds" hx-vals='{"feed_url": "{{.FeedURL}}"}' hx-target="this" hx-swap="outerHTML" | ||
| 50 | + class="text-xs bg-spot-purple/20 text-spot-purple px-4 py-1.5 rounded-pill font-bold uppercase tracking-button hover:bg-spot-purple hover:text-spot-bg transition">Subscribe</button> | ||
| 51 | + </div> | ||
| 52 | + </div> | ||
| 53 | + {{else}} | ||
| 54 | + <div class="px-4 py-8 text-center text-spot-secondary">No feeds in the directory yet.</div> | ||
| 55 | + {{end}} | ||
| 56 | + </div> | ||
| 57 | +</div> | ||
| 58 | +{{end}} | ||
added
internal/tmpl/feeds.html +103 -0 | new file mode 100644 | ||
| @@ -0,0 +1,103 @@ | ||
| 1 | +{{define "feeds.html"}} | |
| 2 | +<div class="flex items-center justify-between mb-6"> | |
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Feeds</h1> | |
| 4 | + <div class="flex items-center gap-3"> | |
| 5 | + <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" | |
| 6 | + hx-indicator="#refresh-indicator" | |
| 7 | + class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition"> | |
| 8 | + Refresh feeds | |
| 9 | + </button> | |
| 10 | + <span id="refresh-indicator" class="htmx-indicator text-sm text-spot-secondary">Fetching...</span> | |
| 11 | + </div> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | |
| 15 | + <div class="lg:col-span-2"> | |
| 16 | + {{if .Categories}} | |
| 17 | + <div class="flex gap-2 mb-6 flex-wrap"> | |
| 18 | + <a href="/feeds" class="text-sm px-4 py-1.5 rounded-full font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a> | |
| 19 | + {{range .Categories}} | |
| 20 | + <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-full font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a> | |
| 21 | + {{end}} | |
| 22 | + </div> | |
| 23 | + {{end}} | |
| 24 | + | |
| 25 | + <div id="feed-list" class="bg-spot-surface rounded-lg divide-y divide-spot-divider"> | |
| 26 | + {{range .Subscriptions}} | |
| 27 | + <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg"> | |
| 28 | + <div class="min-w-0"> | |
| 29 | + <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div> | |
| 30 | + {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} | |
| 31 | + </div> | |
| 32 | + <div class="flex items-center gap-3"> | |
| 33 | + {{if .UnreadCount}}<span class="text-xs bg-spot-purple/20 text-spot-purple px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} | |
| 34 | + <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s" | |
| 35 | + hx-confirm="Unsubscribe from this feed?" | |
| 36 | + class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | |
| 37 | + </div> | |
| 38 | + </div> | |
| 39 | + {{else}} | |
| 40 | + <div class="px-4 py-8 text-center text-spot-secondary"> | |
| 41 | + <p>No feeds yet. Add one below!</p> | |
| 42 | + </div> | |
| 43 | + {{end}} | |
| 44 | + </div> | |
| 45 | + </div> | |
| 46 | + | |
| 47 | + <div class="space-y-6"> | |
| 48 | + <div class="bg-spot-surface rounded-lg p-4 space-y-4"> | |
| 49 | + <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Add feed</h2> | |
| 50 | + <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="space-y-3"> | |
| 51 | + {{csrfInput .CSRFToken}} | |
| 52 | + <input type="url" name="feed_url" placeholder="https://example.com/feed.xml" | |
| 53 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | |
| 54 | + required> | |
| 55 | + <input type="text" name="category" placeholder="Category (optional)" | |
| 56 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"> | |
| 57 | + <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button> | |
| 58 | + </form> | |
| 59 | + <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2"> | |
| 60 | + {{csrfInput .CSRFToken}} | |
| 61 | + <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label> | |
| 62 | + <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer"> | |
| 63 | + <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button> | |
| 64 | + </form> | |
| 65 | + </div> | |
| 66 | + | |
| 67 | + {{if .FeedRecommendations}} | |
| 68 | + <div> | |
| 69 | + <div class="flex items-center justify-between mb-4"> | |
| 70 | + <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Recommended feeds</h2> | |
| 71 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | |
| 72 | + </div> | |
| 73 | + <div class="space-y-3"> | |
| 74 | + {{range .FeedRecommendations}} | |
| 75 | + {{template "recommendation-card.html" .}} | |
| 76 | + {{end}} | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + {{end}} | |
| 80 | + | |
| 81 | + {{if .PeopleRecommendations}} | |
| 82 | + <div> | |
| 83 | + <div class="flex items-center justify-between mb-4"> | |
| 84 | + <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Similar readers</h2> | |
| 85 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | |
| 86 | + </div> | |
| 87 | + <div class="space-y-3"> | |
| 88 | + {{range .PeopleRecommendations}} | |
| 89 | + <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | |
| 90 | + {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}} | |
| 91 | + <div class="min-w-0 flex-1"> | |
| 92 | + <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | |
| 93 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | |
| 94 | + </div> | |
| 95 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span> | |
| 96 | + </div> | |
| 97 | + {{end}} | |
| 98 | + </div> | |
| 99 | + </div> | |
| 100 | + {{end}} | |
| 101 | + </div> | |
| 102 | +</div> | |
| 103 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,103 @@ | |||
| 1 | +{{define "feeds.html"}} | ||
| 2 | +<div class="flex items-center justify-between mb-6"> | ||
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Feeds</h1> | ||
| 4 | + <div class="flex items-center gap-3"> | ||
| 5 | + <button hx-post="/feeds/refresh" hx-target="#feed-list" hx-swap="innerHTML" | ||
| 6 | + hx-indicator="#refresh-indicator" | ||
| 7 | + class="border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-xs font-bold uppercase tracking-button hover:border-spot-text transition"> | ||
| 8 | + Refresh feeds | ||
| 9 | + </button> | ||
| 10 | + <span id="refresh-indicator" class="htmx-indicator text-sm text-spot-secondary">Fetching...</span> | ||
| 11 | + </div> | ||
| 12 | +</div> | ||
| 13 | + | ||
| 14 | +<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> | ||
| 15 | + <div class="lg:col-span-2"> | ||
| 16 | + {{if .Categories}} | ||
| 17 | + <div class="flex gap-2 mb-6 flex-wrap"> | ||
| 18 | + <a href="/feeds" class="text-sm px-4 py-1.5 rounded-full font-bold {{if not .Category}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">All</a> | ||
| 19 | + {{range .Categories}} | ||
| 20 | + <a href="/feeds?category={{.}}" class="text-sm px-4 py-1.5 rounded-full font-bold {{if eq $.Category .}}bg-spot-active-pill-bg text-spot-active-pill-text{{else}}bg-spot-hover text-spot-secondary hover:text-spot-text{{end}} transition">{{.}}</a> | ||
| 21 | + {{end}} | ||
| 22 | + </div> | ||
| 23 | + {{end}} | ||
| 24 | + | ||
| 25 | + <div id="feed-list" class="bg-spot-surface rounded-lg divide-y divide-spot-divider"> | ||
| 26 | + {{range .Subscriptions}} | ||
| 27 | + <div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg"> | ||
| 28 | + <div class="min-w-0"> | ||
| 29 | + <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div> | ||
| 30 | + {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} | ||
| 31 | + </div> | ||
| 32 | + <div class="flex items-center gap-3"> | ||
| 33 | + {{if .UnreadCount}}<span class="text-xs bg-spot-purple/20 text-spot-purple px-2.5 py-0.5 rounded-full font-bold">{{.UnreadCount}}</span>{{end}} | ||
| 34 | + <button hx-delete="/feeds/{{.FeedURL}}" hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s" | ||
| 35 | + hx-confirm="Unsubscribe from this feed?" | ||
| 36 | + class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | ||
| 37 | + </div> | ||
| 38 | + </div> | ||
| 39 | + {{else}} | ||
| 40 | + <div class="px-4 py-8 text-center text-spot-secondary"> | ||
| 41 | + <p>No feeds yet. Add one below!</p> | ||
| 42 | + </div> | ||
| 43 | + {{end}} | ||
| 44 | + </div> | ||
| 45 | + </div> | ||
| 46 | + | ||
| 47 | + <div class="space-y-6"> | ||
| 48 | + <div class="bg-spot-surface rounded-lg p-4 space-y-4"> | ||
| 49 | + <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Add feed</h2> | ||
| 50 | + <form hx-post="/feeds/add" hx-target="#feed-list" hx-swap="beforeend" class="space-y-3"> | ||
| 51 | + {{csrfInput .CSRFToken}} | ||
| 52 | + <input type="url" name="feed_url" placeholder="https://example.com/feed.xml" | ||
| 53 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | ||
| 54 | + required> | ||
| 55 | + <input type="text" name="category" placeholder="Category (optional)" | ||
| 56 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"> | ||
| 57 | + <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-5 py-2 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Add</button> | ||
| 58 | + </form> | ||
| 59 | + <form hx-post="/feeds/opml/upload" hx-encoding="multipart/form-data" class="flex flex-col gap-2"> | ||
| 60 | + {{csrfInput .CSRFToken}} | ||
| 61 | + <label class="text-xs text-spot-secondary font-bold uppercase tracking-wide">Import OPML</label> | ||
| 62 | + <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer"> | ||
| 63 | + <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import</button> | ||
| 64 | + </form> | ||
| 65 | + </div> | ||
| 66 | + | ||
| 67 | + {{if .FeedRecommendations}} | ||
| 68 | + <div> | ||
| 69 | + <div class="flex items-center justify-between mb-4"> | ||
| 70 | + <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Recommended feeds</h2> | ||
| 71 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | ||
| 72 | + </div> | ||
| 73 | + <div class="space-y-3"> | ||
| 74 | + {{range .FeedRecommendations}} | ||
| 75 | + {{template "recommendation-card.html" .}} | ||
| 76 | + {{end}} | ||
| 77 | + </div> | ||
| 78 | + </div> | ||
| 79 | + {{end}} | ||
| 80 | + | ||
| 81 | + {{if .PeopleRecommendations}} | ||
| 82 | + <div> | ||
| 83 | + <div class="flex items-center justify-between mb-4"> | ||
| 84 | + <h2 class="text-sm font-bold text-spot-text uppercase tracking-wide">Similar readers</h2> | ||
| 85 | + <a href="/discover" class="text-xs text-spot-secondary hover:text-spot-purple uppercase tracking-button transition">See all</a> | ||
| 86 | + </div> | ||
| 87 | + <div class="space-y-3"> | ||
| 88 | + {{range .PeopleRecommendations}} | ||
| 89 | + <div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | ||
| 90 | + {{if .avatar_url}}<img src="{{.avatar_url}}" class="w-10 h-10 rounded-full">{{end}} | ||
| 91 | + <div class="min-w-0 flex-1"> | ||
| 92 | + <a href="/profile/{{.handle}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | ||
| 93 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | ||
| 94 | + </div> | ||
| 95 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared</span> | ||
| 96 | + </div> | ||
| 97 | + {{end}} | ||
| 98 | + </div> | ||
| 99 | + </div> | ||
| 100 | + {{end}} | ||
| 101 | + </div> | ||
| 102 | +</div> | ||
| 103 | +{{end}} | ||
added
internal/tmpl/index.html +33 -0 | new file mode 100644 | ||
| @@ -0,0 +1,33 @@ | ||
| 1 | +{{define "index.html"}} | |
| 2 | +<div class="max-w-2xl mx-auto mt-20 lg:mt-32 text-center"> | |
| 3 | + <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1> | |
| 4 | + <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p> | |
| 5 | + <div class="flex flex-col sm:flex-row justify-center gap-3"> | |
| 6 | + <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a> | |
| 7 | + <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a> | |
| 8 | + </div> | |
| 9 | + <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left"> | |
| 10 | + <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot"> | |
| 11 | + <div class="text-spot-purple text-2xl mb-3"> | |
| 12 | + <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | |
| 13 | + </div> | |
| 14 | + <h3 class="font-bold text-spot-text mb-1">Follow any feed</h3> | |
| 15 | + <p class="text-sm text-spot-secondary">Subscribe to RSS and Atom feeds. Organize with categories. Import via OPML.</p> | |
| 16 | + </div> | |
| 17 | + <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot"> | |
| 18 | + <div class="text-spot-purple text-2xl mb-3"> | |
| 19 | + <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg> | |
| 20 | + </div> | |
| 21 | + <h3 class="font-bold text-spot-text mb-1">Read together</h3> | |
| 22 | + <p class="text-sm text-spot-secondary">See what your network reads. Like and annotate articles. Share discoveries.</p> | |
| 23 | + </div> | |
| 24 | + <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot"> | |
| 25 | + <div class="text-spot-purple text-2xl mb-3"> | |
| 26 | + <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | |
| 27 | + </div> | |
| 28 | + <h3 class="font-bold text-spot-text mb-1">Find new sources</h3> | |
| 29 | + <p class="text-sm text-spot-secondary">Get recommendations based on your reading. Find people with similar tastes.</p> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | +</div> | |
| 33 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | +{{define "index.html"}} | ||
| 2 | +<div class="max-w-2xl mx-auto mt-20 lg:mt-32 text-center"> | ||
| 3 | + <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1> | ||
| 4 | + <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p> | ||
| 5 | + <div class="flex flex-col sm:flex-row justify-center gap-3"> | ||
| 6 | + <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a> | ||
| 7 | + <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a> | ||
| 8 | + </div> | ||
| 9 | + <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left"> | ||
| 10 | + <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot"> | ||
| 11 | + <div class="text-spot-purple text-2xl mb-3"> | ||
| 12 | + <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 5c7.18 0 13 5.82 13 13M6 11a7 7 0 017 7m-7-1a1 1 0 11-2 0 1 1 0 012 0z"/></svg> | ||
| 13 | + </div> | ||
| 14 | + <h3 class="font-bold text-spot-text mb-1">Follow any feed</h3> | ||
| 15 | + <p class="text-sm text-spot-secondary">Subscribe to RSS and Atom feeds. Organize with categories. Import via OPML.</p> | ||
| 16 | + </div> | ||
| 17 | + <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot"> | ||
| 18 | + <div class="text-spot-purple text-2xl mb-3"> | ||
| 19 | + <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg> | ||
| 20 | + </div> | ||
| 21 | + <h3 class="font-bold text-spot-text mb-1">Read together</h3> | ||
| 22 | + <p class="text-sm text-spot-secondary">See what your network reads. Like and annotate articles. Share discoveries.</p> | ||
| 23 | + </div> | ||
| 24 | + <div class="bg-spot-surface hover:bg-spot-hover rounded-lg p-6 transition shadow-spot"> | ||
| 25 | + <div class="text-spot-purple text-2xl mb-3"> | ||
| 26 | + <svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg> | ||
| 27 | + </div> | ||
| 28 | + <h3 class="font-bold text-spot-text mb-1">Find new sources</h3> | ||
| 29 | + <p class="text-sm text-spot-secondary">Get recommendations based on your reading. Find people with similar tastes.</p> | ||
| 30 | + </div> | ||
| 31 | + </div> | ||
| 32 | +</div> | ||
| 33 | +{{end}} | ||
added
internal/tmpl/login.html +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +{{define "login.html"}} | |
| 2 | +<div class="max-w-md mx-auto mt-20"> | |
| 3 | + <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8"> | |
| 4 | + <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1> | |
| 5 | + <form action="/auth/callback" method="GET"> | |
| 6 | + <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label> | |
| 7 | + <input type="text" name="handle" placeholder="you.bsky.social" | |
| 8 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | |
| 9 | + required> | |
| 10 | + <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition"> | |
| 11 | + Sign in with Bluesky | |
| 12 | + </button> | |
| 13 | + </form> | |
| 14 | + <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account. No password needed.</p> | |
| 15 | + </div> | |
| 16 | +</div> | |
| 17 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +{{define "login.html"}} | ||
| 2 | +<div class="max-w-md mx-auto mt-20"> | ||
| 3 | + <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8"> | ||
| 4 | + <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1> | ||
| 5 | + <form action="/auth/callback" method="GET"> | ||
| 6 | + <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label> | ||
| 7 | + <input type="text" name="handle" placeholder="you.bsky.social" | ||
| 8 | + class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder" | ||
| 9 | + required> | ||
| 10 | + <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition"> | ||
| 11 | + Sign in with Bluesky | ||
| 12 | + </button> | ||
| 13 | + </form> | ||
| 14 | + <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account. No password needed.</p> | ||
| 15 | + </div> | ||
| 16 | +</div> | ||
| 17 | +{{end}} | ||
added
internal/tmpl/partials/annotation-card.html +22 -0 | new file mode 100644 | ||
| @@ -0,0 +1,22 @@ | ||
| 1 | +{{define "annotation-card.html"}} | |
| 2 | +<div class="bg-spot-surface rounded-lg p-4"> | |
| 3 | + {{if .Quote.Valid}} | |
| 4 | + <blockquote class="border-l-2 border-spot-purple pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote> | |
| 5 | + {{end}} | |
| 6 | + {{if .Note.Valid}} | |
| 7 | + <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p> | |
| 8 | + {{end}} | |
| 9 | + {{if .Tags.Valid}} | |
| 10 | + <div class="flex gap-1 mt-2 flex-wrap"> | |
| 11 | + {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} | |
| 12 | + </div> | |
| 13 | + {{end}} | |
| 14 | + {{if .Rating.Valid}} | |
| 15 | + <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> | |
| 16 | + {{end}} | |
| 17 | + <div class="text-xs text-spot-muted mt-2"> | |
| 18 | + <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a> | |
| 19 | + {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 20 | + </div> | |
| 21 | +</div> | |
| 22 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,22 @@ | |||
| 1 | +{{define "annotation-card.html"}} | ||
| 2 | +<div class="bg-spot-surface rounded-lg p-4"> | ||
| 3 | + {{if .Quote.Valid}} | ||
| 4 | + <blockquote class="border-l-2 border-spot-purple pl-3 text-sm text-spot-body italic">{{.Quote.String}}</blockquote> | ||
| 5 | + {{end}} | ||
| 6 | + {{if .Note.Valid}} | ||
| 7 | + <p class="text-sm text-spot-body mt-2">{{.Note.String}}</p> | ||
| 8 | + {{end}} | ||
| 9 | + {{if .Tags.Valid}} | ||
| 10 | + <div class="flex gap-1 mt-2 flex-wrap"> | ||
| 11 | + {{range $tag := split .Tags.String ","}}<span class="text-xs bg-spot-hover text-spot-secondary px-2.5 py-0.5 rounded-full">{{$tag}}</span>{{end}} | ||
| 12 | + </div> | ||
| 13 | + {{end}} | ||
| 14 | + {{if .Rating.Valid}} | ||
| 15 | + <div class="text-sm text-spot-orange mt-1">{{repeat "★" (int .Rating.Int64)}}</div> | ||
| 16 | + {{end}} | ||
| 17 | + <div class="text-xs text-spot-muted mt-2"> | ||
| 18 | + <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a> | ||
| 19 | + {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 20 | + </div> | ||
| 21 | +</div> | ||
| 22 | +{{end}} | ||
added
internal/tmpl/partials/article-card.html +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +{{define "article-card.html"}} | |
| 2 | +<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | |
| 3 | + <div class="flex items-start justify-between gap-4"> | |
| 4 | + <div class="min-w-0 flex-1"> | |
| 5 | + <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a> | |
| 6 | + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> | |
| 7 | + {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} | |
| 8 | + {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 9 | + <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | |
| 10 | + </div> | |
| 11 | + {{if .Summary.Valid}}{{if .Summary.String}} | |
| 12 | + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p> | |
| 13 | + {{end}}{{end}} | |
| 14 | + </div> | |
| 15 | + <div class="flex flex-col items-center gap-1 shrink-0"> | |
| 16 | + {{template "like-button.html" .}} | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | +</article> | |
| 20 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +{{define "article-card.html"}} | ||
| 2 | +<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | ||
| 3 | + <div class="flex items-start justify-between gap-4"> | ||
| 4 | + <div class="min-w-0 flex-1"> | ||
| 5 | + <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a> | ||
| 6 | + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> | ||
| 7 | + {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} | ||
| 8 | + {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 9 | + <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | ||
| 10 | + </div> | ||
| 11 | + {{if .Summary.Valid}}{{if .Summary.String}} | ||
| 12 | + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p> | ||
| 13 | + {{end}}{{end}} | ||
| 14 | + </div> | ||
| 15 | + <div class="flex flex-col items-center gap-1 shrink-0"> | ||
| 16 | + {{template "like-button.html" .}} | ||
| 17 | + </div> | ||
| 18 | + </div> | ||
| 19 | +</article> | ||
| 20 | +{{end}} | ||
added
internal/tmpl/partials/article-list.html +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +{{define "article-list.html"}} | |
| 2 | +{{range .Articles}} | |
| 3 | +<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | |
| 4 | + <div class="flex items-start justify-between gap-4"> | |
| 5 | + <div class="min-w-0 flex-1"> | |
| 6 | + <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a> | |
| 7 | + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> | |
| 8 | + {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} | |
| 9 | + {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | |
| 10 | + <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | |
| 11 | + </div> | |
| 12 | + {{if .Summary.Valid}}{{if .Summary.String}} | |
| 13 | + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p> | |
| 14 | + {{end}}{{end}} | |
| 15 | + </div> | |
| 16 | + </div> | |
| 17 | +</article> | |
| 18 | +{{else}} | |
| 19 | +<div class="text-center text-spot-secondary py-12">No articles found.</div> | |
| 20 | +{{end}} | |
| 21 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +{{define "article-list.html"}} | ||
| 2 | +{{range .Articles}} | ||
| 3 | +<article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | ||
| 4 | + <div class="flex items-start justify-between gap-4"> | ||
| 5 | + <div class="min-w-0 flex-1"> | ||
| 6 | + <a href="/articles/{{.ID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{.Title}}</a> | ||
| 7 | + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> | ||
| 8 | + {{if .Author.Valid}}{{if .Author.String}}<span>{{.Author.String}}</span>{{end}}{{end}} | ||
| 9 | + {{if .Published.Valid}}<span>{{.Published.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}} | ||
| 10 | + <span class="text-spot-muted">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</span> | ||
| 11 | + </div> | ||
| 12 | + {{if .Summary.Valid}}{{if .Summary.String}} | ||
| 13 | + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{plainText .Summary.String}}</p> | ||
| 14 | + {{end}}{{end}} | ||
| 15 | + </div> | ||
| 16 | + </div> | ||
| 17 | +</article> | ||
| 18 | +{{else}} | ||
| 19 | +<div class="text-center text-spot-secondary py-12">No articles found.</div> | ||
| 20 | +{{end}} | ||
| 21 | +{{end}} | ||
added
internal/tmpl/partials/feed-list.html +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +{{define "feed-list.html"}} | |
| 2 | +{{range .Subscriptions}} | |
| 3 | +<div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg"> | |
| 4 | + <div class="min-w-0"> | |
| 5 | + <div class="font-bold text-spot-text">{{.FeedURL}}</div> | |
| 6 | + {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} | |
| 7 | + </div> | |
| 8 | + <button hx-delete="/feeds/remove" hx-vals='{"url": "{{.FeedURL}}"}' hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s" | |
| 9 | + hx-confirm="Unsubscribe from this feed?" | |
| 10 | + class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | |
| 11 | +</div> | |
| 12 | +{{else}} | |
| 13 | +<div class="px-4 py-8 text-center text-spot-secondary">No feeds yet.</div> | |
| 14 | +{{end}} | |
| 15 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +{{define "feed-list.html"}} | ||
| 2 | +{{range .Subscriptions}} | ||
| 3 | +<div class="px-5 py-4 flex items-center justify-between hover:bg-spot-hover-50 transition rounded-lg"> | ||
| 4 | + <div class="min-w-0"> | ||
| 5 | + <div class="font-bold text-spot-text">{{.FeedURL}}</div> | ||
| 6 | + {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} | ||
| 7 | + </div> | ||
| 8 | + <button hx-delete="/feeds/remove" hx-vals='{"url": "{{.FeedURL}}"}' hx-target="closest .px-5" hx-swap="outerHTML swap:0.3s" | ||
| 9 | + hx-confirm="Unsubscribe from this feed?" | ||
| 10 | + class="text-sm text-spot-secondary hover:text-spot-red transition">Unsubscribe</button> | ||
| 11 | +</div> | ||
| 12 | +{{else}} | ||
| 13 | +<div class="px-4 py-8 text-center text-spot-secondary">No feeds yet.</div> | ||
| 14 | +{{end}} | ||
| 15 | +{{end}} | ||
added
internal/tmpl/partials/like-button.html +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +{{define "like-button.html"}} | |
| 2 | +<button hx-post="/articles/{{.ID}}/like" hx-target="this" hx-swap="outerHTML" | |
| 3 | + class="text-spot-muted hover:text-spot-red text-lg transition">♥</button> | |
| 4 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +{{define "like-button.html"}} | ||
| 2 | +<button hx-post="/articles/{{.ID}}/like" hx-target="this" hx-swap="outerHTML" | ||
| 3 | + class="text-spot-muted hover:text-spot-red text-lg transition">♥</button> | ||
| 4 | +{{end}} | ||
added
internal/tmpl/partials/profile-card.html +9 -0 | new file mode 100644 | ||
| @@ -0,0 +1,9 @@ | ||
| 1 | +{{define "profile-card.html"}} | |
| 2 | +<div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | |
| 3 | + <div class="min-w-0 flex-1"> | |
| 4 | + <a href="/profile/{{.did}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | |
| 5 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | |
| 6 | + </div> | |
| 7 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span> | |
| 8 | +</div> | |
| 9 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | +{{define "profile-card.html"}} | ||
| 2 | +<div class="bg-spot-surface rounded-lg p-4 flex items-center gap-3 hover:bg-spot-hover-50 transition"> | ||
| 3 | + <div class="min-w-0 flex-1"> | ||
| 4 | + <a href="/profile/{{.did}}" class="font-bold text-spot-text hover:text-spot-purple transition">@{{.handle}}</a> | ||
| 5 | + {{if .display_name}}<div class="text-sm text-spot-secondary">{{.display_name}}</div>{{end}} | ||
| 6 | + </div> | ||
| 7 | + <span class="text-xs text-spot-secondary">{{.common_feeds}} shared feeds</span> | ||
| 8 | +</div> | ||
| 9 | +{{end}} | ||
added
internal/tmpl/partials/recommendation-card.html +11 -0 | new file mode 100644 | ||
| @@ -0,0 +1,11 @@ | ||
| 1 | +{{define "recommendation-card.html"}} | |
| 2 | +<div class="bg-spot-surface rounded-lg p-3 hover:bg-spot-hover-50 transition"> | |
| 3 | + <div class="flex items-center justify-between"> | |
| 4 | + <div class="min-w-0"> | |
| 5 | + <div class="font-bold text-sm text-spot-text">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div> | |
| 6 | + {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}} | |
| 7 | + </div> | |
| 8 | + <span class="text-xs text-spot-secondary shrink-0 ml-2">{{.subscriber_count}} subs</span> | |
| 9 | + </div> | |
| 10 | +</div> | |
| 11 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,11 @@ | |||
| 1 | +{{define "recommendation-card.html"}} | ||
| 2 | +<div class="bg-spot-surface rounded-lg p-3 hover:bg-spot-hover-50 transition"> | ||
| 3 | + <div class="flex items-center justify-between"> | ||
| 4 | + <div class="min-w-0"> | ||
| 5 | + <div class="font-bold text-sm text-spot-text">{{if .title}}{{.title}}{{else}}{{.feed_url}}{{end}}</div> | ||
| 6 | + {{if .description}}<p class="text-xs text-spot-secondary truncate mt-0.5">{{.description}}</p>{{end}} | ||
| 7 | + </div> | ||
| 8 | + <span class="text-xs text-spot-secondary shrink-0 ml-2">{{.subscriber_count}} subs</span> | ||
| 9 | + </div> | ||
| 10 | +</div> | ||
| 11 | +{{end}} | ||
added
internal/tmpl/profile.html +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +{{define "profile.html"}} | |
| 2 | +<div class="max-w-2xl mx-auto"> | |
| 3 | + <div class="bg-spot-surface rounded-lg p-6 mb-6"> | |
| 4 | + <div class="flex items-center gap-4"> | |
| 5 | + {{if .ProfileUser.AvatarURL.Valid}}<img src="{{.ProfileUser.AvatarURL.String}}" class="w-16 h-16 rounded-full">{{end}} | |
| 6 | + <div> | |
| 7 | + <h1 class="text-2xl font-bold font-title text-spot-text"> | |
| 8 | + {{if .ProfileUser.DisplayName.Valid}}{{.ProfileUser.DisplayName.String}}{{end}} | |
| 9 | + </h1> | |
| 10 | + <p class="text-spot-secondary">@{{.ProfileUser.Handle}}</p> | |
| 11 | + </div> | |
| 12 | + </div> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <div class="grid grid-cols-2 gap-4 mb-6"> | |
| 16 | + <div class="bg-spot-surface rounded-lg p-4 text-center"> | |
| 17 | + <div class="text-2xl font-bold text-spot-text">{{.SubscriptionCount}}</div> | |
| 18 | + <div class="text-sm text-spot-secondary">Feeds</div> | |
| 19 | + </div> | |
| 20 | + <div class="bg-spot-surface rounded-lg p-4 text-center"> | |
| 21 | + <div class="text-2xl font-bold text-spot-text">{{.AnnotationCount}}</div> | |
| 22 | + <div class="text-sm text-spot-secondary">Annotations</div> | |
| 23 | + </div> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <h2 class="text-lg font-semibold text-spot-text mb-3">Feeds</h2> | |
| 27 | + <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider mb-6"> | |
| 28 | + {{range .Subscriptions}} | |
| 29 | + <div class="px-5 py-3 hover:bg-spot-hover-50 transition rounded-lg"> | |
| 30 | + <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div> | |
| 31 | + {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} | |
| 32 | + </div> | |
| 33 | + {{else}} | |
| 34 | + <div class="px-4 py-6 text-center text-spot-secondary text-sm">No public feeds.</div> | |
| 35 | + {{end}} | |
| 36 | + </div> | |
| 37 | + | |
| 38 | + <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2> | |
| 39 | + <div class="space-y-3"> | |
| 40 | + {{range .Annotations}} | |
| 41 | + {{template "annotation-card.html" .}} | |
| 42 | + {{else}} | |
| 43 | + <p class="text-sm text-spot-secondary">No public annotations.</p> | |
| 44 | + {{end}} | |
| 45 | + </div> | |
| 46 | +</div> | |
| 47 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +{{define "profile.html"}} | ||
| 2 | +<div class="max-w-2xl mx-auto"> | ||
| 3 | + <div class="bg-spot-surface rounded-lg p-6 mb-6"> | ||
| 4 | + <div class="flex items-center gap-4"> | ||
| 5 | + {{if .ProfileUser.AvatarURL.Valid}}<img src="{{.ProfileUser.AvatarURL.String}}" class="w-16 h-16 rounded-full">{{end}} | ||
| 6 | + <div> | ||
| 7 | + <h1 class="text-2xl font-bold font-title text-spot-text"> | ||
| 8 | + {{if .ProfileUser.DisplayName.Valid}}{{.ProfileUser.DisplayName.String}}{{end}} | ||
| 9 | + </h1> | ||
| 10 | + <p class="text-spot-secondary">@{{.ProfileUser.Handle}}</p> | ||
| 11 | + </div> | ||
| 12 | + </div> | ||
| 13 | + </div> | ||
| 14 | + | ||
| 15 | + <div class="grid grid-cols-2 gap-4 mb-6"> | ||
| 16 | + <div class="bg-spot-surface rounded-lg p-4 text-center"> | ||
| 17 | + <div class="text-2xl font-bold text-spot-text">{{.SubscriptionCount}}</div> | ||
| 18 | + <div class="text-sm text-spot-secondary">Feeds</div> | ||
| 19 | + </div> | ||
| 20 | + <div class="bg-spot-surface rounded-lg p-4 text-center"> | ||
| 21 | + <div class="text-2xl font-bold text-spot-text">{{.AnnotationCount}}</div> | ||
| 22 | + <div class="text-sm text-spot-secondary">Annotations</div> | ||
| 23 | + </div> | ||
| 24 | + </div> | ||
| 25 | + | ||
| 26 | + <h2 class="text-lg font-semibold text-spot-text mb-3">Feeds</h2> | ||
| 27 | + <div class="bg-spot-surface rounded-lg divide-y divide-spot-divider mb-6"> | ||
| 28 | + {{range .Subscriptions}} | ||
| 29 | + <div class="px-5 py-3 hover:bg-spot-hover-50 transition rounded-lg"> | ||
| 30 | + <div class="font-bold text-spot-text">{{if .FeedTitle}}{{.FeedTitle}}{{else}}{{.FeedURL}}{{end}}</div> | ||
| 31 | + {{if .Category.Valid}}<span class="text-xs text-spot-secondary">{{.Category.String}}</span>{{end}} | ||
| 32 | + </div> | ||
| 33 | + {{else}} | ||
| 34 | + <div class="px-4 py-6 text-center text-spot-secondary text-sm">No public feeds.</div> | ||
| 35 | + {{end}} | ||
| 36 | + </div> | ||
| 37 | + | ||
| 38 | + <h2 class="text-lg font-semibold text-spot-text mb-3">Recent annotations</h2> | ||
| 39 | + <div class="space-y-3"> | ||
| 40 | + {{range .Annotations}} | ||
| 41 | + {{template "annotation-card.html" .}} | ||
| 42 | + {{else}} | ||
| 43 | + <p class="text-sm text-spot-secondary">No public annotations.</p> | ||
| 44 | + {{end}} | ||
| 45 | + </div> | ||
| 46 | +</div> | ||
| 47 | +{{end}} | ||
added
internal/tmpl/trending.html +31 -0 | new file mode 100644 | ||
| @@ -0,0 +1,31 @@ | ||
| 1 | +{{define "trending.html"}} | |
| 2 | +<div class="mb-6"> | |
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Trending</h1> | |
| 4 | + <p class="text-spot-secondary text-sm mt-1">Most liked and discussed articles across Glean.</p> | |
| 5 | +</div> | |
| 6 | + | |
| 7 | +<div class="space-y-3"> | |
| 8 | + {{range $i, $t := .Trending}} | |
| 9 | + <article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | |
| 10 | + <div class="flex items-start justify-between gap-4"> | |
| 11 | + <div class="min-w-0 flex-1"> | |
| 12 | + <a href="/articles/{{$t.ArticleID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{$t.Title}}</a> | |
| 13 | + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> | |
| 14 | + {{if $t.Author}}<span>{{$t.Author}}</span>{{end}} | |
| 15 | + <span class="text-spot-muted">{{if $t.FeedTitle}}{{$t.FeedTitle}}{{else}}{{$t.FeedURL}}{{end}}</span> | |
| 16 | + </div> | |
| 17 | + {{if $t.Summary}} | |
| 18 | + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{$t.Summary}}</p> | |
| 19 | + {{end}} | |
| 20 | + </div> | |
| 21 | + <div class="flex flex-col items-end gap-1 shrink-0 text-sm text-spot-secondary"> | |
| 22 | + <span>♥ {{$t.LikeCount}}</span> | |
| 23 | + <span class="text-spot-muted">{{$t.AnnotationCount}} notes</span> | |
| 24 | + </div> | |
| 25 | + </div> | |
| 26 | + </article> | |
| 27 | + {{else}} | |
| 28 | + <div class="text-center text-spot-secondary py-12">No trending articles yet. Start liking and annotating!</div> | |
| 29 | + {{end}} | |
| 30 | +</div> | |
| 31 | +{{end}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,31 @@ | |||
| 1 | +{{define "trending.html"}} | ||
| 2 | +<div class="mb-6"> | ||
| 3 | + <h1 class="text-2xl font-bold font-title text-spot-text">Trending</h1> | ||
| 4 | + <p class="text-spot-secondary text-sm mt-1">Most liked and discussed articles across Glean.</p> | ||
| 5 | +</div> | ||
| 6 | + | ||
| 7 | +<div class="space-y-3"> | ||
| 8 | + {{range $i, $t := .Trending}} | ||
| 9 | + <article class="bg-spot-surface rounded-lg px-5 py-4 hover:bg-spot-hover-50 transition shadow-spot"> | ||
| 10 | + <div class="flex items-start justify-between gap-4"> | ||
| 11 | + <div class="min-w-0 flex-1"> | ||
| 12 | + <a href="/articles/{{$t.ArticleID}}" class="font-bold text-spot-text hover:text-spot-purple transition text-lg leading-tight">{{$t.Title}}</a> | ||
| 13 | + <div class="text-sm text-spot-secondary mt-1 flex items-center gap-2"> | ||
| 14 | + {{if $t.Author}}<span>{{$t.Author}}</span>{{end}} | ||
| 15 | + <span class="text-spot-muted">{{if $t.FeedTitle}}{{$t.FeedTitle}}{{else}}{{$t.FeedURL}}{{end}}</span> | ||
| 16 | + </div> | ||
| 17 | + {{if $t.Summary}} | ||
| 18 | + <p class="text-sm text-spot-secondary mt-2 line-clamp-2">{{$t.Summary}}</p> | ||
| 19 | + {{end}} | ||
| 20 | + </div> | ||
| 21 | + <div class="flex flex-col items-end gap-1 shrink-0 text-sm text-spot-secondary"> | ||
| 22 | + <span>♥ {{$t.LikeCount}}</span> | ||
| 23 | + <span class="text-spot-muted">{{$t.AnnotationCount}} notes</span> | ||
| 24 | + </div> | ||
| 25 | + </div> | ||
| 26 | + </article> | ||
| 27 | + {{else}} | ||
| 28 | + <div class="text-center text-spot-secondary py-12">No trending articles yet. Start liking and annotating!</div> | ||
| 29 | + {{end}} | ||
| 30 | +</div> | ||
| 31 | +{{end}} | ||
modified
main.go +107 -1 | @@ -1,3 +1,109 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | -func main() {} | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "flag" | |
| 6 | + "fmt" | |
| 7 | + "log/slog" | |
| 8 | + "net/http" | |
| 9 | + "os" | |
| 10 | + "os/signal" | |
| 11 | + "syscall" | |
| 12 | + "time" | |
| 13 | + | |
| 14 | + "pkg.rbrt.fr/glean/internal/atproto" | |
| 15 | + "pkg.rbrt.fr/glean/internal/cluster" | |
| 16 | + "pkg.rbrt.fr/glean/internal/db" | |
| 17 | + "pkg.rbrt.fr/glean/internal/feed" | |
| 18 | + "pkg.rbrt.fr/glean/internal/server" | |
| 19 | +) | |
| 20 | + | |
| 21 | +func main() { | |
| 22 | + addr := flag.String("addr", envOr("GLEAN_ADDR", ":8080"), "listen address") | |
| 23 | + dbPath := flag.String("db", envOr("GLEAN_DB", "glean.db"), "database path") | |
| 24 | + relayURL := flag.String("relay", envOr("GLEAN_RELAY", "wss://bsky.network"), "AT Relay URL") | |
| 25 | + flag.Parse() | |
| 26 | + | |
| 27 | + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) | |
| 28 | + | |
| 29 | + database, err := db.Open(*dbPath) | |
| 30 | + if err != nil { | |
| 31 | + logger.Error("failed to open database", "error", err) | |
| 32 | + os.Exit(1) | |
| 33 | + } | |
| 34 | + defer database.Close() | |
| 35 | + | |
| 36 | + oauth := &atproto.OAuthConfig{ | |
| 37 | + ClientID: envOr("GLEAN_OAUTH_CLIENT_ID", ""), | |
| 38 | + RedirectURL: envOr("GLEAN_OAUTH_REDIRECT_URL", ""), | |
| 39 | + } | |
| 40 | + | |
| 41 | + srv := server.New(database, oauth, logger) | |
| 42 | + | |
| 43 | + storeAdapter := db.NewFeedStoreAdapter(database) | |
| 44 | + scheduler := feed.NewScheduler(storeAdapter, logger) | |
| 45 | + | |
| 46 | + engine := cluster.NewEngine(database.DB, logger) | |
| 47 | + cron := cluster.NewCron(engine, 6*time.Hour, logger) | |
| 48 | + | |
| 49 | + firehose := atproto.NewFirehoseConsumer(*relayURL, func(ctx context.Context, event *atproto.FirehoseEvent) error { | |
| 50 | + logger.Debug("firehose event", "type", event.Type, "collection", event.Collection, "did", event.DID) | |
| 51 | + return nil | |
| 52 | + }, logger) | |
| 53 | + | |
| 54 | + ctx, cancel := context.WithCancel(context.Background()) | |
| 55 | + defer cancel() | |
| 56 | + | |
| 57 | + go func() { | |
| 58 | + if err := scheduler.Run(ctx); err != nil && ctx.Err() == nil { | |
| 59 | + logger.Error("scheduler error", "error", err) | |
| 60 | + } | |
| 61 | + }() | |
| 62 | + go func() { | |
| 63 | + if err := cron.Run(ctx); err != nil && ctx.Err() == nil { | |
| 64 | + logger.Error("cron error", "error", err) | |
| 65 | + } | |
| 66 | + }() | |
| 67 | + go func() { | |
| 68 | + if err := firehose.Start(ctx); err != nil && ctx.Err() == nil { | |
| 69 | + logger.Error("firehose error", "error", err) | |
| 70 | + } | |
| 71 | + }() | |
| 72 | + | |
| 73 | + httpServer := &http.Server{ | |
| 74 | + Addr: *addr, | |
| 75 | + Handler: srv, | |
| 76 | + } | |
| 77 | + | |
| 78 | + sigCh := make(chan os.Signal, 1) | |
| 79 | + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | |
| 80 | + | |
| 81 | + go func() { | |
| 82 | + logger.Info("starting server", "addr", *addr) | |
| 83 | + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |
| 84 | + logger.Error("server error", "error", err) | |
| 85 | + os.Exit(1) | |
| 86 | + } | |
| 87 | + }() | |
| 88 | + | |
| 89 | + sig := <-sigCh | |
| 90 | + logger.Info("shutting down", "signal", sig) | |
| 91 | + | |
| 92 | + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 93 | + defer shutdownCancel() | |
| 94 | + | |
| 95 | + if err := httpServer.Shutdown(shutdownCtx); err != nil { | |
| 96 | + logger.Error("shutdown error", "error", err) | |
| 97 | + } | |
| 98 | + | |
| 99 | + cancel() | |
| 100 | + | |
| 101 | + fmt.Println("glean stopped") | |
| 102 | +} | |
| 103 | + | |
| 104 | +func envOr(key, fallback string) string { | |
| 105 | + if v := os.Getenv(key); v != "" { | |
| 106 | + return v | |
| 107 | + } | |
| 108 | + return fallback | |
| 109 | +} | |
| @@ -1,3 +1,109 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | -func main() {} | 3 | +import ( |
| 4 | + "context" | ||
| 5 | + "flag" | ||
| 6 | + "fmt" | ||
| 7 | + "log/slog" | ||
| 8 | + "net/http" | ||
| 9 | + "os" | ||
| 10 | + "os/signal" | ||
| 11 | + "syscall" | ||
| 12 | + "time" | ||
| 13 | + | ||
| 14 | + "pkg.rbrt.fr/glean/internal/atproto" | ||
| 15 | + "pkg.rbrt.fr/glean/internal/cluster" | ||
| 16 | + "pkg.rbrt.fr/glean/internal/db" | ||
| 17 | + "pkg.rbrt.fr/glean/internal/feed" | ||
| 18 | + "pkg.rbrt.fr/glean/internal/server" | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | +func main() { | ||
| 22 | + addr := flag.String("addr", envOr("GLEAN_ADDR", ":8080"), "listen address") | ||
| 23 | + dbPath := flag.String("db", envOr("GLEAN_DB", "glean.db"), "database path") | ||
| 24 | + relayURL := flag.String("relay", envOr("GLEAN_RELAY", "wss://bsky.network"), "AT Relay URL") | ||
| 25 | + flag.Parse() | ||
| 26 | + | ||
| 27 | + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) | ||
| 28 | + | ||
| 29 | + database, err := db.Open(*dbPath) | ||
| 30 | + if err != nil { | ||
| 31 | + logger.Error("failed to open database", "error", err) | ||
| 32 | + os.Exit(1) | ||
| 33 | + } | ||
| 34 | + defer database.Close() | ||
| 35 | + | ||
| 36 | + oauth := &atproto.OAuthConfig{ | ||
| 37 | + ClientID: envOr("GLEAN_OAUTH_CLIENT_ID", ""), | ||
| 38 | + RedirectURL: envOr("GLEAN_OAUTH_REDIRECT_URL", ""), | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + srv := server.New(database, oauth, logger) | ||
| 42 | + | ||
| 43 | + storeAdapter := db.NewFeedStoreAdapter(database) | ||
| 44 | + scheduler := feed.NewScheduler(storeAdapter, logger) | ||
| 45 | + | ||
| 46 | + engine := cluster.NewEngine(database.DB, logger) | ||
| 47 | + cron := cluster.NewCron(engine, 6*time.Hour, logger) | ||
| 48 | + | ||
| 49 | + firehose := atproto.NewFirehoseConsumer(*relayURL, func(ctx context.Context, event *atproto.FirehoseEvent) error { | ||
| 50 | + logger.Debug("firehose event", "type", event.Type, "collection", event.Collection, "did", event.DID) | ||
| 51 | + return nil | ||
| 52 | + }, logger) | ||
| 53 | + | ||
| 54 | + ctx, cancel := context.WithCancel(context.Background()) | ||
| 55 | + defer cancel() | ||
| 56 | + | ||
| 57 | + go func() { | ||
| 58 | + if err := scheduler.Run(ctx); err != nil && ctx.Err() == nil { | ||
| 59 | + logger.Error("scheduler error", "error", err) | ||
| 60 | + } | ||
| 61 | + }() | ||
| 62 | + go func() { | ||
| 63 | + if err := cron.Run(ctx); err != nil && ctx.Err() == nil { | ||
| 64 | + logger.Error("cron error", "error", err) | ||
| 65 | + } | ||
| 66 | + }() | ||
| 67 | + go func() { | ||
| 68 | + if err := firehose.Start(ctx); err != nil && ctx.Err() == nil { | ||
| 69 | + logger.Error("firehose error", "error", err) | ||
| 70 | + } | ||
| 71 | + }() | ||
| 72 | + | ||
| 73 | + httpServer := &http.Server{ | ||
| 74 | + Addr: *addr, | ||
| 75 | + Handler: srv, | ||
| 76 | + } | ||
| 77 | + | ||
| 78 | + sigCh := make(chan os.Signal, 1) | ||
| 79 | + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) | ||
| 80 | + | ||
| 81 | + go func() { | ||
| 82 | + logger.Info("starting server", "addr", *addr) | ||
| 83 | + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | ||
| 84 | + logger.Error("server error", "error", err) | ||
| 85 | + os.Exit(1) | ||
| 86 | + } | ||
| 87 | + }() | ||
| 88 | + | ||
| 89 | + sig := <-sigCh | ||
| 90 | + logger.Info("shutting down", "signal", sig) | ||
| 91 | + | ||
| 92 | + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| 93 | + defer shutdownCancel() | ||
| 94 | + | ||
| 95 | + if err := httpServer.Shutdown(shutdownCtx); err != nil { | ||
| 96 | + logger.Error("shutdown error", "error", err) | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + cancel() | ||
| 100 | + | ||
| 101 | + fmt.Println("glean stopped") | ||
| 102 | +} | ||
| 103 | + | ||
| 104 | +func envOr(key, fallback string) string { | ||
| 105 | + if v := os.Getenv(key); v != "" { | ||
| 106 | + return v | ||
| 107 | + } | ||
| 108 | + return fallback | ||
| 109 | +} | ||
added
static/input.css +10 -0 | new file mode 100644 | ||
| @@ -0,0 +1,10 @@ | ||
| 1 | +@tailwind base; | |
| 2 | +@tailwind components; | |
| 3 | +@tailwind utilities; | |
| 4 | + | |
| 5 | +.line-clamp-2 { | |
| 6 | + display: -webkit-box; | |
| 7 | + -webkit-line-clamp: 2; | |
| 8 | + -webkit-box-orient: vertical; | |
| 9 | + overflow: hidden; | |
| 10 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | +@tailwind base; | ||
| 2 | +@tailwind components; | ||
| 3 | +@tailwind utilities; | ||
| 4 | + | ||
| 5 | +.line-clamp-2 { | ||
| 6 | + display: -webkit-box; | ||
| 7 | + -webkit-line-clamp: 2; | ||
| 8 | + -webkit-box-orient: vertical; | ||
| 9 | + overflow: hidden; | ||
| 10 | +} | ||
added
tailwind.config.js +47 -0 | new file mode 100644 | ||
| @@ -0,0 +1,47 @@ | ||
| 1 | +/** @type {import('tailwindcss').Config} */ | |
| 2 | +module.exports = { | |
| 3 | + content: ["./internal/tmpl/**/*.html"], | |
| 4 | + theme: { | |
| 5 | + extend: { | |
| 6 | + colors: { | |
| 7 | + spot: { | |
| 8 | + purple: '#a855f7', | |
| 9 | + 'purple-border': '#9333ea', | |
| 10 | + bg: 'var(--spot-bg)', | |
| 11 | + surface: 'var(--spot-surface)', | |
| 12 | + hover: 'var(--spot-hover)', | |
| 13 | + 'hover-50': 'var(--spot-hover-50)', | |
| 14 | + text: 'var(--spot-text)', | |
| 15 | + secondary: 'var(--spot-secondary)', | |
| 16 | + body: 'var(--spot-body)', | |
| 17 | + muted: 'var(--spot-muted)', | |
| 18 | + divider: 'var(--spot-divider)', | |
| 19 | + 'divider-30': 'var(--spot-divider-30)', | |
| 20 | + outline: 'var(--spot-outline)', | |
| 21 | + placeholder: 'var(--spot-placeholder)', | |
| 22 | + 'active-pill-bg': 'var(--spot-active-bg)', | |
| 23 | + 'active-pill-text': 'var(--spot-active-text)', | |
| 24 | + red: '#f3727f', | |
| 25 | + orange: '#ffa42b', | |
| 26 | + blue: '#539df5', | |
| 27 | + } | |
| 28 | + }, | |
| 29 | + fontFamily: { | |
| 30 | + ui: ['SpotifyMixUI', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'], | |
| 31 | + title: ['SpotifyMixUITitle', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'], | |
| 32 | + }, | |
| 33 | + borderRadius: { | |
| 34 | + pill: '9999px', | |
| 35 | + 'pill-lg': '500px', | |
| 36 | + }, | |
| 37 | + boxShadow: { | |
| 38 | + 'spot': 'var(--spot-shadow)', | |
| 39 | + 'spot-heavy': 'var(--spot-shadow-heavy)', | |
| 40 | + }, | |
| 41 | + letterSpacing: { | |
| 42 | + button: '1.4px', | |
| 43 | + }, | |
| 44 | + }, | |
| 45 | + }, | |
| 46 | + plugins: [], | |
| 47 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,47 @@ | |||
| 1 | +/** @type {import('tailwindcss').Config} */ | ||
| 2 | +module.exports = { | ||
| 3 | + content: ["./internal/tmpl/**/*.html"], | ||
| 4 | + theme: { | ||
| 5 | + extend: { | ||
| 6 | + colors: { | ||
| 7 | + spot: { | ||
| 8 | + purple: '#a855f7', | ||
| 9 | + 'purple-border': '#9333ea', | ||
| 10 | + bg: 'var(--spot-bg)', | ||
| 11 | + surface: 'var(--spot-surface)', | ||
| 12 | + hover: 'var(--spot-hover)', | ||
| 13 | + 'hover-50': 'var(--spot-hover-50)', | ||
| 14 | + text: 'var(--spot-text)', | ||
| 15 | + secondary: 'var(--spot-secondary)', | ||
| 16 | + body: 'var(--spot-body)', | ||
| 17 | + muted: 'var(--spot-muted)', | ||
| 18 | + divider: 'var(--spot-divider)', | ||
| 19 | + 'divider-30': 'var(--spot-divider-30)', | ||
| 20 | + outline: 'var(--spot-outline)', | ||
| 21 | + placeholder: 'var(--spot-placeholder)', | ||
| 22 | + 'active-pill-bg': 'var(--spot-active-bg)', | ||
| 23 | + 'active-pill-text': 'var(--spot-active-text)', | ||
| 24 | + red: '#f3727f', | ||
| 25 | + orange: '#ffa42b', | ||
| 26 | + blue: '#539df5', | ||
| 27 | + } | ||
| 28 | + }, | ||
| 29 | + fontFamily: { | ||
| 30 | + ui: ['SpotifyMixUI', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'], | ||
| 31 | + title: ['SpotifyMixUITitle', 'CircularSp-Arab', 'CircularSp-Hebr', 'CircularSp-Cyrl', 'CircularSp-Grek', 'CircularSp-Deva', 'Helvetica Neue', 'helvetica', 'arial', 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'MS Gothic', 'sans-serif'], | ||
| 32 | + }, | ||
| 33 | + borderRadius: { | ||
| 34 | + pill: '9999px', | ||
| 35 | + 'pill-lg': '500px', | ||
| 36 | + }, | ||
| 37 | + boxShadow: { | ||
| 38 | + 'spot': 'var(--spot-shadow)', | ||
| 39 | + 'spot-heavy': 'var(--spot-shadow-heavy)', | ||
| 40 | + }, | ||
| 41 | + letterSpacing: { | ||
| 42 | + button: '1.4px', | ||
| 43 | + }, | ||
| 44 | + }, | ||
| 45 | + }, | ||
| 46 | + plugins: [], | ||
| 47 | +} | ||