experiments in a post-browser web
10
fork

Configure Feed

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

backend abstractionizing

+529 -358
+366 -1
backend/electron/datastore.ts
··· 6 6 */ 7 7 8 8 import Database from 'better-sqlite3'; 9 - import type { TableName } from '../types/index.js'; 9 + import type { 10 + TableName, 11 + Address, 12 + Visit, 13 + Content, 14 + Tag, 15 + AddressTag, 16 + DatastoreStats, 17 + AddressFilter, 18 + VisitFilter, 19 + ContentFilter, 20 + AddressOptions, 21 + VisitOptions, 22 + ContentOptions, 23 + } from '../types/index.js'; 10 24 import { tableNames } from '../types/index.js'; 11 25 12 26 // SQL Schema ··· 376 390 console.error('main', 'TinyBase migration failed:', (error as Error).message); 377 391 } 378 392 } 393 + 394 + // ==================== Address Operations ==================== 395 + 396 + export function addAddress(uri: string, options: AddressOptions = {}): { id: string } { 397 + const normalizedUri = normalizeUrl(uri); 398 + const parsed = parseUrl(normalizedUri); 399 + const addressId = generateId('addr'); 400 + const timestamp = now(); 401 + 402 + getDb().prepare(` 403 + INSERT INTO addresses (id, uri, protocol, domain, path, title, mimeType, favicon, description, tags, metadata, createdAt, updatedAt, lastVisitAt, visitCount, starred, archived) 404 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 405 + `).run( 406 + addressId, 407 + normalizedUri, 408 + options.protocol || parsed.protocol, 409 + options.domain || parsed.domain, 410 + options.path || parsed.path, 411 + options.title || '', 412 + options.mimeType || 'text/html', 413 + options.favicon || '', 414 + options.description || '', 415 + options.tags || '', 416 + options.metadata || '{}', 417 + timestamp, 418 + timestamp, 419 + options.lastVisitAt || 0, 420 + options.visitCount || 0, 421 + options.starred || 0, 422 + options.archived || 0 423 + ); 424 + 425 + return { id: addressId }; 426 + } 427 + 428 + export function getAddress(id: string): Address | undefined { 429 + return getDb().prepare('SELECT * FROM addresses WHERE id = ?').get(id) as Address | undefined; 430 + } 431 + 432 + export function updateAddress(id: string, updates: Partial<Address>): Address | undefined { 433 + const existing = getAddress(id); 434 + if (!existing) return undefined; 435 + 436 + const updated = { ...existing, ...updates, updatedAt: now() }; 437 + const columns = Object.keys(updated).filter(k => k !== 'id'); 438 + const setClause = columns.map(col => `${col} = ?`).join(', '); 439 + const values = columns.map(col => updated[col as keyof Address]); 440 + 441 + getDb().prepare(`UPDATE addresses SET ${setClause} WHERE id = ?`).run(...values, id); 442 + return updated as Address; 443 + } 444 + 445 + export function queryAddresses(filter: AddressFilter = {}): Address[] { 446 + let sql = 'SELECT * FROM addresses WHERE 1=1'; 447 + const params: (string | number)[] = []; 448 + 449 + if (filter.domain) { 450 + sql += ' AND domain = ?'; 451 + params.push(filter.domain); 452 + } 453 + if (filter.protocol) { 454 + sql += ' AND protocol = ?'; 455 + params.push(filter.protocol); 456 + } 457 + if (filter.starred !== undefined) { 458 + sql += ' AND starred = ?'; 459 + params.push(filter.starred); 460 + } 461 + if (filter.tag) { 462 + sql += ' AND tags LIKE ?'; 463 + params.push(`%${filter.tag}%`); 464 + } 465 + 466 + const sortMap: Record<string, string> = { 467 + lastVisit: 'lastVisitAt DESC', 468 + visitCount: 'visitCount DESC', 469 + created: 'createdAt DESC' 470 + }; 471 + sql += ` ORDER BY ${sortMap[filter.sortBy || ''] || 'updatedAt DESC'}`; 472 + 473 + if (filter.limit) { 474 + sql += ' LIMIT ?'; 475 + params.push(filter.limit); 476 + } 477 + 478 + return getDb().prepare(sql).all(...params) as Address[]; 479 + } 480 + 481 + // ==================== Visit Operations ==================== 482 + 483 + export function addVisit(addressId: string, options: VisitOptions = {}): { id: string } { 484 + const visitId = generateId('visit'); 485 + const timestamp = now(); 486 + 487 + getDb().prepare(` 488 + INSERT INTO visits (id, addressId, timestamp, duration, source, sourceId, windowType, metadata, scrollDepth, interacted) 489 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 490 + `).run( 491 + visitId, 492 + addressId, 493 + options.timestamp || timestamp, 494 + options.duration || 0, 495 + options.source || 'direct', 496 + options.sourceId || '', 497 + options.windowType || 'main', 498 + options.metadata || '{}', 499 + options.scrollDepth || 0, 500 + options.interacted || 0 501 + ); 502 + 503 + // Update address visit stats 504 + getDb().prepare(` 505 + UPDATE addresses SET lastVisitAt = ?, visitCount = visitCount + 1, updatedAt = ? 506 + WHERE id = ? 507 + `).run(timestamp, timestamp, addressId); 508 + 509 + return { id: visitId }; 510 + } 511 + 512 + export function queryVisits(filter: VisitFilter = {}): Visit[] { 513 + let sql = 'SELECT * FROM visits WHERE 1=1'; 514 + const params: (string | number)[] = []; 515 + 516 + if (filter.addressId) { 517 + sql += ' AND addressId = ?'; 518 + params.push(filter.addressId); 519 + } 520 + if (filter.source) { 521 + sql += ' AND source = ?'; 522 + params.push(filter.source); 523 + } 524 + if (filter.since) { 525 + sql += ' AND timestamp >= ?'; 526 + params.push(filter.since); 527 + } 528 + 529 + sql += ' ORDER BY timestamp DESC'; 530 + 531 + if (filter.limit) { 532 + sql += ' LIMIT ?'; 533 + params.push(filter.limit); 534 + } 535 + 536 + return getDb().prepare(sql).all(...params) as Visit[]; 537 + } 538 + 539 + // ==================== Content Operations ==================== 540 + 541 + export function addContent(options: ContentOptions = {}): { id: string } { 542 + const contentId = generateId('content'); 543 + const timestamp = now(); 544 + 545 + getDb().prepare(` 546 + INSERT INTO content (id, title, content, mimeType, contentType, language, encoding, tags, addressRefs, parentId, metadata, createdAt, updatedAt, syncPath, synced, starred, archived) 547 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 548 + `).run( 549 + contentId, 550 + options.title || 'Untitled', 551 + options.content || '', 552 + options.mimeType || 'text/plain', 553 + options.contentType || 'plain', 554 + options.language || '', 555 + options.encoding || 'utf-8', 556 + options.tags || '', 557 + options.addressRefs || '', 558 + options.parentId || '', 559 + options.metadata || '{}', 560 + timestamp, 561 + timestamp, 562 + options.syncPath || '', 563 + options.synced || 0, 564 + options.starred || 0, 565 + options.archived || 0 566 + ); 567 + 568 + return { id: contentId }; 569 + } 570 + 571 + export function queryContent(filter: ContentFilter = {}): Content[] { 572 + let sql = 'SELECT * FROM content WHERE 1=1'; 573 + const params: (string | number)[] = []; 574 + 575 + if (filter.contentType) { 576 + sql += ' AND contentType = ?'; 577 + params.push(filter.contentType); 578 + } 579 + if (filter.mimeType) { 580 + sql += ' AND mimeType = ?'; 581 + params.push(filter.mimeType); 582 + } 583 + if (filter.synced !== undefined) { 584 + sql += ' AND synced = ?'; 585 + params.push(filter.synced); 586 + } 587 + if (filter.starred !== undefined) { 588 + sql += ' AND starred = ?'; 589 + params.push(filter.starred); 590 + } 591 + if (filter.tag) { 592 + sql += ' AND tags LIKE ?'; 593 + params.push(`%${filter.tag}%`); 594 + } 595 + 596 + const sortMap: Record<string, string> = { 597 + updated: 'updatedAt DESC', 598 + created: 'createdAt DESC' 599 + }; 600 + sql += ` ORDER BY ${sortMap[filter.sortBy || ''] || 'updatedAt DESC'}`; 601 + 602 + if (filter.limit) { 603 + sql += ' LIMIT ?'; 604 + params.push(filter.limit); 605 + } 606 + 607 + return getDb().prepare(sql).all(...params) as Content[]; 608 + } 609 + 610 + // ==================== Tag Operations ==================== 611 + 612 + export function getOrCreateTag(name: string): { tag: Tag; created: boolean } { 613 + const slug = name.toLowerCase().trim().replace(/\s+/g, '-'); 614 + const timestamp = now(); 615 + 616 + const existingTag = getDb().prepare('SELECT * FROM tags WHERE LOWER(name) = LOWER(?)').get(name) as Tag | undefined; 617 + if (existingTag) { 618 + return { tag: existingTag, created: false }; 619 + } 620 + 621 + const tagId = generateId('tag'); 622 + getDb().prepare(` 623 + INSERT INTO tags (id, name, slug, color, parentId, description, metadata, createdAt, updatedAt, frequency, lastUsedAt, frecencyScore) 624 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 625 + `).run(tagId, name.trim(), slug, '#999999', '', '', '{}', timestamp, timestamp, 0, 0, 0); 626 + 627 + const newTag = getDb().prepare('SELECT * FROM tags WHERE id = ?').get(tagId) as Tag; 628 + return { tag: newTag, created: true }; 629 + } 630 + 631 + export function tagAddress(addressId: string, tagId: string): { link: AddressTag; alreadyExists: boolean } { 632 + const timestamp = now(); 633 + 634 + const existingLink = getDb().prepare('SELECT * FROM address_tags WHERE addressId = ? AND tagId = ?').get(addressId, tagId) as AddressTag | undefined; 635 + if (existingLink) { 636 + return { link: existingLink, alreadyExists: true }; 637 + } 638 + 639 + const linkId = generateId('address_tag'); 640 + getDb().prepare('INSERT INTO address_tags (id, addressId, tagId, createdAt) VALUES (?, ?, ?, ?)').run(linkId, addressId, tagId, timestamp); 641 + 642 + // Update tag frequency and frecency 643 + const tag = getDb().prepare('SELECT * FROM tags WHERE id = ?').get(tagId) as Tag | undefined; 644 + if (tag) { 645 + const newFrequency = (tag.frequency || 0) + 1; 646 + const frecencyScore = calculateFrecency(newFrequency, timestamp); 647 + getDb().prepare('UPDATE tags SET frequency = ?, lastUsedAt = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?') 648 + .run(newFrequency, timestamp, frecencyScore, timestamp, tagId); 649 + } 650 + 651 + const newLink = getDb().prepare('SELECT * FROM address_tags WHERE id = ?').get(linkId) as AddressTag; 652 + return { link: newLink, alreadyExists: false }; 653 + } 654 + 655 + export function untagAddress(addressId: string, tagId: string): boolean { 656 + const result = getDb().prepare('DELETE FROM address_tags WHERE addressId = ? AND tagId = ?').run(addressId, tagId); 657 + return result.changes > 0; 658 + } 659 + 660 + export function getTagsByFrecency(domain?: string): Tag[] { 661 + let tags = getDb().prepare('SELECT * FROM tags').all() as Tag[]; 662 + 663 + // Recalculate frecency scores 664 + tags = tags.map(tag => ({ 665 + ...tag, 666 + frecencyScore: calculateFrecency(tag.frequency || 0, tag.lastUsedAt || 0) 667 + })); 668 + 669 + // If domain provided, boost tags used on same-domain addresses 670 + if (domain) { 671 + const domainTagIds = new Set( 672 + (getDb().prepare(` 673 + SELECT DISTINCT at.tagId FROM address_tags at 674 + JOIN addresses a ON at.addressId = a.id 675 + WHERE a.domain = ? 676 + `).all(domain) as { tagId: string }[]).map(row => row.tagId) 677 + ); 678 + 679 + tags = tags.map(tag => ({ 680 + ...tag, 681 + frecencyScore: domainTagIds.has(tag.id) ? tag.frecencyScore * 2 : tag.frecencyScore 682 + })); 683 + } 684 + 685 + tags.sort((a, b) => b.frecencyScore - a.frecencyScore); 686 + return tags; 687 + } 688 + 689 + export function getAddressTags(addressId: string): Tag[] { 690 + return getDb().prepare(` 691 + SELECT t.* FROM tags t 692 + JOIN address_tags at ON t.id = at.tagId 693 + WHERE at.addressId = ? 694 + `).all(addressId) as Tag[]; 695 + } 696 + 697 + export function getAddressesByTag(tagId: string): Address[] { 698 + return getDb().prepare(` 699 + SELECT a.* FROM addresses a 700 + JOIN address_tags at ON a.id = at.addressId 701 + WHERE at.tagId = ? 702 + `).all(tagId) as Address[]; 703 + } 704 + 705 + export function getUntaggedAddresses(): Address[] { 706 + return getDb().prepare(` 707 + SELECT a.* FROM addresses a 708 + LEFT JOIN address_tags at ON a.id = at.addressId 709 + WHERE at.id IS NULL 710 + ORDER BY a.visitCount DESC 711 + `).all() as Address[]; 712 + } 713 + 714 + // ==================== Generic Table Operations ==================== 715 + 716 + export function getTable(tableName: TableName): Record<string, Record<string, unknown>> { 717 + const rows = getDb().prepare(`SELECT * FROM ${tableName}`).all() as Array<{ id: string } & Record<string, unknown>>; 718 + const table: Record<string, Record<string, unknown>> = {}; 719 + for (const row of rows) { 720 + table[row.id] = row; 721 + } 722 + return table; 723 + } 724 + 725 + export function setRow(tableName: TableName, rowId: string, rowData: Record<string, unknown>): void { 726 + const row: Record<string, unknown> = { id: rowId, ...rowData }; 727 + const columns = Object.keys(row); 728 + const placeholders = columns.map(() => '?').join(', '); 729 + const values = columns.map(col => row[col]); 730 + 731 + getDb().prepare(`INSERT OR REPLACE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`).run(...values); 732 + } 733 + 734 + export function getStats(): DatastoreStats { 735 + const d = getDb(); 736 + return { 737 + totalAddresses: (d.prepare('SELECT COUNT(*) as count FROM addresses').get() as { count: number }).count, 738 + totalVisits: (d.prepare('SELECT COUNT(*) as count FROM visits').get() as { count: number }).count, 739 + avgVisitDuration: (d.prepare('SELECT AVG(duration) as avg FROM visits').get() as { avg: number | null }).avg || 0, 740 + totalContent: (d.prepare('SELECT COUNT(*) as count FROM content').get() as { count: number }).count, 741 + syncedContent: (d.prepare('SELECT COUNT(*) as count FROM content WHERE synced = 1').get() as { count: number }).count 742 + }; 743 + }
+34 -1
backend/electron/index.ts
··· 4 4 * Exports database functions and types for the Electron main process. 5 5 */ 6 6 7 - // Database functions 7 + // Database lifecycle and helpers 8 8 export { 9 9 initDatabase, 10 10 closeDatabase, ··· 17 17 calculateFrecency, 18 18 } from './datastore.js'; 19 19 20 + // Datastore operations 21 + export { 22 + // Addresses 23 + addAddress, 24 + getAddress, 25 + updateAddress, 26 + queryAddresses, 27 + // Visits 28 + addVisit, 29 + queryVisits, 30 + // Content 31 + addContent, 32 + queryContent, 33 + // Tags 34 + getOrCreateTag, 35 + tagAddress, 36 + untagAddress, 37 + getTagsByFrecency, 38 + getAddressTags, 39 + getAddressesByTag, 40 + getUntaggedAddresses, 41 + // Generic 42 + getTable, 43 + setRow, 44 + getStats, 45 + } from './datastore.js'; 46 + 20 47 // Re-export shared data types 21 48 export type { 22 49 Address, ··· 28 55 ExtensionSetting, 29 56 DatastoreStats, 30 57 TableName, 58 + AddressFilter, 59 + VisitFilter, 60 + ContentFilter, 61 + AddressOptions, 62 + VisitOptions, 63 + ContentOptions, 31 64 } from '../types/index.js'; 32 65 33 66 export { tableNames } from '../types/index.js';
+72
backend/types/index.ts
··· 114 114 syncedContent: number; 115 115 } 116 116 117 + // ==================== Filter Types ==================== 118 + 119 + export interface AddressFilter { 120 + domain?: string; 121 + protocol?: string; 122 + starred?: number; 123 + tag?: string; 124 + sortBy?: 'lastVisit' | 'visitCount' | 'created'; 125 + limit?: number; 126 + } 127 + 128 + export interface VisitFilter { 129 + addressId?: string; 130 + source?: string; 131 + since?: number; 132 + limit?: number; 133 + } 134 + 135 + export interface ContentFilter { 136 + contentType?: string; 137 + mimeType?: string; 138 + synced?: number; 139 + starred?: number; 140 + tag?: string; 141 + sortBy?: 'updated' | 'created'; 142 + limit?: number; 143 + } 144 + 145 + export interface AddressOptions { 146 + protocol?: string; 147 + domain?: string; 148 + path?: string; 149 + title?: string; 150 + mimeType?: string; 151 + favicon?: string; 152 + description?: string; 153 + tags?: string; 154 + metadata?: string; 155 + lastVisitAt?: number; 156 + visitCount?: number; 157 + starred?: number; 158 + archived?: number; 159 + } 160 + 161 + export interface VisitOptions { 162 + timestamp?: number; 163 + duration?: number; 164 + source?: string; 165 + sourceId?: string; 166 + windowType?: string; 167 + metadata?: string; 168 + scrollDepth?: number; 169 + interacted?: number; 170 + } 171 + 172 + export interface ContentOptions { 173 + title?: string; 174 + content?: string; 175 + mimeType?: string; 176 + contentType?: string; 177 + language?: string; 178 + encoding?: string; 179 + tags?: string; 180 + addressRefs?: string; 181 + parentId?: string; 182 + metadata?: string; 183 + syncPath?: string; 184 + synced?: number; 185 + starred?: number; 186 + archived?: number; 187 + } 188 + 117 189 // ==================== Table Names ==================== 118 190 119 191 export type TableName =
+57 -356
index.js
··· 22 22 initDatabase, 23 23 closeDatabase, 24 24 getDb, 25 - generateId, 26 - now, 27 - parseUrl, 28 - normalizeUrl, 29 25 isValidTable, 30 - calculateFrecency, 26 + // Datastore operations 27 + addAddress, 28 + getAddress, 29 + updateAddress, 30 + queryAddresses, 31 + addVisit, 32 + queryVisits, 33 + addContent, 34 + queryContent, 35 + getOrCreateTag, 36 + tagAddress, 37 + untagAddress, 38 + getTagsByFrecency, 39 + getAddressTags, 40 + getAddressesByTag, 41 + getUntaggedAddresses, 42 + getTable, 43 + setRow, 44 + getStats, 31 45 } from './dist/backend/electron/index.js'; 32 46 import unhandled from 'electron-unhandled'; 33 47 ··· 102 116 103 117 console.log('PROFILE', PROFILE, app.isPackaged ? '(packaged)' : '(source)'); 104 118 119 + // Test profiles skip certain behaviors (devtools, dialogs, etc.) 120 + const isTestProfile = PROFILE.startsWith('test'); 121 + 105 122 // Profile dirs are subdir of userData dir 106 123 // ..................................... ↓ we set this per profile 107 124 // ··· 155 172 }; 156 173 157 174 // ***** System / OS / Theme ***** 175 + 176 + // Use system theme by default 177 + nativeTheme.themeSource = 'system'; 158 178 159 179 // system dark mode handling 160 180 ipcMain.handle('dark-mode:toggle', () => { ··· 897 917 const win = new BrowserWindow(winPrefs); 898 918 win.loadURL(webCoreAddress); 899 919 900 - // Setup devtools for the background window (always open in debug mode) 901 - if (DEBUG) { 920 + // Setup devtools for the background window (debug mode, but not in tests) 921 + if (DEBUG && !isTestProfile) { 902 922 win.webContents.openDevTools({ mode: 'detach', activate: false }); 903 923 } 904 924 ··· 1535 1555 ipcMain.handle('datastore-add-address', async (ev, data) => { 1536 1556 try { 1537 1557 const { uri, options = {} } = data; 1538 - const normalizedUri = normalizeUrl(uri); 1539 - const parsed = parseUrl(normalizedUri); 1540 - const addressId = generateId('addr'); 1541 - const timestamp = now(); 1542 - const db = getDb(); 1543 - 1544 - const stmt = db.prepare(` 1545 - INSERT INTO addresses (id, uri, protocol, domain, path, title, mimeType, favicon, description, tags, metadata, createdAt, updatedAt, lastVisitAt, visitCount, starred, archived) 1546 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 1547 - `); 1548 - 1549 - stmt.run( 1550 - addressId, 1551 - normalizedUri, 1552 - options.protocol || parsed.protocol, 1553 - options.domain || parsed.domain, 1554 - options.path || parsed.path, 1555 - options.title || '', 1556 - options.mimeType || 'text/html', 1557 - options.favicon || '', 1558 - options.description || '', 1559 - options.tags || '', 1560 - options.metadata || '{}', 1561 - timestamp, 1562 - timestamp, 1563 - options.lastVisitAt || 0, 1564 - options.visitCount || 0, 1565 - options.starred || 0, 1566 - options.archived || 0 1567 - ); 1568 - 1569 - return { success: true, id: addressId }; 1558 + const result = addAddress(uri, options); 1559 + return { success: true, id: result.id }; 1570 1560 } catch (error) { 1571 1561 console.error('datastore-add-address error:', error); 1572 1562 return { success: false, error: error.message }; ··· 1576 1566 ipcMain.handle('datastore-get-address', async (ev, data) => { 1577 1567 try { 1578 1568 const { id } = data; 1579 - const db = getDb(); 1580 - const row = db.prepare('SELECT * FROM addresses WHERE id = ?').get(id); 1569 + const row = getAddress(id); 1581 1570 return { success: true, data: row || {} }; 1582 1571 } catch (error) { 1583 1572 console.error('datastore-get-address error:', error); ··· 1588 1577 ipcMain.handle('datastore-update-address', async (ev, data) => { 1589 1578 try { 1590 1579 const { id, updates } = data; 1591 - const db = getDb(); 1592 - const existing = db.prepare('SELECT * FROM addresses WHERE id = ?').get(id); 1593 - if (!existing) { 1580 + const updated = updateAddress(id, updates); 1581 + if (!updated) { 1594 1582 return { success: false, error: 'Address not found' }; 1595 1583 } 1596 - 1597 - const updated = { ...existing, ...updates, updatedAt: now() }; 1598 - const columns = Object.keys(updated).filter(k => k !== 'id'); 1599 - const setClause = columns.map(col => `${col} = ?`).join(', '); 1600 - const values = columns.map(col => updated[col]); 1601 - 1602 - db.prepare(`UPDATE addresses SET ${setClause} WHERE id = ?`).run(...values, id); 1603 - return { success: true, data: { id, ...updated } }; 1584 + return { success: true, data: updated }; 1604 1585 } catch (error) { 1605 1586 console.error('datastore-update-address error:', error); 1606 1587 return { success: false, error: error.message }; ··· 1610 1591 ipcMain.handle('datastore-query-addresses', async (ev, data) => { 1611 1592 try { 1612 1593 const { filter = {} } = data; 1613 - const db = getDb(); 1614 - 1615 - let sql = 'SELECT * FROM addresses WHERE 1=1'; 1616 - const params = []; 1617 - 1618 - if (filter.domain) { 1619 - sql += ' AND domain = ?'; 1620 - params.push(filter.domain); 1621 - } 1622 - if (filter.protocol) { 1623 - sql += ' AND protocol = ?'; 1624 - params.push(filter.protocol); 1625 - } 1626 - if (filter.starred !== undefined) { 1627 - sql += ' AND starred = ?'; 1628 - params.push(filter.starred); 1629 - } 1630 - if (filter.tag) { 1631 - sql += ' AND tags LIKE ?'; 1632 - params.push(`%${filter.tag}%`); 1633 - } 1634 - 1635 - // Sort 1636 - const sortMap = { 1637 - lastVisit: 'lastVisitAt DESC', 1638 - visitCount: 'visitCount DESC', 1639 - created: 'createdAt DESC' 1640 - }; 1641 - sql += ` ORDER BY ${sortMap[filter.sortBy] || 'updatedAt DESC'}`; 1642 - 1643 - // Limit 1644 - if (filter.limit) { 1645 - sql += ' LIMIT ?'; 1646 - params.push(filter.limit); 1647 - } 1648 - 1649 - const results = db.prepare(sql).all(...params); 1594 + const results = queryAddresses(filter); 1650 1595 return { success: true, data: results }; 1651 1596 } catch (error) { 1652 1597 console.error('datastore-query-addresses error:', error); ··· 1657 1602 ipcMain.handle('datastore-add-visit', async (ev, data) => { 1658 1603 try { 1659 1604 const { addressId, options = {} } = data; 1660 - const visitId = generateId('visit'); 1661 - const timestamp = now(); 1662 - const db = getDb(); 1663 - 1664 - db.prepare(` 1665 - INSERT INTO visits (id, addressId, timestamp, duration, source, sourceId, windowType, metadata, scrollDepth, interacted) 1666 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 1667 - `).run( 1668 - visitId, 1669 - addressId, 1670 - options.timestamp || timestamp, 1671 - options.duration || 0, 1672 - options.source || 'direct', 1673 - options.sourceId || '', 1674 - options.windowType || 'main', 1675 - options.metadata || '{}', 1676 - options.scrollDepth || 0, 1677 - options.interacted || 0 1678 - ); 1679 - 1680 - // Update address visit stats 1681 - db.prepare(` 1682 - UPDATE addresses SET lastVisitAt = ?, visitCount = visitCount + 1, updatedAt = ? 1683 - WHERE id = ? 1684 - `).run(timestamp, timestamp, addressId); 1685 - 1686 - return { success: true, id: visitId }; 1605 + const result = addVisit(addressId, options); 1606 + return { success: true, id: result.id }; 1687 1607 } catch (error) { 1688 1608 console.error('datastore-add-visit error:', error); 1689 1609 return { success: false, error: error.message }; ··· 1693 1613 ipcMain.handle('datastore-query-visits', async (ev, data) => { 1694 1614 try { 1695 1615 const { filter = {} } = data; 1696 - const db = getDb(); 1697 - 1698 - let sql = 'SELECT * FROM visits WHERE 1=1'; 1699 - const params = []; 1700 - 1701 - if (filter.addressId) { 1702 - sql += ' AND addressId = ?'; 1703 - params.push(filter.addressId); 1704 - } 1705 - if (filter.source) { 1706 - sql += ' AND source = ?'; 1707 - params.push(filter.source); 1708 - } 1709 - if (filter.since) { 1710 - const since = typeof filter.since === 'number' ? filter.since : now() - filter.since; 1711 - sql += ' AND timestamp >= ?'; 1712 - params.push(since); 1713 - } 1714 - 1715 - sql += ' ORDER BY timestamp DESC'; 1716 - 1717 - if (filter.limit) { 1718 - sql += ' LIMIT ?'; 1719 - params.push(filter.limit); 1720 - } 1721 - 1722 - const results = db.prepare(sql).all(...params); 1616 + const results = queryVisits(filter); 1723 1617 return { success: true, data: results }; 1724 1618 } catch (error) { 1725 1619 console.error('datastore-query-visits error:', error); ··· 1730 1624 ipcMain.handle('datastore-add-content', async (ev, data) => { 1731 1625 try { 1732 1626 const { options = {} } = data; 1733 - const contentId = generateId('content'); 1734 - const timestamp = now(); 1735 - const db = getDb(); 1736 - 1737 - db.prepare(` 1738 - INSERT INTO content (id, title, content, mimeType, contentType, language, encoding, tags, addressRefs, parentId, metadata, createdAt, updatedAt, syncPath, synced, starred, archived) 1739 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 1740 - `).run( 1741 - contentId, 1742 - options.title || 'Untitled', 1743 - options.content || '', 1744 - options.mimeType || 'text/plain', 1745 - options.contentType || 'plain', 1746 - options.language || '', 1747 - options.encoding || 'utf-8', 1748 - options.tags || '', 1749 - options.addressRefs || '', 1750 - options.parentId || '', 1751 - options.metadata || '{}', 1752 - timestamp, 1753 - timestamp, 1754 - options.syncPath || '', 1755 - options.synced || 0, 1756 - options.starred || 0, 1757 - options.archived || 0 1758 - ); 1759 - return { success: true, id: contentId }; 1627 + const result = addContent(options); 1628 + return { success: true, id: result.id }; 1760 1629 } catch (error) { 1761 1630 console.error('datastore-add-content error:', error); 1762 1631 return { success: false, error: error.message }; ··· 1766 1635 ipcMain.handle('datastore-query-content', async (ev, data) => { 1767 1636 try { 1768 1637 const { filter = {} } = data; 1769 - const db = getDb(); 1770 - 1771 - let sql = 'SELECT * FROM content WHERE 1=1'; 1772 - const params = []; 1773 - 1774 - if (filter.contentType) { 1775 - sql += ' AND contentType = ?'; 1776 - params.push(filter.contentType); 1777 - } 1778 - if (filter.mimeType) { 1779 - sql += ' AND mimeType = ?'; 1780 - params.push(filter.mimeType); 1781 - } 1782 - if (filter.synced !== undefined) { 1783 - sql += ' AND synced = ?'; 1784 - params.push(filter.synced); 1785 - } 1786 - if (filter.starred !== undefined) { 1787 - sql += ' AND starred = ?'; 1788 - params.push(filter.starred); 1789 - } 1790 - if (filter.tag) { 1791 - sql += ' AND tags LIKE ?'; 1792 - params.push(`%${filter.tag}%`); 1793 - } 1794 - 1795 - // Sort 1796 - const sortMap = { 1797 - updated: 'updatedAt DESC', 1798 - created: 'createdAt DESC' 1799 - }; 1800 - sql += ` ORDER BY ${sortMap[filter.sortBy] || 'updatedAt DESC'}`; 1801 - 1802 - if (filter.limit) { 1803 - sql += ' LIMIT ?'; 1804 - params.push(filter.limit); 1805 - } 1806 - 1807 - const results = db.prepare(sql).all(...params); 1638 + const results = queryContent(filter); 1808 1639 return { success: true, data: results }; 1809 1640 } catch (error) { 1810 1641 console.error('datastore-query-content error:', error); ··· 1815 1646 ipcMain.handle('datastore-get-table', async (ev, data) => { 1816 1647 try { 1817 1648 const { tableName } = data; 1818 - const db = getDb(); 1819 - // Validate table name against known tables 1820 - const validTables = ['addresses', 'visits', 'content', 'tags', 'address_tags', 'blobs', 'scripts_data', 'feeds', 'extensions', 'extension_settings']; 1821 - if (!validTables.includes(tableName)) { 1649 + if (!isValidTable(tableName)) { 1822 1650 return { success: false, error: `Invalid table name: ${tableName}` }; 1823 1651 } 1824 - const rows = db.prepare(`SELECT * FROM ${tableName}`).all(); 1825 - // Convert to object keyed by id for compatibility 1826 - const table = {}; 1827 - for (const row of rows) { 1828 - table[row.id] = row; 1829 - } 1652 + const table = getTable(tableName); 1830 1653 return { success: true, data: table }; 1831 1654 } catch (error) { 1832 1655 console.error('datastore-get-table error:', error); ··· 1837 1660 ipcMain.handle('datastore-set-row', async (ev, data) => { 1838 1661 try { 1839 1662 const { tableName, rowId, rowData } = data; 1840 - const db = getDb(); 1841 - // Validate table name against known tables 1842 - const validTables = ['addresses', 'visits', 'content', 'tags', 'address_tags', 'blobs', 'scripts_data', 'feeds', 'extensions', 'extension_settings']; 1843 - if (!validTables.includes(tableName)) { 1663 + if (!isValidTable(tableName)) { 1844 1664 return { success: false, error: `Invalid table name: ${tableName}` }; 1845 1665 } 1846 - const row = { id: rowId, ...rowData }; 1847 - const columns = Object.keys(row); 1848 - const placeholders = columns.map(() => '?').join(', '); 1849 - const values = columns.map(col => row[col]); 1850 - 1851 - db.prepare(`INSERT OR REPLACE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`).run(...values); 1666 + setRow(tableName, rowId, rowData); 1852 1667 return { success: true }; 1853 1668 } catch (error) { 1854 1669 console.error('datastore-set-row error:', error); ··· 1858 1673 1859 1674 ipcMain.handle('datastore-get-stats', async () => { 1860 1675 try { 1861 - const db = getDb(); 1862 - const stats = { 1863 - totalAddresses: db.prepare('SELECT COUNT(*) as count FROM addresses').get().count, 1864 - totalVisits: db.prepare('SELECT COUNT(*) as count FROM visits').get().count, 1865 - avgVisitDuration: db.prepare('SELECT AVG(duration) as avg FROM visits').get().avg || 0, 1866 - totalContent: db.prepare('SELECT COUNT(*) as count FROM content').get().count, 1867 - syncedContent: db.prepare('SELECT COUNT(*) as count FROM content WHERE synced = 1').get().count 1868 - }; 1676 + const stats = getStats(); 1869 1677 return { success: true, data: stats }; 1870 1678 } catch (error) { 1871 1679 console.error('datastore-get-stats error:', error); ··· 1875 1683 1876 1684 // ***** Tag IPC Handlers ***** 1877 1685 1878 - // Get or create a tag by name 1879 1686 ipcMain.handle('datastore-get-or-create-tag', async (ev, data) => { 1880 1687 try { 1881 1688 const { name } = data; 1882 - console.log('datastore-get-or-create-tag:', name); 1883 - const slug = name.toLowerCase().trim().replace(/\s+/g, '-'); 1884 - const timestamp = now(); 1885 - const db = getDb(); 1886 - 1887 - // Look for existing tag by name (case-insensitive) 1888 - const existingTag = db.prepare('SELECT * FROM tags WHERE LOWER(name) = LOWER(?)').get(name); 1889 - 1890 - if (existingTag) { 1891 - console.log(' -> found existing tag:', existingTag.id); 1892 - return { success: true, data: existingTag, created: false }; 1893 - } 1894 - 1895 - // Create new tag 1896 - const tagId = generateId('tag'); 1897 - db.prepare(` 1898 - INSERT INTO tags (id, name, slug, color, parentId, description, metadata, createdAt, updatedAt, frequency, lastUsedAt, frecencyScore) 1899 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 1900 - `).run(tagId, name.trim(), slug, '#999999', '', '', '{}', timestamp, timestamp, 0, 0, 0); 1901 - 1902 - const newTag = db.prepare('SELECT * FROM tags WHERE id = ?').get(tagId); 1903 - console.log(' -> created new tag:', tagId); 1904 - return { success: true, data: newTag, created: true }; 1689 + const result = getOrCreateTag(name); 1690 + return { success: true, data: result.tag, created: result.created }; 1905 1691 } catch (error) { 1906 1692 console.error('datastore-get-or-create-tag error:', error); 1907 1693 return { success: false, error: error.message }; 1908 1694 } 1909 1695 }); 1910 1696 1911 - // Tag an address (create the link and update frecency) 1912 1697 ipcMain.handle('datastore-tag-address', async (ev, data) => { 1913 1698 try { 1914 1699 const { addressId, tagId } = data; 1915 - console.log('datastore-tag-address:', { addressId, tagId }); 1916 - const timestamp = now(); 1917 - const db = getDb(); 1918 - 1919 - // Check if link already exists 1920 - const existingLink = db.prepare('SELECT * FROM address_tags WHERE addressId = ? AND tagId = ?').get(addressId, tagId); 1921 - if (existingLink) { 1922 - return { success: true, data: existingLink, alreadyExists: true }; 1923 - } 1924 - 1925 - // Create the link 1926 - const linkId = generateId('address_tag'); 1927 - db.prepare('INSERT INTO address_tags (id, addressId, tagId, createdAt) VALUES (?, ?, ?, ?)').run(linkId, addressId, tagId, timestamp); 1928 - 1929 - // Update tag frequency and frecency 1930 - const tag = db.prepare('SELECT * FROM tags WHERE id = ?').get(tagId); 1931 - if (tag) { 1932 - const newFrequency = (tag.frequency || 0) + 1; 1933 - const frecencyScore = calculateFrecency(newFrequency, timestamp); 1934 - db.prepare('UPDATE tags SET frequency = ?, lastUsedAt = ?, frecencyScore = ?, updatedAt = ? WHERE id = ?') 1935 - .run(newFrequency, timestamp, frecencyScore, timestamp, tagId); 1936 - } 1937 - 1938 - const newLink = db.prepare('SELECT * FROM address_tags WHERE id = ?').get(linkId); 1939 - return { success: true, data: newLink }; 1700 + const result = tagAddress(addressId, tagId); 1701 + return { success: true, data: result.link, alreadyExists: result.alreadyExists }; 1940 1702 } catch (error) { 1941 1703 console.error('datastore-tag-address error:', error); 1942 1704 return { success: false, error: error.message }; 1943 1705 } 1944 1706 }); 1945 1707 1946 - // Untag an address (remove the link) 1947 1708 ipcMain.handle('datastore-untag-address', async (ev, data) => { 1948 1709 try { 1949 1710 const { addressId, tagId } = data; 1950 - const db = getDb(); 1951 - 1952 - const result = db.prepare('DELETE FROM address_tags WHERE addressId = ? AND tagId = ?').run(addressId, tagId); 1953 - return { success: true, removed: result.changes > 0 }; 1711 + const removed = untagAddress(addressId, tagId); 1712 + return { success: true, removed }; 1954 1713 } catch (error) { 1955 1714 console.error('datastore-untag-address error:', error); 1956 1715 return { success: false, error: error.message }; 1957 1716 } 1958 1717 }); 1959 1718 1960 - // Get all tags sorted by frecency 1961 1719 ipcMain.handle('datastore-get-tags-by-frecency', async (ev, data = {}) => { 1962 1720 try { 1963 1721 const { domain } = data || {}; 1964 - const db = getDb(); 1965 - let tags = db.prepare('SELECT * FROM tags').all(); 1966 - console.log('datastore-get-tags-by-frecency: tags table has', tags.length, 'tags'); 1967 - 1968 - // Recalculate frecency scores (they decay over time) 1969 - tags = tags.map(tag => ({ 1970 - ...tag, 1971 - frecencyScore: calculateFrecency(tag.frequency || 0, tag.lastUsedAt || 0) 1972 - })); 1973 - 1974 - // If domain provided, boost tags used on same-domain addresses 1975 - if (domain) { 1976 - // Find tag IDs used on addresses with matching domain using JOIN 1977 - const domainTagIds = new Set( 1978 - db.prepare(` 1979 - SELECT DISTINCT at.tagId FROM address_tags at 1980 - JOIN addresses a ON at.addressId = a.id 1981 - WHERE a.domain = ? 1982 - `).all(domain).map(row => row.tagId) 1983 - ); 1984 - 1985 - // Apply 2x boost 1986 - tags = tags.map(tag => ({ 1987 - ...tag, 1988 - frecencyScore: domainTagIds.has(tag.id) ? tag.frecencyScore * 2 : tag.frecencyScore 1989 - })); 1990 - } 1991 - 1992 - // Sort by frecency descending 1993 - tags.sort((a, b) => b.frecencyScore - a.frecencyScore); 1994 - 1722 + const tags = getTagsByFrecency(domain); 1995 1723 return { success: true, data: tags }; 1996 1724 } catch (error) { 1997 1725 console.error('datastore-get-tags-by-frecency error:', error); ··· 1999 1727 } 2000 1728 }); 2001 1729 2002 - // Get tags for a specific address 2003 1730 ipcMain.handle('datastore-get-address-tags', async (ev, data) => { 2004 1731 try { 2005 1732 const { addressId } = data; 2006 - const db = getDb(); 2007 - 2008 - // Use JOIN to get tags directly 2009 - const tags = db.prepare(` 2010 - SELECT t.* FROM tags t 2011 - JOIN address_tags at ON t.id = at.tagId 2012 - WHERE at.addressId = ? 2013 - `).all(addressId); 2014 - 1733 + const tags = getAddressTags(addressId); 2015 1734 return { success: true, data: tags }; 2016 1735 } catch (error) { 2017 1736 console.error('datastore-get-address-tags error:', error); ··· 2019 1738 } 2020 1739 }); 2021 1740 2022 - // Get addresses with a specific tag 2023 1741 ipcMain.handle('datastore-get-addresses-by-tag', async (ev, data) => { 2024 1742 try { 2025 1743 const { tagId } = data; 2026 - const db = getDb(); 2027 - 2028 - // Use JOIN to get addresses directly 2029 - const addresses = db.prepare(` 2030 - SELECT a.* FROM addresses a 2031 - JOIN address_tags at ON a.id = at.addressId 2032 - WHERE at.tagId = ? 2033 - `).all(tagId); 2034 - 1744 + const addresses = getAddressesByTag(tagId); 2035 1745 return { success: true, data: addresses }; 2036 1746 } catch (error) { 2037 1747 console.error('datastore-get-addresses-by-tag error:', error); ··· 2039 1749 } 2040 1750 }); 2041 1751 2042 - // Get addresses that have no tags 2043 1752 ipcMain.handle('datastore-get-untagged-addresses', async (ev, data) => { 2044 1753 try { 2045 - const db = getDb(); 2046 - // Use LEFT JOIN + NULL check to find untagged addresses 2047 - const addresses = db.prepare(` 2048 - SELECT a.* FROM addresses a 2049 - LEFT JOIN address_tags at ON a.id = at.addressId 2050 - WHERE at.id IS NULL 2051 - ORDER BY a.visitCount DESC 2052 - `).all(); 2053 - 1754 + const addresses = getUntaggedAddresses(); 2054 1755 return { success: true, data: addresses }; 2055 1756 } catch (error) { 2056 1757 console.error('datastore-get-untagged-addresses error:', error); ··· 2784 2485 2785 2486 console.log('winDevtoolsConfig:', bw.id, 'openDevTools:', params.openDevTools, 'address:', params.address); 2786 2487 2787 - // Check if devTools should be opened 2788 - if (params.openDevTools === true) { 2488 + // Check if devTools should be opened (never in test profiles) 2489 + if (params.openDevTools === true && !isTestProfile) { 2789 2490 const isDetached = params.detachedDevTools === true; 2790 2491 // Determine if detached mode should be used 2791 2492 // activate: false prevents devtools from stealing focus (only works with detach/undocked)