experiments in a post-browser web
6
fork

Configure Feed

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

feat: add visit tracking to items and filter groups for URLs only

- Add visitCount and lastVisitAt fields to Item interface
- Add items table columns and migration for visit tracking
- Update addVisit() to also update matching items when URL is visited
- Update groups extension to:
- Handle both Address (uri) and Item (content) objects
- Parse metadata.title for items
- Filter to show only URL items (not text/tagset/image)
- Add test:sync:desktop script for server+desktop sync testing
- Creates temp profile that's cleaned up on exit
- Pre-configures sync and runs initial pull
- Seeds test data on server

+353 -17
+41 -1
backend/electron/datastore.ts
··· 255 255 updatedAt INTEGER NOT NULL, 256 256 deletedAt INTEGER DEFAULT 0, 257 257 starred INTEGER DEFAULT 0, 258 - archived INTEGER DEFAULT 0 258 + archived INTEGER DEFAULT 0, 259 + visitCount INTEGER DEFAULT 0, 260 + lastVisitAt INTEGER DEFAULT 0 259 261 ); 260 262 CREATE INDEX IF NOT EXISTS idx_items_type ON items(type); 261 263 CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId); ··· 289 291 migrateTinyBaseData(); 290 292 migrateSyncColumns(); 291 293 migrateItemTypes(); 294 + migrateItemVisitColumns(); 292 295 293 296 DEBUG && console.log('main', 'database initialized successfully'); 294 297 return db; ··· 605 608 } 606 609 } 607 610 611 + /** 612 + * Add visit tracking columns to items table for existing databases 613 + */ 614 + function migrateItemVisitColumns(): void { 615 + if (!db) return; 616 + 617 + const columns = db.prepare(`PRAGMA table_info(items)`).all() as { name: string }[]; 618 + const hasVisitCount = columns.some(col => col.name === 'visitCount'); 619 + 620 + if (!hasVisitCount) { 621 + DEBUG && console.log('main', 'Adding visit columns to items table'); 622 + try { 623 + db.exec(`ALTER TABLE items ADD COLUMN visitCount INTEGER DEFAULT 0`); 624 + db.exec(`ALTER TABLE items ADD COLUMN lastVisitAt INTEGER DEFAULT 0`); 625 + } catch (error) { 626 + DEBUG && console.log('main', `Visit columns migration for items:`, (error as Error).message); 627 + } 628 + } 629 + 630 + // Always ensure indexes exist (handles both new and migrated tables) 631 + try { 632 + db.exec(`CREATE INDEX IF NOT EXISTS idx_items_lastVisitAt ON items(lastVisitAt)`); 633 + db.exec(`CREATE INDEX IF NOT EXISTS idx_items_visitCount ON items(visitCount)`); 634 + } catch (error) { 635 + DEBUG && console.log('main', `Visit indexes for items:`, (error as Error).message); 636 + } 637 + } 638 + 608 639 // ==================== Address Operations ==================== 609 640 610 641 export function addAddress(uri: string, options: AddressOptions = {}): { id: string } { ··· 719 750 UPDATE addresses SET lastVisitAt = ?, visitCount = visitCount + 1, updatedAt = ? 720 751 WHERE id = ? 721 752 `).run(timestamp, timestamp, addressId); 753 + 754 + // Also update any items with matching URL content 755 + const address = getAddress(addressId); 756 + if (address && address.uri) { 757 + getDb().prepare(` 758 + UPDATE items SET lastVisitAt = ?, visitCount = visitCount + 1, updatedAt = ? 759 + WHERE type = 'url' AND content = ? AND deletedAt = 0 760 + `).run(timestamp, timestamp, address.uri); 761 + } 722 762 723 763 return { id: visitId }; 724 764 }
+2
backend/types/index.ts
··· 102 102 deletedAt: number; 103 103 starred: number; 104 104 archived: number; 105 + visitCount: number; 106 + lastVisitAt: number; 105 107 } 106 108 107 109 export interface ItemTag {
+43 -16
extensions/groups/home.js
··· 218 218 state.tags = result.data; 219 219 debug && console.log('Loaded tags:', state.tags.length); 220 220 221 - // Fetch item count for each tag 221 + // Fetch URL item count for each tag (only URLs for now) 222 222 for (const tag of state.tags) { 223 223 const itemsResult = await api.datastore.getItemsByTag(tag.id); 224 - tag.addressCount = itemsResult.success ? itemsResult.data.length : 0; 224 + if (itemsResult.success) { 225 + tag.addressCount = itemsResult.data.filter(item => item.type === 'url').length; 226 + } else { 227 + tag.addressCount = 0; 228 + } 225 229 } 226 230 } else { 227 231 console.error('Failed to load tags:', result.error); 228 232 state.tags = []; 229 233 } 230 234 231 - // Get count of untagged items 235 + // Get count of untagged URL items (only URLs for now) 232 236 // Query all items and filter out those with tags 233 237 const allItemsResult = await api.datastore.queryItems({}); 234 238 if (allItemsResult.success) { 235 239 const untaggedItems = []; 236 240 for (const item of allItemsResult.data) { 241 + // Only include URL items 242 + if (item.type !== 'url') continue; 237 243 const tagsResult = await api.datastore.getItemTags(item.id); 238 244 if (tagsResult.success && tagsResult.data.length === 0) { 239 245 untaggedItems.push(item); 240 246 } 241 247 } 242 248 state.untaggedCount = untaggedItems.length; 243 - debug && console.log('Untagged items:', state.untaggedCount); 249 + debug && console.log('Untagged URL items:', state.untaggedCount); 244 250 } else { 245 251 state.untaggedCount = 0; 246 252 } 247 253 }; 248 254 249 255 /** 250 - * Load items for a specific tag 256 + * Load URL items for a specific tag (only URLs for now) 251 257 */ 252 258 const loadAddressesForTag = async (tagId) => { 253 259 const result = await api.datastore.getItemsByTag(tagId); 254 260 if (result.success) { 255 - state.addresses = result.data; 256 - debug && console.log('Loaded items for tag:', state.addresses.length); 261 + // Only include URL items 262 + state.addresses = result.data.filter(item => item.type === 'url'); 263 + debug && console.log('Loaded URL items for tag:', state.addresses.length); 257 264 } else { 258 265 console.error('Failed to load addresses:', result.error); 259 266 state.addresses = []; ··· 271 278 272 279 /** 273 280 * Filter addresses by search query (title or URL) 281 + * Handles both Address (uri) and Item (content) objects 274 282 */ 275 283 const filterAddresses = (addresses) => { 276 284 if (!state.searchQuery) return addresses; 277 285 const q = state.searchQuery.toLowerCase(); 278 - return addresses.filter(addr => 279 - (addr.title || '').toLowerCase().includes(q) || 280 - addr.uri.toLowerCase().includes(q) 281 - ); 286 + return addresses.filter(addr => { 287 + const url = addr.uri || addr.content || ''; 288 + return (addr.title || '').toLowerCase().includes(q) || 289 + url.toLowerCase().includes(q); 290 + }); 282 291 }; 283 292 284 293 /** ··· 349 358 state.currentTag = tag; 350 359 state.searchQuery = ''; 351 360 352 - // Load items - handle special untagged group 361 + // Load URL items - handle special untagged group (only URLs for now) 353 362 if (tag.isSpecial && tag.id === '__untagged__') { 354 363 const allItemsResult = await api.datastore.queryItems({}); 355 364 if (allItemsResult.success) { 356 365 const untaggedItems = []; 357 366 for (const item of allItemsResult.data) { 367 + // Only include URL items 368 + if (item.type !== 'url') continue; 358 369 const tagsResult = await api.datastore.getItemTags(item.id); 359 370 if (tagsResult.success && tagsResult.data.length === 0) { 360 371 untaggedItems.push(item); ··· 445 456 446 457 /** 447 458 * Create a card element for an address 459 + * Handles both Address (uri) and Item (content) objects 448 460 */ 449 461 const createAddressCard = (address) => { 450 462 const card = document.createElement('div'); 451 463 card.className = 'card address-card'; 452 464 card.dataset.addressId = address.id; 453 465 466 + // Get URL from either uri (Address) or content (Item) 467 + const addressUrl = address.uri || address.content; 468 + 469 + // Get title - Items store title in metadata, Addresses have it directly 470 + let displayTitle = address.title; 471 + if (!displayTitle && address.metadata) { 472 + try { 473 + const meta = typeof address.metadata === 'string' ? JSON.parse(address.metadata) : address.metadata; 474 + displayTitle = meta.title; 475 + } catch (e) { 476 + // Ignore parse errors 477 + } 478 + } 479 + displayTitle = displayTitle || addressUrl; 480 + 454 481 const favicon = document.createElement('img'); 455 482 favicon.className = 'card-favicon'; 456 483 favicon.src = address.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; ··· 463 490 464 491 const title = document.createElement('h2'); 465 492 title.className = 'card-title'; 466 - title.textContent = address.title || address.uri; 493 + title.textContent = displayTitle; 467 494 468 495 const url = document.createElement('div'); 469 496 url.className = 'card-url'; 470 - url.textContent = address.uri; 497 + url.textContent = addressUrl; 471 498 472 499 const meta = document.createElement('div'); 473 500 meta.className = 'card-meta'; ··· 483 510 484 511 // Click to open address 485 512 card.addEventListener('click', async () => { 486 - debug && console.log('Opening address:', address.uri); 487 - const result = await api.window.open(address.uri, { 513 + debug && console.log('Opening address:', addressUrl); 514 + const result = await api.window.open(addressUrl, { 488 515 width: 800, 489 516 height: 600 490 517 });
+1
package.json
··· 75 75 "test:sync:e2e:prod": "node backend/tests/sync-e2e-prod.test.js", 76 76 "test:sync:e2e:prod:verbose": "VERBOSE=1 node backend/tests/sync-e2e-prod.test.js", 77 77 "test:sync:verify-logs": "node backend/tests/verify-railway-logs.js", 78 + "test:sync:desktop": "./scripts/test-sync-desktop.sh", 78 79 "test:mobile": "cd backend/tauri-mobile && npm test", 79 80 "//-- Packaged Electron --//": "", 80 81 "kill:packaged": "pkill -f 'out/mac-arm64/Peek.app' || true",
+118
scripts/preconfigure-sync.mjs
··· 1 + /** 2 + * Pre-configure sync settings and run initial sync for test profile 3 + * 4 + * This script must be run with Electron (not Node) because better-sqlite3 5 + * is compiled for Electron's Node version. 6 + * 7 + * Usage: electron scripts/preconfigure-sync.mjs 8 + * 9 + * Environment variables: 10 + * - PROFILE: Profile name (required) 11 + * - SERVER_URL: Sync server URL (required) 12 + * - API_KEY: Server API key (required) 13 + */ 14 + 15 + import { app } from 'electron'; 16 + import { join } from 'path'; 17 + import { mkdirSync, existsSync } from 'fs'; 18 + import { homedir } from 'os'; 19 + 20 + // Prevent Electron from showing a window 21 + app.disableHardwareAcceleration(); 22 + 23 + const PROFILE = process.env.PROFILE; 24 + const SERVER_URL = process.env.SERVER_URL; 25 + const API_KEY = process.env.API_KEY; 26 + 27 + if (!PROFILE || !SERVER_URL || !API_KEY) { 28 + console.error('Missing required environment variables: PROFILE, SERVER_URL, API_KEY'); 29 + process.exit(1); 30 + } 31 + 32 + app.whenReady().then(async () => { 33 + // Import compiled modules (after app is ready) 34 + const datastore = await import('../dist/backend/electron/datastore.js'); 35 + const sync = await import('../dist/backend/electron/sync.js'); 36 + const profiles = await import('../dist/backend/electron/profiles.js'); 37 + 38 + // Paths 39 + const userDataPath = join(homedir(), 'Library', 'Application Support', 'Peek'); 40 + const profileDir = join(userDataPath, PROFILE); 41 + const dbPath = join(profileDir, 'datastore.sqlite'); 42 + 43 + console.log(` Profile: ${PROFILE}`); 44 + console.log(` Database: ${dbPath}`); 45 + console.log(` Server: ${SERVER_URL}`); 46 + 47 + // Create profile directory if needed 48 + if (!existsSync(profileDir)) { 49 + mkdirSync(profileDir, { recursive: true }); 50 + console.log(' Created profile directory'); 51 + } 52 + 53 + // Initialize profiles database (uses .dev-profiles.db for dev builds) 54 + profiles.initProfilesDb(userDataPath, '.dev-profiles.db'); 55 + profiles.migrateExistingProfiles(); 56 + profiles.ensureDefaultProfile(); 57 + 58 + // Create the test profile if it doesn't exist 59 + try { 60 + const existingProfile = profiles.getProfile(PROFILE); 61 + if (!existingProfile) { 62 + profiles.createProfile(PROFILE); 63 + console.log(' Created test profile in profiles.db'); 64 + } 65 + } catch (error) { 66 + // Profile might already exist with different name 67 + console.log(` Profile setup: ${error.message}`); 68 + } 69 + 70 + // Set this profile as active 71 + try { 72 + profiles.setActiveProfile(PROFILE); 73 + console.log(' Set active profile'); 74 + } catch (error) { 75 + console.log(` Could not set active profile: ${error.message}`); 76 + } 77 + 78 + // Initialize datastore 79 + datastore.initDatabase(dbPath); 80 + console.log(' Database initialized'); 81 + 82 + // Enable sync for this profile 83 + const activeProfile = profiles.getActiveProfile(); 84 + profiles.enableSync(activeProfile.id, API_KEY, 'default'); 85 + 86 + // Set server URL globally 87 + sync.setSyncConfig({ 88 + serverUrl: SERVER_URL, 89 + apiKey: API_KEY, 90 + lastSyncTime: 0, 91 + autoSync: false, 92 + }); 93 + console.log(' Sync configured'); 94 + 95 + // Run initial sync (pull from server) 96 + try { 97 + const result = await sync.pullFromServer(SERVER_URL, API_KEY); 98 + console.log(` Pulled ${result.pulled} items from server`); 99 + } catch (error) { 100 + console.error(' Sync failed:', error.message); 101 + datastore.closeDatabase(); 102 + profiles.closeProfilesDb(); 103 + app.exit(1); 104 + return; 105 + } 106 + 107 + // Close databases 108 + datastore.closeDatabase(); 109 + profiles.closeProfilesDb(); 110 + console.log(' Pre-configuration complete'); 111 + 112 + app.exit(0); 113 + }); 114 + 115 + // Handle window-all-closed to prevent default behavior 116 + app.on('window-all-closed', () => { 117 + // Do nothing - we'll exit manually 118 + });
+148
scripts/test-sync-desktop.sh
··· 1 + #!/bin/bash 2 + # Desktop + Server sync test environment 3 + # Creates temp profile for desktop and temp data dir for server 4 + # Pre-configures sync settings and runs initial sync 5 + # Cleans up everything on exit 6 + 7 + set -e 8 + 9 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 10 + ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" 11 + SERVER_DIR="$ROOT_DIR/backend/server" 12 + 13 + # Generate unique identifiers 14 + TIMESTAMP=$(date +%s) 15 + PROFILE_NAME="test-sync-$TIMESTAMP" 16 + SERVER_TEMP_DIR=$(mktemp -d) 17 + API_KEY="test-sync-key-$TIMESTAMP" 18 + PORT=$((RANDOM % 10000 + 20000)) 19 + SERVER_URL="http://localhost:$PORT" 20 + 21 + # Get userData path (macOS) 22 + USER_DATA_PATH="$HOME/Library/Application Support/Peek" 23 + PROFILE_DIR="$USER_DATA_PATH/$PROFILE_NAME" 24 + 25 + echo "==========================================" 26 + echo " Desktop + Server Sync Test Environment" 27 + echo "==========================================" 28 + echo "" 29 + echo "Profile: $PROFILE_NAME" 30 + echo "Profile dir: $PROFILE_DIR" 31 + echo "Server temp: $SERVER_TEMP_DIR" 32 + echo "Server URL: $SERVER_URL" 33 + echo "API Key: $API_KEY" 34 + echo "" 35 + 36 + # Cleanup function 37 + cleanup() { 38 + echo "" 39 + echo "Cleaning up..." 40 + 41 + # Kill background processes 42 + if [ -n "$SERVER_PID" ]; then 43 + echo " Stopping server (PID: $SERVER_PID)..." 44 + kill $SERVER_PID 2>/dev/null || true 45 + fi 46 + 47 + if [ -n "$DESKTOP_PID" ]; then 48 + echo " Stopping desktop (PID: $DESKTOP_PID)..." 49 + kill $DESKTOP_PID 2>/dev/null || true 50 + fi 51 + 52 + # Wait for processes to exit 53 + sleep 1 54 + 55 + # Remove temp directories 56 + if [ -d "$SERVER_TEMP_DIR" ]; then 57 + echo " Removing server temp dir: $SERVER_TEMP_DIR" 58 + rm -rf "$SERVER_TEMP_DIR" 59 + fi 60 + 61 + if [ -d "$PROFILE_DIR" ]; then 62 + echo " Removing profile dir: $PROFILE_DIR" 63 + rm -rf "$PROFILE_DIR" 64 + fi 65 + 66 + echo "Cleanup complete." 67 + exit 0 68 + } 69 + 70 + trap cleanup SIGINT SIGTERM EXIT 71 + 72 + # Build first 73 + echo "Building..." 74 + cd "$ROOT_DIR" 75 + yarn build 76 + 77 + # Start server 78 + echo "" 79 + echo "Starting server..." 80 + cd "$SERVER_DIR" 81 + DATA_DIR="$SERVER_TEMP_DIR" API_KEY="$API_KEY" PORT="$PORT" node index.js & 82 + SERVER_PID=$! 83 + echo " Server started (PID: $SERVER_PID)" 84 + 85 + # Wait for server to be ready 86 + echo " Waiting for server..." 87 + for i in {1..30}; do 88 + if curl -s "$SERVER_URL/" > /dev/null 2>&1; then 89 + echo " Server is ready" 90 + break 91 + fi 92 + sleep 0.1 93 + done 94 + 95 + # Seed test data on server 96 + echo "" 97 + echo "Seeding test data on server..." 98 + curl -s -X POST "$SERVER_URL/items" \ 99 + -H "Authorization: Bearer $API_KEY" \ 100 + -H "Content-Type: application/json" \ 101 + -d '{"type":"url","content":"https://example.com/synced-url-1","tags":["test","synced"],"metadata":{"title":"Example Synced URL"}}' > /dev/null 102 + 103 + curl -s -X POST "$SERVER_URL/items" \ 104 + -H "Authorization: Bearer $API_KEY" \ 105 + -H "Content-Type: application/json" \ 106 + -d '{"type":"url","content":"https://github.com/test/repo","tags":["test","github"],"metadata":{"title":"Test Repository - GitHub"}}' > /dev/null 107 + 108 + curl -s -X POST "$SERVER_URL/items" \ 109 + -H "Authorization: Bearer $API_KEY" \ 110 + -H "Content-Type: application/json" \ 111 + -d '{"type":"text","content":"This is a synced text note","tags":["test","note"]}' > /dev/null 112 + 113 + echo " Seeded 3 test items" 114 + 115 + # Pre-configure sync and run initial sync (must use electron, not node, for better-sqlite3) 116 + echo "" 117 + echo "Pre-configuring sync and running initial sync..." 118 + cd "$ROOT_DIR" 119 + PROFILE="$PROFILE_NAME" SERVER_URL="$SERVER_URL" API_KEY="$API_KEY" electron scripts/preconfigure-sync.mjs 120 + 121 + # Start desktop with temp profile 122 + echo "" 123 + echo "Starting desktop with profile: $PROFILE_NAME" 124 + cd "$ROOT_DIR" 125 + PROFILE="$PROFILE_NAME" DEBUG=1 electron . & 126 + DESKTOP_PID=$! 127 + echo " Desktop started (PID: $DESKTOP_PID)" 128 + 129 + # Print instructions 130 + echo "" 131 + echo "==========================================" 132 + echo " Test Environment Ready!" 133 + echo "==========================================" 134 + echo "" 135 + echo "Sync is pre-configured and initial sync completed." 136 + echo "Open the Groups extension to see synced items." 137 + echo "" 138 + echo "Test data synced from server:" 139 + echo " - https://example.com/synced-url-1 (tags: test, synced)" 140 + echo " - https://github.com/test/repo (tags: test, github)" 141 + echo " - Text note: 'This is a synced text note'" 142 + echo "" 143 + echo "Press Ctrl+C to stop and clean up." 144 + echo "==========================================" 145 + echo "" 146 + 147 + # Wait for processes 148 + wait