experiments in a post-browser web
10
fork

Configure Feed

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

feat(datastore): implement item_events table and CRUD operations

- Add 'series' and 'feed' to ItemType
- Add ItemEvent, ItemEventFilter, ItemEventOptions types
- Add item_events table with indexes
- Implement CRUD functions: addItemEvent, getItemEvent, queryItemEvents,
deleteItemEvent, deleteItemEvents, getLatestItemEvent, countItemEvents
- Register IPC handlers for all item_events operations

Series items store time-series observations (numeric or text values).
Feed items store entries from external sources (RSS, ATProto, APIs).

+248 -2
+134 -1
backend/electron/datastore.ts
··· 31 31 ItemVisit, 32 32 ItemVisitFilter, 33 33 ItemVisitOptions, 34 + ItemEvent, 35 + ItemEventFilter, 36 + ItemEventOptions, 34 37 } from '../types/index.js'; 35 38 import { tableNames } from '../types/index.js'; 36 39 import { DEBUG } from './config.js'; ··· 263 266 264 267 CREATE TABLE IF NOT EXISTS items ( 265 268 id TEXT PRIMARY KEY, 266 - type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image')), 269 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed')), 267 270 content TEXT, 268 271 mimeType TEXT DEFAULT '', 269 272 metadata TEXT DEFAULT '{}', ··· 345 348 CREATE INDEX IF NOT EXISTS idx_item_group_members_groupId ON item_group_members(groupId); 346 349 CREATE INDEX IF NOT EXISTS idx_item_group_members_itemId ON item_group_members(itemId); 347 350 CREATE UNIQUE INDEX IF NOT EXISTS idx_item_group_members_unique ON item_group_members(groupId, itemId); 351 + 352 + CREATE TABLE IF NOT EXISTS item_events ( 353 + id TEXT PRIMARY KEY, 354 + itemId TEXT NOT NULL, 355 + content TEXT, 356 + value REAL, 357 + occurredAt INTEGER NOT NULL, 358 + metadata TEXT DEFAULT '{}', 359 + createdAt INTEGER NOT NULL, 360 + FOREIGN KEY(itemId) REFERENCES items(id) 361 + ); 362 + CREATE INDEX IF NOT EXISTS idx_item_events_item_time ON item_events(itemId, occurredAt DESC); 363 + CREATE INDEX IF NOT EXISTS idx_item_events_occurred ON item_events(occurredAt DESC); 348 364 `; 349 365 350 366 // Module state ··· 2470 2486 return getDb().prepare( 2471 2487 `SELECT * FROM items ${whereClause} ORDER BY frecencyScore DESC, lastVisitAt DESC ${limit}` 2472 2488 ).all(...values) as Item[]; 2489 + } 2490 + 2491 + // ==================== Item Events (Series & Feeds) ==================== 2492 + 2493 + /** 2494 + * Add an event/entry to a series or feed item 2495 + */ 2496 + export function addItemEvent(itemId: string, options: ItemEventOptions = {}): { id: string } { 2497 + const eventId = generateId('event'); 2498 + const timestamp = now(); 2499 + 2500 + getDb().prepare(` 2501 + INSERT INTO item_events (id, itemId, content, value, occurredAt, metadata, createdAt) 2502 + VALUES (?, ?, ?, ?, ?, ?, ?) 2503 + `).run( 2504 + eventId, 2505 + itemId, 2506 + options.content ?? null, 2507 + options.value ?? null, 2508 + options.occurredAt ?? timestamp, 2509 + options.metadata || '{}', 2510 + timestamp 2511 + ); 2512 + 2513 + return { id: eventId }; 2514 + } 2515 + 2516 + /** 2517 + * Get an event by ID 2518 + */ 2519 + export function getItemEvent(eventId: string): ItemEvent | null { 2520 + const result = getDb().prepare('SELECT * FROM item_events WHERE id = ?').get(eventId); 2521 + return (result as ItemEvent) || null; 2522 + } 2523 + 2524 + /** 2525 + * Query events for a series or feed 2526 + */ 2527 + export function queryItemEvents(filter: ItemEventFilter = {}): ItemEvent[] { 2528 + const conditions: string[] = []; 2529 + const values: unknown[] = []; 2530 + 2531 + if (filter.itemId) { 2532 + conditions.push('itemId = ?'); 2533 + values.push(filter.itemId); 2534 + } 2535 + if (filter.itemIds && filter.itemIds.length > 0) { 2536 + const placeholders = filter.itemIds.map(() => '?').join(', '); 2537 + conditions.push(`itemId IN (${placeholders})`); 2538 + values.push(...filter.itemIds); 2539 + } 2540 + if (filter.since) { 2541 + conditions.push('occurredAt >= ?'); 2542 + values.push(filter.since); 2543 + } 2544 + if (filter.until) { 2545 + conditions.push('occurredAt <= ?'); 2546 + values.push(filter.until); 2547 + } 2548 + 2549 + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; 2550 + const order = filter.order === 'asc' ? 'ASC' : 'DESC'; 2551 + const limit = filter.limit ? `LIMIT ${filter.limit}` : ''; 2552 + const offset = filter.offset ? `OFFSET ${filter.offset}` : ''; 2553 + 2554 + return getDb().prepare( 2555 + `SELECT * FROM item_events ${whereClause} ORDER BY occurredAt ${order} ${limit} ${offset}` 2556 + ).all(...values) as ItemEvent[]; 2557 + } 2558 + 2559 + /** 2560 + * Delete an event by ID 2561 + */ 2562 + export function deleteItemEvent(eventId: string): boolean { 2563 + const result = getDb().prepare('DELETE FROM item_events WHERE id = ?').run(eventId); 2564 + return result.changes > 0; 2565 + } 2566 + 2567 + /** 2568 + * Delete all events for an item (used when deleting a series or feed) 2569 + */ 2570 + export function deleteItemEvents(itemId: string): number { 2571 + const result = getDb().prepare('DELETE FROM item_events WHERE itemId = ?').run(itemId); 2572 + return result.changes; 2573 + } 2574 + 2575 + /** 2576 + * Get the latest event for a series or feed 2577 + */ 2578 + export function getLatestItemEvent(itemId: string): ItemEvent | null { 2579 + const result = getDb().prepare( 2580 + 'SELECT * FROM item_events WHERE itemId = ? ORDER BY occurredAt DESC LIMIT 1' 2581 + ).get(itemId); 2582 + return (result as ItemEvent) || null; 2583 + } 2584 + 2585 + /** 2586 + * Count events for a series or feed 2587 + */ 2588 + export function countItemEvents(itemId: string, filter: { since?: number; until?: number } = {}): number { 2589 + const conditions: string[] = ['itemId = ?']; 2590 + const values: unknown[] = [itemId]; 2591 + 2592 + if (filter.since) { 2593 + conditions.push('occurredAt >= ?'); 2594 + values.push(filter.since); 2595 + } 2596 + if (filter.until) { 2597 + conditions.push('occurredAt <= ?'); 2598 + values.push(filter.until); 2599 + } 2600 + 2601 + const result = getDb().prepare( 2602 + `SELECT COUNT(*) as count FROM item_events WHERE ${conditions.join(' AND ')}` 2603 + ).get(...values) as { count: number }; 2604 + 2605 + return result.count; 2473 2606 } 2474 2607 2475 2608 /**
+79
backend/electron/ipc.ts
··· 49 49 queryItemVisits, 50 50 trackNavigation, 51 51 queryItemsByFrecency, 52 + // Item event operations (series & feeds) 53 + addItemEvent, 54 + getItemEvent, 55 + queryItemEvents, 56 + deleteItemEvent, 57 + deleteItemEvents, 58 + getLatestItemEvent, 59 + countItemEvents, 52 60 // History operations 53 61 trackWindowLoad, 54 62 getHistory, ··· 594 602 ipcMain.handle('datastore-query-items-by-frecency', async (ev, data = {}) => { 595 603 try { 596 604 const result = queryItemsByFrecency(data.filter); 605 + return { success: true, data: result }; 606 + } catch (error) { 607 + const message = error instanceof Error ? error.message : String(error); 608 + return { success: false, error: message }; 609 + } 610 + }); 611 + 612 + // Item event operations (series & feeds) 613 + ipcMain.handle('datastore-add-item-event', async (ev, data) => { 614 + try { 615 + const result = addItemEvent(data.itemId, data.options); 616 + return { success: true, data: result }; 617 + } catch (error) { 618 + const message = error instanceof Error ? error.message : String(error); 619 + return { success: false, error: message }; 620 + } 621 + }); 622 + 623 + ipcMain.handle('datastore-get-item-event', async (ev, data) => { 624 + try { 625 + const result = getItemEvent(data.eventId); 626 + return { success: true, data: result }; 627 + } catch (error) { 628 + const message = error instanceof Error ? error.message : String(error); 629 + return { success: false, error: message }; 630 + } 631 + }); 632 + 633 + ipcMain.handle('datastore-query-item-events', async (ev, data = {}) => { 634 + try { 635 + const result = queryItemEvents(data.filter); 636 + return { success: true, data: result }; 637 + } catch (error) { 638 + const message = error instanceof Error ? error.message : String(error); 639 + return { success: false, error: message }; 640 + } 641 + }); 642 + 643 + ipcMain.handle('datastore-delete-item-event', async (ev, data) => { 644 + try { 645 + const result = deleteItemEvent(data.eventId); 646 + return { success: true, data: result }; 647 + } catch (error) { 648 + const message = error instanceof Error ? error.message : String(error); 649 + return { success: false, error: message }; 650 + } 651 + }); 652 + 653 + ipcMain.handle('datastore-delete-item-events', async (ev, data) => { 654 + try { 655 + const result = deleteItemEvents(data.itemId); 656 + return { success: true, data: result }; 657 + } catch (error) { 658 + const message = error instanceof Error ? error.message : String(error); 659 + return { success: false, error: message }; 660 + } 661 + }); 662 + 663 + ipcMain.handle('datastore-get-latest-item-event', async (ev, data) => { 664 + try { 665 + const result = getLatestItemEvent(data.itemId); 666 + return { success: true, data: result }; 667 + } catch (error) { 668 + const message = error instanceof Error ? error.message : String(error); 669 + return { success: false, error: message }; 670 + } 671 + }); 672 + 673 + ipcMain.handle('datastore-count-item-events', async (ev, data) => { 674 + try { 675 + const result = countItemEvents(data.itemId, data.filter); 597 676 return { success: true, data: result }; 598 677 } catch (error) { 599 678 const message = error instanceof Error ? error.message : String(error);
+35 -1
backend/types/index.ts
··· 88 88 // - text: Text content/notes 89 89 // - tagset: Tag-only items 90 90 // - image: Binary images 91 - export type ItemType = 'url' | 'text' | 'tagset' | 'image'; 91 + // - series: Time-series data (numeric or text values over time) 92 + // - feed: External feeds (RSS, ATProto, APIs, logs) 93 + export type ItemType = 'url' | 'text' | 'tagset' | 'image' | 'series' | 'feed'; 92 94 93 95 export interface Item { 94 96 id: string; ··· 307 309 interacted?: number; 308 310 } 309 311 312 + // Event/entry record for series and feeds (item_events table) 313 + // - For series: events are observations (value is numeric or content is text) 314 + // - For feeds: events are entries (content is URL/URI, metadata has title/author/etc) 315 + export interface ItemEvent { 316 + id: string; 317 + itemId: string; 318 + content: string | null; 319 + value: number | null; 320 + occurredAt: number; 321 + metadata: string; 322 + createdAt: number; 323 + } 324 + 325 + export interface ItemEventFilter { 326 + itemId?: string; 327 + itemIds?: string[]; 328 + since?: number; 329 + until?: number; 330 + limit?: number; 331 + offset?: number; 332 + order?: 'asc' | 'desc'; 333 + } 334 + 335 + export interface ItemEventOptions { 336 + content?: string; 337 + value?: number; 338 + occurredAt?: number; 339 + metadata?: string; 340 + } 341 + 310 342 // ==================== Table Names ==================== 311 343 312 344 export type TableName = ··· 324 356 | 'items' 325 357 | 'item_tags' 326 358 | 'item_visits' 359 + | 'item_events' 327 360 | 'item_groups' 328 361 | 'item_group_members' 329 362 | 'settings'; ··· 343 376 'items', 344 377 'item_tags', 345 378 'item_visits', 379 + 'item_events', 346 380 'item_groups', 347 381 'item_group_members', 348 382 'settings'