···99app/dist/
1010dist/
11111212+# Test output
1313+test-results/
1414+playwright-report/
1515+1216# Claude Code local settings
1317.claude/
1418CLAUDE.md
+378
backend/electron/datastore.ts
···11+/**
22+ * Electron backend - SQLite datastore
33+ *
44+ * Simple module with database functions for Electron's main process.
55+ * Uses better-sqlite3 for synchronous SQLite access.
66+ */
77+88+import Database from 'better-sqlite3';
99+import type { TableName } from '../types/index.js';
1010+import { tableNames } from '../types/index.js';
1111+1212+// SQL Schema
1313+const createTableStatements = `
1414+ CREATE TABLE IF NOT EXISTS addresses (
1515+ id TEXT PRIMARY KEY,
1616+ uri TEXT NOT NULL,
1717+ protocol TEXT DEFAULT 'https',
1818+ domain TEXT,
1919+ path TEXT DEFAULT '',
2020+ title TEXT DEFAULT '',
2121+ mimeType TEXT DEFAULT 'text/html',
2222+ favicon TEXT DEFAULT '',
2323+ description TEXT DEFAULT '',
2424+ tags TEXT DEFAULT '',
2525+ metadata TEXT DEFAULT '{}',
2626+ createdAt INTEGER,
2727+ updatedAt INTEGER,
2828+ lastVisitAt INTEGER DEFAULT 0,
2929+ visitCount INTEGER DEFAULT 0,
3030+ starred INTEGER DEFAULT 0,
3131+ archived INTEGER DEFAULT 0
3232+ );
3333+ CREATE INDEX IF NOT EXISTS idx_addresses_uri ON addresses(uri);
3434+ CREATE INDEX IF NOT EXISTS idx_addresses_domain ON addresses(domain);
3535+ CREATE INDEX IF NOT EXISTS idx_addresses_protocol ON addresses(protocol);
3636+ CREATE INDEX IF NOT EXISTS idx_addresses_lastVisitAt ON addresses(lastVisitAt);
3737+ CREATE INDEX IF NOT EXISTS idx_addresses_visitCount ON addresses(visitCount);
3838+ CREATE INDEX IF NOT EXISTS idx_addresses_starred ON addresses(starred);
3939+4040+ CREATE TABLE IF NOT EXISTS visits (
4141+ id TEXT PRIMARY KEY,
4242+ addressId TEXT,
4343+ timestamp INTEGER,
4444+ duration INTEGER DEFAULT 0,
4545+ source TEXT DEFAULT 'direct',
4646+ sourceId TEXT DEFAULT '',
4747+ windowType TEXT DEFAULT 'main',
4848+ metadata TEXT DEFAULT '{}',
4949+ scrollDepth INTEGER DEFAULT 0,
5050+ interacted INTEGER DEFAULT 0
5151+ );
5252+ CREATE INDEX IF NOT EXISTS idx_visits_addressId ON visits(addressId);
5353+ CREATE INDEX IF NOT EXISTS idx_visits_timestamp ON visits(timestamp);
5454+ CREATE INDEX IF NOT EXISTS idx_visits_source ON visits(source);
5555+5656+ CREATE TABLE IF NOT EXISTS content (
5757+ id TEXT PRIMARY KEY,
5858+ title TEXT DEFAULT 'Untitled',
5959+ content TEXT DEFAULT '',
6060+ mimeType TEXT DEFAULT 'text/plain',
6161+ contentType TEXT DEFAULT 'plain',
6262+ language TEXT DEFAULT '',
6363+ encoding TEXT DEFAULT 'utf-8',
6464+ tags TEXT DEFAULT '',
6565+ addressRefs TEXT DEFAULT '',
6666+ parentId TEXT DEFAULT '',
6767+ metadata TEXT DEFAULT '{}',
6868+ createdAt INTEGER,
6969+ updatedAt INTEGER,
7070+ syncPath TEXT DEFAULT '',
7171+ synced INTEGER DEFAULT 0,
7272+ starred INTEGER DEFAULT 0,
7373+ archived INTEGER DEFAULT 0
7474+ );
7575+ CREATE INDEX IF NOT EXISTS idx_content_contentType ON content(contentType);
7676+ CREATE INDEX IF NOT EXISTS idx_content_mimeType ON content(mimeType);
7777+ CREATE INDEX IF NOT EXISTS idx_content_synced ON content(synced);
7878+ CREATE INDEX IF NOT EXISTS idx_content_updatedAt ON content(updatedAt);
7979+8080+ CREATE TABLE IF NOT EXISTS tags (
8181+ id TEXT PRIMARY KEY,
8282+ name TEXT NOT NULL,
8383+ slug TEXT,
8484+ color TEXT DEFAULT '#999999',
8585+ parentId TEXT DEFAULT '',
8686+ description TEXT DEFAULT '',
8787+ metadata TEXT DEFAULT '{}',
8888+ createdAt INTEGER,
8989+ updatedAt INTEGER,
9090+ frequency INTEGER DEFAULT 0,
9191+ lastUsedAt INTEGER DEFAULT 0,
9292+ frecencyScore INTEGER DEFAULT 0
9393+ );
9494+ CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
9595+ CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug);
9696+ CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId);
9797+ CREATE INDEX IF NOT EXISTS idx_tags_frecencyScore ON tags(frecencyScore);
9898+9999+ CREATE TABLE IF NOT EXISTS address_tags (
100100+ id TEXT PRIMARY KEY,
101101+ addressId TEXT NOT NULL,
102102+ tagId TEXT NOT NULL,
103103+ createdAt INTEGER
104104+ );
105105+ CREATE INDEX IF NOT EXISTS idx_address_tags_addressId ON address_tags(addressId);
106106+ CREATE INDEX IF NOT EXISTS idx_address_tags_tagId ON address_tags(tagId);
107107+ CREATE UNIQUE INDEX IF NOT EXISTS idx_address_tags_unique ON address_tags(addressId, tagId);
108108+109109+ CREATE TABLE IF NOT EXISTS blobs (
110110+ id TEXT PRIMARY KEY,
111111+ filename TEXT,
112112+ mimeType TEXT,
113113+ mediaType TEXT,
114114+ size INTEGER,
115115+ hash TEXT,
116116+ extension TEXT,
117117+ path TEXT,
118118+ addressId TEXT DEFAULT '',
119119+ contentId TEXT DEFAULT '',
120120+ tags TEXT DEFAULT '',
121121+ metadata TEXT DEFAULT '{}',
122122+ createdAt INTEGER,
123123+ width INTEGER DEFAULT 0,
124124+ height INTEGER DEFAULT 0,
125125+ duration INTEGER DEFAULT 0,
126126+ thumbnail TEXT DEFAULT ''
127127+ );
128128+ CREATE INDEX IF NOT EXISTS idx_blobs_mediaType ON blobs(mediaType);
129129+ CREATE INDEX IF NOT EXISTS idx_blobs_mimeType ON blobs(mimeType);
130130+ CREATE INDEX IF NOT EXISTS idx_blobs_addressId ON blobs(addressId);
131131+ CREATE INDEX IF NOT EXISTS idx_blobs_contentId ON blobs(contentId);
132132+133133+ CREATE TABLE IF NOT EXISTS scripts_data (
134134+ id TEXT PRIMARY KEY,
135135+ scriptId TEXT,
136136+ scriptName TEXT,
137137+ addressId TEXT,
138138+ selector TEXT,
139139+ content TEXT,
140140+ contentType TEXT DEFAULT 'text',
141141+ metadata TEXT DEFAULT '{}',
142142+ extractedAt INTEGER,
143143+ previousValue TEXT DEFAULT '',
144144+ changed INTEGER DEFAULT 0
145145+ );
146146+ CREATE INDEX IF NOT EXISTS idx_scripts_data_scriptId ON scripts_data(scriptId);
147147+ CREATE INDEX IF NOT EXISTS idx_scripts_data_addressId ON scripts_data(addressId);
148148+ CREATE INDEX IF NOT EXISTS idx_scripts_data_changed ON scripts_data(changed);
149149+150150+ CREATE TABLE IF NOT EXISTS feeds (
151151+ id TEXT PRIMARY KEY,
152152+ name TEXT,
153153+ description TEXT DEFAULT '',
154154+ type TEXT,
155155+ query TEXT DEFAULT '',
156156+ schedule TEXT DEFAULT '',
157157+ source TEXT DEFAULT 'internal',
158158+ tags TEXT DEFAULT '',
159159+ metadata TEXT DEFAULT '{}',
160160+ createdAt INTEGER,
161161+ updatedAt INTEGER,
162162+ lastFetchedAt INTEGER DEFAULT 0,
163163+ enabled INTEGER DEFAULT 1
164164+ );
165165+ CREATE INDEX IF NOT EXISTS idx_feeds_type ON feeds(type);
166166+ CREATE INDEX IF NOT EXISTS idx_feeds_enabled ON feeds(enabled);
167167+168168+ CREATE TABLE IF NOT EXISTS extensions (
169169+ id TEXT PRIMARY KEY,
170170+ name TEXT,
171171+ description TEXT DEFAULT '',
172172+ version TEXT DEFAULT '1.0.0',
173173+ path TEXT,
174174+ backgroundUrl TEXT DEFAULT '',
175175+ settingsUrl TEXT DEFAULT '',
176176+ iconPath TEXT DEFAULT '',
177177+ builtin INTEGER DEFAULT 0,
178178+ enabled INTEGER DEFAULT 1,
179179+ status TEXT DEFAULT 'installed',
180180+ installedAt INTEGER,
181181+ updatedAt INTEGER,
182182+ lastErrorAt INTEGER DEFAULT 0,
183183+ lastError TEXT DEFAULT '',
184184+ metadata TEXT DEFAULT '{}'
185185+ );
186186+ CREATE INDEX IF NOT EXISTS idx_extensions_enabled ON extensions(enabled);
187187+ CREATE INDEX IF NOT EXISTS idx_extensions_status ON extensions(status);
188188+ CREATE INDEX IF NOT EXISTS idx_extensions_builtin ON extensions(builtin);
189189+190190+ CREATE TABLE IF NOT EXISTS extension_settings (
191191+ id TEXT PRIMARY KEY,
192192+ extensionId TEXT NOT NULL,
193193+ key TEXT NOT NULL,
194194+ value TEXT,
195195+ updatedAt INTEGER
196196+ );
197197+ CREATE INDEX IF NOT EXISTS idx_extension_settings_extensionId ON extension_settings(extensionId);
198198+ CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique ON extension_settings(extensionId, key);
199199+`;
200200+201201+// Module state
202202+let db: Database.Database | null = null;
203203+204204+// ==================== Lifecycle ====================
205205+206206+export function initDatabase(dbPath: string): Database.Database {
207207+ console.log('main', 'initializing database at:', dbPath);
208208+209209+ db = new Database(dbPath);
210210+ db.pragma('journal_mode = WAL');
211211+ db.exec(createTableStatements);
212212+213213+ migrateTinyBaseData();
214214+215215+ console.log('main', 'database initialized successfully');
216216+ return db;
217217+}
218218+219219+export function closeDatabase(): void {
220220+ if (db) {
221221+ db.close();
222222+ db = null;
223223+ console.log('main', 'database closed');
224224+ }
225225+}
226226+227227+export function getDb(): Database.Database {
228228+ if (!db) {
229229+ throw new Error('Database not initialized');
230230+ }
231231+ return db;
232232+}
233233+234234+// ==================== Helpers ====================
235235+236236+export function generateId(prefix = 'id'): string {
237237+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
238238+}
239239+240240+export function now(): number {
241241+ return Date.now();
242242+}
243243+244244+export function parseUrl(uri: string): { protocol: string; domain: string; path: string } {
245245+ try {
246246+ const url = new URL(uri);
247247+ return {
248248+ protocol: url.protocol.replace(':', ''),
249249+ domain: url.hostname,
250250+ path: url.pathname + url.search + url.hash,
251251+ };
252252+ } catch {
253253+ return {
254254+ protocol: 'unknown',
255255+ domain: uri,
256256+ path: '',
257257+ };
258258+ }
259259+}
260260+261261+export function normalizeUrl(uri: string): string {
262262+ if (!uri) return uri;
263263+264264+ try {
265265+ const url = new URL(uri);
266266+267267+ // Remove trailing slash from path (except for root)
268268+ if (url.pathname !== '/' && url.pathname.endsWith('/')) {
269269+ url.pathname = url.pathname.slice(0, -1);
270270+ }
271271+272272+ // Remove default ports
273273+ if ((url.protocol === 'http:' && url.port === '80') || (url.protocol === 'https:' && url.port === '443')) {
274274+ url.port = '';
275275+ }
276276+277277+ // Sort query parameters for consistency
278278+ if (url.search) {
279279+ const params = new URLSearchParams(url.search);
280280+ const sortedParams = new URLSearchParams([...params.entries()].sort());
281281+ url.search = sortedParams.toString();
282282+ }
283283+284284+ return url.toString();
285285+ } catch {
286286+ return uri;
287287+ }
288288+}
289289+290290+export function isValidTable(tableName: string): tableName is TableName {
291291+ return (tableNames as readonly string[]).includes(tableName);
292292+}
293293+294294+export function calculateFrecency(frequency: number, lastUsedAt: number): number {
295295+ const currentTime = Date.now();
296296+ const daysSinceUse = (currentTime - lastUsedAt) / (1000 * 60 * 60 * 24);
297297+ const decayFactor = 1 / (1 + daysSinceUse / 7);
298298+ return Math.round(frequency * 10 * decayFactor);
299299+}
300300+301301+// ==================== Migration ====================
302302+303303+function migrateTinyBaseData(): void {
304304+ if (!db) return;
305305+306306+ // Check if tinybase table exists
307307+ const tinybaseExists = db
308308+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='tinybase'`)
309309+ .get();
310310+311311+ if (!tinybaseExists) {
312312+ return;
313313+ }
314314+315315+ // Check if we already migrated
316316+ const existingData = db.prepare('SELECT COUNT(*) as count FROM addresses').get() as { count: number };
317317+ if (existingData.count > 0) {
318318+ console.log('main', 'TinyBase data already migrated, skipping');
319319+ return;
320320+ }
321321+322322+ console.log('main', 'Migrating TinyBase data to direct tables...');
323323+324324+ try {
325325+ const tinybaseRow = db.prepare('SELECT * FROM tinybase').get() as Record<string, unknown> | undefined;
326326+ if (!tinybaseRow) {
327327+ console.log('main', 'No TinyBase data found');
328328+ return;
329329+ }
330330+331331+ const rawData = Object.values(tinybaseRow)[1] as string;
332332+ if (!rawData) {
333333+ console.log('main', 'TinyBase data is empty');
334334+ return;
335335+ }
336336+337337+ const [tables] = JSON.parse(rawData) as [Record<string, Record<string, Record<string, unknown>>>];
338338+ if (!tables) {
339339+ console.log('main', 'No tables in TinyBase data');
340340+ return;
341341+ }
342342+343343+ const tablesToMigrate = [
344344+ 'addresses', 'visits', 'tags', 'address_tags', 'extension_settings',
345345+ 'extensions', 'content', 'blobs', 'scripts_data', 'feeds',
346346+ ];
347347+348348+ for (const tableName of tablesToMigrate) {
349349+ const tableData = tables[tableName];
350350+ if (!tableData || typeof tableData !== 'object') continue;
351351+352352+ const entries = Object.entries(tableData);
353353+ if (entries.length === 0) continue;
354354+355355+ console.log('main', ` Migrating ${entries.length} rows from ${tableName}`);
356356+357357+ for (const [id, row] of entries) {
358358+ try {
359359+ const fullRow = { id, ...row } as Record<string, unknown>;
360360+ const columns = Object.keys(fullRow);
361361+ const placeholders = columns.map(() => '?').join(', ');
362362+ const values = columns.map((col) => fullRow[col]);
363363+364364+ db.prepare(
365365+ `INSERT OR IGNORE INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`
366366+ ).run(...values);
367367+ } catch (err) {
368368+ console.error('main', ` Error migrating row ${id} in ${tableName}:`, (err as Error).message);
369369+ }
370370+ }
371371+ }
372372+373373+ db.exec('DROP TABLE IF EXISTS tinybase');
374374+ console.log('main', 'TinyBase migration complete, removed tinybase table');
375375+ } catch (error) {
376376+ console.error('main', 'TinyBase migration failed:', (error as Error).message);
377377+ }
378378+}
+48
backend/electron/index.ts
···11+/**
22+ * Electron Backend Entry Point
33+ *
44+ * Exports database functions and types for the Electron main process.
55+ */
66+77+// Database functions
88+export {
99+ initDatabase,
1010+ closeDatabase,
1111+ getDb,
1212+ generateId,
1313+ now,
1414+ parseUrl,
1515+ normalizeUrl,
1616+ isValidTable,
1717+ calculateFrecency,
1818+} from './datastore.js';
1919+2020+// Re-export shared data types
2121+export type {
2222+ Address,
2323+ Visit,
2424+ Content,
2525+ Tag,
2626+ AddressTag,
2727+ Extension,
2828+ ExtensionSetting,
2929+ DatastoreStats,
3030+ TableName,
3131+} from '../types/index.js';
3232+3333+export { tableNames } from '../types/index.js';
3434+3535+// Re-export frontend API types (the contract that preload.js implements)
3636+export type {
3737+ IPeekApi,
3838+ ApiResult,
3939+ ApiScope,
4040+ IShortcutsApi,
4141+ IWindowApi,
4242+ IDatastoreApi,
4343+ IPubSubApi,
4444+ ICommandsApi,
4545+ IExtensionsApi,
4646+ ISettingsApi,
4747+ IEscapeApi,
4848+} from '../types/api.js';
···778778 }
779779780780 // Register as default handler for http/https URLs (if not already and user hasn't declined)
781781+ // Skip for test profiles to avoid system dialogs during automated testing
782782+ const isTestProfile = PROFILE.startsWith('test');
783783+ if (isTestProfile) {
784784+ console.log('Skipping default browser check for test profile:', PROFILE);
785785+ }
786786+781787 const defaultBrowserPrefFile = path.join(profileDataPath, 'default-browser-pref.json');
782782- let shouldPromptForDefault = true;
788788+ let shouldPromptForDefault = !isTestProfile;
783789784790 // Check if user has previously declined
785791 try {