···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+ * Sessions use SNSS binary format (complex to parse).
67 */
7889import { existsSync, readFileSync, copyFileSync, mkdtempSync } from 'fs';
···1011import { tmpdir } from 'os';
1112import Database from 'better-sqlite3';
1213import type { BrowserReader } from './types.js';
1313-import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js';
1414+import type { HistoryEntry, BookmarkEntry, SessionTab, DataInspection, VisitRecord } from '../types.js';
14151516/**
1617 * Copy a SQLite DB to a temp location before reading.
···9697 return entries;
9798}
9899100100+/**
101101+ * Try to read session tabs from Chrome's Preferences JSON.
102102+ *
103103+ * Chrome's full session data is in SNSS binary format (Current Session, Current Tabs,
104104+ * Sessions/Session_*, Sessions/Tabs_*), which is complex to parse without a dedicated
105105+ * library. For now, we extract what we can from the Preferences JSON:
106106+ * - Pinned tabs (session.startup_urls when restore_on_startup = 4)
107107+ * - Tab groups metadata
108108+ *
109109+ * TODO: Full SNSS parsing. The SNSS format uses Pickle serialization with command types
110110+ * for UpdateTabNavigation, SetSelectedTabInWindow, SetTabGroup, etc. No reliable npm
111111+ * package exists for this format. Consider writing a custom parser or using the
112112+ * chrome-session-parser approach (reverse-engineered format).
113113+ */
114114+function readChromeSessionTabs(profilePath: string): SessionTab[] {
115115+ const tabs: SessionTab[] = [];
116116+117117+ // Try reading Preferences for startup/pinned tabs
118118+ const prefsPath = join(profilePath, 'Preferences');
119119+ if (!existsSync(prefsPath)) return tabs;
120120+121121+ try {
122122+ const prefs = JSON.parse(readFileSync(prefsPath, 'utf-8'));
123123+124124+ // Check for startup URLs (when user has "Open a specific page or set of pages")
125125+ const startupUrls = prefs?.session?.startup_urls;
126126+ if (Array.isArray(startupUrls)) {
127127+ for (const url of startupUrls) {
128128+ if (typeof url === 'string' && !url.startsWith('chrome://') &&
129129+ !url.startsWith('chrome-extension://') && !url.startsWith('brave://')) {
130130+ tabs.push({
131131+ url,
132132+ title: '', // Preferences doesn't store titles for startup URLs
133133+ pinned: false,
134134+ });
135135+ }
136136+ }
137137+ }
138138+139139+ // Check for pinned tabs
140140+ const pinnedTabs = prefs?.pinned_tabs;
141141+ if (Array.isArray(pinnedTabs)) {
142142+ for (const tab of pinnedTabs) {
143143+ if (tab && tab.url && typeof tab.url === 'string' &&
144144+ !tab.url.startsWith('chrome://') && !tab.url.startsWith('brave://')) {
145145+ tabs.push({
146146+ url: tab.url,
147147+ title: tab.title || '',
148148+ pinned: true,
149149+ });
150150+ }
151151+ }
152152+ }
153153+154154+ // Extract tab group names if available (for tagging reference)
155155+ // Chrome stores tab_groups in Preferences with group IDs mapping to titles
156156+ const tabGroups = prefs?.tab_groups;
157157+ if (tabGroups && typeof tabGroups === 'object') {
158158+ // Tab group data is stored but can't be fully linked without SNSS session data
159159+ // We store the group names for reference
160160+ }
161161+ } catch {
162162+ // Can't parse preferences
163163+ }
164164+165165+ return tabs;
166166+}
167167+168168+/**
169169+ * Count session tabs from Chrome Preferences
170170+ */
171171+function countChromeSessionTabs(profilePath: string): number {
172172+ try {
173173+ return readChromeSessionTabs(profilePath).length;
174174+ } catch {
175175+ return 0;
176176+ }
177177+}
178178+99179export const chromeReader: BrowserReader = {
100180 readHistory(profilePath: string): HistoryEntry[] {
101181 const dbPath = join(profilePath, 'History');
···124204 last_visit_time: number;
125205 }>;
126206207207+ // Build visit timeline from visits table
208208+ const visitsByUrl = new Map<string, VisitRecord[]>();
209209+ try {
210210+ const visitRows = db.prepare(`
211211+ SELECT
212212+ u.url,
213213+ v.visit_time,
214214+ v.transition
215215+ FROM visits v
216216+ JOIN urls u ON v.url = u.id
217217+ WHERE u.url NOT LIKE 'chrome://%'
218218+ AND u.url NOT LIKE 'chrome-extension://%'
219219+ AND u.url NOT LIKE 'brave://%'
220220+ AND u.url NOT LIKE 'edge://%'
221221+ ORDER BY v.visit_time ASC
222222+ `).all() as Array<{
223223+ url: string;
224224+ visit_time: number;
225225+ transition: number;
226226+ }>;
227227+228228+ for (const vr of visitRows) {
229229+ let visits = visitsByUrl.get(vr.url);
230230+ if (!visits) {
231231+ visits = [];
232232+ visitsByUrl.set(vr.url, visits);
233233+ }
234234+ visits.push({
235235+ timestamp: chromeTimeToUnixMs(vr.visit_time),
236236+ visitType: vr.transition & 0xFF, // Core transition type is in lower bits
237237+ });
238238+ }
239239+ } catch {
240240+ // visits query failed, continue without individual visits
241241+ }
242242+127243 return rows.map(row => ({
128244 url: row.url,
129245 title: row.title || '',
130246 visitCount: row.visit_count,
131247 lastVisitTime: chromeTimeToUnixMs(row.last_visit_time),
248248+ visits: visitsByUrl.get(row.url),
132249 }));
133250 } catch {
134251 return [];
···160277 }
161278 },
162279280280+ readSessions(profilePath: string): SessionTab[] {
281281+ return readChromeSessionTabs(profilePath);
282282+ },
283283+163284 inspect(profilePath: string): DataInspection[] {
164285 const results: DataInspection[] = [];
165286···187308 const bookmarksPath = join(profilePath, 'Bookmarks');
188309 if (existsSync(bookmarksPath)) {
189310 try {
190190- const data = JSON.parse(readFileSync(bookmarksPath, 'utf-8'));
191311 const entries = this.readBookmarks(profilePath);
192312 results.push({ type: 'bookmarks', count: entries.length, importable: true, label: 'Bookmarks' });
193313 } catch {
194314 results.push({ type: 'bookmarks', count: 0, importable: true, label: 'Bookmarks' });
195315 }
196316 }
317317+318318+ // Sessions (from Preferences, partial)
319319+ const sessionCount = countChromeSessionTabs(profilePath);
320320+ const sessionLabel = sessionCount > 0
321321+ ? 'Session Tabs (from Preferences, partial)'
322322+ : 'Session Tabs (SNSS format, not yet supported)';
323323+ results.push({
324324+ type: 'sessions',
325325+ count: sessionCount,
326326+ importable: sessionCount > 0,
327327+ label: sessionLabel,
328328+ });
197329198330 // Passwords (Login Data SQLite)
199331 const loginDb = openSafely(join(profilePath, 'Login Data'));
+144-1
tools/browser-import/src/browsers/firefox.ts
···22 * Firefox profile reader
33 *
44 * Reads history and bookmarks from Firefox's places.sqlite,
55+ * sessions from sessionstore-backups/recovery.jsonlz4,
56 * and inspects other data files (passwords, cookies, form data, extensions).
67 */
78···1011import { tmpdir } from 'os';
1112import Database from 'better-sqlite3';
1213import type { BrowserReader } from './types.js';
1313-import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js';
1414+import type { HistoryEntry, BookmarkEntry, SessionTab, DataInspection, VisitRecord } from '../types.js';
1515+import { decompressJsonlz4 } from '../util/jsonlz4.js';
14161517/**
1618 * Copy a SQLite DB to a temp location before reading.
···4244 }
4345}
44464747+/**
4848+ * Find the Firefox session file. Tries multiple locations in priority order:
4949+ * 1. sessionstore-backups/recovery.jsonlz4 (most current, written every ~15s)
5050+ * 2. sessionstore.jsonlz4 (written on clean shutdown)
5151+ */
5252+function findSessionFile(profilePath: string): string | null {
5353+ const candidates = [
5454+ join(profilePath, 'sessionstore-backups', 'recovery.jsonlz4'),
5555+ join(profilePath, 'sessionstore.jsonlz4'),
5656+ ];
5757+5858+ for (const candidate of candidates) {
5959+ if (existsSync(candidate)) return candidate;
6060+ }
6161+ return null;
6262+}
6363+6464+/**
6565+ * Parse a Firefox session JSON structure into SessionTab entries.
6666+ * Session structure: { windows: [{ tabs: [{ entries: [{ url, title }], index, ... }] }] }
6767+ */
6868+function parseFirefoxSession(sessionData: any): SessionTab[] {
6969+ const tabs: SessionTab[] = [];
7070+7171+ if (!sessionData || !Array.isArray(sessionData.windows)) return tabs;
7272+7373+ for (let windowIndex = 0; windowIndex < sessionData.windows.length; windowIndex++) {
7474+ const window = sessionData.windows[windowIndex];
7575+ if (!window || !Array.isArray(window.tabs)) continue;
7676+7777+ for (const tab of window.tabs) {
7878+ if (!tab || !Array.isArray(tab.entries) || tab.entries.length === 0) continue;
7979+8080+ // Current entry is at index - 1 (1-based)
8181+ const currentIndex = (tab.index || tab.entries.length) - 1;
8282+ const entry = tab.entries[Math.min(currentIndex, tab.entries.length - 1)];
8383+8484+ if (!entry || !entry.url) continue;
8585+8686+ // Skip internal Firefox URLs
8787+ if (entry.url.startsWith('about:') || entry.url.startsWith('resource:') ||
8888+ entry.url.startsWith('chrome:') || entry.url.startsWith('moz-extension:')) {
8989+ continue;
9090+ }
9191+9292+ // Determine tab group name (Firefox 131+ tab groups)
9393+ let tabGroup: string | undefined;
9494+ if (tab.groupId !== undefined && tab.groupId !== -1 && window.tabGroups) {
9595+ const group = window.tabGroups.find?.((g: any) => g.id === tab.groupId);
9696+ if (group && group.name) {
9797+ tabGroup = group.name;
9898+ }
9999+ }
100100+101101+ tabs.push({
102102+ url: entry.url,
103103+ title: entry.title || '',
104104+ tabGroup,
105105+ pinned: !!tab.pinned,
106106+ lastAccessed: tab.lastAccessed || undefined,
107107+ windowIndex,
108108+ });
109109+ }
110110+ }
111111+112112+ return tabs;
113113+}
114114+115115+/**
116116+ * Count tabs in a Firefox session file without fully parsing
117117+ */
118118+function countSessionTabs(profilePath: string): number {
119119+ const sessionFile = findSessionFile(profilePath);
120120+ if (!sessionFile) return 0;
121121+122122+ try {
123123+ const sessionData = decompressJsonlz4(sessionFile);
124124+ const tabs = parseFirefoxSession(sessionData);
125125+ return tabs.length;
126126+ } catch {
127127+ return 0;
128128+ }
129129+}
130130+45131export const firefoxReader: BrowserReader = {
46132 readHistory(profilePath: string): HistoryEntry[] {
47133 const dbPath = join(profilePath, 'places.sqlite');
···49135 if (!db) return [];
5013651137 try {
138138+ // Get history entries with aggregated visit data
52139 const rows = db.prepare(`
53140 SELECT
54141 p.url,
···68155 last_visit_date: number;
69156 }>;
70157158158+ // Build a map of place URL -> individual visits for visit timeline
159159+ const visitsByUrl = new Map<string, VisitRecord[]>();
160160+ try {
161161+ const visitRows = db.prepare(`
162162+ SELECT
163163+ p.url,
164164+ v.visit_date,
165165+ v.visit_type
166166+ FROM moz_historyvisits v
167167+ JOIN moz_places p ON v.place_id = p.id
168168+ WHERE p.url NOT LIKE 'place:%'
169169+ AND p.url NOT LIKE 'about:%'
170170+ ORDER BY v.visit_date ASC
171171+ `).all() as Array<{
172172+ url: string;
173173+ visit_date: number;
174174+ visit_type: number;
175175+ }>;
176176+177177+ for (const vr of visitRows) {
178178+ let visits = visitsByUrl.get(vr.url);
179179+ if (!visits) {
180180+ visits = [];
181181+ visitsByUrl.set(vr.url, visits);
182182+ }
183183+ visits.push({
184184+ timestamp: Math.floor(vr.visit_date / 1000), // microseconds to ms
185185+ visitType: vr.visit_type,
186186+ });
187187+ }
188188+ } catch {
189189+ // visits query failed, continue without individual visits
190190+ }
191191+71192 return rows.map(row => ({
72193 url: row.url,
73194 title: row.title || '',
74195 visitCount: row.visit_count,
75196 // Firefox stores timestamps in microseconds since epoch
76197 lastVisitTime: Math.floor(row.last_visit_date / 1000),
198198+ visits: visitsByUrl.get(row.url),
77199 }));
78200 } catch {
79201 return [];
···148270 }
149271 },
150272273273+ readSessions(profilePath: string): SessionTab[] {
274274+ const sessionFile = findSessionFile(profilePath);
275275+ if (!sessionFile) return [];
276276+277277+ try {
278278+ const sessionData = decompressJsonlz4(sessionFile);
279279+ return parseFirefoxSession(sessionData);
280280+ } catch {
281281+ return [];
282282+ }
283283+ },
284284+151285 inspect(profilePath: string): DataInspection[] {
152286 const results: DataInspection[] = [];
153287···174308 placesDb.close();
175309 }
176310 }
311311+312312+ // Sessions
313313+ const sessionTabCount = countSessionTabs(profilePath);
314314+ results.push({
315315+ type: 'sessions',
316316+ count: sessionTabCount,
317317+ importable: true,
318318+ label: 'Session Tabs',
319319+ });
177320178321 // Passwords (logins.json)
179322 const loginsPath = join(profilePath, 'logins.json');
+4-1
tools/browser-import/src/browsers/types.ts
···22 * Shared browser reader types
33 */
4455-import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js';
55+import type { HistoryEntry, BookmarkEntry, SessionTab, DataInspection } from '../types.js';
6677export interface BrowserReader {
88 /** Read history entries from the profile */
···10101111 /** Read bookmark entries from the profile */
1212 readBookmarks(profilePath: string): BookmarkEntry[];
1313+1414+ /** Read session/tab entries from the profile */
1515+ readSessions(profilePath: string): SessionTab[];
13161417 /** Inspect what data is available in the profile */
1518 inspect(profilePath: string): DataInspection[];
+37
tools/browser-import/src/datastore.ts
···234234}
235235236236/**
237237+ * Get the item ID for a URL, or null if it doesn't exist
238238+ */
239239+export function getItemIdByUrl(url: string): string | null {
240240+ if (!db) throw new Error('Database not opened');
241241+ const row = db.prepare(
242242+ "SELECT id FROM items WHERE type = 'url' AND content = ? AND deletedAt = 0"
243243+ ).get(url) as { id: string } | undefined;
244244+ return row ? row.id : null;
245245+}
246246+247247+/**
248248+ * Update visit count and lastVisitAt on an existing item
249249+ */
250250+export function updateItemVisitData(itemId: string, visitCount: number, lastVisitAt: number): void {
251251+ if (!db) throw new Error('Database not opened');
252252+ db.prepare(
253253+ 'UPDATE items SET visitCount = MAX(visitCount, ?), lastVisitAt = MAX(lastVisitAt, ?) WHERE id = ?'
254254+ ).run(visitCount, lastVisitAt, itemId);
255255+}
256256+257257+/**
258258+ * Record a visit event for an item in the item_events table
259259+ */
260260+export function recordVisit(itemId: string, timestamp: number, metadata?: Record<string, unknown>): void {
261261+ if (!db) throw new Error('Database not opened');
262262+263263+ const eventId = generateId('evt');
264264+ const ts = now();
265265+ const metadataJson = JSON.stringify(metadata || { eventType: 'visit' });
266266+267267+ db.prepare(`
268268+ INSERT INTO item_events (id, itemId, content, value, occurredAt, metadata, createdAt)
269269+ VALUES (?, ?, 'visit', NULL, ?, ?, ?)
270270+ `).run(eventId, itemId, timestamp, metadataJson, ts);
271271+}
272272+273273+/**
237274 * Run a function inside a transaction for performance
238275 */
239276export function runInTransaction<T>(fn: () => T): T {
···11+/**
22+ * Mozilla jsonlz4 decompressor
33+ *
44+ * Firefox uses a custom compression format for session files (.jsonlz4):
55+ * - 8-byte magic header: "mozLz40\0"
66+ * - 4-byte little-endian uint32: uncompressed size
77+ * - Remaining bytes: raw LZ4 block-compressed data
88+ *
99+ * This is NOT standard LZ4 framing — it's a raw LZ4 block with a Mozilla-specific header.
1010+ * Mozilla adopted LZ4 before the framing standard was finalized, so they use their own wrapper.
1111+ */
1212+1313+import { readFileSync } from 'fs';
1414+// @ts-ignore — lz4 doesn't have great type definitions
1515+import * as lz4 from 'lz4';
1616+1717+const MOZLZ4_MAGIC = 'mozLz40\0';
1818+1919+/**
2020+ * Decompress a .jsonlz4 file and parse the JSON contents.
2121+ *
2222+ * @param filePath Path to the .jsonlz4 file
2323+ * @returns Parsed JSON object
2424+ * @throws Error if the file is not a valid jsonlz4 file
2525+ */
2626+export function decompressJsonlz4(filePath: string): any {
2727+ const buf = readFileSync(filePath);
2828+2929+ // Verify magic header
3030+ if (buf.length < 12) {
3131+ throw new Error(`File too small to be jsonlz4: ${buf.length} bytes`);
3232+ }
3333+3434+ const magic = buf.subarray(0, 8).toString('ascii');
3535+ if (magic !== MOZLZ4_MAGIC) {
3636+ throw new Error(
3737+ `Invalid jsonlz4 magic header: expected "mozLz40\\0", got "${magic}"`
3838+ );
3939+ }
4040+4141+ // Read uncompressed size (4-byte LE uint32)
4242+ const uncompressedSize = buf.readUInt32LE(8);
4343+4444+ // Extract compressed data (everything after the 12-byte header)
4545+ const compressed = buf.subarray(12);
4646+4747+ // Allocate output buffer with the known uncompressed size
4848+ const output = Buffer.alloc(uncompressedSize);
4949+5050+ // Decompress the raw LZ4 block
5151+ const decodedSize = lz4.decodeBlock(compressed, output);
5252+5353+ if (decodedSize < 0) {
5454+ throw new Error(`LZ4 decompression failed at offset ${Math.abs(decodedSize)}`);
5555+ }
5656+5757+ // Parse the decompressed data as UTF-8 JSON
5858+ const jsonString = output.subarray(0, decodedSize).toString('utf-8');
5959+ return JSON.parse(jsonString);
6060+}