experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(feeds): add feed reader extension and API documentation

Extensions:
- extensions/feeds/ - Simple RSS/Atom feed reader demonstrating the feeds API
- Subscribe to feeds, poll for entries, view in UI
- Option+F shortcut to open reader
- Commands: feeds, feeds:subscribe, feeds:refresh

Documentation:
- docs/feeds-api.md - Comprehensive API reference with examples for:
- Stock price tracking (numeric series)
- Mood tracking (text series)
- Habit streaks with calculations
- Ticket availability triggers
- RSS feed reading
- System action logging
- Spotify listening history

+1433
+596
docs/feeds-api.md
··· 1 + # Feeds & Series API 2 + 3 + This document covers the APIs for time-series data and external feeds in Peek. 4 + 5 + ## Overview 6 + 7 + Peek uses two item types for temporal data: 8 + 9 + - **`series`** - Scalar values over time (numeric or text) 10 + - **`feed`** - Entries from external sources (RSS, APIs, logs) 11 + 12 + Both store their data in the `item_events` table. The item defines *what* you're tracking; events record *when* things happened. 13 + 14 + ## Core Concepts 15 + 16 + ### Items = Definitions 17 + 18 + Items with `type: 'series'` or `type: 'feed'` define what you're tracking: 19 + 20 + ```javascript 21 + // A series tracks scalar values 22 + const series = await api.datastore.addItem('series', { 23 + content: 'AAPL Stock Price', 24 + metadata: JSON.stringify({ unit: 'USD', source: 'yahoo-finance' }) 25 + }); 26 + 27 + // A feed tracks entries from a source 28 + const feed = await api.datastore.addItem('feed', { 29 + content: 'https://example.com/rss', 30 + metadata: JSON.stringify({ name: 'Example Blog', format: 'rss' }) 31 + }); 32 + ``` 33 + 34 + ### Events = Facts 35 + 36 + Events are append-only records of what happened: 37 + 38 + ```javascript 39 + // Record a series observation 40 + await api.datastore.addItemEvent(series.data.id, { 41 + value: 156.32, // numeric value 42 + occurredAt: Date.now() 43 + }); 44 + 45 + // Record a feed entry 46 + await api.datastore.addItemEvent(feed.data.id, { 47 + content: 'https://example.com/article-123', // entry URL 48 + occurredAt: pubDate, 49 + metadata: JSON.stringify({ title: 'Article Title', author: 'Jane' }) 50 + }); 51 + ``` 52 + 53 + ## API Reference 54 + 55 + ### Item Operations 56 + 57 + Standard item CRUD works for series and feeds: 58 + 59 + ```javascript 60 + // Create 61 + const result = await api.datastore.addItem('series', { content: 'Mood', metadata: '{}' }); 62 + 63 + // Read 64 + const item = await api.datastore.getItem(itemId); 65 + 66 + // Update 67 + await api.datastore.updateItem(itemId, { metadata: '{"unit":"points"}' }); 68 + 69 + // Delete (also delete events first) 70 + await api.datastore.deleteItemEvents(itemId); 71 + await api.datastore.deleteItem(itemId); 72 + 73 + // Query all series 74 + const series = await api.datastore.queryItems({ type: 'series' }); 75 + 76 + // Query all feeds 77 + const feeds = await api.datastore.queryItems({ type: 'feed' }); 78 + ``` 79 + 80 + ### Event Operations 81 + 82 + #### `addItemEvent(itemId, options)` 83 + 84 + Add an event to a series or feed. 85 + 86 + ```javascript 87 + await api.datastore.addItemEvent(itemId, { 88 + content: 'text value or URL', // optional 89 + value: 42, // optional, for numeric data 90 + occurredAt: Date.now(), // optional, defaults to now 91 + metadata: JSON.stringify({...}) // optional, JSON string 92 + }); 93 + ``` 94 + 95 + #### `getItemEvent(eventId)` 96 + 97 + Get a single event by ID. 98 + 99 + ```javascript 100 + const event = await api.datastore.getItemEvent(eventId); 101 + // { id, itemId, content, value, occurredAt, metadata, createdAt } 102 + ``` 103 + 104 + #### `queryItemEvents(filter)` 105 + 106 + Query events with filters. 107 + 108 + ```javascript 109 + const events = await api.datastore.queryItemEvents({ 110 + itemId: 'item_123', // single item 111 + // OR 112 + itemIds: ['item_1', 'item_2'], // multiple items 113 + since: Date.now() - 86400000, // after timestamp 114 + until: Date.now(), // before timestamp 115 + limit: 50, // max results 116 + offset: 0, // skip first N 117 + order: 'desc' // 'asc' or 'desc' by occurredAt 118 + }); 119 + ``` 120 + 121 + #### `deleteItemEvent(eventId)` 122 + 123 + Delete a single event. 124 + 125 + ```javascript 126 + await api.datastore.deleteItemEvent(eventId); 127 + ``` 128 + 129 + #### `deleteItemEvents(itemId)` 130 + 131 + Delete all events for an item. 132 + 133 + ```javascript 134 + const count = await api.datastore.deleteItemEvents(itemId); 135 + ``` 136 + 137 + #### `getLatestItemEvent(itemId)` 138 + 139 + Get the most recent event. 140 + 141 + ```javascript 142 + const latest = await api.datastore.getLatestItemEvent(itemId); 143 + ``` 144 + 145 + #### `countItemEvents(itemId, filter)` 146 + 147 + Count events for an item. 148 + 149 + ```javascript 150 + const count = await api.datastore.countItemEvents(itemId, { 151 + since: Date.now() - 86400000 // last 24 hours 152 + }); 153 + ``` 154 + 155 + ## Use-Case Examples 156 + 157 + ### 1. Stock Price Tracking 158 + 159 + Track numeric values over time from a web source. 160 + 161 + ```javascript 162 + // Create the series 163 + const stock = await api.datastore.addItem('series', { 164 + content: 'AAPL', 165 + metadata: JSON.stringify({ 166 + name: 'Apple Stock Price', 167 + unit: 'USD', 168 + source_url: 'https://finance.yahoo.com/quote/AAPL' 169 + }) 170 + }); 171 + const stockId = stock.data.id; 172 + 173 + // Record observations (from polling job) 174 + async function recordPrice(price) { 175 + await api.datastore.addItemEvent(stockId, { 176 + value: price, 177 + occurredAt: Date.now() 178 + }); 179 + } 180 + 181 + // Query last 7 days 182 + const prices = await api.datastore.queryItemEvents({ 183 + itemId: stockId, 184 + since: Date.now() - 7 * 24 * 60 * 60 * 1000, 185 + order: 'asc' 186 + }); 187 + 188 + // Calculate average 189 + const avg = prices.data.reduce((sum, e) => sum + e.value, 0) / prices.data.length; 190 + 191 + // Get latest price 192 + const latest = await api.datastore.getLatestItemEvent(stockId); 193 + console.log(`Current: $${latest.data.value}, 7-day avg: $${avg.toFixed(2)}`); 194 + ``` 195 + 196 + ### 2. Mood Tracking (Text Values) 197 + 198 + Track non-numeric values like mood or status. 199 + 200 + ```javascript 201 + // Create the series 202 + const mood = await api.datastore.addItem('series', { 203 + content: 'Mood', 204 + metadata: JSON.stringify({ input: 'tag-board' }) 205 + }); 206 + const moodId = mood.data.id; 207 + 208 + // Record mood (from tag button board) 209 + async function recordMood(moodValue) { 210 + await api.datastore.addItemEvent(moodId, { 211 + content: moodValue, // 'great', 'good', 'meh', 'bad' 212 + occurredAt: Date.now() 213 + }); 214 + } 215 + 216 + // Usage 217 + await recordMood('great'); 218 + await recordMood('meh'); 219 + 220 + // Query this week's moods 221 + const moods = await api.datastore.queryItemEvents({ 222 + itemId: moodId, 223 + since: Date.now() - 7 * 24 * 60 * 60 * 1000 224 + }); 225 + 226 + // Count by mood 227 + const counts = {}; 228 + for (const event of moods.data) { 229 + counts[event.content] = (counts[event.content] || 0) + 1; 230 + } 231 + console.log('Mood distribution:', counts); 232 + ``` 233 + 234 + ### 3. Habit Tracking with Streaks 235 + 236 + Track daily habits and calculate streaks. 237 + 238 + ```javascript 239 + // Create the series 240 + const pushups = await api.datastore.addItem('series', { 241 + content: 'Pushups', 242 + metadata: JSON.stringify({ frequency: 'daily', tag: 'pushups' }) 243 + }); 244 + const pushupId = pushups.data.id; 245 + 246 + // Record daily check-in 247 + async function logPushups(count) { 248 + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD 249 + await api.datastore.addItemEvent(pushupId, { 250 + value: count, 251 + occurredAt: Date.now(), 252 + metadata: JSON.stringify({ date: today }) 253 + }); 254 + } 255 + 256 + // Calculate current streak 257 + async function getStreak() { 258 + const events = await api.datastore.queryItemEvents({ 259 + itemId: pushupId, 260 + limit: 365, 261 + order: 'desc' 262 + }); 263 + 264 + if (!events.success || events.data.length === 0) return 0; 265 + 266 + let streak = 0; 267 + let expectedDate = new Date(); 268 + expectedDate.setHours(0, 0, 0, 0); 269 + 270 + for (const event of events.data) { 271 + const meta = JSON.parse(event.metadata || '{}'); 272 + const eventDate = new Date(meta.date); 273 + eventDate.setHours(0, 0, 0, 0); 274 + 275 + // Check if this is the expected date (consecutive day) 276 + const diffDays = Math.round((expectedDate - eventDate) / 86400000); 277 + 278 + if (diffDays === 0) { 279 + streak++; 280 + expectedDate.setDate(expectedDate.getDate() - 1); 281 + } else if (diffDays === 1) { 282 + // Skip today if not logged yet 283 + expectedDate.setDate(expectedDate.getDate() - 1); 284 + if (expectedDate.getTime() === eventDate.getTime()) { 285 + streak++; 286 + expectedDate.setDate(expectedDate.getDate() - 1); 287 + } else { 288 + break; 289 + } 290 + } else { 291 + break; 292 + } 293 + } 294 + 295 + return streak; 296 + } 297 + 298 + // Usage 299 + await logPushups(20); 300 + const streak = await getStreak(); 301 + console.log(`Current streak: ${streak} days`); 302 + 303 + // Get personal best 304 + const allEvents = await api.datastore.queryItemEvents({ itemId: pushupId }); 305 + const max = Math.max(...allEvents.data.map(e => e.value || 0)); 306 + console.log(`Personal best: ${max} pushups`); 307 + ``` 308 + 309 + ### 4. Ticket Availability Trigger 310 + 311 + Poll a page and notify when condition is met. 312 + 313 + ```javascript 314 + // Create the series (acts as trigger definition) 315 + const tickets = await api.datastore.addItem('series', { 316 + content: 'Taylor Swift Tickets', 317 + metadata: JSON.stringify({ 318 + source_url: 'https://ticketmaster.com/event/123', 319 + selector: '.buy-button', 320 + condition: 'exists', 321 + notified: false 322 + }) 323 + }); 324 + const ticketId = tickets.data.id; 325 + 326 + // Polling function (run periodically) 327 + async function checkTickets() { 328 + const item = await api.datastore.getItem(ticketId); 329 + const meta = JSON.parse(item.data.metadata || '{}'); 330 + 331 + if (meta.notified) return; // Already notified 332 + 333 + // In a real extension, you'd load the page in a hidden window 334 + // and run document.querySelector(meta.selector) 335 + const available = await checkPageForElement(meta.source_url, meta.selector); 336 + 337 + // Record the check 338 + await api.datastore.addItemEvent(ticketId, { 339 + value: available ? 1 : 0, 340 + content: available ? 'Available!' : 'Not available', 341 + occurredAt: Date.now() 342 + }); 343 + 344 + // Trigger notification 345 + if (available) { 346 + meta.notified = true; 347 + await api.datastore.updateItem(ticketId, { 348 + metadata: JSON.stringify(meta) 349 + }); 350 + 351 + // Show notification 352 + new Notification('Tickets Available!', { 353 + body: 'Taylor Swift tickets are now on sale!' 354 + }); 355 + } 356 + } 357 + ``` 358 + 359 + ### 5. RSS Feed Reader 360 + 361 + Subscribe to and poll RSS feeds. 362 + 363 + ```javascript 364 + // Subscribe to a feed 365 + async function subscribe(url) { 366 + const response = await fetch(url); 367 + const xml = await response.text(); 368 + const title = parseFeedTitle(xml); // Your XML parsing function 369 + 370 + const feed = await api.datastore.addItem('feed', { 371 + content: url, 372 + metadata: JSON.stringify({ 373 + name: title, 374 + format: 'rss', 375 + lastPolledAt: 0 376 + }) 377 + }); 378 + 379 + return feed.data.id; 380 + } 381 + 382 + // Poll a feed for new entries 383 + async function pollFeed(feedId) { 384 + const item = await api.datastore.getItem(feedId); 385 + const feedUrl = item.data.content; 386 + 387 + const response = await fetch(feedUrl); 388 + const xml = await response.text(); 389 + const entries = parseFeedEntries(xml); // Your XML parsing function 390 + 391 + // Get existing entries to dedupe 392 + const existing = await api.datastore.queryItemEvents({ itemId: feedId, limit: 100 }); 393 + const existingGuids = new Set( 394 + existing.data.map(e => JSON.parse(e.metadata || '{}').guid) 395 + ); 396 + 397 + // Add new entries 398 + for (const entry of entries) { 399 + if (existingGuids.has(entry.guid)) continue; 400 + 401 + await api.datastore.addItemEvent(feedId, { 402 + content: entry.link, 403 + occurredAt: entry.pubDate, 404 + metadata: JSON.stringify({ 405 + guid: entry.guid, 406 + title: entry.title, 407 + author: entry.author 408 + }) 409 + }); 410 + } 411 + 412 + // Update last polled 413 + const meta = JSON.parse(item.data.metadata || '{}'); 414 + meta.lastPolledAt = Date.now(); 415 + await api.datastore.updateItem(feedId, { metadata: JSON.stringify(meta) }); 416 + } 417 + 418 + // Get all entries across feeds 419 + async function getAllEntries(limit = 50) { 420 + const feeds = await api.datastore.queryItems({ type: 'feed' }); 421 + const feedIds = feeds.data.map(f => f.id); 422 + 423 + const entries = await api.datastore.queryItemEvents({ 424 + itemIds: feedIds, 425 + limit: limit, 426 + order: 'desc' 427 + }); 428 + 429 + return entries.data.map(e => ({ 430 + ...e, 431 + meta: JSON.parse(e.metadata || '{}') 432 + })); 433 + } 434 + ``` 435 + 436 + ### 6. System Metrics / Action Log 437 + 438 + Track internal application events. 439 + 440 + ```javascript 441 + // Create a log feed 442 + const actionLog = await api.datastore.addItem('feed', { 443 + content: 'peek:system:actions', 444 + metadata: JSON.stringify({ format: 'internal' }) 445 + }); 446 + const logId = actionLog.data.id; 447 + 448 + // Log an action 449 + async function logAction(action, details = {}) { 450 + await api.datastore.addItemEvent(logId, { 451 + content: action, 452 + occurredAt: Date.now(), 453 + metadata: JSON.stringify(details) 454 + }); 455 + } 456 + 457 + // Usage 458 + await logAction('page_open', { url: 'https://example.com', source: 'bookmark' }); 459 + await logAction('settings_changed', { key: 'theme', value: 'dark' }); 460 + await logAction('extension_loaded', { id: 'feeds', version: '1.0.0' }); 461 + 462 + // Query recent actions 463 + const recent = await api.datastore.queryItemEvents({ 464 + itemId: logId, 465 + since: Date.now() - 3600000, // last hour 466 + order: 'desc' 467 + }); 468 + 469 + // Count actions by type 470 + const counts = {}; 471 + for (const event of recent.data) { 472 + counts[event.content] = (counts[event.content] || 0) + 1; 473 + } 474 + console.log('Action counts:', counts); 475 + ``` 476 + 477 + ### 7. Spotify Listening History 478 + 479 + Pull data from external APIs. 480 + 481 + ```javascript 482 + // Create the feed 483 + const spotify = await api.datastore.addItem('feed', { 484 + content: 'spotify:me:recently-played', 485 + metadata: JSON.stringify({ 486 + format: 'api', 487 + service: 'spotify', 488 + lastSyncedAt: 0 489 + }) 490 + }); 491 + const spotifyId = spotify.data.id; 492 + 493 + // Sync from Spotify API (requires OAuth token) 494 + async function syncSpotify(accessToken) { 495 + const response = await fetch('https://api.spotify.com/v1/me/player/recently-played?limit=50', { 496 + headers: { 'Authorization': `Bearer ${accessToken}` } 497 + }); 498 + const data = await response.json(); 499 + 500 + // Get existing to dedupe 501 + const existing = await api.datastore.queryItemEvents({ itemId: spotifyId, limit: 100 }); 502 + const existingIds = new Set( 503 + existing.data.map(e => JSON.parse(e.metadata || '{}').playedAt) 504 + ); 505 + 506 + // Add new plays 507 + for (const item of data.items) { 508 + const playedAt = new Date(item.played_at).getTime(); 509 + if (existingIds.has(item.played_at)) continue; 510 + 511 + await api.datastore.addItemEvent(spotifyId, { 512 + content: item.track.uri, 513 + occurredAt: playedAt, 514 + metadata: JSON.stringify({ 515 + playedAt: item.played_at, 516 + trackName: item.track.name, 517 + artistName: item.track.artists[0]?.name, 518 + albumName: item.track.album?.name, 519 + durationMs: item.track.duration_ms 520 + }) 521 + }); 522 + } 523 + 524 + // Update sync time 525 + const meta = JSON.parse((await api.datastore.getItem(spotifyId)).data.metadata || '{}'); 526 + meta.lastSyncedAt = Date.now(); 527 + await api.datastore.updateItem(spotifyId, { metadata: JSON.stringify(meta) }); 528 + } 529 + 530 + // Get top artists from listening history 531 + async function getTopArtists(days = 30) { 532 + const plays = await api.datastore.queryItemEvents({ 533 + itemId: spotifyId, 534 + since: Date.now() - days * 24 * 60 * 60 * 1000 535 + }); 536 + 537 + const artistCounts = {}; 538 + for (const play of plays.data) { 539 + const meta = JSON.parse(play.metadata || '{}'); 540 + const artist = meta.artistName || 'Unknown'; 541 + artistCounts[artist] = (artistCounts[artist] || 0) + 1; 542 + } 543 + 544 + return Object.entries(artistCounts) 545 + .sort((a, b) => b[1] - a[1]) 546 + .slice(0, 10); 547 + } 548 + ``` 549 + 550 + ## Schema Reference 551 + 552 + ### items table (existing, extended) 553 + 554 + ```sql 555 + items ( 556 + id TEXT PRIMARY KEY, 557 + type TEXT NOT NULL, -- 'url', 'text', 'tagset', 'image', 'series', 'feed' 558 + content TEXT, -- identifier, URL, or name 559 + metadata TEXT, -- JSON with type-specific data 560 + ... 561 + ) 562 + ``` 563 + 564 + ### item_events table (new) 565 + 566 + ```sql 567 + item_events ( 568 + id TEXT PRIMARY KEY, 569 + itemId TEXT NOT NULL, -- FK to series or feed 570 + content TEXT, -- text value, URL, or message 571 + value REAL, -- numeric value (optional) 572 + occurredAt INTEGER, -- when it happened (Unix ms) 573 + metadata TEXT, -- JSON extras 574 + createdAt INTEGER -- when recorded 575 + ) 576 + 577 + -- Indexes 578 + CREATE INDEX idx_item_events_item_time ON item_events(itemId, occurredAt DESC); 579 + CREATE INDEX idx_item_events_occurred ON item_events(occurredAt DESC); 580 + ``` 581 + 582 + ## Best Practices 583 + 584 + 1. **Use `value` for numbers, `content` for text/URLs** - Makes queries and aggregations simpler. 585 + 586 + 2. **Store dedup keys in metadata** - For feeds, store `guid` to avoid duplicate entries. 587 + 588 + 3. **Set `occurredAt` to event time, not record time** - For RSS entries, use `pubDate`; for API data, use the original timestamp. 589 + 590 + 4. **Keep metadata lightweight** - Truncate descriptions, store only what you need for display. 591 + 592 + 5. **Delete events when deleting items** - Always call `deleteItemEvents(itemId)` before `deleteItem(itemId)`. 593 + 594 + 6. **Use `order: 'desc'` for recent-first** - Most UIs want newest first. 595 + 596 + 7. **Batch queries with `itemIds`** - When showing entries from multiple feeds, query once with all IDs.
+10
extensions/feeds/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <title>Feeds Extension</title> 6 + </head> 7 + <body> 8 + <script type="module" src="background.js"></script> 9 + </body> 10 + </html>
+393
extensions/feeds/background.js
··· 1 + /** 2 + * Feeds Extension - Simple RSS/Atom Feed Reader 3 + * 4 + * Demonstrates: 5 + * - Using the 'feed' item type for subscriptions 6 + * - Using item_events for feed entries 7 + * - Polling feeds and parsing RSS/Atom 8 + * - Commands for subscribing and viewing feeds 9 + */ 10 + 11 + const api = window.app; 12 + const POLL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes 13 + 14 + let pollTimer = null; 15 + 16 + // ==================== Feed Parsing ==================== 17 + 18 + /** 19 + * Parse RSS or Atom feed XML into normalized entries 20 + */ 21 + function parseFeed(xml, feedUrl) { 22 + const parser = new DOMParser(); 23 + const doc = parser.parseFromString(xml, 'text/xml'); 24 + 25 + // Check for parse errors 26 + const parseError = doc.querySelector('parsererror'); 27 + if (parseError) { 28 + throw new Error('Invalid XML: ' + parseError.textContent); 29 + } 30 + 31 + // Detect feed type and parse accordingly 32 + const rssChannel = doc.querySelector('channel'); 33 + const atomFeed = doc.querySelector('feed'); 34 + 35 + if (rssChannel) { 36 + return parseRSS(rssChannel, feedUrl); 37 + } else if (atomFeed) { 38 + return parseAtom(atomFeed, feedUrl); 39 + } else { 40 + throw new Error('Unknown feed format'); 41 + } 42 + } 43 + 44 + /** 45 + * Parse RSS 2.0 feed 46 + */ 47 + function parseRSS(channel, feedUrl) { 48 + const title = channel.querySelector('title')?.textContent || feedUrl; 49 + const items = channel.querySelectorAll('item'); 50 + 51 + const entries = []; 52 + for (const item of items) { 53 + const link = item.querySelector('link')?.textContent; 54 + const guid = item.querySelector('guid')?.textContent || link; 55 + const pubDate = item.querySelector('pubDate')?.textContent; 56 + 57 + entries.push({ 58 + guid: guid || link || `${feedUrl}#${entries.length}`, 59 + title: item.querySelector('title')?.textContent || 'Untitled', 60 + link: link || '', 61 + author: item.querySelector('author')?.textContent || 62 + item.querySelector('dc\\:creator')?.textContent || '', 63 + description: item.querySelector('description')?.textContent || '', 64 + pubDate: pubDate ? new Date(pubDate).getTime() : Date.now() 65 + }); 66 + } 67 + 68 + return { title, entries }; 69 + } 70 + 71 + /** 72 + * Parse Atom feed 73 + */ 74 + function parseAtom(feed, feedUrl) { 75 + const title = feed.querySelector('title')?.textContent || feedUrl; 76 + const items = feed.querySelectorAll('entry'); 77 + 78 + const entries = []; 79 + for (const item of items) { 80 + const link = item.querySelector('link[rel="alternate"]')?.getAttribute('href') || 81 + item.querySelector('link')?.getAttribute('href'); 82 + const id = item.querySelector('id')?.textContent || link; 83 + const published = item.querySelector('published')?.textContent || 84 + item.querySelector('updated')?.textContent; 85 + 86 + entries.push({ 87 + guid: id || link || `${feedUrl}#${entries.length}`, 88 + title: item.querySelector('title')?.textContent || 'Untitled', 89 + link: link || '', 90 + author: item.querySelector('author name')?.textContent || '', 91 + description: item.querySelector('summary')?.textContent || 92 + item.querySelector('content')?.textContent || '', 93 + pubDate: published ? new Date(published).getTime() : Date.now() 94 + }); 95 + } 96 + 97 + return { title, entries }; 98 + } 99 + 100 + // ==================== Feed Operations ==================== 101 + 102 + /** 103 + * Subscribe to a new feed 104 + */ 105 + async function subscribeFeed(url) { 106 + // Check if already subscribed 107 + const existing = await api.datastore.queryItems({ type: 'feed' }); 108 + if (existing.success) { 109 + const alreadySubscribed = existing.data.find(f => f.content === url); 110 + if (alreadySubscribed) { 111 + console.log('[feeds] Already subscribed to:', url); 112 + return { success: false, error: 'Already subscribed to this feed' }; 113 + } 114 + } 115 + 116 + // Fetch and parse to validate and get title 117 + try { 118 + const response = await fetch(url); 119 + if (!response.ok) { 120 + throw new Error(`HTTP ${response.status}`); 121 + } 122 + const xml = await response.text(); 123 + const { title } = parseFeed(xml, url); 124 + 125 + // Create feed item 126 + const result = await api.datastore.addItem('feed', { 127 + content: url, 128 + metadata: JSON.stringify({ 129 + name: title, 130 + format: 'rss', 131 + lastPolledAt: 0, 132 + errorCount: 0 133 + }) 134 + }); 135 + 136 + if (result.success) { 137 + console.log('[feeds] Subscribed to:', title); 138 + // Poll immediately 139 + await pollFeed(result.data.id, url); 140 + return { success: true, data: { id: result.data.id, title, url } }; 141 + } 142 + return result; 143 + } catch (error) { 144 + console.error('[feeds] Subscribe error:', error); 145 + return { success: false, error: error.message }; 146 + } 147 + } 148 + 149 + /** 150 + * Unsubscribe from a feed 151 + */ 152 + async function unsubscribeFeed(feedId) { 153 + // Delete all entries first 154 + await api.datastore.deleteItemEvents(feedId); 155 + // Delete the feed item 156 + return await api.datastore.deleteItem(feedId); 157 + } 158 + 159 + /** 160 + * Get all subscribed feeds 161 + */ 162 + async function getFeeds() { 163 + const result = await api.datastore.queryItems({ type: 'feed' }); 164 + if (!result.success) return []; 165 + 166 + return result.data.map(item => { 167 + let metadata = {}; 168 + try { 169 + metadata = JSON.parse(item.metadata || '{}'); 170 + } catch {} 171 + return { 172 + id: item.id, 173 + url: item.content, 174 + name: metadata.name || item.content, 175 + lastPolledAt: metadata.lastPolledAt || 0, 176 + errorCount: metadata.errorCount || 0 177 + }; 178 + }); 179 + } 180 + 181 + /** 182 + * Poll a single feed for new entries 183 + */ 184 + async function pollFeed(feedId, feedUrl) { 185 + console.log('[feeds] Polling:', feedUrl); 186 + 187 + try { 188 + const response = await fetch(feedUrl); 189 + if (!response.ok) { 190 + throw new Error(`HTTP ${response.status}`); 191 + } 192 + const xml = await response.text(); 193 + const { title, entries } = parseFeed(xml, feedUrl); 194 + 195 + // Get existing entries to avoid duplicates 196 + const existingEvents = await api.datastore.queryItemEvents({ itemId: feedId, limit: 100 }); 197 + const existingGuids = new Set(); 198 + if (existingEvents.success) { 199 + for (const event of existingEvents.data) { 200 + try { 201 + const meta = JSON.parse(event.metadata || '{}'); 202 + if (meta.guid) existingGuids.add(meta.guid); 203 + } catch {} 204 + } 205 + } 206 + 207 + // Add new entries 208 + let newCount = 0; 209 + for (const entry of entries) { 210 + if (existingGuids.has(entry.guid)) continue; 211 + 212 + await api.datastore.addItemEvent(feedId, { 213 + content: entry.link, 214 + occurredAt: entry.pubDate, 215 + metadata: JSON.stringify({ 216 + guid: entry.guid, 217 + title: entry.title, 218 + author: entry.author, 219 + description: entry.description?.slice(0, 500) // Truncate long descriptions 220 + }) 221 + }); 222 + newCount++; 223 + } 224 + 225 + // Update feed metadata 226 + await api.datastore.updateItem(feedId, { 227 + metadata: JSON.stringify({ 228 + name: title, 229 + format: 'rss', 230 + lastPolledAt: Date.now(), 231 + errorCount: 0 232 + }) 233 + }); 234 + 235 + console.log(`[feeds] Polled ${feedUrl}: ${newCount} new entries`); 236 + return { success: true, newCount }; 237 + } catch (error) { 238 + console.error('[feeds] Poll error:', feedUrl, error); 239 + 240 + // Increment error count 241 + const item = await api.datastore.getItem(feedId); 242 + if (item.success && item.data) { 243 + let metadata = {}; 244 + try { metadata = JSON.parse(item.data.metadata || '{}'); } catch {} 245 + metadata.errorCount = (metadata.errorCount || 0) + 1; 246 + metadata.lastError = error.message; 247 + await api.datastore.updateItem(feedId, { 248 + metadata: JSON.stringify(metadata) 249 + }); 250 + } 251 + 252 + return { success: false, error: error.message }; 253 + } 254 + } 255 + 256 + /** 257 + * Poll all feeds 258 + */ 259 + async function pollAllFeeds() { 260 + const feeds = await getFeeds(); 261 + console.log(`[feeds] Polling ${feeds.length} feeds...`); 262 + 263 + for (const feed of feeds) { 264 + await pollFeed(feed.id, feed.url); 265 + } 266 + } 267 + 268 + /** 269 + * Get entries for a feed or all feeds 270 + */ 271 + async function getEntries(feedId = null, limit = 50) { 272 + const filter = { limit, order: 'desc' }; 273 + 274 + if (feedId) { 275 + filter.itemId = feedId; 276 + } else { 277 + // Get all feeds to query their entries 278 + const feeds = await getFeeds(); 279 + if (feeds.length === 0) return []; 280 + filter.itemIds = feeds.map(f => f.id); 281 + } 282 + 283 + const result = await api.datastore.queryItemEvents(filter); 284 + if (!result.success) return []; 285 + 286 + return result.data.map(event => { 287 + let metadata = {}; 288 + try { metadata = JSON.parse(event.metadata || '{}'); } catch {} 289 + return { 290 + id: event.id, 291 + feedId: event.itemId, 292 + url: event.content, 293 + title: metadata.title || event.content, 294 + author: metadata.author || '', 295 + description: metadata.description || '', 296 + pubDate: event.occurredAt 297 + }; 298 + }); 299 + } 300 + 301 + // ==================== UI ==================== 302 + 303 + function openFeedsUI() { 304 + api.window.open('peek://ext/feeds/feeds.html', { 305 + key: 'feeds-reader', 306 + width: 900, 307 + height: 700, 308 + title: 'Feed Reader' 309 + }); 310 + } 311 + 312 + // ==================== Extension ==================== 313 + 314 + const extension = { 315 + id: 'feeds', 316 + labels: { 317 + name: 'Feed Reader' 318 + }, 319 + 320 + registerCommands() { 321 + api.commands.register({ 322 + name: 'feeds', 323 + description: 'Open feed reader', 324 + execute: openFeedsUI 325 + }); 326 + 327 + api.commands.register({ 328 + name: 'feeds:subscribe', 329 + description: 'Subscribe to an RSS/Atom feed', 330 + execute: async (ctx) => { 331 + const url = ctx?.input?.text || ctx?.input; 332 + if (url && typeof url === 'string' && url.startsWith('http')) { 333 + const result = await subscribeFeed(url); 334 + if (result.success) { 335 + openFeedsUI(); 336 + } 337 + } else { 338 + // Open UI with subscribe prompt 339 + openFeedsUI(); 340 + } 341 + } 342 + }); 343 + 344 + api.commands.register({ 345 + name: 'feeds:refresh', 346 + description: 'Refresh all feeds', 347 + execute: pollAllFeeds 348 + }); 349 + 350 + console.log('[feeds] Commands registered'); 351 + }, 352 + 353 + init() { 354 + console.log('[feeds] Initializing...'); 355 + 356 + // Wait for cmd:ready before registering commands 357 + api.subscribe('cmd:ready', () => { 358 + this.registerCommands(); 359 + }, api.scopes.GLOBAL); 360 + 361 + api.publish('cmd:query', {}, api.scopes.GLOBAL); 362 + 363 + // Register global shortcut 364 + api.shortcuts.register('Option+f', openFeedsUI, { global: true }); 365 + 366 + // Start polling timer 367 + pollTimer = setInterval(pollAllFeeds, POLL_INTERVAL_MS); 368 + 369 + // Initial poll after short delay 370 + setTimeout(pollAllFeeds, 5000); 371 + 372 + console.log('[feeds] Extension loaded'); 373 + }, 374 + 375 + uninit() { 376 + console.log('[feeds] Cleaning up...'); 377 + 378 + if (pollTimer) { 379 + clearInterval(pollTimer); 380 + pollTimer = null; 381 + } 382 + 383 + api.shortcuts.unregister('Option+f', { global: true }); 384 + api.commands.unregister('feeds'); 385 + api.commands.unregister('feeds:subscribe'); 386 + api.commands.unregister('feeds:refresh'); 387 + } 388 + }; 389 + 390 + export default extension; 391 + 392 + // Export for UI 393 + export { getFeeds, getEntries, subscribeFeed, unsubscribeFeed, pollFeed, pollAllFeeds };
+411
extensions/feeds/feeds.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <title>Feed Reader</title> 6 + <style> 7 + * { 8 + box-sizing: border-box; 9 + margin: 0; 10 + padding: 0; 11 + } 12 + 13 + body { 14 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 15 + background: var(--bg-primary, #1a1a1a); 16 + color: var(--text-primary, #e0e0e0); 17 + height: 100vh; 18 + display: flex; 19 + flex-direction: column; 20 + } 21 + 22 + header { 23 + display: flex; 24 + align-items: center; 25 + gap: 12px; 26 + padding: 12px 16px; 27 + background: var(--bg-secondary, #252525); 28 + border-bottom: 1px solid var(--border, #333); 29 + } 30 + 31 + header h1 { 32 + font-size: 16px; 33 + font-weight: 500; 34 + flex: 1; 35 + } 36 + 37 + .btn { 38 + background: var(--bg-tertiary, #333); 39 + border: 1px solid var(--border, #444); 40 + color: var(--text-primary, #e0e0e0); 41 + padding: 6px 12px; 42 + border-radius: 4px; 43 + cursor: pointer; 44 + font-size: 13px; 45 + } 46 + 47 + .btn:hover { 48 + background: var(--bg-hover, #3a3a3a); 49 + } 50 + 51 + .btn-primary { 52 + background: var(--accent, #4a9eff); 53 + border-color: var(--accent, #4a9eff); 54 + color: white; 55 + } 56 + 57 + .btn-primary:hover { 58 + background: var(--accent-hover, #3a8eef); 59 + } 60 + 61 + .container { 62 + display: flex; 63 + flex: 1; 64 + overflow: hidden; 65 + } 66 + 67 + .sidebar { 68 + width: 240px; 69 + background: var(--bg-secondary, #252525); 70 + border-right: 1px solid var(--border, #333); 71 + display: flex; 72 + flex-direction: column; 73 + } 74 + 75 + .sidebar-header { 76 + padding: 12px; 77 + border-bottom: 1px solid var(--border, #333); 78 + font-size: 12px; 79 + font-weight: 600; 80 + text-transform: uppercase; 81 + color: var(--text-secondary, #888); 82 + } 83 + 84 + .feed-list { 85 + flex: 1; 86 + overflow-y: auto; 87 + } 88 + 89 + .feed-item { 90 + padding: 10px 12px; 91 + cursor: pointer; 92 + border-bottom: 1px solid var(--border, #2a2a2a); 93 + display: flex; 94 + align-items: center; 95 + gap: 8px; 96 + } 97 + 98 + .feed-item:hover { 99 + background: var(--bg-hover, #2a2a2a); 100 + } 101 + 102 + .feed-item.active { 103 + background: var(--bg-active, #333); 104 + } 105 + 106 + .feed-item .name { 107 + flex: 1; 108 + font-size: 13px; 109 + white-space: nowrap; 110 + overflow: hidden; 111 + text-overflow: ellipsis; 112 + } 113 + 114 + .feed-item .delete { 115 + opacity: 0; 116 + color: var(--text-secondary, #888); 117 + font-size: 16px; 118 + } 119 + 120 + .feed-item:hover .delete { 121 + opacity: 1; 122 + } 123 + 124 + .all-feeds { 125 + font-weight: 500; 126 + } 127 + 128 + .main { 129 + flex: 1; 130 + display: flex; 131 + flex-direction: column; 132 + overflow: hidden; 133 + } 134 + 135 + .entries { 136 + flex: 1; 137 + overflow-y: auto; 138 + padding: 12px; 139 + } 140 + 141 + .entry { 142 + padding: 12px; 143 + margin-bottom: 8px; 144 + background: var(--bg-secondary, #252525); 145 + border-radius: 6px; 146 + border: 1px solid var(--border, #333); 147 + } 148 + 149 + .entry:hover { 150 + border-color: var(--border-hover, #444); 151 + } 152 + 153 + .entry-title { 154 + font-size: 14px; 155 + font-weight: 500; 156 + margin-bottom: 4px; 157 + } 158 + 159 + .entry-title a { 160 + color: var(--text-primary, #e0e0e0); 161 + text-decoration: none; 162 + } 163 + 164 + .entry-title a:hover { 165 + color: var(--accent, #4a9eff); 166 + } 167 + 168 + .entry-meta { 169 + font-size: 12px; 170 + color: var(--text-secondary, #888); 171 + margin-bottom: 6px; 172 + } 173 + 174 + .entry-description { 175 + font-size: 13px; 176 + color: var(--text-secondary, #aaa); 177 + line-height: 1.4; 178 + max-height: 60px; 179 + overflow: hidden; 180 + } 181 + 182 + .empty-state { 183 + text-align: center; 184 + padding: 60px 20px; 185 + color: var(--text-secondary, #888); 186 + } 187 + 188 + .empty-state h2 { 189 + font-size: 18px; 190 + font-weight: 500; 191 + margin-bottom: 8px; 192 + } 193 + 194 + .empty-state p { 195 + font-size: 14px; 196 + margin-bottom: 16px; 197 + } 198 + 199 + .subscribe-form { 200 + padding: 12px; 201 + border-top: 1px solid var(--border, #333); 202 + } 203 + 204 + .subscribe-form input { 205 + width: 100%; 206 + padding: 8px 10px; 207 + background: var(--bg-primary, #1a1a1a); 208 + border: 1px solid var(--border, #444); 209 + border-radius: 4px; 210 + color: var(--text-primary, #e0e0e0); 211 + font-size: 13px; 212 + } 213 + 214 + .subscribe-form input:focus { 215 + outline: none; 216 + border-color: var(--accent, #4a9eff); 217 + } 218 + 219 + .loading { 220 + text-align: center; 221 + padding: 20px; 222 + color: var(--text-secondary, #888); 223 + } 224 + </style> 225 + </head> 226 + <body> 227 + <header> 228 + <h1>Feed Reader</h1> 229 + <button class="btn" id="refreshBtn">Refresh All</button> 230 + </header> 231 + 232 + <div class="container"> 233 + <div class="sidebar"> 234 + <div class="sidebar-header">Feeds</div> 235 + <div class="feed-list" id="feedList"> 236 + <div class="feed-item all-feeds active" data-feed-id=""> 237 + <span class="name">All Feeds</span> 238 + </div> 239 + </div> 240 + <div class="subscribe-form"> 241 + <input type="url" id="subscribeInput" placeholder="Enter feed URL..." /> 242 + </div> 243 + </div> 244 + 245 + <div class="main"> 246 + <div class="entries" id="entries"> 247 + <div class="loading">Loading...</div> 248 + </div> 249 + </div> 250 + </div> 251 + 252 + <script type="module"> 253 + import { getFeeds, getEntries, subscribeFeed, unsubscribeFeed, pollAllFeeds } from './background.js'; 254 + 255 + const api = window.app; 256 + let currentFeedId = null; 257 + 258 + // ==================== Rendering ==================== 259 + 260 + async function renderFeeds() { 261 + const feeds = await getFeeds(); 262 + const feedList = document.getElementById('feedList'); 263 + 264 + // Keep the "All Feeds" item 265 + const allFeedsItem = feedList.querySelector('.all-feeds'); 266 + 267 + feedList.innerHTML = ''; 268 + feedList.appendChild(allFeedsItem); 269 + 270 + for (const feed of feeds) { 271 + const item = document.createElement('div'); 272 + item.className = 'feed-item' + (currentFeedId === feed.id ? ' active' : ''); 273 + item.dataset.feedId = feed.id; 274 + item.innerHTML = ` 275 + <span class="name" title="${feed.url}">${feed.name}</span> 276 + <span class="delete" title="Unsubscribe">&times;</span> 277 + `; 278 + feedList.appendChild(item); 279 + } 280 + } 281 + 282 + async function renderEntries() { 283 + const entries = await getEntries(currentFeedId, 100); 284 + const container = document.getElementById('entries'); 285 + 286 + if (entries.length === 0) { 287 + container.innerHTML = ` 288 + <div class="empty-state"> 289 + <h2>No entries yet</h2> 290 + <p>${currentFeedId ? 'This feed has no entries.' : 'Subscribe to a feed to get started.'}</p> 291 + </div> 292 + `; 293 + return; 294 + } 295 + 296 + container.innerHTML = entries.map(entry => ` 297 + <div class="entry"> 298 + <div class="entry-title"> 299 + <a href="${escapeHtml(entry.url)}" target="_blank">${escapeHtml(entry.title)}</a> 300 + </div> 301 + <div class="entry-meta"> 302 + ${entry.author ? escapeHtml(entry.author) + ' · ' : ''}${formatDate(entry.pubDate)} 303 + </div> 304 + ${entry.description ? `<div class="entry-description">${escapeHtml(stripHtml(entry.description))}</div>` : ''} 305 + </div> 306 + `).join(''); 307 + } 308 + 309 + function escapeHtml(str) { 310 + if (!str) return ''; 311 + return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 312 + } 313 + 314 + function stripHtml(str) { 315 + if (!str) return ''; 316 + return str.replace(/<[^>]*>/g, '').trim(); 317 + } 318 + 319 + function formatDate(timestamp) { 320 + if (!timestamp) return ''; 321 + const date = new Date(timestamp); 322 + const now = new Date(); 323 + const diff = now - date; 324 + 325 + if (diff < 60000) return 'Just now'; 326 + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; 327 + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; 328 + if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`; 329 + 330 + return date.toLocaleDateString(); 331 + } 332 + 333 + // ==================== Event Handlers ==================== 334 + 335 + document.getElementById('feedList').addEventListener('click', async (e) => { 336 + const feedItem = e.target.closest('.feed-item'); 337 + if (!feedItem) return; 338 + 339 + // Handle delete button 340 + if (e.target.classList.contains('delete')) { 341 + const feedId = feedItem.dataset.feedId; 342 + if (feedId && confirm('Unsubscribe from this feed?')) { 343 + await unsubscribeFeed(feedId); 344 + if (currentFeedId === feedId) { 345 + currentFeedId = null; 346 + } 347 + await renderFeeds(); 348 + await renderEntries(); 349 + } 350 + return; 351 + } 352 + 353 + // Select feed 354 + document.querySelectorAll('.feed-item').forEach(el => el.classList.remove('active')); 355 + feedItem.classList.add('active'); 356 + currentFeedId = feedItem.dataset.feedId || null; 357 + await renderEntries(); 358 + }); 359 + 360 + document.getElementById('subscribeInput').addEventListener('keypress', async (e) => { 361 + if (e.key === 'Enter') { 362 + const input = e.target; 363 + const url = input.value.trim(); 364 + if (!url) return; 365 + 366 + input.disabled = true; 367 + const result = await subscribeFeed(url); 368 + input.disabled = false; 369 + 370 + if (result.success) { 371 + input.value = ''; 372 + await renderFeeds(); 373 + await renderEntries(); 374 + } else { 375 + alert('Failed to subscribe: ' + result.error); 376 + } 377 + } 378 + }); 379 + 380 + document.getElementById('refreshBtn').addEventListener('click', async () => { 381 + const btn = document.getElementById('refreshBtn'); 382 + btn.disabled = true; 383 + btn.textContent = 'Refreshing...'; 384 + 385 + await pollAllFeeds(); 386 + await renderEntries(); 387 + 388 + btn.disabled = false; 389 + btn.textContent = 'Refresh All'; 390 + }); 391 + 392 + // Handle entry link clicks 393 + document.getElementById('entries').addEventListener('click', (e) => { 394 + const link = e.target.closest('a'); 395 + if (link && link.href) { 396 + e.preventDefault(); 397 + api.window.open(link.href); 398 + } 399 + }); 400 + 401 + // ==================== Init ==================== 402 + 403 + async function init() { 404 + await renderFeeds(); 405 + await renderEntries(); 406 + } 407 + 408 + init(); 409 + </script> 410 + </body> 411 + </html>
+9
extensions/feeds/manifest.json
··· 1 + { 2 + "id": "feeds", 3 + "shortname": "feeds", 4 + "name": "Feed Reader", 5 + "description": "Simple RSS/Atom feed reader demonstrating the series and feeds APIs", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "settingsSchema": "./settings-schema.json" 9 + }
+14
extensions/feeds/settings-schema.json
··· 1 + { 2 + "pollInterval": { 3 + "type": "select", 4 + "label": "Poll Interval", 5 + "description": "How often to check feeds for new entries", 6 + "options": [ 7 + { "value": "5", "label": "5 minutes" }, 8 + { "value": "15", "label": "15 minutes" }, 9 + { "value": "30", "label": "30 minutes" }, 10 + { "value": "60", "label": "1 hour" } 11 + ], 12 + "default": "15" 13 + } 14 + }