···11+/**
22+ * Chrome/Brave profile reader
33+ *
44+ * Both Chrome and Brave use the same Chromium profile format.
55+ * History is in a SQLite DB, bookmarks in a JSON file.
66+ */
77+88+import { existsSync, readFileSync, copyFileSync, mkdtempSync } from 'fs';
99+import { join } from 'path';
1010+import { tmpdir } from 'os';
1111+import Database from 'better-sqlite3';
1212+import type { BrowserReader } from './types.js';
1313+import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js';
1414+1515+/**
1616+ * Copy a SQLite DB to a temp location before reading.
1717+ * Browsers hold locks on their DBs; reading in-place can fail or corrupt data.
1818+ */
1919+function copyToTemp(dbPath: string): string {
2020+ const tempDir = mkdtempSync(join(tmpdir(), 'browser-import-'));
2121+ const tempPath = join(tempDir, 'copy.sqlite');
2222+ copyFileSync(dbPath, tempPath);
2323+ const walPath = dbPath + '-wal';
2424+ const shmPath = dbPath + '-shm';
2525+ if (existsSync(walPath)) {
2626+ copyFileSync(walPath, tempPath + '-wal');
2727+ }
2828+ if (existsSync(shmPath)) {
2929+ copyFileSync(shmPath, tempPath + '-shm');
3030+ }
3131+ return tempPath;
3232+}
3333+3434+function openSafely(dbPath: string): Database.Database | null {
3535+ if (!existsSync(dbPath)) return null;
3636+ try {
3737+ const tempPath = copyToTemp(dbPath);
3838+ return new Database(tempPath, { readonly: true });
3939+ } catch {
4040+ return null;
4141+ }
4242+}
4343+4444+/**
4545+ * Chrome epoch: microseconds since 1601-01-01.
4646+ * Convert to Unix milliseconds.
4747+ */
4848+function chromeTimeToUnixMs(chromeTime: number): number {
4949+ // Chrome time is microseconds since 1601-01-01
5050+ // Unix epoch is 1970-01-01
5151+ // Difference: 11644473600 seconds
5252+ const unixMicroseconds = chromeTime - 11644473600000000;
5353+ return Math.floor(unixMicroseconds / 1000);
5454+}
5555+5656+/**
5757+ * WebKit time (used in Bookmarks JSON): microseconds since 1601-01-01
5858+ * Same conversion as Chrome time.
5959+ */
6060+function webkitTimeToUnixMs(webkitTime: string | number): number {
6161+ const t = typeof webkitTime === 'string' ? parseInt(webkitTime, 10) : webkitTime;
6262+ if (isNaN(t) || t === 0) return Date.now();
6363+ return chromeTimeToUnixMs(t);
6464+}
6565+6666+interface ChromeBookmarkNode {
6767+ type: 'url' | 'folder';
6868+ name: string;
6969+ url?: string;
7070+ date_added?: string;
7171+ children?: ChromeBookmarkNode[];
7272+}
7373+7474+function flattenBookmarks(node: ChromeBookmarkNode, folderPath: string): BookmarkEntry[] {
7575+ const entries: BookmarkEntry[] = [];
7676+7777+ if (node.type === 'url' && node.url) {
7878+ // Skip internal chrome:// and edge:// URLs
7979+ if (!node.url.startsWith('chrome://') && !node.url.startsWith('edge://') && !node.url.startsWith('brave://')) {
8080+ entries.push({
8181+ url: node.url,
8282+ title: node.name || '',
8383+ folderPath,
8484+ dateAdded: webkitTimeToUnixMs(node.date_added || '0'),
8585+ });
8686+ }
8787+ }
8888+8989+ if (node.children) {
9090+ const childPath = folderPath ? `${folderPath}/${node.name}` : node.name;
9191+ for (const child of node.children) {
9292+ entries.push(...flattenBookmarks(child, childPath));
9393+ }
9494+ }
9595+9696+ return entries;
9797+}
9898+9999+export const chromeReader: BrowserReader = {
100100+ readHistory(profilePath: string): HistoryEntry[] {
101101+ const dbPath = join(profilePath, 'History');
102102+ const db = openSafely(dbPath);
103103+ if (!db) return [];
104104+105105+ try {
106106+ const rows = db.prepare(`
107107+ SELECT
108108+ u.url,
109109+ u.title,
110110+ u.visit_count,
111111+ MAX(v.visit_time) as last_visit_time
112112+ FROM urls u
113113+ JOIN visits v ON u.id = v.url
114114+ WHERE u.url NOT LIKE 'chrome://%'
115115+ AND u.url NOT LIKE 'chrome-extension://%'
116116+ AND u.url NOT LIKE 'brave://%'
117117+ AND u.url NOT LIKE 'edge://%'
118118+ GROUP BY u.id
119119+ ORDER BY last_visit_time DESC
120120+ `).all() as Array<{
121121+ url: string;
122122+ title: string | null;
123123+ visit_count: number;
124124+ last_visit_time: number;
125125+ }>;
126126+127127+ return rows.map(row => ({
128128+ url: row.url,
129129+ title: row.title || '',
130130+ visitCount: row.visit_count,
131131+ lastVisitTime: chromeTimeToUnixMs(row.last_visit_time),
132132+ }));
133133+ } catch {
134134+ return [];
135135+ } finally {
136136+ db.close();
137137+ }
138138+ },
139139+140140+ readBookmarks(profilePath: string): BookmarkEntry[] {
141141+ const bookmarksPath = join(profilePath, 'Bookmarks');
142142+ if (!existsSync(bookmarksPath)) return [];
143143+144144+ try {
145145+ const data = JSON.parse(readFileSync(bookmarksPath, 'utf-8'));
146146+ const roots = data.roots;
147147+ if (!roots) return [];
148148+149149+ const entries: BookmarkEntry[] = [];
150150+ // Chrome has bookmark_bar, other, synced as root folders
151151+ for (const rootKey of Object.keys(roots)) {
152152+ const rootNode = roots[rootKey] as ChromeBookmarkNode;
153153+ if (rootNode && typeof rootNode === 'object' && 'children' in rootNode) {
154154+ entries.push(...flattenBookmarks(rootNode, ''));
155155+ }
156156+ }
157157+ return entries;
158158+ } catch {
159159+ return [];
160160+ }
161161+ },
162162+163163+ inspect(profilePath: string): DataInspection[] {
164164+ const results: DataInspection[] = [];
165165+166166+ // History
167167+ const historyDb = openSafely(join(profilePath, 'History'));
168168+ if (historyDb) {
169169+ try {
170170+ const count = (historyDb.prepare(
171171+ `SELECT COUNT(DISTINCT u.id) as cnt FROM urls u
172172+ JOIN visits v ON u.id = v.url
173173+ WHERE u.url NOT LIKE 'chrome://%'
174174+ AND u.url NOT LIKE 'chrome-extension://%'
175175+ AND u.url NOT LIKE 'brave://%'
176176+ AND u.url NOT LIKE 'edge://%'`
177177+ ).get() as { cnt: number }).cnt;
178178+ results.push({ type: 'history', count, importable: true, label: 'History' });
179179+ } catch {
180180+ // table may not exist
181181+ } finally {
182182+ historyDb.close();
183183+ }
184184+ }
185185+186186+ // Bookmarks
187187+ const bookmarksPath = join(profilePath, 'Bookmarks');
188188+ if (existsSync(bookmarksPath)) {
189189+ try {
190190+ const data = JSON.parse(readFileSync(bookmarksPath, 'utf-8'));
191191+ const entries = this.readBookmarks(profilePath);
192192+ results.push({ type: 'bookmarks', count: entries.length, importable: true, label: 'Bookmarks' });
193193+ } catch {
194194+ results.push({ type: 'bookmarks', count: 0, importable: true, label: 'Bookmarks' });
195195+ }
196196+ }
197197+198198+ // Passwords (Login Data SQLite)
199199+ const loginDb = openSafely(join(profilePath, 'Login Data'));
200200+ if (loginDb) {
201201+ try {
202202+ const count = (loginDb.prepare('SELECT COUNT(*) as cnt FROM logins').get() as { cnt: number }).cnt;
203203+ results.push({ type: 'passwords', count, importable: false, label: 'Passwords' });
204204+ } catch {
205205+ // table may not exist
206206+ } finally {
207207+ loginDb.close();
208208+ }
209209+ }
210210+211211+ // Cookies
212212+ const cookieDb = openSafely(join(profilePath, 'Cookies'));
213213+ if (cookieDb) {
214214+ try {
215215+ const count = (cookieDb.prepare('SELECT COUNT(*) as cnt FROM cookies').get() as { cnt: number }).cnt;
216216+ results.push({ type: 'cookies', count, importable: false, label: 'Cookies' });
217217+ } catch {
218218+ // table may not exist
219219+ } finally {
220220+ cookieDb.close();
221221+ }
222222+ }
223223+224224+ // Form/Autofill data
225225+ const webDataDb = openSafely(join(profilePath, 'Web Data'));
226226+ if (webDataDb) {
227227+ try {
228228+ const count = (webDataDb.prepare('SELECT COUNT(*) as cnt FROM autofill').get() as { cnt: number }).cnt;
229229+ results.push({ type: 'formdata', count, importable: false, label: 'Form Data' });
230230+ } catch {
231231+ // table may not exist
232232+ } finally {
233233+ webDataDb.close();
234234+ }
235235+ }
236236+237237+ // Extensions
238238+ const prefsPath = join(profilePath, 'Preferences');
239239+ if (existsSync(prefsPath)) {
240240+ try {
241241+ const prefs = JSON.parse(readFileSync(prefsPath, 'utf-8'));
242242+ const extensions = prefs.extensions?.settings;
243243+ if (extensions && typeof extensions === 'object') {
244244+ const extCount = Object.values(extensions).filter(
245245+ (ext: any) => ext.manifest && ext.manifest.manifest_version
246246+ ).length;
247247+ results.push({ type: 'extensions', count: extCount, importable: false, label: 'Extensions' });
248248+ }
249249+ } catch {
250250+ // can't parse prefs
251251+ }
252252+ }
253253+254254+ return results;
255255+ },
256256+};
+230
tools/browser-import/src/browsers/firefox.ts
···11+/**
22+ * Firefox profile reader
33+ *
44+ * Reads history and bookmarks from Firefox's places.sqlite,
55+ * and inspects other data files (passwords, cookies, form data, extensions).
66+ */
77+88+import { existsSync, readFileSync, copyFileSync, mkdtempSync } from 'fs';
99+import { join } from 'path';
1010+import { tmpdir } from 'os';
1111+import Database from 'better-sqlite3';
1212+import type { BrowserReader } from './types.js';
1313+import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js';
1414+1515+/**
1616+ * Copy a SQLite DB to a temp location before reading.
1717+ * Browsers hold locks on their DBs; reading in-place can fail or corrupt data.
1818+ */
1919+function copyToTemp(dbPath: string): string {
2020+ const tempDir = mkdtempSync(join(tmpdir(), 'browser-import-'));
2121+ const tempPath = join(tempDir, 'copy.sqlite');
2222+ copyFileSync(dbPath, tempPath);
2323+ // Also copy WAL and SHM files if they exist (needed for consistent reads)
2424+ const walPath = dbPath + '-wal';
2525+ const shmPath = dbPath + '-shm';
2626+ if (existsSync(walPath)) {
2727+ copyFileSync(walPath, tempPath + '-wal');
2828+ }
2929+ if (existsSync(shmPath)) {
3030+ copyFileSync(shmPath, tempPath + '-shm');
3131+ }
3232+ return tempPath;
3333+}
3434+3535+function openSafely(dbPath: string): Database.Database | null {
3636+ if (!existsSync(dbPath)) return null;
3737+ try {
3838+ const tempPath = copyToTemp(dbPath);
3939+ return new Database(tempPath, { readonly: true });
4040+ } catch {
4141+ return null;
4242+ }
4343+}
4444+4545+export const firefoxReader: BrowserReader = {
4646+ readHistory(profilePath: string): HistoryEntry[] {
4747+ const dbPath = join(profilePath, 'places.sqlite');
4848+ const db = openSafely(dbPath);
4949+ if (!db) return [];
5050+5151+ try {
5252+ const rows = db.prepare(`
5353+ SELECT
5454+ p.url,
5555+ p.title,
5656+ p.visit_count,
5757+ MAX(v.visit_date) as last_visit_date
5858+ FROM moz_places p
5959+ JOIN moz_historyvisits v ON p.id = v.place_id
6060+ WHERE p.url NOT LIKE 'place:%'
6161+ AND p.url NOT LIKE 'about:%'
6262+ GROUP BY p.id
6363+ ORDER BY last_visit_date DESC
6464+ `).all() as Array<{
6565+ url: string;
6666+ title: string | null;
6767+ visit_count: number;
6868+ last_visit_date: number;
6969+ }>;
7070+7171+ return rows.map(row => ({
7272+ url: row.url,
7373+ title: row.title || '',
7474+ visitCount: row.visit_count,
7575+ // Firefox stores timestamps in microseconds since epoch
7676+ lastVisitTime: Math.floor(row.last_visit_date / 1000),
7777+ }));
7878+ } catch {
7979+ return [];
8080+ } finally {
8181+ db.close();
8282+ }
8383+ },
8484+8585+ readBookmarks(profilePath: string): BookmarkEntry[] {
8686+ const dbPath = join(profilePath, 'places.sqlite');
8787+ const db = openSafely(dbPath);
8888+ if (!db) return [];
8989+9090+ try {
9191+ // First build the folder tree for path resolution
9292+ const folders = db.prepare(`
9393+ SELECT id, title, parent
9494+ FROM moz_bookmarks
9595+ WHERE type = 2
9696+ `).all() as Array<{ id: number; title: string | null; parent: number }>;
9797+9898+ const folderMap = new Map<number, { title: string; parent: number }>();
9999+ for (const f of folders) {
100100+ folderMap.set(f.id, { title: f.title || '', parent: f.parent });
101101+ }
102102+103103+ function getFolderPath(parentId: number): string {
104104+ const parts: string[] = [];
105105+ let current = parentId;
106106+ const visited = new Set<number>();
107107+ while (current && folderMap.has(current) && !visited.has(current)) {
108108+ visited.add(current);
109109+ const folder = folderMap.get(current)!;
110110+ if (folder.title) {
111111+ parts.unshift(folder.title);
112112+ }
113113+ current = folder.parent;
114114+ }
115115+ return parts.join('/');
116116+ }
117117+118118+ // Get bookmarks (type=1 is bookmark, not folder or separator)
119119+ const rows = db.prepare(`
120120+ SELECT
121121+ b.title as bookmark_title,
122122+ b.dateAdded,
123123+ b.parent,
124124+ p.url
125125+ FROM moz_bookmarks b
126126+ JOIN moz_places p ON b.fk = p.id
127127+ WHERE b.type = 1
128128+ AND p.url NOT LIKE 'place:%'
129129+ AND p.url NOT LIKE 'about:%'
130130+ `).all() as Array<{
131131+ bookmark_title: string | null;
132132+ dateAdded: number;
133133+ parent: number;
134134+ url: string;
135135+ }>;
136136+137137+ return rows.map(row => ({
138138+ url: row.url,
139139+ title: row.bookmark_title || '',
140140+ folderPath: getFolderPath(row.parent),
141141+ // Firefox stores dateAdded in microseconds
142142+ dateAdded: Math.floor(row.dateAdded / 1000),
143143+ }));
144144+ } catch {
145145+ return [];
146146+ } finally {
147147+ db.close();
148148+ }
149149+ },
150150+151151+ inspect(profilePath: string): DataInspection[] {
152152+ const results: DataInspection[] = [];
153153+154154+ // History
155155+ const placesDb = openSafely(join(profilePath, 'places.sqlite'));
156156+ if (placesDb) {
157157+ try {
158158+ const historyCount = (placesDb.prepare(
159159+ `SELECT COUNT(DISTINCT p.id) as cnt FROM moz_places p
160160+ JOIN moz_historyvisits v ON p.id = v.place_id
161161+ WHERE p.url NOT LIKE 'place:%' AND p.url NOT LIKE 'about:%'`
162162+ ).get() as { cnt: number }).cnt;
163163+ results.push({ type: 'history', count: historyCount, importable: true, label: 'History' });
164164+165165+ const bookmarkCount = (placesDb.prepare(
166166+ `SELECT COUNT(*) as cnt FROM moz_bookmarks b
167167+ JOIN moz_places p ON b.fk = p.id
168168+ WHERE b.type = 1 AND p.url NOT LIKE 'place:%' AND p.url NOT LIKE 'about:%'`
169169+ ).get() as { cnt: number }).cnt;
170170+ results.push({ type: 'bookmarks', count: bookmarkCount, importable: true, label: 'Bookmarks' });
171171+ } catch {
172172+ // tables may not exist
173173+ } finally {
174174+ placesDb.close();
175175+ }
176176+ }
177177+178178+ // Passwords (logins.json)
179179+ const loginsPath = join(profilePath, 'logins.json');
180180+ if (existsSync(loginsPath)) {
181181+ try {
182182+ const logins = JSON.parse(readFileSync(loginsPath, 'utf-8'));
183183+ const count = Array.isArray(logins.logins) ? logins.logins.length : 0;
184184+ results.push({ type: 'passwords', count, importable: false, label: 'Passwords' });
185185+ } catch {
186186+ results.push({ type: 'passwords', count: 0, importable: false, label: 'Passwords' });
187187+ }
188188+ }
189189+190190+ // Cookies
191191+ const cookiesDb = openSafely(join(profilePath, 'cookies.sqlite'));
192192+ if (cookiesDb) {
193193+ try {
194194+ const count = (cookiesDb.prepare('SELECT COUNT(*) as cnt FROM moz_cookies').get() as { cnt: number }).cnt;
195195+ results.push({ type: 'cookies', count, importable: false, label: 'Cookies' });
196196+ } catch {
197197+ // table may not exist
198198+ } finally {
199199+ cookiesDb.close();
200200+ }
201201+ }
202202+203203+ // Form data
204204+ const formDb = openSafely(join(profilePath, 'formhistory.sqlite'));
205205+ if (formDb) {
206206+ try {
207207+ const count = (formDb.prepare('SELECT COUNT(*) as cnt FROM moz_formhistory').get() as { cnt: number }).cnt;
208208+ results.push({ type: 'formdata', count, importable: false, label: 'Form Data' });
209209+ } catch {
210210+ // table may not exist
211211+ } finally {
212212+ formDb.close();
213213+ }
214214+ }
215215+216216+ // Extensions
217217+ const extensionsPath = join(profilePath, 'extensions.json');
218218+ if (existsSync(extensionsPath)) {
219219+ try {
220220+ const data = JSON.parse(readFileSync(extensionsPath, 'utf-8'));
221221+ const count = Array.isArray(data.addons) ? data.addons.filter((a: { type: string }) => a.type === 'extension').length : 0;
222222+ results.push({ type: 'extensions', count, importable: false, label: 'Extensions' });
223223+ } catch {
224224+ results.push({ type: 'extensions', count: 0, importable: false, label: 'Extensions' });
225225+ }
226226+ }
227227+228228+ return results;
229229+ },
230230+};
+16
tools/browser-import/src/browsers/types.ts
···11+/**
22+ * Shared browser reader types
33+ */
44+55+import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js';
66+77+export interface BrowserReader {
88+ /** Read history entries from the profile */
99+ readHistory(profilePath: string): HistoryEntry[];
1010+1111+ /** Read bookmark entries from the profile */
1212+ readBookmarks(profilePath: string): BookmarkEntry[];
1313+1414+ /** Inspect what data is available in the profile */
1515+ inspect(profilePath: string): DataInspection[];
1616+}
+252
tools/browser-import/src/datastore.ts
···11+/**
22+ * Peek datastore writer
33+ *
44+ * Creates and writes to a Peek-compatible SQLite database,
55+ * using the same schema as the Peek desktop app.
66+ */
77+88+import Database from 'better-sqlite3';
99+import { existsSync } from 'fs';
1010+1111+let db: Database.Database | null = null;
1212+1313+/**
1414+ * Generate a unique ID matching Peek's format
1515+ */
1616+function generateId(prefix = 'id'): string {
1717+ return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1818+}
1919+2020+function now(): number {
2121+ return Date.now();
2222+}
2323+2424+/**
2525+ * Open or create a Peek-compatible database
2626+ */
2727+export function openDatabase(dbPath: string): void {
2828+ const isNew = !existsSync(dbPath);
2929+ db = new Database(dbPath);
3030+3131+ // Enable WAL mode for better performance
3232+ db.pragma('journal_mode = WAL');
3333+3434+ if (isNew) {
3535+ initializeSchema();
3636+ } else {
3737+ // Verify it has the expected tables
3838+ const tables = db.prepare(
3939+ "SELECT name FROM sqlite_master WHERE type='table'"
4040+ ).all() as Array<{ name: string }>;
4141+ const tableNames = new Set(tables.map(t => t.name));
4242+ if (!tableNames.has('items') || !tableNames.has('tags') || !tableNames.has('item_tags')) {
4343+ initializeSchema();
4444+ }
4545+ }
4646+}
4747+4848+/**
4949+ * Initialize the database with Peek's schema
5050+ */
5151+function initializeSchema(): void {
5252+ if (!db) throw new Error('Database not opened');
5353+5454+ db.exec(`
5555+ CREATE TABLE IF NOT EXISTS items (
5656+ id TEXT PRIMARY KEY,
5757+ type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity')),
5858+ content TEXT,
5959+ mimeType TEXT DEFAULT '',
6060+ metadata TEXT DEFAULT '{}',
6161+ syncId TEXT DEFAULT '',
6262+ syncedAt INTEGER DEFAULT 0,
6363+ createdAt INTEGER NOT NULL,
6464+ updatedAt INTEGER NOT NULL,
6565+ deletedAt INTEGER DEFAULT 0,
6666+ starred INTEGER DEFAULT 0,
6767+ archived INTEGER DEFAULT 0,
6868+ visitCount INTEGER DEFAULT 0,
6969+ lastVisitAt INTEGER DEFAULT 0,
7070+ frecencyScore INTEGER DEFAULT 0,
7171+ title TEXT DEFAULT '',
7272+ domain TEXT DEFAULT '',
7373+ favicon TEXT DEFAULT ''
7474+ );
7575+ CREATE INDEX IF NOT EXISTS idx_items_type ON items(type);
7676+ CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId);
7777+ CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt);
7878+ CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC);
7979+ CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred);
8080+ CREATE INDEX IF NOT EXISTS idx_items_lastVisitAt ON items(lastVisitAt);
8181+ CREATE INDEX IF NOT EXISTS idx_items_visitCount ON items(visitCount);
8282+ CREATE INDEX IF NOT EXISTS idx_items_frecencyScore ON items(frecencyScore DESC);
8383+ CREATE INDEX IF NOT EXISTS idx_items_domain ON items(domain);
8484+8585+ CREATE TABLE IF NOT EXISTS tags (
8686+ id TEXT PRIMARY KEY,
8787+ name TEXT NOT NULL UNIQUE,
8888+ slug TEXT,
8989+ color TEXT DEFAULT '#999999',
9090+ parentId TEXT DEFAULT '',
9191+ description TEXT DEFAULT '',
9292+ metadata TEXT DEFAULT '{}',
9393+ createdAt INTEGER,
9494+ updatedAt INTEGER,
9595+ frequency INTEGER DEFAULT 0,
9696+ lastUsed INTEGER DEFAULT 0,
9797+ frecencyScore REAL DEFAULT 0.0
9898+ );
9999+ CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);
100100+ CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug);
101101+ CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId);
102102+ CREATE INDEX IF NOT EXISTS idx_tags_frecencyScore ON tags(frecencyScore DESC);
103103+104104+ CREATE TABLE IF NOT EXISTS item_tags (
105105+ id TEXT PRIMARY KEY,
106106+ itemId TEXT NOT NULL,
107107+ tagId TEXT NOT NULL,
108108+ createdAt INTEGER NOT NULL
109109+ );
110110+ CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId);
111111+ CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId);
112112+ CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId);
113113+114114+ CREATE TABLE IF NOT EXISTS item_events (
115115+ id TEXT PRIMARY KEY,
116116+ itemId TEXT NOT NULL,
117117+ content TEXT,
118118+ value REAL,
119119+ occurredAt INTEGER NOT NULL,
120120+ metadata TEXT DEFAULT '{}',
121121+ createdAt INTEGER NOT NULL
122122+ );
123123+ CREATE INDEX IF NOT EXISTS idx_item_events_item_time ON item_events(itemId, occurredAt DESC);
124124+ CREATE INDEX IF NOT EXISTS idx_item_events_occurred ON item_events(occurredAt DESC);
125125+ `);
126126+}
127127+128128+/**
129129+ * Check if a URL item already exists in the database
130130+ */
131131+export function itemExistsByUrl(url: string): boolean {
132132+ if (!db) throw new Error('Database not opened');
133133+ const row = db.prepare(
134134+ "SELECT id FROM items WHERE type = 'url' AND content = ? AND deletedAt = 0"
135135+ ).get(url);
136136+ return !!row;
137137+}
138138+139139+/**
140140+ * Create a new item in the database
141141+ */
142142+export function createItem(options: {
143143+ type: string;
144144+ content: string;
145145+ metadata?: Record<string, unknown>;
146146+ title?: string;
147147+ visitCount?: number;
148148+ lastVisitAt?: number;
149149+}): string {
150150+ if (!db) throw new Error('Database not opened');
151151+152152+ const itemId = generateId('item');
153153+ const timestamp = now();
154154+ const metadataJson = JSON.stringify(options.metadata || {});
155155+156156+ let domain = '';
157157+ if (options.type === 'url' && options.content) {
158158+ try {
159159+ const parsed = new URL(options.content);
160160+ domain = parsed.hostname;
161161+ } catch {
162162+ // not a valid URL
163163+ }
164164+ }
165165+166166+ db.prepare(`
167167+ INSERT INTO items (id, type, content, mimeType, metadata, syncId, createdAt, updatedAt,
168168+ deletedAt, starred, archived, visitCount, lastVisitAt, frecencyScore,
169169+ title, domain, favicon)
170170+ VALUES (?, ?, ?, '', ?, '', ?, ?, 0, 0, 0, ?, ?, 0, ?, ?, '')
171171+ `).run(
172172+ itemId,
173173+ options.type,
174174+ options.content,
175175+ metadataJson,
176176+ timestamp,
177177+ timestamp,
178178+ options.visitCount || 0,
179179+ options.lastVisitAt || 0,
180180+ options.title || '',
181181+ domain,
182182+ );
183183+184184+ return itemId;
185185+}
186186+187187+/**
188188+ * Get or create a tag by name (matching Peek's logic)
189189+ */
190190+export function getOrCreateTag(name: string): { tagId: string; created: boolean } {
191191+ if (!db) throw new Error('Database not opened');
192192+193193+ const slug = name.toLowerCase().trim().replace(/\s+/g, '-');
194194+ const existing = db.prepare(
195195+ 'SELECT id FROM tags WHERE LOWER(name) = LOWER(?)'
196196+ ).get(name) as { id: string } | undefined;
197197+198198+ if (existing) {
199199+ return { tagId: existing.id, created: false };
200200+ }
201201+202202+ const tagId = generateId('tag');
203203+ const timestamp = now();
204204+205205+ db.prepare(`
206206+ INSERT INTO tags (id, name, slug, color, parentId, description, metadata, createdAt, updatedAt, frequency, lastUsed, frecencyScore)
207207+ VALUES (?, ?, ?, '#999999', '', '', '{}', ?, ?, 0, 0, 0)
208208+ `).run(tagId, name.trim(), slug, timestamp, timestamp);
209209+210210+ return { tagId, created: true };
211211+}
212212+213213+/**
214214+ * Tag an item (create a link in item_tags)
215215+ */
216216+export function tagItem(itemId: string, tagId: string): boolean {
217217+ if (!db) throw new Error('Database not opened');
218218+219219+ // Check if already tagged
220220+ const existing = db.prepare(
221221+ 'SELECT id FROM item_tags WHERE itemId = ? AND tagId = ?'
222222+ ).get(itemId, tagId);
223223+224224+ if (existing) return false;
225225+226226+ const linkId = generateId('item_tag');
227227+ const timestamp = now();
228228+229229+ db.prepare(
230230+ 'INSERT INTO item_tags (id, itemId, tagId, createdAt) VALUES (?, ?, ?, ?)'
231231+ ).run(linkId, itemId, tagId, timestamp);
232232+233233+ return true;
234234+}
235235+236236+/**
237237+ * Run a function inside a transaction for performance
238238+ */
239239+export function runInTransaction<T>(fn: () => T): T {
240240+ if (!db) throw new Error('Database not opened');
241241+ return db.transaction(fn)();
242242+}
243243+244244+/**
245245+ * Close the database
246246+ */
247247+export function closeDatabase(): void {
248248+ if (db) {
249249+ db.close();
250250+ db = null;
251251+ }
252252+}
+112
tools/browser-import/src/importers/bookmarks.ts
···11+/**
22+ * Bookmarks importer
33+ *
44+ * Reads browser bookmarks and creates url items in the Peek datastore,
55+ * including tags derived from the bookmark folder hierarchy.
66+ */
77+88+import type { BookmarkEntry, ImportResult, BrowserName } from '../types.js';
99+import { itemExistsByUrl, createItem, getOrCreateTag, tagItem, runInTransaction } from '../datastore.js';
1010+1111+/**
1212+ * Convert a bookmark folder path into a set of hierarchical tags.
1313+ * e.g., "Toolbar/Work/Research" => ["bookmark:Toolbar", "bookmark:Toolbar/Work", "bookmark:Toolbar/Work/Research"]
1414+ */
1515+function folderPathToTags(folderPath: string): string[] {
1616+ if (!folderPath) return [];
1717+1818+ const parts = folderPath.split('/').filter(p => p.length > 0);
1919+ const tags: string[] = [];
2020+ let accumulated = '';
2121+2222+ for (const part of parts) {
2323+ accumulated = accumulated ? `${accumulated}/${part}` : part;
2424+ tags.push(`bookmark:${accumulated}`);
2525+ }
2626+2727+ return tags;
2828+}
2929+3030+export function importBookmarks(
3131+ entries: BookmarkEntry[],
3232+ browserName: BrowserName,
3333+ profileName: string,
3434+): ImportResult {
3535+ const result: ImportResult = {
3636+ itemsImported: 0,
3737+ tagsCreated: 0,
3838+ duplicatesSkipped: 0,
3939+ errors: [],
4040+ };
4141+4242+ if (entries.length === 0) return result;
4343+4444+ const importTimestamp = new Date().toISOString();
4545+4646+ runInTransaction(() => {
4747+ // Create shared tags
4848+ const browserTag = getOrCreateTag('import:browser');
4949+ if (browserTag.created) result.tagsCreated++;
5050+5151+ const browserNameTag = getOrCreateTag(`import:${browserName}`);
5252+ if (browserNameTag.created) result.tagsCreated++;
5353+5454+ const profileTag = getOrCreateTag(`import:${profileName}`);
5555+ if (profileTag.created) result.tagsCreated++;
5656+5757+ const bookmarkTag = getOrCreateTag('browser:bookmark');
5858+ if (bookmarkTag.created) result.tagsCreated++;
5959+6060+ // Cache folder tags to avoid redundant lookups
6161+ const folderTagCache = new Map<string, string>();
6262+6363+ for (const entry of entries) {
6464+ try {
6565+ // Skip if URL already exists
6666+ if (itemExistsByUrl(entry.url)) {
6767+ result.duplicatesSkipped++;
6868+ continue;
6969+ }
7070+7171+ const itemId = createItem({
7272+ type: 'url',
7373+ content: entry.url,
7474+ title: entry.title,
7575+ metadata: {
7676+ title: entry.title,
7777+ folderPath: entry.folderPath,
7878+ dateAdded: entry.dateAdded,
7979+ sourceProfile: profileName,
8080+ sourceBrowser: browserName,
8181+ importedAt: importTimestamp,
8282+ },
8383+ });
8484+8585+ // Apply shared tags
8686+ tagItem(itemId, browserTag.tagId);
8787+ tagItem(itemId, browserNameTag.tagId);
8888+ tagItem(itemId, profileTag.tagId);
8989+ tagItem(itemId, bookmarkTag.tagId);
9090+9191+ // Apply folder hierarchy tags
9292+ const folderTags = folderPathToTags(entry.folderPath);
9393+ for (const folderTagName of folderTags) {
9494+ let folderTagId = folderTagCache.get(folderTagName);
9595+ if (!folderTagId) {
9696+ const { tagId, created } = getOrCreateTag(folderTagName);
9797+ folderTagId = tagId;
9898+ folderTagCache.set(folderTagName, tagId);
9999+ if (created) result.tagsCreated++;
100100+ }
101101+ tagItem(itemId, folderTagId);
102102+ }
103103+104104+ result.itemsImported++;
105105+ } catch (err) {
106106+ result.errors.push(`Failed to import ${entry.url}: ${(err as Error).message}`);
107107+ }
108108+ }
109109+ });
110110+111111+ return result;
112112+}