experiments in a post-browser web
10
fork

Configure Feed

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

feat(tools): add browser profile discovery and import tool

+2102
+539
tools/browser-import/package-lock.json
··· 1 + { 2 + "name": "@peek/browser-import", 3 + "version": "1.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "@peek/browser-import", 9 + "version": "1.0.0", 10 + "dependencies": { 11 + "better-sqlite3": "^12.5.0", 12 + "chalk": "^5.3.0", 13 + "commander": "^12.1.0" 14 + }, 15 + "bin": { 16 + "browser-import": "dist/index.js" 17 + }, 18 + "devDependencies": { 19 + "@types/better-sqlite3": "^7.6.8", 20 + "@types/node": "^20.11.0", 21 + "typescript": "^5.3.0" 22 + } 23 + }, 24 + "node_modules/@types/better-sqlite3": { 25 + "version": "7.6.13", 26 + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", 27 + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", 28 + "dev": true, 29 + "license": "MIT", 30 + "dependencies": { 31 + "@types/node": "*" 32 + } 33 + }, 34 + "node_modules/@types/node": { 35 + "version": "20.19.35", 36 + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", 37 + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", 38 + "dev": true, 39 + "license": "MIT", 40 + "dependencies": { 41 + "undici-types": "~6.21.0" 42 + } 43 + }, 44 + "node_modules/base64-js": { 45 + "version": "1.5.1", 46 + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 47 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 48 + "funding": [ 49 + { 50 + "type": "github", 51 + "url": "https://github.com/sponsors/feross" 52 + }, 53 + { 54 + "type": "patreon", 55 + "url": "https://www.patreon.com/feross" 56 + }, 57 + { 58 + "type": "consulting", 59 + "url": "https://feross.org/support" 60 + } 61 + ], 62 + "license": "MIT" 63 + }, 64 + "node_modules/better-sqlite3": { 65 + "version": "12.6.2", 66 + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", 67 + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", 68 + "hasInstallScript": true, 69 + "license": "MIT", 70 + "dependencies": { 71 + "bindings": "^1.5.0", 72 + "prebuild-install": "^7.1.1" 73 + }, 74 + "engines": { 75 + "node": "20.x || 22.x || 23.x || 24.x || 25.x" 76 + } 77 + }, 78 + "node_modules/bindings": { 79 + "version": "1.5.0", 80 + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", 81 + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", 82 + "license": "MIT", 83 + "dependencies": { 84 + "file-uri-to-path": "1.0.0" 85 + } 86 + }, 87 + "node_modules/bl": { 88 + "version": "4.1.0", 89 + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 90 + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 91 + "license": "MIT", 92 + "dependencies": { 93 + "buffer": "^5.5.0", 94 + "inherits": "^2.0.4", 95 + "readable-stream": "^3.4.0" 96 + } 97 + }, 98 + "node_modules/buffer": { 99 + "version": "5.7.1", 100 + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 101 + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 102 + "funding": [ 103 + { 104 + "type": "github", 105 + "url": "https://github.com/sponsors/feross" 106 + }, 107 + { 108 + "type": "patreon", 109 + "url": "https://www.patreon.com/feross" 110 + }, 111 + { 112 + "type": "consulting", 113 + "url": "https://feross.org/support" 114 + } 115 + ], 116 + "license": "MIT", 117 + "dependencies": { 118 + "base64-js": "^1.3.1", 119 + "ieee754": "^1.1.13" 120 + } 121 + }, 122 + "node_modules/chalk": { 123 + "version": "5.6.2", 124 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", 125 + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", 126 + "license": "MIT", 127 + "engines": { 128 + "node": "^12.17.0 || ^14.13 || >=16.0.0" 129 + }, 130 + "funding": { 131 + "url": "https://github.com/chalk/chalk?sponsor=1" 132 + } 133 + }, 134 + "node_modules/chownr": { 135 + "version": "1.1.4", 136 + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 137 + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", 138 + "license": "ISC" 139 + }, 140 + "node_modules/commander": { 141 + "version": "12.1.0", 142 + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", 143 + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", 144 + "license": "MIT", 145 + "engines": { 146 + "node": ">=18" 147 + } 148 + }, 149 + "node_modules/decompress-response": { 150 + "version": "6.0.0", 151 + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", 152 + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", 153 + "license": "MIT", 154 + "dependencies": { 155 + "mimic-response": "^3.1.0" 156 + }, 157 + "engines": { 158 + "node": ">=10" 159 + }, 160 + "funding": { 161 + "url": "https://github.com/sponsors/sindresorhus" 162 + } 163 + }, 164 + "node_modules/deep-extend": { 165 + "version": "0.6.0", 166 + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", 167 + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", 168 + "license": "MIT", 169 + "engines": { 170 + "node": ">=4.0.0" 171 + } 172 + }, 173 + "node_modules/detect-libc": { 174 + "version": "2.1.2", 175 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", 176 + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", 177 + "license": "Apache-2.0", 178 + "engines": { 179 + "node": ">=8" 180 + } 181 + }, 182 + "node_modules/end-of-stream": { 183 + "version": "1.4.5", 184 + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", 185 + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", 186 + "license": "MIT", 187 + "dependencies": { 188 + "once": "^1.4.0" 189 + } 190 + }, 191 + "node_modules/expand-template": { 192 + "version": "2.0.3", 193 + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", 194 + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", 195 + "license": "(MIT OR WTFPL)", 196 + "engines": { 197 + "node": ">=6" 198 + } 199 + }, 200 + "node_modules/file-uri-to-path": { 201 + "version": "1.0.0", 202 + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", 203 + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", 204 + "license": "MIT" 205 + }, 206 + "node_modules/fs-constants": { 207 + "version": "1.0.0", 208 + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 209 + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", 210 + "license": "MIT" 211 + }, 212 + "node_modules/github-from-package": { 213 + "version": "0.0.0", 214 + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", 215 + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", 216 + "license": "MIT" 217 + }, 218 + "node_modules/ieee754": { 219 + "version": "1.2.1", 220 + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 221 + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 222 + "funding": [ 223 + { 224 + "type": "github", 225 + "url": "https://github.com/sponsors/feross" 226 + }, 227 + { 228 + "type": "patreon", 229 + "url": "https://www.patreon.com/feross" 230 + }, 231 + { 232 + "type": "consulting", 233 + "url": "https://feross.org/support" 234 + } 235 + ], 236 + "license": "BSD-3-Clause" 237 + }, 238 + "node_modules/inherits": { 239 + "version": "2.0.4", 240 + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 241 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 242 + "license": "ISC" 243 + }, 244 + "node_modules/ini": { 245 + "version": "1.3.8", 246 + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", 247 + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", 248 + "license": "ISC" 249 + }, 250 + "node_modules/mimic-response": { 251 + "version": "3.1.0", 252 + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", 253 + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", 254 + "license": "MIT", 255 + "engines": { 256 + "node": ">=10" 257 + }, 258 + "funding": { 259 + "url": "https://github.com/sponsors/sindresorhus" 260 + } 261 + }, 262 + "node_modules/minimist": { 263 + "version": "1.2.8", 264 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 265 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 266 + "license": "MIT", 267 + "funding": { 268 + "url": "https://github.com/sponsors/ljharb" 269 + } 270 + }, 271 + "node_modules/mkdirp-classic": { 272 + "version": "0.5.3", 273 + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", 274 + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", 275 + "license": "MIT" 276 + }, 277 + "node_modules/napi-build-utils": { 278 + "version": "2.0.0", 279 + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", 280 + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", 281 + "license": "MIT" 282 + }, 283 + "node_modules/node-abi": { 284 + "version": "3.87.0", 285 + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", 286 + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", 287 + "license": "MIT", 288 + "dependencies": { 289 + "semver": "^7.3.5" 290 + }, 291 + "engines": { 292 + "node": ">=10" 293 + } 294 + }, 295 + "node_modules/once": { 296 + "version": "1.4.0", 297 + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 298 + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 299 + "license": "ISC", 300 + "dependencies": { 301 + "wrappy": "1" 302 + } 303 + }, 304 + "node_modules/prebuild-install": { 305 + "version": "7.1.3", 306 + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", 307 + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", 308 + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", 309 + "license": "MIT", 310 + "dependencies": { 311 + "detect-libc": "^2.0.0", 312 + "expand-template": "^2.0.3", 313 + "github-from-package": "0.0.0", 314 + "minimist": "^1.2.3", 315 + "mkdirp-classic": "^0.5.3", 316 + "napi-build-utils": "^2.0.0", 317 + "node-abi": "^3.3.0", 318 + "pump": "^3.0.0", 319 + "rc": "^1.2.7", 320 + "simple-get": "^4.0.0", 321 + "tar-fs": "^2.0.0", 322 + "tunnel-agent": "^0.6.0" 323 + }, 324 + "bin": { 325 + "prebuild-install": "bin.js" 326 + }, 327 + "engines": { 328 + "node": ">=10" 329 + } 330 + }, 331 + "node_modules/pump": { 332 + "version": "3.0.4", 333 + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", 334 + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", 335 + "license": "MIT", 336 + "dependencies": { 337 + "end-of-stream": "^1.1.0", 338 + "once": "^1.3.1" 339 + } 340 + }, 341 + "node_modules/rc": { 342 + "version": "1.2.8", 343 + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", 344 + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", 345 + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", 346 + "dependencies": { 347 + "deep-extend": "^0.6.0", 348 + "ini": "~1.3.0", 349 + "minimist": "^1.2.0", 350 + "strip-json-comments": "~2.0.1" 351 + }, 352 + "bin": { 353 + "rc": "cli.js" 354 + } 355 + }, 356 + "node_modules/readable-stream": { 357 + "version": "3.6.2", 358 + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 359 + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 360 + "license": "MIT", 361 + "dependencies": { 362 + "inherits": "^2.0.3", 363 + "string_decoder": "^1.1.1", 364 + "util-deprecate": "^1.0.1" 365 + }, 366 + "engines": { 367 + "node": ">= 6" 368 + } 369 + }, 370 + "node_modules/safe-buffer": { 371 + "version": "5.2.1", 372 + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 373 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 374 + "funding": [ 375 + { 376 + "type": "github", 377 + "url": "https://github.com/sponsors/feross" 378 + }, 379 + { 380 + "type": "patreon", 381 + "url": "https://www.patreon.com/feross" 382 + }, 383 + { 384 + "type": "consulting", 385 + "url": "https://feross.org/support" 386 + } 387 + ], 388 + "license": "MIT" 389 + }, 390 + "node_modules/semver": { 391 + "version": "7.7.4", 392 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 393 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 394 + "license": "ISC", 395 + "bin": { 396 + "semver": "bin/semver.js" 397 + }, 398 + "engines": { 399 + "node": ">=10" 400 + } 401 + }, 402 + "node_modules/simple-concat": { 403 + "version": "1.0.1", 404 + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", 405 + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", 406 + "funding": [ 407 + { 408 + "type": "github", 409 + "url": "https://github.com/sponsors/feross" 410 + }, 411 + { 412 + "type": "patreon", 413 + "url": "https://www.patreon.com/feross" 414 + }, 415 + { 416 + "type": "consulting", 417 + "url": "https://feross.org/support" 418 + } 419 + ], 420 + "license": "MIT" 421 + }, 422 + "node_modules/simple-get": { 423 + "version": "4.0.1", 424 + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", 425 + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", 426 + "funding": [ 427 + { 428 + "type": "github", 429 + "url": "https://github.com/sponsors/feross" 430 + }, 431 + { 432 + "type": "patreon", 433 + "url": "https://www.patreon.com/feross" 434 + }, 435 + { 436 + "type": "consulting", 437 + "url": "https://feross.org/support" 438 + } 439 + ], 440 + "license": "MIT", 441 + "dependencies": { 442 + "decompress-response": "^6.0.0", 443 + "once": "^1.3.1", 444 + "simple-concat": "^1.0.0" 445 + } 446 + }, 447 + "node_modules/string_decoder": { 448 + "version": "1.3.0", 449 + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 450 + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 451 + "license": "MIT", 452 + "dependencies": { 453 + "safe-buffer": "~5.2.0" 454 + } 455 + }, 456 + "node_modules/strip-json-comments": { 457 + "version": "2.0.1", 458 + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", 459 + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", 460 + "license": "MIT", 461 + "engines": { 462 + "node": ">=0.10.0" 463 + } 464 + }, 465 + "node_modules/tar-fs": { 466 + "version": "2.1.4", 467 + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", 468 + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", 469 + "license": "MIT", 470 + "dependencies": { 471 + "chownr": "^1.1.1", 472 + "mkdirp-classic": "^0.5.2", 473 + "pump": "^3.0.0", 474 + "tar-stream": "^2.1.4" 475 + } 476 + }, 477 + "node_modules/tar-stream": { 478 + "version": "2.2.0", 479 + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 480 + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 481 + "license": "MIT", 482 + "dependencies": { 483 + "bl": "^4.0.3", 484 + "end-of-stream": "^1.4.1", 485 + "fs-constants": "^1.0.0", 486 + "inherits": "^2.0.3", 487 + "readable-stream": "^3.1.1" 488 + }, 489 + "engines": { 490 + "node": ">=6" 491 + } 492 + }, 493 + "node_modules/tunnel-agent": { 494 + "version": "0.6.0", 495 + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", 496 + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", 497 + "license": "Apache-2.0", 498 + "dependencies": { 499 + "safe-buffer": "^5.0.1" 500 + }, 501 + "engines": { 502 + "node": "*" 503 + } 504 + }, 505 + "node_modules/typescript": { 506 + "version": "5.9.3", 507 + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", 508 + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 509 + "dev": true, 510 + "license": "Apache-2.0", 511 + "bin": { 512 + "tsc": "bin/tsc", 513 + "tsserver": "bin/tsserver" 514 + }, 515 + "engines": { 516 + "node": ">=14.17" 517 + } 518 + }, 519 + "node_modules/undici-types": { 520 + "version": "6.21.0", 521 + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 522 + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 523 + "dev": true, 524 + "license": "MIT" 525 + }, 526 + "node_modules/util-deprecate": { 527 + "version": "1.0.2", 528 + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 529 + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 530 + "license": "MIT" 531 + }, 532 + "node_modules/wrappy": { 533 + "version": "1.0.2", 534 + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 535 + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", 536 + "license": "ISC" 537 + } 538 + } 539 + }
+24
tools/browser-import/package.json
··· 1 + { 2 + "name": "@peek/browser-import", 3 + "version": "1.0.0", 4 + "description": "CLI tool to index browser profiles and import their data into a Peek datastore", 5 + "type": "module", 6 + "bin": { 7 + "browser-import": "./dist/index.js" 8 + }, 9 + "scripts": { 10 + "build": "tsc", 11 + "start": "node dist/index.js", 12 + "dev": "tsc && node dist/index.js" 13 + }, 14 + "dependencies": { 15 + "better-sqlite3": "^12.5.0", 16 + "chalk": "^5.3.0", 17 + "commander": "^12.1.0" 18 + }, 19 + "devDependencies": { 20 + "@types/better-sqlite3": "^7.6.8", 21 + "@types/node": "^20.11.0", 22 + "typescript": "^5.3.0" 23 + } 24 + }
+256
tools/browser-import/src/browsers/chrome.ts
··· 1 + /** 2 + * Chrome/Brave profile reader 3 + * 4 + * Both Chrome and Brave use the same Chromium profile format. 5 + * History is in a SQLite DB, bookmarks in a JSON file. 6 + */ 7 + 8 + import { existsSync, readFileSync, copyFileSync, mkdtempSync } from 'fs'; 9 + import { join } from 'path'; 10 + import { tmpdir } from 'os'; 11 + import Database from 'better-sqlite3'; 12 + import type { BrowserReader } from './types.js'; 13 + import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js'; 14 + 15 + /** 16 + * Copy a SQLite DB to a temp location before reading. 17 + * Browsers hold locks on their DBs; reading in-place can fail or corrupt data. 18 + */ 19 + function copyToTemp(dbPath: string): string { 20 + const tempDir = mkdtempSync(join(tmpdir(), 'browser-import-')); 21 + const tempPath = join(tempDir, 'copy.sqlite'); 22 + copyFileSync(dbPath, tempPath); 23 + const walPath = dbPath + '-wal'; 24 + const shmPath = dbPath + '-shm'; 25 + if (existsSync(walPath)) { 26 + copyFileSync(walPath, tempPath + '-wal'); 27 + } 28 + if (existsSync(shmPath)) { 29 + copyFileSync(shmPath, tempPath + '-shm'); 30 + } 31 + return tempPath; 32 + } 33 + 34 + function openSafely(dbPath: string): Database.Database | null { 35 + if (!existsSync(dbPath)) return null; 36 + try { 37 + const tempPath = copyToTemp(dbPath); 38 + return new Database(tempPath, { readonly: true }); 39 + } catch { 40 + return null; 41 + } 42 + } 43 + 44 + /** 45 + * Chrome epoch: microseconds since 1601-01-01. 46 + * Convert to Unix milliseconds. 47 + */ 48 + function chromeTimeToUnixMs(chromeTime: number): number { 49 + // Chrome time is microseconds since 1601-01-01 50 + // Unix epoch is 1970-01-01 51 + // Difference: 11644473600 seconds 52 + const unixMicroseconds = chromeTime - 11644473600000000; 53 + return Math.floor(unixMicroseconds / 1000); 54 + } 55 + 56 + /** 57 + * WebKit time (used in Bookmarks JSON): microseconds since 1601-01-01 58 + * Same conversion as Chrome time. 59 + */ 60 + function webkitTimeToUnixMs(webkitTime: string | number): number { 61 + const t = typeof webkitTime === 'string' ? parseInt(webkitTime, 10) : webkitTime; 62 + if (isNaN(t) || t === 0) return Date.now(); 63 + return chromeTimeToUnixMs(t); 64 + } 65 + 66 + interface ChromeBookmarkNode { 67 + type: 'url' | 'folder'; 68 + name: string; 69 + url?: string; 70 + date_added?: string; 71 + children?: ChromeBookmarkNode[]; 72 + } 73 + 74 + function flattenBookmarks(node: ChromeBookmarkNode, folderPath: string): BookmarkEntry[] { 75 + const entries: BookmarkEntry[] = []; 76 + 77 + if (node.type === 'url' && node.url) { 78 + // Skip internal chrome:// and edge:// URLs 79 + if (!node.url.startsWith('chrome://') && !node.url.startsWith('edge://') && !node.url.startsWith('brave://')) { 80 + entries.push({ 81 + url: node.url, 82 + title: node.name || '', 83 + folderPath, 84 + dateAdded: webkitTimeToUnixMs(node.date_added || '0'), 85 + }); 86 + } 87 + } 88 + 89 + if (node.children) { 90 + const childPath = folderPath ? `${folderPath}/${node.name}` : node.name; 91 + for (const child of node.children) { 92 + entries.push(...flattenBookmarks(child, childPath)); 93 + } 94 + } 95 + 96 + return entries; 97 + } 98 + 99 + export const chromeReader: BrowserReader = { 100 + readHistory(profilePath: string): HistoryEntry[] { 101 + const dbPath = join(profilePath, 'History'); 102 + const db = openSafely(dbPath); 103 + if (!db) return []; 104 + 105 + try { 106 + const rows = db.prepare(` 107 + SELECT 108 + u.url, 109 + u.title, 110 + u.visit_count, 111 + MAX(v.visit_time) as last_visit_time 112 + FROM urls u 113 + JOIN visits v ON u.id = v.url 114 + WHERE u.url NOT LIKE 'chrome://%' 115 + AND u.url NOT LIKE 'chrome-extension://%' 116 + AND u.url NOT LIKE 'brave://%' 117 + AND u.url NOT LIKE 'edge://%' 118 + GROUP BY u.id 119 + ORDER BY last_visit_time DESC 120 + `).all() as Array<{ 121 + url: string; 122 + title: string | null; 123 + visit_count: number; 124 + last_visit_time: number; 125 + }>; 126 + 127 + return rows.map(row => ({ 128 + url: row.url, 129 + title: row.title || '', 130 + visitCount: row.visit_count, 131 + lastVisitTime: chromeTimeToUnixMs(row.last_visit_time), 132 + })); 133 + } catch { 134 + return []; 135 + } finally { 136 + db.close(); 137 + } 138 + }, 139 + 140 + readBookmarks(profilePath: string): BookmarkEntry[] { 141 + const bookmarksPath = join(profilePath, 'Bookmarks'); 142 + if (!existsSync(bookmarksPath)) return []; 143 + 144 + try { 145 + const data = JSON.parse(readFileSync(bookmarksPath, 'utf-8')); 146 + const roots = data.roots; 147 + if (!roots) return []; 148 + 149 + const entries: BookmarkEntry[] = []; 150 + // Chrome has bookmark_bar, other, synced as root folders 151 + for (const rootKey of Object.keys(roots)) { 152 + const rootNode = roots[rootKey] as ChromeBookmarkNode; 153 + if (rootNode && typeof rootNode === 'object' && 'children' in rootNode) { 154 + entries.push(...flattenBookmarks(rootNode, '')); 155 + } 156 + } 157 + return entries; 158 + } catch { 159 + return []; 160 + } 161 + }, 162 + 163 + inspect(profilePath: string): DataInspection[] { 164 + const results: DataInspection[] = []; 165 + 166 + // History 167 + const historyDb = openSafely(join(profilePath, 'History')); 168 + if (historyDb) { 169 + try { 170 + const count = (historyDb.prepare( 171 + `SELECT COUNT(DISTINCT u.id) as cnt FROM urls u 172 + JOIN visits v ON u.id = v.url 173 + WHERE u.url NOT LIKE 'chrome://%' 174 + AND u.url NOT LIKE 'chrome-extension://%' 175 + AND u.url NOT LIKE 'brave://%' 176 + AND u.url NOT LIKE 'edge://%'` 177 + ).get() as { cnt: number }).cnt; 178 + results.push({ type: 'history', count, importable: true, label: 'History' }); 179 + } catch { 180 + // table may not exist 181 + } finally { 182 + historyDb.close(); 183 + } 184 + } 185 + 186 + // Bookmarks 187 + const bookmarksPath = join(profilePath, 'Bookmarks'); 188 + if (existsSync(bookmarksPath)) { 189 + try { 190 + const data = JSON.parse(readFileSync(bookmarksPath, 'utf-8')); 191 + const entries = this.readBookmarks(profilePath); 192 + results.push({ type: 'bookmarks', count: entries.length, importable: true, label: 'Bookmarks' }); 193 + } catch { 194 + results.push({ type: 'bookmarks', count: 0, importable: true, label: 'Bookmarks' }); 195 + } 196 + } 197 + 198 + // Passwords (Login Data SQLite) 199 + const loginDb = openSafely(join(profilePath, 'Login Data')); 200 + if (loginDb) { 201 + try { 202 + const count = (loginDb.prepare('SELECT COUNT(*) as cnt FROM logins').get() as { cnt: number }).cnt; 203 + results.push({ type: 'passwords', count, importable: false, label: 'Passwords' }); 204 + } catch { 205 + // table may not exist 206 + } finally { 207 + loginDb.close(); 208 + } 209 + } 210 + 211 + // Cookies 212 + const cookieDb = openSafely(join(profilePath, 'Cookies')); 213 + if (cookieDb) { 214 + try { 215 + const count = (cookieDb.prepare('SELECT COUNT(*) as cnt FROM cookies').get() as { cnt: number }).cnt; 216 + results.push({ type: 'cookies', count, importable: false, label: 'Cookies' }); 217 + } catch { 218 + // table may not exist 219 + } finally { 220 + cookieDb.close(); 221 + } 222 + } 223 + 224 + // Form/Autofill data 225 + const webDataDb = openSafely(join(profilePath, 'Web Data')); 226 + if (webDataDb) { 227 + try { 228 + const count = (webDataDb.prepare('SELECT COUNT(*) as cnt FROM autofill').get() as { cnt: number }).cnt; 229 + results.push({ type: 'formdata', count, importable: false, label: 'Form Data' }); 230 + } catch { 231 + // table may not exist 232 + } finally { 233 + webDataDb.close(); 234 + } 235 + } 236 + 237 + // Extensions 238 + const prefsPath = join(profilePath, 'Preferences'); 239 + if (existsSync(prefsPath)) { 240 + try { 241 + const prefs = JSON.parse(readFileSync(prefsPath, 'utf-8')); 242 + const extensions = prefs.extensions?.settings; 243 + if (extensions && typeof extensions === 'object') { 244 + const extCount = Object.values(extensions).filter( 245 + (ext: any) => ext.manifest && ext.manifest.manifest_version 246 + ).length; 247 + results.push({ type: 'extensions', count: extCount, importable: false, label: 'Extensions' }); 248 + } 249 + } catch { 250 + // can't parse prefs 251 + } 252 + } 253 + 254 + return results; 255 + }, 256 + };
+230
tools/browser-import/src/browsers/firefox.ts
··· 1 + /** 2 + * Firefox profile reader 3 + * 4 + * Reads history and bookmarks from Firefox's places.sqlite, 5 + * and inspects other data files (passwords, cookies, form data, extensions). 6 + */ 7 + 8 + import { existsSync, readFileSync, copyFileSync, mkdtempSync } from 'fs'; 9 + import { join } from 'path'; 10 + import { tmpdir } from 'os'; 11 + import Database from 'better-sqlite3'; 12 + import type { BrowserReader } from './types.js'; 13 + import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js'; 14 + 15 + /** 16 + * Copy a SQLite DB to a temp location before reading. 17 + * Browsers hold locks on their DBs; reading in-place can fail or corrupt data. 18 + */ 19 + function copyToTemp(dbPath: string): string { 20 + const tempDir = mkdtempSync(join(tmpdir(), 'browser-import-')); 21 + const tempPath = join(tempDir, 'copy.sqlite'); 22 + copyFileSync(dbPath, tempPath); 23 + // Also copy WAL and SHM files if they exist (needed for consistent reads) 24 + const walPath = dbPath + '-wal'; 25 + const shmPath = dbPath + '-shm'; 26 + if (existsSync(walPath)) { 27 + copyFileSync(walPath, tempPath + '-wal'); 28 + } 29 + if (existsSync(shmPath)) { 30 + copyFileSync(shmPath, tempPath + '-shm'); 31 + } 32 + return tempPath; 33 + } 34 + 35 + function openSafely(dbPath: string): Database.Database | null { 36 + if (!existsSync(dbPath)) return null; 37 + try { 38 + const tempPath = copyToTemp(dbPath); 39 + return new Database(tempPath, { readonly: true }); 40 + } catch { 41 + return null; 42 + } 43 + } 44 + 45 + export const firefoxReader: BrowserReader = { 46 + readHistory(profilePath: string): HistoryEntry[] { 47 + const dbPath = join(profilePath, 'places.sqlite'); 48 + const db = openSafely(dbPath); 49 + if (!db) return []; 50 + 51 + try { 52 + const rows = db.prepare(` 53 + SELECT 54 + p.url, 55 + p.title, 56 + p.visit_count, 57 + MAX(v.visit_date) as last_visit_date 58 + FROM moz_places p 59 + JOIN moz_historyvisits v ON p.id = v.place_id 60 + WHERE p.url NOT LIKE 'place:%' 61 + AND p.url NOT LIKE 'about:%' 62 + GROUP BY p.id 63 + ORDER BY last_visit_date DESC 64 + `).all() as Array<{ 65 + url: string; 66 + title: string | null; 67 + visit_count: number; 68 + last_visit_date: number; 69 + }>; 70 + 71 + return rows.map(row => ({ 72 + url: row.url, 73 + title: row.title || '', 74 + visitCount: row.visit_count, 75 + // Firefox stores timestamps in microseconds since epoch 76 + lastVisitTime: Math.floor(row.last_visit_date / 1000), 77 + })); 78 + } catch { 79 + return []; 80 + } finally { 81 + db.close(); 82 + } 83 + }, 84 + 85 + readBookmarks(profilePath: string): BookmarkEntry[] { 86 + const dbPath = join(profilePath, 'places.sqlite'); 87 + const db = openSafely(dbPath); 88 + if (!db) return []; 89 + 90 + try { 91 + // First build the folder tree for path resolution 92 + const folders = db.prepare(` 93 + SELECT id, title, parent 94 + FROM moz_bookmarks 95 + WHERE type = 2 96 + `).all() as Array<{ id: number; title: string | null; parent: number }>; 97 + 98 + const folderMap = new Map<number, { title: string; parent: number }>(); 99 + for (const f of folders) { 100 + folderMap.set(f.id, { title: f.title || '', parent: f.parent }); 101 + } 102 + 103 + function getFolderPath(parentId: number): string { 104 + const parts: string[] = []; 105 + let current = parentId; 106 + const visited = new Set<number>(); 107 + while (current && folderMap.has(current) && !visited.has(current)) { 108 + visited.add(current); 109 + const folder = folderMap.get(current)!; 110 + if (folder.title) { 111 + parts.unshift(folder.title); 112 + } 113 + current = folder.parent; 114 + } 115 + return parts.join('/'); 116 + } 117 + 118 + // Get bookmarks (type=1 is bookmark, not folder or separator) 119 + const rows = db.prepare(` 120 + SELECT 121 + b.title as bookmark_title, 122 + b.dateAdded, 123 + b.parent, 124 + p.url 125 + FROM moz_bookmarks b 126 + JOIN moz_places p ON b.fk = p.id 127 + WHERE b.type = 1 128 + AND p.url NOT LIKE 'place:%' 129 + AND p.url NOT LIKE 'about:%' 130 + `).all() as Array<{ 131 + bookmark_title: string | null; 132 + dateAdded: number; 133 + parent: number; 134 + url: string; 135 + }>; 136 + 137 + return rows.map(row => ({ 138 + url: row.url, 139 + title: row.bookmark_title || '', 140 + folderPath: getFolderPath(row.parent), 141 + // Firefox stores dateAdded in microseconds 142 + dateAdded: Math.floor(row.dateAdded / 1000), 143 + })); 144 + } catch { 145 + return []; 146 + } finally { 147 + db.close(); 148 + } 149 + }, 150 + 151 + inspect(profilePath: string): DataInspection[] { 152 + const results: DataInspection[] = []; 153 + 154 + // History 155 + const placesDb = openSafely(join(profilePath, 'places.sqlite')); 156 + if (placesDb) { 157 + try { 158 + const historyCount = (placesDb.prepare( 159 + `SELECT COUNT(DISTINCT p.id) as cnt FROM moz_places p 160 + JOIN moz_historyvisits v ON p.id = v.place_id 161 + WHERE p.url NOT LIKE 'place:%' AND p.url NOT LIKE 'about:%'` 162 + ).get() as { cnt: number }).cnt; 163 + results.push({ type: 'history', count: historyCount, importable: true, label: 'History' }); 164 + 165 + const bookmarkCount = (placesDb.prepare( 166 + `SELECT COUNT(*) as cnt FROM moz_bookmarks b 167 + JOIN moz_places p ON b.fk = p.id 168 + WHERE b.type = 1 AND p.url NOT LIKE 'place:%' AND p.url NOT LIKE 'about:%'` 169 + ).get() as { cnt: number }).cnt; 170 + results.push({ type: 'bookmarks', count: bookmarkCount, importable: true, label: 'Bookmarks' }); 171 + } catch { 172 + // tables may not exist 173 + } finally { 174 + placesDb.close(); 175 + } 176 + } 177 + 178 + // Passwords (logins.json) 179 + const loginsPath = join(profilePath, 'logins.json'); 180 + if (existsSync(loginsPath)) { 181 + try { 182 + const logins = JSON.parse(readFileSync(loginsPath, 'utf-8')); 183 + const count = Array.isArray(logins.logins) ? logins.logins.length : 0; 184 + results.push({ type: 'passwords', count, importable: false, label: 'Passwords' }); 185 + } catch { 186 + results.push({ type: 'passwords', count: 0, importable: false, label: 'Passwords' }); 187 + } 188 + } 189 + 190 + // Cookies 191 + const cookiesDb = openSafely(join(profilePath, 'cookies.sqlite')); 192 + if (cookiesDb) { 193 + try { 194 + const count = (cookiesDb.prepare('SELECT COUNT(*) as cnt FROM moz_cookies').get() as { cnt: number }).cnt; 195 + results.push({ type: 'cookies', count, importable: false, label: 'Cookies' }); 196 + } catch { 197 + // table may not exist 198 + } finally { 199 + cookiesDb.close(); 200 + } 201 + } 202 + 203 + // Form data 204 + const formDb = openSafely(join(profilePath, 'formhistory.sqlite')); 205 + if (formDb) { 206 + try { 207 + const count = (formDb.prepare('SELECT COUNT(*) as cnt FROM moz_formhistory').get() as { cnt: number }).cnt; 208 + results.push({ type: 'formdata', count, importable: false, label: 'Form Data' }); 209 + } catch { 210 + // table may not exist 211 + } finally { 212 + formDb.close(); 213 + } 214 + } 215 + 216 + // Extensions 217 + const extensionsPath = join(profilePath, 'extensions.json'); 218 + if (existsSync(extensionsPath)) { 219 + try { 220 + const data = JSON.parse(readFileSync(extensionsPath, 'utf-8')); 221 + const count = Array.isArray(data.addons) ? data.addons.filter((a: { type: string }) => a.type === 'extension').length : 0; 222 + results.push({ type: 'extensions', count, importable: false, label: 'Extensions' }); 223 + } catch { 224 + results.push({ type: 'extensions', count: 0, importable: false, label: 'Extensions' }); 225 + } 226 + } 227 + 228 + return results; 229 + }, 230 + };
+16
tools/browser-import/src/browsers/types.ts
··· 1 + /** 2 + * Shared browser reader types 3 + */ 4 + 5 + import type { HistoryEntry, BookmarkEntry, DataInspection } from '../types.js'; 6 + 7 + export interface BrowserReader { 8 + /** Read history entries from the profile */ 9 + readHistory(profilePath: string): HistoryEntry[]; 10 + 11 + /** Read bookmark entries from the profile */ 12 + readBookmarks(profilePath: string): BookmarkEntry[]; 13 + 14 + /** Inspect what data is available in the profile */ 15 + inspect(profilePath: string): DataInspection[]; 16 + }
+252
tools/browser-import/src/datastore.ts
··· 1 + /** 2 + * Peek datastore writer 3 + * 4 + * Creates and writes to a Peek-compatible SQLite database, 5 + * using the same schema as the Peek desktop app. 6 + */ 7 + 8 + import Database from 'better-sqlite3'; 9 + import { existsSync } from 'fs'; 10 + 11 + let db: Database.Database | null = null; 12 + 13 + /** 14 + * Generate a unique ID matching Peek's format 15 + */ 16 + function generateId(prefix = 'id'): string { 17 + return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 18 + } 19 + 20 + function now(): number { 21 + return Date.now(); 22 + } 23 + 24 + /** 25 + * Open or create a Peek-compatible database 26 + */ 27 + export function openDatabase(dbPath: string): void { 28 + const isNew = !existsSync(dbPath); 29 + db = new Database(dbPath); 30 + 31 + // Enable WAL mode for better performance 32 + db.pragma('journal_mode = WAL'); 33 + 34 + if (isNew) { 35 + initializeSchema(); 36 + } else { 37 + // Verify it has the expected tables 38 + const tables = db.prepare( 39 + "SELECT name FROM sqlite_master WHERE type='table'" 40 + ).all() as Array<{ name: string }>; 41 + const tableNames = new Set(tables.map(t => t.name)); 42 + if (!tableNames.has('items') || !tableNames.has('tags') || !tableNames.has('item_tags')) { 43 + initializeSchema(); 44 + } 45 + } 46 + } 47 + 48 + /** 49 + * Initialize the database with Peek's schema 50 + */ 51 + function initializeSchema(): void { 52 + if (!db) throw new Error('Database not opened'); 53 + 54 + db.exec(` 55 + CREATE TABLE IF NOT EXISTS items ( 56 + id TEXT PRIMARY KEY, 57 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity')), 58 + content TEXT, 59 + mimeType TEXT DEFAULT '', 60 + metadata TEXT DEFAULT '{}', 61 + syncId TEXT DEFAULT '', 62 + syncedAt INTEGER DEFAULT 0, 63 + createdAt INTEGER NOT NULL, 64 + updatedAt INTEGER NOT NULL, 65 + deletedAt INTEGER DEFAULT 0, 66 + starred INTEGER DEFAULT 0, 67 + archived INTEGER DEFAULT 0, 68 + visitCount INTEGER DEFAULT 0, 69 + lastVisitAt INTEGER DEFAULT 0, 70 + frecencyScore INTEGER DEFAULT 0, 71 + title TEXT DEFAULT '', 72 + domain TEXT DEFAULT '', 73 + favicon TEXT DEFAULT '' 74 + ); 75 + CREATE INDEX IF NOT EXISTS idx_items_type ON items(type); 76 + CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId); 77 + CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt); 78 + CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC); 79 + CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred); 80 + CREATE INDEX IF NOT EXISTS idx_items_lastVisitAt ON items(lastVisitAt); 81 + CREATE INDEX IF NOT EXISTS idx_items_visitCount ON items(visitCount); 82 + CREATE INDEX IF NOT EXISTS idx_items_frecencyScore ON items(frecencyScore DESC); 83 + CREATE INDEX IF NOT EXISTS idx_items_domain ON items(domain); 84 + 85 + CREATE TABLE IF NOT EXISTS tags ( 86 + id TEXT PRIMARY KEY, 87 + name TEXT NOT NULL UNIQUE, 88 + slug TEXT, 89 + color TEXT DEFAULT '#999999', 90 + parentId TEXT DEFAULT '', 91 + description TEXT DEFAULT '', 92 + metadata TEXT DEFAULT '{}', 93 + createdAt INTEGER, 94 + updatedAt INTEGER, 95 + frequency INTEGER DEFAULT 0, 96 + lastUsed INTEGER DEFAULT 0, 97 + frecencyScore REAL DEFAULT 0.0 98 + ); 99 + CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); 100 + CREATE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug); 101 + CREATE INDEX IF NOT EXISTS idx_tags_parentId ON tags(parentId); 102 + CREATE INDEX IF NOT EXISTS idx_tags_frecencyScore ON tags(frecencyScore DESC); 103 + 104 + CREATE TABLE IF NOT EXISTS item_tags ( 105 + id TEXT PRIMARY KEY, 106 + itemId TEXT NOT NULL, 107 + tagId TEXT NOT NULL, 108 + createdAt INTEGER NOT NULL 109 + ); 110 + CREATE INDEX IF NOT EXISTS idx_item_tags_itemId ON item_tags(itemId); 111 + CREATE INDEX IF NOT EXISTS idx_item_tags_tagId ON item_tags(tagId); 112 + CREATE UNIQUE INDEX IF NOT EXISTS idx_item_tags_unique ON item_tags(itemId, tagId); 113 + 114 + CREATE TABLE IF NOT EXISTS item_events ( 115 + id TEXT PRIMARY KEY, 116 + itemId TEXT NOT NULL, 117 + content TEXT, 118 + value REAL, 119 + occurredAt INTEGER NOT NULL, 120 + metadata TEXT DEFAULT '{}', 121 + createdAt INTEGER NOT NULL 122 + ); 123 + CREATE INDEX IF NOT EXISTS idx_item_events_item_time ON item_events(itemId, occurredAt DESC); 124 + CREATE INDEX IF NOT EXISTS idx_item_events_occurred ON item_events(occurredAt DESC); 125 + `); 126 + } 127 + 128 + /** 129 + * Check if a URL item already exists in the database 130 + */ 131 + export function itemExistsByUrl(url: string): boolean { 132 + if (!db) throw new Error('Database not opened'); 133 + const row = db.prepare( 134 + "SELECT id FROM items WHERE type = 'url' AND content = ? AND deletedAt = 0" 135 + ).get(url); 136 + return !!row; 137 + } 138 + 139 + /** 140 + * Create a new item in the database 141 + */ 142 + export function createItem(options: { 143 + type: string; 144 + content: string; 145 + metadata?: Record<string, unknown>; 146 + title?: string; 147 + visitCount?: number; 148 + lastVisitAt?: number; 149 + }): string { 150 + if (!db) throw new Error('Database not opened'); 151 + 152 + const itemId = generateId('item'); 153 + const timestamp = now(); 154 + const metadataJson = JSON.stringify(options.metadata || {}); 155 + 156 + let domain = ''; 157 + if (options.type === 'url' && options.content) { 158 + try { 159 + const parsed = new URL(options.content); 160 + domain = parsed.hostname; 161 + } catch { 162 + // not a valid URL 163 + } 164 + } 165 + 166 + db.prepare(` 167 + INSERT INTO items (id, type, content, mimeType, metadata, syncId, createdAt, updatedAt, 168 + deletedAt, starred, archived, visitCount, lastVisitAt, frecencyScore, 169 + title, domain, favicon) 170 + VALUES (?, ?, ?, '', ?, '', ?, ?, 0, 0, 0, ?, ?, 0, ?, ?, '') 171 + `).run( 172 + itemId, 173 + options.type, 174 + options.content, 175 + metadataJson, 176 + timestamp, 177 + timestamp, 178 + options.visitCount || 0, 179 + options.lastVisitAt || 0, 180 + options.title || '', 181 + domain, 182 + ); 183 + 184 + return itemId; 185 + } 186 + 187 + /** 188 + * Get or create a tag by name (matching Peek's logic) 189 + */ 190 + export function getOrCreateTag(name: string): { tagId: string; created: boolean } { 191 + if (!db) throw new Error('Database not opened'); 192 + 193 + const slug = name.toLowerCase().trim().replace(/\s+/g, '-'); 194 + const existing = db.prepare( 195 + 'SELECT id FROM tags WHERE LOWER(name) = LOWER(?)' 196 + ).get(name) as { id: string } | undefined; 197 + 198 + if (existing) { 199 + return { tagId: existing.id, created: false }; 200 + } 201 + 202 + const tagId = generateId('tag'); 203 + const timestamp = now(); 204 + 205 + db.prepare(` 206 + INSERT INTO tags (id, name, slug, color, parentId, description, metadata, createdAt, updatedAt, frequency, lastUsed, frecencyScore) 207 + VALUES (?, ?, ?, '#999999', '', '', '{}', ?, ?, 0, 0, 0) 208 + `).run(tagId, name.trim(), slug, timestamp, timestamp); 209 + 210 + return { tagId, created: true }; 211 + } 212 + 213 + /** 214 + * Tag an item (create a link in item_tags) 215 + */ 216 + export function tagItem(itemId: string, tagId: string): boolean { 217 + if (!db) throw new Error('Database not opened'); 218 + 219 + // Check if already tagged 220 + const existing = db.prepare( 221 + 'SELECT id FROM item_tags WHERE itemId = ? AND tagId = ?' 222 + ).get(itemId, tagId); 223 + 224 + if (existing) return false; 225 + 226 + const linkId = generateId('item_tag'); 227 + const timestamp = now(); 228 + 229 + db.prepare( 230 + 'INSERT INTO item_tags (id, itemId, tagId, createdAt) VALUES (?, ?, ?, ?)' 231 + ).run(linkId, itemId, tagId, timestamp); 232 + 233 + return true; 234 + } 235 + 236 + /** 237 + * Run a function inside a transaction for performance 238 + */ 239 + export function runInTransaction<T>(fn: () => T): T { 240 + if (!db) throw new Error('Database not opened'); 241 + return db.transaction(fn)(); 242 + } 243 + 244 + /** 245 + * Close the database 246 + */ 247 + export function closeDatabase(): void { 248 + if (db) { 249 + db.close(); 250 + db = null; 251 + } 252 + }
+112
tools/browser-import/src/importers/bookmarks.ts
··· 1 + /** 2 + * Bookmarks importer 3 + * 4 + * Reads browser bookmarks and creates url items in the Peek datastore, 5 + * including tags derived from the bookmark folder hierarchy. 6 + */ 7 + 8 + import type { BookmarkEntry, ImportResult, BrowserName } from '../types.js'; 9 + import { itemExistsByUrl, createItem, getOrCreateTag, tagItem, runInTransaction } from '../datastore.js'; 10 + 11 + /** 12 + * Convert a bookmark folder path into a set of hierarchical tags. 13 + * e.g., "Toolbar/Work/Research" => ["bookmark:Toolbar", "bookmark:Toolbar/Work", "bookmark:Toolbar/Work/Research"] 14 + */ 15 + function folderPathToTags(folderPath: string): string[] { 16 + if (!folderPath) return []; 17 + 18 + const parts = folderPath.split('/').filter(p => p.length > 0); 19 + const tags: string[] = []; 20 + let accumulated = ''; 21 + 22 + for (const part of parts) { 23 + accumulated = accumulated ? `${accumulated}/${part}` : part; 24 + tags.push(`bookmark:${accumulated}`); 25 + } 26 + 27 + return tags; 28 + } 29 + 30 + export function importBookmarks( 31 + entries: BookmarkEntry[], 32 + browserName: BrowserName, 33 + profileName: string, 34 + ): ImportResult { 35 + const result: ImportResult = { 36 + itemsImported: 0, 37 + tagsCreated: 0, 38 + duplicatesSkipped: 0, 39 + errors: [], 40 + }; 41 + 42 + if (entries.length === 0) return result; 43 + 44 + const importTimestamp = new Date().toISOString(); 45 + 46 + runInTransaction(() => { 47 + // Create shared tags 48 + const browserTag = getOrCreateTag('import:browser'); 49 + if (browserTag.created) result.tagsCreated++; 50 + 51 + const browserNameTag = getOrCreateTag(`import:${browserName}`); 52 + if (browserNameTag.created) result.tagsCreated++; 53 + 54 + const profileTag = getOrCreateTag(`import:${profileName}`); 55 + if (profileTag.created) result.tagsCreated++; 56 + 57 + const bookmarkTag = getOrCreateTag('browser:bookmark'); 58 + if (bookmarkTag.created) result.tagsCreated++; 59 + 60 + // Cache folder tags to avoid redundant lookups 61 + const folderTagCache = new Map<string, string>(); 62 + 63 + for (const entry of entries) { 64 + try { 65 + // Skip if URL already exists 66 + if (itemExistsByUrl(entry.url)) { 67 + result.duplicatesSkipped++; 68 + continue; 69 + } 70 + 71 + const itemId = createItem({ 72 + type: 'url', 73 + content: entry.url, 74 + title: entry.title, 75 + metadata: { 76 + title: entry.title, 77 + folderPath: entry.folderPath, 78 + dateAdded: entry.dateAdded, 79 + sourceProfile: profileName, 80 + sourceBrowser: browserName, 81 + importedAt: importTimestamp, 82 + }, 83 + }); 84 + 85 + // Apply shared tags 86 + tagItem(itemId, browserTag.tagId); 87 + tagItem(itemId, browserNameTag.tagId); 88 + tagItem(itemId, profileTag.tagId); 89 + tagItem(itemId, bookmarkTag.tagId); 90 + 91 + // Apply folder hierarchy tags 92 + const folderTags = folderPathToTags(entry.folderPath); 93 + for (const folderTagName of folderTags) { 94 + let folderTagId = folderTagCache.get(folderTagName); 95 + if (!folderTagId) { 96 + const { tagId, created } = getOrCreateTag(folderTagName); 97 + folderTagId = tagId; 98 + folderTagCache.set(folderTagName, tagId); 99 + if (created) result.tagsCreated++; 100 + } 101 + tagItem(itemId, folderTagId); 102 + } 103 + 104 + result.itemsImported++; 105 + } catch (err) { 106 + result.errors.push(`Failed to import ${entry.url}: ${(err as Error).message}`); 107 + } 108 + } 109 + }); 110 + 111 + return result; 112 + }
+79
tools/browser-import/src/importers/history.ts
··· 1 + /** 2 + * History importer 3 + * 4 + * Reads browser history entries and creates url items in the Peek datastore. 5 + */ 6 + 7 + import type { HistoryEntry, ImportResult, BrowserName } from '../types.js'; 8 + import { itemExistsByUrl, createItem, getOrCreateTag, tagItem, runInTransaction } from '../datastore.js'; 9 + 10 + export function importHistory( 11 + entries: HistoryEntry[], 12 + browserName: BrowserName, 13 + profileName: string, 14 + ): ImportResult { 15 + const result: ImportResult = { 16 + itemsImported: 0, 17 + tagsCreated: 0, 18 + duplicatesSkipped: 0, 19 + errors: [], 20 + }; 21 + 22 + if (entries.length === 0) return result; 23 + 24 + // Pre-create tags outside the per-item loop 25 + const importTimestamp = new Date().toISOString(); 26 + 27 + runInTransaction(() => { 28 + // Create shared tags 29 + const browserTag = getOrCreateTag('import:browser'); 30 + if (browserTag.created) result.tagsCreated++; 31 + 32 + const browserNameTag = getOrCreateTag(`import:${browserName}`); 33 + if (browserNameTag.created) result.tagsCreated++; 34 + 35 + const profileTag = getOrCreateTag(`import:${profileName}`); 36 + if (profileTag.created) result.tagsCreated++; 37 + 38 + const historyTag = getOrCreateTag('browser:history'); 39 + if (historyTag.created) result.tagsCreated++; 40 + 41 + for (const entry of entries) { 42 + try { 43 + // Skip if URL already exists 44 + if (itemExistsByUrl(entry.url)) { 45 + result.duplicatesSkipped++; 46 + continue; 47 + } 48 + 49 + const itemId = createItem({ 50 + type: 'url', 51 + content: entry.url, 52 + title: entry.title, 53 + visitCount: entry.visitCount, 54 + lastVisitAt: entry.lastVisitTime, 55 + metadata: { 56 + title: entry.title, 57 + visitCount: entry.visitCount, 58 + lastVisit: entry.lastVisitTime, 59 + sourceProfile: profileName, 60 + sourceBrowser: browserName, 61 + importedAt: importTimestamp, 62 + }, 63 + }); 64 + 65 + // Apply tags 66 + tagItem(itemId, browserTag.tagId); 67 + tagItem(itemId, browserNameTag.tagId); 68 + tagItem(itemId, profileTag.tagId); 69 + tagItem(itemId, historyTag.tagId); 70 + 71 + result.itemsImported++; 72 + } catch (err) { 73 + result.errors.push(`Failed to import ${entry.url}: ${(err as Error).message}`); 74 + } 75 + } 76 + }); 77 + 78 + return result; 79 + }
+6
tools/browser-import/src/importers/index.ts
··· 1 + /** 2 + * Importer registry 3 + */ 4 + 5 + export { importHistory } from './history.js'; 6 + export { importBookmarks } from './bookmarks.js';
+291
tools/browser-import/src/index.ts
··· 1 + #!/usr/bin/env node 2 + 3 + /** 4 + * browser-import CLI 5 + * 6 + * Indexes browser profiles on disk and imports their data 7 + * into a Peek datastore database. 8 + * 9 + * Usage: 10 + * browser-import discover List all found browser profiles 11 + * browser-import inspect <profile> Show what data is available 12 + * browser-import import <profile> [options] Import data into Peek DB 13 + */ 14 + 15 + import { Command } from 'commander'; 16 + import chalk from 'chalk'; 17 + import { resolve } from 'path'; 18 + import { discoverAllProfiles, findProfile } from './profiles.js'; 19 + import { firefoxReader } from './browsers/firefox.js'; 20 + import { chromeReader } from './browsers/chrome.js'; 21 + import { importHistory, importBookmarks } from './importers/index.js'; 22 + import { openDatabase, closeDatabase } from './datastore.js'; 23 + import type { BrowserProfile, BrowserName, ImportType } from './types.js'; 24 + import type { BrowserReader } from './browsers/types.js'; 25 + 26 + const program = new Command(); 27 + 28 + program 29 + .name('browser-import') 30 + .description('Import browser history and bookmarks into a Peek datastore') 31 + .version('1.0.0'); 32 + 33 + // ==================== discover ==================== 34 + 35 + program 36 + .command('discover') 37 + .description('List all browser profiles found on this system') 38 + .action(() => { 39 + const profiles = discoverAllProfiles(); 40 + 41 + if (profiles.length === 0) { 42 + console.log(chalk.yellow('No browser profiles found.')); 43 + return; 44 + } 45 + 46 + console.log(chalk.bold(`\nFound ${profiles.length} browser profile(s):\n`)); 47 + 48 + for (const profile of profiles) { 49 + const browserLabel = formatBrowserName(profile.browser); 50 + const dataTypes = profile.dataFiles 51 + .filter(d => d.exists) 52 + .map(d => d.type) 53 + .join(', '); 54 + 55 + console.log( 56 + ` ${chalk.cyan(browserLabel)} / ${chalk.white(profile.profileName)}` 57 + ); 58 + console.log( 59 + ` Path: ${chalk.dim(profile.profilePath)}` 60 + ); 61 + console.log( 62 + ` Data: ${dataTypes ? chalk.green(dataTypes) : chalk.dim('none found')}` 63 + ); 64 + console.log(); 65 + } 66 + 67 + console.log(chalk.dim('Use "browser-import inspect <profile>" to see details.')); 68 + console.log(chalk.dim('Profile can be a path or "browser/name" (e.g., "firefox/default-release").')); 69 + }); 70 + 71 + // ==================== inspect ==================== 72 + 73 + program 74 + .command('inspect') 75 + .description('Show what data is available in a browser profile') 76 + .argument('<profile>', 'Profile path or "browser/name"') 77 + .action((profileQuery: string) => { 78 + const profiles = discoverAllProfiles(); 79 + const profile = findProfile(profileQuery, profiles); 80 + 81 + if (!profile) { 82 + console.log(chalk.red(`Profile not found: ${profileQuery}`)); 83 + console.log(chalk.dim('\nAvailable profiles:')); 84 + for (const p of profiles) { 85 + console.log(chalk.dim(` ${p.browser}/${p.profileName}`)); 86 + } 87 + process.exit(1); 88 + } 89 + 90 + const reader = getReader(profile.browser); 91 + const inspections = reader.inspect(profile.profilePath); 92 + 93 + console.log( 94 + chalk.bold(`\nProfile: ${formatBrowserName(profile.browser)} / ${profile.profileName}`) 95 + ); 96 + console.log(chalk.dim(` Path: ${profile.profilePath}\n`)); 97 + 98 + if (inspections.length === 0) { 99 + console.log(chalk.yellow(' No data files found.')); 100 + return; 101 + } 102 + 103 + for (const item of inspections) { 104 + const icon = item.importable ? chalk.green('\u2713') : chalk.dim('\u25CB'); 105 + const count = chalk.white(item.count.toLocaleString()); 106 + const status = item.importable 107 + ? chalk.green('(importable)') 108 + : chalk.dim('(not yet supported)'); 109 + 110 + console.log(` ${icon} ${item.label}: ${count} entries ${status}`); 111 + } 112 + 113 + console.log(); 114 + console.log(chalk.dim('Use "browser-import import <profile>" to import data.')); 115 + }); 116 + 117 + // ==================== import ==================== 118 + 119 + program 120 + .command('import') 121 + .description('Import browser data into a Peek datastore') 122 + .argument('<profile>', 'Profile path or "browser/name"') 123 + .option('--db <path>', 'Path to Peek database file', './peek.db') 124 + .option('--types <types>', 'Comma-separated import types: history,bookmarks', 'history,bookmarks') 125 + .option('--dry-run', 'Show what would be imported without writing', false) 126 + .action((profileQuery: string, options: { db: string; types: string; dryRun: boolean }) => { 127 + const profiles = discoverAllProfiles(); 128 + const profile = findProfile(profileQuery, profiles); 129 + 130 + if (!profile) { 131 + console.log(chalk.red(`Profile not found: ${profileQuery}`)); 132 + console.log(chalk.dim('\nAvailable profiles:')); 133 + for (const p of profiles) { 134 + console.log(chalk.dim(` ${p.browser}/${p.profileName}`)); 135 + } 136 + process.exit(1); 137 + } 138 + 139 + const importTypes = options.types.split(',').map(t => t.trim()) as ImportType[]; 140 + const reader = getReader(profile.browser); 141 + const dbPath = resolve(options.db); 142 + 143 + console.log( 144 + chalk.bold(`\nImporting from: ${formatBrowserName(profile.browser)} / ${profile.profileName}`) 145 + ); 146 + console.log(chalk.dim(` Source: ${profile.profilePath}`)); 147 + console.log(chalk.dim(` Target: ${dbPath}`)); 148 + console.log(chalk.dim(` Types: ${importTypes.join(', ')}`)); 149 + console.log(); 150 + 151 + if (options.dryRun) { 152 + console.log(chalk.yellow('DRY RUN - no data will be written.\n')); 153 + dryRun(reader, profile, importTypes); 154 + return; 155 + } 156 + 157 + // Open the target database 158 + openDatabase(dbPath); 159 + 160 + let totalImported = 0; 161 + let totalTags = 0; 162 + let totalDuplicates = 0; 163 + let totalErrors = 0; 164 + 165 + try { 166 + // Import history 167 + if (importTypes.includes('history')) { 168 + console.log(chalk.cyan('Reading history...')); 169 + const entries = reader.readHistory(profile.profilePath); 170 + console.log(chalk.dim(` Found ${entries.length.toLocaleString()} entries`)); 171 + 172 + if (entries.length > 0) { 173 + console.log(chalk.cyan('Importing history...')); 174 + const result = importHistory(entries, profile.browser, profile.profileName); 175 + totalImported += result.itemsImported; 176 + totalTags += result.tagsCreated; 177 + totalDuplicates += result.duplicatesSkipped; 178 + totalErrors += result.errors.length; 179 + 180 + console.log(chalk.green(` Imported: ${result.itemsImported.toLocaleString()}`)); 181 + if (result.duplicatesSkipped > 0) { 182 + console.log(chalk.yellow(` Skipped (duplicates): ${result.duplicatesSkipped.toLocaleString()}`)); 183 + } 184 + if (result.errors.length > 0) { 185 + console.log(chalk.red(` Errors: ${result.errors.length}`)); 186 + for (const err of result.errors.slice(0, 5)) { 187 + console.log(chalk.red(` ${err}`)); 188 + } 189 + if (result.errors.length > 5) { 190 + console.log(chalk.dim(` ... and ${result.errors.length - 5} more`)); 191 + } 192 + } 193 + } 194 + console.log(); 195 + } 196 + 197 + // Import bookmarks 198 + if (importTypes.includes('bookmarks')) { 199 + console.log(chalk.cyan('Reading bookmarks...')); 200 + const entries = reader.readBookmarks(profile.profilePath); 201 + console.log(chalk.dim(` Found ${entries.length.toLocaleString()} entries`)); 202 + 203 + if (entries.length > 0) { 204 + console.log(chalk.cyan('Importing bookmarks...')); 205 + const result = importBookmarks(entries, profile.browser, profile.profileName); 206 + totalImported += result.itemsImported; 207 + totalTags += result.tagsCreated; 208 + totalDuplicates += result.duplicatesSkipped; 209 + totalErrors += result.errors.length; 210 + 211 + console.log(chalk.green(` Imported: ${result.itemsImported.toLocaleString()}`)); 212 + if (result.duplicatesSkipped > 0) { 213 + console.log(chalk.yellow(` Skipped (duplicates): ${result.duplicatesSkipped.toLocaleString()}`)); 214 + } 215 + if (result.errors.length > 0) { 216 + console.log(chalk.red(` Errors: ${result.errors.length}`)); 217 + for (const err of result.errors.slice(0, 5)) { 218 + console.log(chalk.red(` ${err}`)); 219 + } 220 + } 221 + } 222 + console.log(); 223 + } 224 + 225 + // Summary 226 + console.log(chalk.bold('Summary:')); 227 + console.log(` Items imported: ${chalk.green(totalImported.toLocaleString())}`); 228 + console.log(` Tags created: ${chalk.green(totalTags.toLocaleString())}`); 229 + console.log(` Duplicates skipped: ${chalk.yellow(totalDuplicates.toLocaleString())}`); 230 + if (totalErrors > 0) { 231 + console.log(` Errors: ${chalk.red(totalErrors.toLocaleString())}`); 232 + } 233 + console.log(`\n Database: ${chalk.cyan(dbPath)}`); 234 + } finally { 235 + closeDatabase(); 236 + } 237 + }); 238 + 239 + // ==================== Helpers ==================== 240 + 241 + function formatBrowserName(name: BrowserName): string { 242 + const names: Record<BrowserName, string> = { 243 + firefox: 'Firefox', 244 + chrome: 'Chrome', 245 + brave: 'Brave', 246 + }; 247 + return names[name] || name; 248 + } 249 + 250 + function getReader(browser: BrowserName): BrowserReader { 251 + switch (browser) { 252 + case 'firefox': 253 + return firefoxReader; 254 + case 'chrome': 255 + case 'brave': 256 + return chromeReader; 257 + default: 258 + throw new Error(`Unsupported browser: ${browser}`); 259 + } 260 + } 261 + 262 + function dryRun( 263 + reader: BrowserReader, 264 + profile: BrowserProfile, 265 + importTypes: ImportType[], 266 + ): void { 267 + if (importTypes.includes('history')) { 268 + const entries = reader.readHistory(profile.profilePath); 269 + console.log(` History: ${chalk.white(entries.length.toLocaleString())} entries would be imported`); 270 + if (entries.length > 0) { 271 + console.log(chalk.dim(' Sample entries:')); 272 + for (const entry of entries.slice(0, 5)) { 273 + console.log(chalk.dim(` ${entry.title || entry.url}`)); 274 + } 275 + } 276 + } 277 + 278 + if (importTypes.includes('bookmarks')) { 279 + const entries = reader.readBookmarks(profile.profilePath); 280 + console.log(` Bookmarks: ${chalk.white(entries.length.toLocaleString())} entries would be imported`); 281 + if (entries.length > 0) { 282 + console.log(chalk.dim(' Sample entries:')); 283 + for (const entry of entries.slice(0, 5)) { 284 + const folder = entry.folderPath ? ` [${entry.folderPath}]` : ''; 285 + console.log(chalk.dim(` ${entry.title || entry.url}${folder}`)); 286 + } 287 + } 288 + } 289 + } 290 + 291 + program.parse();
+229
tools/browser-import/src/profiles.ts
··· 1 + /** 2 + * Browser profile discovery 3 + * 4 + * Scans known browser profile directories on macOS, Linux, and Windows 5 + * to find all browser profiles and their available data files. 6 + */ 7 + 8 + import { existsSync, readdirSync, readFileSync } from 'fs'; 9 + import { join, basename } from 'path'; 10 + import { homedir, platform } from 'os'; 11 + import type { BrowserProfile, BrowserName, DataFileInfo, DataType } from './types.js'; 12 + 13 + interface BrowserPaths { 14 + mac: string; 15 + linux: string; 16 + windows: string; 17 + } 18 + 19 + const BROWSER_PATHS: Record<BrowserName, BrowserPaths> = { 20 + firefox: { 21 + mac: join(homedir(), 'Library/Application Support/Firefox/Profiles'), 22 + linux: join(homedir(), '.mozilla/firefox'), 23 + windows: join(homedir(), 'AppData/Roaming/Mozilla/Firefox/Profiles'), 24 + }, 25 + chrome: { 26 + mac: join(homedir(), 'Library/Application Support/Google/Chrome'), 27 + linux: join(homedir(), '.config/google-chrome'), 28 + windows: join(homedir(), 'AppData/Local/Google/Chrome/User Data'), 29 + }, 30 + brave: { 31 + mac: join(homedir(), 'Library/Application Support/BraveSoftware/Brave-Browser'), 32 + linux: join(homedir(), '.config/BraveSoftware/Brave-Browser'), 33 + windows: join(homedir(), 'AppData/Local/BraveSoftware/Brave-Browser/User Data'), 34 + }, 35 + }; 36 + 37 + /** Firefox data files to look for */ 38 + const FIREFOX_DATA_FILES: Array<{ type: DataType; filename: string; importable: boolean }> = [ 39 + { type: 'history', filename: 'places.sqlite', importable: true }, 40 + { type: 'bookmarks', filename: 'places.sqlite', importable: true }, 41 + { type: 'passwords', filename: 'logins.json', importable: false }, 42 + { type: 'cookies', filename: 'cookies.sqlite', importable: false }, 43 + { type: 'formdata', filename: 'formhistory.sqlite', importable: false }, 44 + { type: 'extensions', filename: 'extensions.json', importable: false }, 45 + ]; 46 + 47 + /** Chromium data files to look for */ 48 + const CHROMIUM_DATA_FILES: Array<{ type: DataType; filename: string; importable: boolean }> = [ 49 + { type: 'history', filename: 'History', importable: true }, 50 + { type: 'bookmarks', filename: 'Bookmarks', importable: true }, 51 + { type: 'passwords', filename: 'Login Data', importable: false }, 52 + { type: 'cookies', filename: 'Cookies', importable: false }, 53 + { type: 'formdata', filename: 'Web Data', importable: false }, 54 + ]; 55 + 56 + function getPlatformKey(): 'mac' | 'linux' | 'windows' { 57 + const p = platform(); 58 + if (p === 'darwin') return 'mac'; 59 + if (p === 'win32') return 'windows'; 60 + return 'linux'; 61 + } 62 + 63 + function checkDataFiles(profilePath: string, dataFiles: Array<{ type: DataType; filename: string; importable: boolean }>): DataFileInfo[] { 64 + const results: DataFileInfo[] = []; 65 + const seen = new Set<DataType>(); 66 + 67 + for (const df of dataFiles) { 68 + // Avoid duplicate entries (e.g., history and bookmarks both use places.sqlite in Firefox) 69 + if (seen.has(df.type)) continue; 70 + seen.add(df.type); 71 + 72 + const filePath = join(profilePath, df.filename); 73 + results.push({ 74 + type: df.type, 75 + path: filePath, 76 + exists: existsSync(filePath), 77 + importable: df.importable, 78 + }); 79 + } 80 + 81 + return results; 82 + } 83 + 84 + /** 85 + * Discover Firefox profiles by reading profiles.ini 86 + */ 87 + function discoverFirefoxProfiles(): BrowserProfile[] { 88 + const profiles: BrowserProfile[] = []; 89 + const platformKey = getPlatformKey(); 90 + const profilesDir = BROWSER_PATHS.firefox[platformKey]; 91 + 92 + // Firefox uses profiles.ini one level up from the Profiles directory 93 + const firefoxRoot = join(profilesDir, '..'); 94 + const profilesIniPath = join(firefoxRoot, 'profiles.ini'); 95 + 96 + if (existsSync(profilesIniPath)) { 97 + try { 98 + const content = readFileSync(profilesIniPath, 'utf-8'); 99 + const sections = content.split(/\[([^\]]+)\]/); 100 + 101 + for (let i = 1; i < sections.length; i += 2) { 102 + const sectionName = sections[i]; 103 + const sectionContent = sections[i + 1]; 104 + 105 + if (!sectionName.startsWith('Profile')) continue; 106 + 107 + const pathMatch = sectionContent.match(/Path=(.+)/); 108 + const nameMatch = sectionContent.match(/Name=(.+)/); 109 + const isRelative = sectionContent.match(/IsRelative=(\d)/); 110 + 111 + if (pathMatch) { 112 + const profilePath = isRelative && isRelative[1] === '1' 113 + ? join(firefoxRoot, pathMatch[1].trim()) 114 + : pathMatch[1].trim(); 115 + 116 + if (existsSync(profilePath)) { 117 + profiles.push({ 118 + browser: 'firefox', 119 + profileName: nameMatch ? nameMatch[1].trim() : basename(profilePath), 120 + profilePath, 121 + dataFiles: checkDataFiles(profilePath, FIREFOX_DATA_FILES), 122 + }); 123 + } 124 + } 125 + } 126 + } catch { 127 + // Can't read profiles.ini 128 + } 129 + } 130 + 131 + // Fallback: scan the Profiles directory directly 132 + if (profiles.length === 0 && existsSync(profilesDir)) { 133 + try { 134 + const entries = readdirSync(profilesDir, { withFileTypes: true }); 135 + for (const entry of entries) { 136 + if (entry.isDirectory()) { 137 + const profilePath = join(profilesDir, entry.name); 138 + // Check if it looks like a Firefox profile (has places.sqlite) 139 + if (existsSync(join(profilePath, 'places.sqlite'))) { 140 + profiles.push({ 141 + browser: 'firefox', 142 + profileName: entry.name, 143 + profilePath, 144 + dataFiles: checkDataFiles(profilePath, FIREFOX_DATA_FILES), 145 + }); 146 + } 147 + } 148 + } 149 + } catch { 150 + // Can't read directory 151 + } 152 + } 153 + 154 + return profiles; 155 + } 156 + 157 + /** 158 + * Discover Chromium-based browser profiles (Chrome, Brave). 159 + * Profile directories are "Default", "Profile 1", "Profile 2", etc. 160 + */ 161 + function discoverChromiumProfiles(browser: 'chrome' | 'brave'): BrowserProfile[] { 162 + const profiles: BrowserProfile[] = []; 163 + const platformKey = getPlatformKey(); 164 + const browserDir = BROWSER_PATHS[browser][platformKey]; 165 + 166 + if (!existsSync(browserDir)) return profiles; 167 + 168 + try { 169 + const entries = readdirSync(browserDir, { withFileTypes: true }); 170 + for (const entry of entries) { 171 + if (!entry.isDirectory()) continue; 172 + 173 + // Chrome profiles are named "Default", "Profile 1", "Profile 2", etc. 174 + if (entry.name === 'Default' || entry.name.startsWith('Profile ')) { 175 + const profilePath = join(browserDir, entry.name); 176 + // Verify it's a real profile by checking for History file 177 + if (existsSync(join(profilePath, 'History')) || existsSync(join(profilePath, 'Bookmarks'))) { 178 + profiles.push({ 179 + browser, 180 + profileName: entry.name, 181 + profilePath, 182 + dataFiles: checkDataFiles(profilePath, CHROMIUM_DATA_FILES), 183 + }); 184 + } 185 + } 186 + } 187 + } catch { 188 + // Can't read directory 189 + } 190 + 191 + return profiles; 192 + } 193 + 194 + /** 195 + * Discover all browser profiles on this system 196 + */ 197 + export function discoverAllProfiles(): BrowserProfile[] { 198 + const profiles: BrowserProfile[] = []; 199 + 200 + profiles.push(...discoverFirefoxProfiles()); 201 + profiles.push(...discoverChromiumProfiles('chrome')); 202 + profiles.push(...discoverChromiumProfiles('brave')); 203 + 204 + return profiles; 205 + } 206 + 207 + /** 208 + * Find a profile by a search string (browser/name, path, or partial match) 209 + */ 210 + export function findProfile(query: string, profiles: BrowserProfile[]): BrowserProfile | null { 211 + // Exact path match 212 + const byPath = profiles.find(p => p.profilePath === query); 213 + if (byPath) return byPath; 214 + 215 + // Browser/name match (e.g., "firefox/default-release") 216 + const byBrowserName = profiles.find(p => 217 + `${p.browser}/${p.profileName}`.toLowerCase() === query.toLowerCase() 218 + ); 219 + if (byBrowserName) return byBrowserName; 220 + 221 + // Partial name match 222 + const byPartialName = profiles.find(p => 223 + p.profileName.toLowerCase().includes(query.toLowerCase()) || 224 + p.browser.toLowerCase().includes(query.toLowerCase()) 225 + ); 226 + if (byPartialName) return byPartialName; 227 + 228 + return null; 229 + }
+51
tools/browser-import/src/types.ts
··· 1 + /** 2 + * Shared types for browser-import tool 3 + */ 4 + 5 + export interface BrowserProfile { 6 + browser: BrowserName; 7 + profileName: string; 8 + profilePath: string; 9 + dataFiles: DataFileInfo[]; 10 + } 11 + 12 + export type BrowserName = 'firefox' | 'chrome' | 'brave'; 13 + 14 + export interface DataFileInfo { 15 + type: DataType; 16 + path: string; 17 + exists: boolean; 18 + importable: boolean; 19 + } 20 + 21 + export type DataType = 'history' | 'bookmarks' | 'passwords' | 'cookies' | 'formdata' | 'extensions'; 22 + 23 + export interface DataInspection { 24 + type: DataType; 25 + count: number; 26 + importable: boolean; 27 + label: string; 28 + } 29 + 30 + export interface HistoryEntry { 31 + url: string; 32 + title: string; 33 + visitCount: number; 34 + lastVisitTime: number; // Unix ms 35 + } 36 + 37 + export interface BookmarkEntry { 38 + url: string; 39 + title: string; 40 + folderPath: string; 41 + dateAdded: number; // Unix ms 42 + } 43 + 44 + export interface ImportResult { 45 + itemsImported: number; 46 + tagsCreated: number; 47 + duplicatesSkipped: number; 48 + errors: string[]; 49 + } 50 + 51 + export type ImportType = 'history' | 'bookmarks';
+17
tools/browser-import/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2022", 4 + "module": "ESNext", 5 + "moduleResolution": "node", 6 + "outDir": "./dist", 7 + "rootDir": "./src", 8 + "strict": true, 9 + "esModuleInterop": true, 10 + "skipLibCheck": true, 11 + "forceConsistentCasingInFileNames": true, 12 + "resolveJsonModule": true, 13 + "declaration": true, 14 + "sourceMap": true 15 + }, 16 + "include": ["src/**/*"] 17 + }