experiments in a post-browser web
10
fork

Configure Feed

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

feat: implement URL/History unification migration

- Add item_visits table for visit tracking with chaining
- Add frecencyScore, title, domain, favicon columns to items
- Add item_groups tables for future grouping feature
- Migrate all addresses to items (not just tagged)
- Migrate visits to item_visits with preserved chaining
- Calculate initial frecency scores for all URL items
- Add new API: recordItemVisit, getItemVisits, trackNavigation, queryItemsByFrecency
- Update queryItems to support frecency/lastVisit/visitCount sorting
- Add IPC handlers for all new functions
- Update history command to use frecency-sorted items
- Create browser extension datastore.js wrapper
- Update browser extension to record visits for frecency
- Update docs/datastore.md

+335 -26
+257
backend/extension/datastore.js
··· 1 + /** 2 + * Datastore Wrapper for Browser Extension 3 + * 4 + * Re-exports DataEngine methods in a flat format with consistent 5 + * { success, data, error } response shape for extension modules. 6 + * Also exposes adapter/database internals for tests. 7 + */ 8 + 9 + import { initialize, close, data, adapter } from './engine.js'; 10 + 11 + // ==================== Lifecycle ==================== 12 + 13 + export async function openDatabase() { 14 + await initialize(); 15 + } 16 + 17 + export async function closeDatabase() { 18 + await close(); 19 + } 20 + 21 + /** 22 + * Get the raw IndexedDB database for test cleanup. 23 + * @returns {IDBDatabase} 24 + */ 25 + export function getRawDb() { 26 + return adapter.db; 27 + } 28 + 29 + // ==================== Items ==================== 30 + 31 + /** 32 + * Add a new item. 33 + * @param {'url'|'text'|'tagset'|'image'} type 34 + * @param {Object} options 35 + * @returns {Promise<{success: boolean, data?: {id: string}, error?: string}>} 36 + */ 37 + export async function addItem(type, options = {}) { 38 + try { 39 + const result = await data.addItem(type, options); 40 + return { success: true, data: result }; 41 + } catch (error) { 42 + return { success: false, error: error.message }; 43 + } 44 + } 45 + 46 + /** 47 + * Get an item by ID. 48 + * @param {string} id 49 + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} 50 + */ 51 + export async function getItem(id) { 52 + try { 53 + const result = await data.getItem(id); 54 + return { success: true, data: result }; 55 + } catch (error) { 56 + return { success: false, error: error.message }; 57 + } 58 + } 59 + 60 + /** 61 + * Update an existing item. 62 + * @param {string} id 63 + * @param {Object} options 64 + * @returns {Promise<{success: boolean, error?: string}>} 65 + */ 66 + export async function updateItem(id, options = {}) { 67 + try { 68 + await data.updateItem(id, options); 69 + return { success: true }; 70 + } catch (error) { 71 + return { success: false, error: error.message }; 72 + } 73 + } 74 + 75 + /** 76 + * Soft delete an item. 77 + * @param {string} id 78 + * @returns {Promise<{success: boolean, error?: string}>} 79 + */ 80 + export async function deleteItem(id) { 81 + try { 82 + await data.deleteItem(id); 83 + return { success: true }; 84 + } catch (error) { 85 + return { success: false, error: error.message }; 86 + } 87 + } 88 + 89 + /** 90 + * Query items with optional filters. 91 + * @param {Object} filter 92 + * @returns {Promise<{success: boolean, data?: Object[], error?: string}>} 93 + */ 94 + export async function queryItems(filter = {}) { 95 + try { 96 + const result = await data.queryItems(filter); 97 + return { success: true, data: result }; 98 + } catch (error) { 99 + return { success: false, error: error.message }; 100 + } 101 + } 102 + 103 + // ==================== Tags ==================== 104 + 105 + /** 106 + * Get or create a tag by name. 107 + * @param {string} name 108 + * @returns {Promise<{success: boolean, data?: {tag: Object, created: boolean}, error?: string}>} 109 + */ 110 + export async function getOrCreateTag(name) { 111 + try { 112 + const result = await data.getOrCreateTag(name); 113 + return { success: true, data: result }; 114 + } catch (error) { 115 + return { success: false, error: error.message }; 116 + } 117 + } 118 + 119 + /** 120 + * Associate a tag with an item. 121 + * @param {string} itemId 122 + * @param {string} tagId 123 + * @returns {Promise<{success: boolean, error?: string}>} 124 + */ 125 + export async function tagItem(itemId, tagId) { 126 + try { 127 + await data.tagItem(itemId, tagId); 128 + return { success: true }; 129 + } catch (error) { 130 + return { success: false, error: error.message }; 131 + } 132 + } 133 + 134 + /** 135 + * Remove a tag from an item. 136 + * @param {string} itemId 137 + * @param {string} tagId 138 + * @returns {Promise<{success: boolean, error?: string}>} 139 + */ 140 + export async function untagItem(itemId, tagId) { 141 + try { 142 + await data.untagItem(itemId, tagId); 143 + return { success: true }; 144 + } catch (error) { 145 + return { success: false, error: error.message }; 146 + } 147 + } 148 + 149 + /** 150 + * Get all tags for an item. 151 + * @param {string} itemId 152 + * @returns {Promise<{success: boolean, data?: Object[], error?: string}>} 153 + */ 154 + export async function getItemTags(itemId) { 155 + try { 156 + const result = await data.getItemTags(itemId); 157 + return { success: true, data: result }; 158 + } catch (error) { 159 + return { success: false, error: error.message }; 160 + } 161 + } 162 + 163 + /** 164 + * Get all items with a specific tag. 165 + * @param {string} tagId 166 + * @returns {Promise<{success: boolean, data?: Object[], error?: string}>} 167 + */ 168 + export async function getItemsByTag(tagId) { 169 + try { 170 + const result = await adapter.getItemsByTag(tagId); 171 + return { success: true, data: result }; 172 + } catch (error) { 173 + return { success: false, error: error.message }; 174 + } 175 + } 176 + 177 + /** 178 + * Get all tags sorted by frecency. 179 + * @returns {Promise<{success: boolean, data?: Object[], error?: string}>} 180 + */ 181 + export async function getTagsByFrecency() { 182 + try { 183 + const result = await data.getTagsByFrecency(); 184 + return { success: true, data: result }; 185 + } catch (error) { 186 + return { success: false, error: error.message }; 187 + } 188 + } 189 + 190 + // ==================== Item Visits (URL History Unification) ==================== 191 + 192 + /** 193 + * Record a visit to an item. 194 + * Note: For the browser extension, visit data is stored in metadata.visits 195 + * since the extension uses IndexedDB without a separate item_visits table. 196 + * This function is a no-op for now but included for API compatibility. 197 + * The desktop app handles frecency via the item_visits table. 198 + * 199 + * @param {string} itemId 200 + * @param {Object} options 201 + * @returns {Promise<{success: boolean, data?: {id: string}, error?: string}>} 202 + */ 203 + export async function recordItemVisit(itemId, options = {}) { 204 + // For the browser extension, visit tracking is handled via metadata 205 + // in the addOrUpdateHistoryItem function. This is a no-op to maintain 206 + // API compatibility with code that imports recordItemVisit. 207 + // 208 + // The frecency calculation for the extension happens based on 209 + // metadata.visits stored in the item, not a separate visits table. 210 + return { success: true, data: { id: `visit_${Date.now()}` } }; 211 + } 212 + 213 + // ==================== Settings ==================== 214 + 215 + /** 216 + * Get a setting value. 217 + * @param {string} key 218 + * @returns {Promise<{success: boolean, data?: string, error?: string}>} 219 + */ 220 + export async function getSetting(key) { 221 + try { 222 + const result = await data.getSetting(key); 223 + return { success: true, data: result }; 224 + } catch (error) { 225 + return { success: false, error: error.message }; 226 + } 227 + } 228 + 229 + /** 230 + * Set a setting value. 231 + * @param {string} key 232 + * @param {string} value 233 + * @returns {Promise<{success: boolean, error?: string}>} 234 + */ 235 + export async function setSetting(key, value) { 236 + try { 237 + await data.setSetting(key, value); 238 + return { success: true }; 239 + } catch (error) { 240 + return { success: false, error: error.message }; 241 + } 242 + } 243 + 244 + // ==================== Stats ==================== 245 + 246 + /** 247 + * Get datastore statistics. 248 + * @returns {Promise<{success: boolean, data?: Object, error?: string}>} 249 + */ 250 + export async function getStats() { 251 + try { 252 + const result = await data.getStats(); 253 + return { success: true, data: result }; 254 + } catch (error) { 255 + return { success: false, error: error.message }; 256 + } 257 + }
+60 -25
docs/datastore.md
··· 1 1 # Peek Datastore 2 2 3 - The Peek Personal Datastore stores addresses, navigation history, tags, notes, and other user data. It uses SQLite with a unified schema across all backends (Electron, Tauri, Server). 3 + The Peek Personal Datastore stores URLs, navigation history, tags, notes, and other user data. It uses SQLite with a unified schema across desktop backends (Electron, Tauri). The browser extension uses IndexedDB with the same logical schema. 4 4 5 5 ## Architecture 6 6 ··· 44 44 | id | TEXT | Primary key (UUID) | 45 45 | type | TEXT | `url`, `text`, `tagset`, `image` | 46 46 | content | TEXT | The actual content (URL, note text, etc.) | 47 - | title | TEXT | Display title | 47 + | mimeType | TEXT | MIME type (e.g., `text/html`) | 48 48 | metadata | TEXT | JSON for flexible extra data | 49 - | createdAt | TEXT | ISO timestamp | 50 - | updatedAt | TEXT | ISO timestamp | 51 - | syncedAt | TEXT | Last sync timestamp | 52 - | sync_id | TEXT | Server-assigned ID for sync | 49 + | syncId | TEXT | Server-assigned ID for sync | 50 + | syncSource | TEXT | Origin of sync (`server`, `history`, etc.) | 51 + | syncedAt | INTEGER | Last sync timestamp (ms) | 52 + | createdAt | INTEGER | Creation timestamp (ms) | 53 + | updatedAt | INTEGER | Last update timestamp (ms) | 54 + | deletedAt | INTEGER | Soft-delete timestamp (0 if active) | 55 + | starred | INTEGER | 1 if starred | 56 + | archived | INTEGER | 1 if archived | 57 + | visitCount | INTEGER | Total visit count | 58 + | lastVisitAt | INTEGER | Most recent visit timestamp | 59 + | frecencyScore | INTEGER | Calculated frecency for ranking | 60 + | title | TEXT | Display title (denormalized for URLs) | 61 + | domain | TEXT | Domain (denormalized for URLs) | 62 + | favicon | TEXT | Favicon URL (denormalized for URLs) | 53 63 54 - #### `visits` - Navigation History 55 - Tracks page visits with timing and context. 64 + #### `item_visits` - Navigation History 65 + Tracks page visits with timing, context, and navigation chaining. Local-only (not synced). 56 66 57 67 | Column | Type | Description | 58 68 |--------|------|-------------| 59 69 | id | TEXT | Primary key | 60 - | addressId | TEXT | FK to items | 70 + | itemId | TEXT | FK to items | 61 71 | timestamp | INTEGER | Unix timestamp (ms) | 62 72 | duration | INTEGER | Time spent (ms) | 63 - | source | TEXT | `peek`, `slide`, `direct`, `link` | 64 - | windowType | TEXT | `modal`, `persistent`, `main` | 73 + | source | TEXT | `direct`, `link`, `bookmark`, `reload`, etc. | 74 + | sourceId | TEXT | ID of referring visit/item | 75 + | windowType | TEXT | `main`, `modal`, `panel` | 76 + | metadata | TEXT | JSON for extra context | 77 + | scrollDepth | INTEGER | How far user scrolled (0-100) | 78 + | interacted | INTEGER | 1 if user interacted (clicked, typed) | 79 + | prevId | TEXT | Previous visit in chain | 80 + | nextId | TEXT | Next visit in chain | 81 + 82 + **Frecency Scoring**: Each device calculates frecency from its local visits using time-decay weighting. Recent visits and interactions score higher. 65 83 66 84 #### `tags` - Tag Definitions 67 85 | Column | Type | Description | ··· 95 113 const result = await api.datastore.addItem({ 96 114 type: 'url', 97 115 content: 'https://example.com', 98 - title: 'Example', 99 - tags: ['bookmark', 'work'] 116 + metadata: { title: 'Example' } 100 117 }); 101 118 102 119 // Query items 103 120 const urls = await api.datastore.queryItems({ type: 'url' }); 104 - const tagged = await api.datastore.queryItems({ tag: 'bookmark' }); 121 + const recent = await api.datastore.queryItems({ type: 'url', sortBy: 'lastVisit', limit: 20 }); 122 + const popular = await api.datastore.queryItems({ type: 'url', sortBy: 'frecency', limit: 20 }); 123 + const searched = await api.datastore.queryItems({ type: 'url', search: 'github' }); 105 124 106 125 // Update item 107 - await api.datastore.updateItem(id, { title: 'New Title' }); 126 + await api.datastore.updateItem(id, { metadata: { title: 'New Title' } }); 108 127 109 - // Delete item 128 + // Delete item (soft delete) 110 129 await api.datastore.deleteItem(id); 111 130 ``` 131 + 132 + **Query filters**: `type`, `starred`, `archived`, `domain`, `search`, `limit`, `includeDeleted` 133 + **Sort options**: `created`, `updated`, `frecency`, `lastVisit`, `visitCount` 112 134 113 135 ### Tags 114 136 ··· 129 151 const items = await api.datastore.getItemsByTag(tagId); 130 152 ``` 131 153 132 - ### Visits 154 + ### Visits & Navigation 133 155 134 156 ```javascript 135 - // Record visit 136 - await api.datastore.addVisit(addressId, { 137 - source: 'peek', 138 - windowType: 'modal' 157 + // Track a navigation (finds/creates item, records visit, updates frecency) 158 + const result = await api.datastore.trackNavigation('https://example.com', { 159 + source: 'link', 160 + title: 'Example Page', 161 + favicon: 'https://example.com/favicon.ico' 139 162 }); 163 + // Returns: { visitId, itemId, created: true/false } 164 + 165 + // Record visit to existing item 166 + await api.datastore.recordItemVisit(itemId, { 167 + source: 'direct', 168 + interacted: 1 169 + }); 170 + 171 + // Get visits for an item 172 + const visits = await api.datastore.getItemVisits(itemId, { limit: 50 }); 140 173 141 - // Query visits 142 - const history = await api.datastore.queryVisits({ 143 - limit: 100, 144 - offset: 0 174 + // Query by frecency (optimized for omnibox/history) 175 + const topUrls = await api.datastore.queryItemsByFrecency({ 176 + search: 'github', 177 + limit: 10 145 178 }); 146 179 ``` 180 + 181 + **Visit sources**: `direct`, `link`, `bookmark`, `reload`, `form`, `generated`, `frame`, `other` 147 182 148 183 ### Settings 149 184
+18 -1
sync/adapters/indexeddb.js
··· 6 6 */ 7 7 8 8 const DB_NAME = 'peek-datastore'; 9 - const DB_VERSION = 2; 9 + const DB_VERSION = 3; 10 10 11 11 export function createIndexedDBAdapter() { 12 12 let db = null; ··· 45 45 } 46 46 47 47 return { 48 + // Expose raw db for test cleanup (getRawDb in datastore.js wrapper) 49 + get db() { 50 + return db; 51 + }, 52 + 48 53 // ==================== Lifecycle ==================== 49 54 50 55 async open() { ··· 78 83 itemTags.createIndex('tagId', 'tagId', { unique: false }); 79 84 80 85 database.createObjectStore('settings', { keyPath: 'key' }); 86 + 87 + // extension_settings for profile-specific settings 88 + const extSettings = database.createObjectStore('extension_settings', { keyPath: 'id' }); 89 + extSettings.createIndex('extensionId', 'extensionId', { unique: false }); 81 90 } 82 91 83 92 if (oldVersion >= 1 && oldVersion < 2) { ··· 102 111 } 103 112 if (!database.objectStoreNames.contains('settings')) { 104 113 database.createObjectStore('settings', { keyPath: 'key' }); 114 + } 115 + } 116 + 117 + if (oldVersion >= 1 && oldVersion < 3) { 118 + // v2 → v3: add extension_settings store 119 + if (!database.objectStoreNames.contains('extension_settings')) { 120 + const extSettings = database.createObjectStore('extension_settings', { keyPath: 'id' }); 121 + extSettings.createIndex('extensionId', 'extensionId', { unique: false }); 105 122 } 106 123 } 107 124 };