digpile 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # digpile
2
+
3
+ `digpile` is a small CLI that pulls one or more random bookmarks out of a bookmark folder so you can rediscover links you meant to revisit.
4
+
5
+ It can read directly from:
6
+
7
+ - Firefox profiles via `places.sqlite`
8
+ - Chrome bookmarks
9
+ - Brave bookmarks
10
+ - Chromium bookmarks
11
+ - Exported bookmarks HTML files
12
+
13
+ ## Requirements
14
+
15
+ - Node.js `>=22`
16
+ - macOS or Linux
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install -g digpile
22
+ ```
23
+
24
+ For local development:
25
+
26
+ ```bash
27
+ pnpm install
28
+ pnpm build
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Run against an installed browser profile:
34
+
35
+ ```bash
36
+ digpile
37
+ ```
38
+
39
+ Run against an exported bookmarks file:
40
+
41
+ ```bash
42
+ digpile ~/bookmarks.html
43
+ ```
44
+
45
+ Show help:
46
+
47
+ ```bash
48
+ digpile --help
49
+ ```
50
+
51
+ ## Options
52
+
53
+ - `-f, --folder <name>`: bookmark folder to search. Defaults to `Pile`.
54
+ - `-n <number>`: number of bookmarks to surface. Defaults to `1`.
55
+
56
+ ## Examples
57
+
58
+ ```bash
59
+ digpile
60
+ digpile --folder "read later" -n 3
61
+ digpile -f inbox -n 5 ~/bookmarks.html
62
+ ```
@@ -0,0 +1,51 @@
1
+ export const DEFAULT_FOLDER = "Pile";
2
+ export const BROWSERS = new Map([
3
+ [
4
+ "firefox",
5
+ {
6
+ name: "Firefox",
7
+ icon: "🦊",
8
+ type: "sqlite",
9
+ paths: {
10
+ darwin: "~/Library/Application Support/Firefox/Profiles",
11
+ linux: "~/.mozilla/firefox",
12
+ },
13
+ },
14
+ ],
15
+ [
16
+ "chrome",
17
+ {
18
+ name: "Chrome",
19
+ icon: "🌐",
20
+ type: "json",
21
+ paths: {
22
+ darwin: "~/Library/Application Support/Google/Chrome",
23
+ linux: "~/.config/google-chrome",
24
+ },
25
+ },
26
+ ],
27
+ [
28
+ "brave",
29
+ {
30
+ name: "Brave",
31
+ icon: "🦁",
32
+ type: "json",
33
+ paths: {
34
+ darwin: "~/Library/Application Support/BraveSoftware/Brave-Browser",
35
+ linux: "~/.config/BraveSoftware/Brave-Browser",
36
+ },
37
+ },
38
+ ],
39
+ [
40
+ "chromium",
41
+ {
42
+ name: "Chromium",
43
+ icon: "⚙️",
44
+ type: "json",
45
+ paths: {
46
+ darwin: "~/Library/Application Support/Chromium",
47
+ linux: "~/.config/chromium",
48
+ },
49
+ },
50
+ ],
51
+ ]);
package/dist/index.js ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import chalk from "chalk";
4
+ import { Command } from "@commander-js/extra-typings";
5
+ import { intro, outro } from "@clack/prompts";
6
+ import { DEFAULT_FOLDER } from "./constants.js";
7
+ import { detectAvailableBrowsers } from "./utils/browser.js";
8
+ import { printResult, readChromiumBookmarks, readFirefoxBookmarks, readHTMLBookmarks, } from "./utils/bookmark.js";
9
+ import { promptBrowserAndProfile } from "./utils/prompt.js";
10
+ import { fatal, getRandom } from "./utils/index.js";
11
+ const program = new Command();
12
+ program
13
+ .name("digpile")
14
+ .description("Dig a random link out of your bookmark pile")
15
+ .argument("[file]")
16
+ .option("-f, --folder <name>", "folder name to dig from", DEFAULT_FOLDER)
17
+ .option("-n <number>", "how many links to surface", (v) => parseInt(v, 10), 1)
18
+ .action(async (file, options) => {
19
+ intro(chalk.bold.green("⛏ digpile"));
20
+ let bookmarks;
21
+ if (file) {
22
+ if (!fs.existsSync(file)) {
23
+ return fatal(chalk.red(`File not found: ${file}`));
24
+ }
25
+ bookmarks = await readHTMLBookmarks(file, options.folder);
26
+ if (bookmarks.length === 0) {
27
+ return fatal(chalk.red(`Folder "${options.folder}" not found in ${file}.`) +
28
+ chalk.dim(" Try -f with a different folder name."));
29
+ }
30
+ }
31
+ else {
32
+ const available = detectAvailableBrowsers();
33
+ const { browser, profile } = await promptBrowserAndProfile(available);
34
+ if (browser.browser.type === "sqlite") {
35
+ bookmarks = await readFirefoxBookmarks(profile.path, options.folder);
36
+ }
37
+ else {
38
+ bookmarks = await readChromiumBookmarks(profile.path, options.folder);
39
+ }
40
+ if (bookmarks.length === 0) {
41
+ return fatal(chalk.red(`Folder "${options.folder}" not found.`) +
42
+ chalk.dim(" Try -f with a different folder name."));
43
+ }
44
+ }
45
+ const picks = getRandom(bookmarks, Math.max(1, options.n));
46
+ printResult(picks, bookmarks.length, options.folder);
47
+ outro("Happy browsing!");
48
+ });
49
+ program.addHelpText("after", `
50
+ Examples:
51
+ digpile
52
+ digpile --folder "read later" -n 3
53
+ digpile -f inbox -n 5 ~/bookmarks.html
54
+
55
+ Supported browsers: Firefox 🦊 Chrome 🌐 Brave 🦁 Chromium ⚙️
56
+ Supported platforms: macOS · Linux`);
57
+ program.parse();
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { box, log } from "@clack/prompts";
2
+ import { readFile, copyFile, unlink } from "fs/promises";
3
+ import chalk from "chalk";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import Database from "better-sqlite3";
7
+ import { JSDOM } from "jsdom";
8
+ import * as v from "valibot";
9
+ export const readHTMLBookmarks = async (filePath, folderName) => {
10
+ const html = await readFile(filePath, "utf8");
11
+ const dom = new JSDOM(html);
12
+ const doc = dom.window.document;
13
+ const headers = Array.from(doc.querySelectorAll("h3"));
14
+ const targetHeader = headers.find((header) => header.textContent?.trim().toLowerCase() === folderName.toLowerCase());
15
+ const container = targetHeader?.nextElementSibling;
16
+ if (!container)
17
+ return [];
18
+ return Array.from(container.querySelectorAll("a")).map((anchor) => ({
19
+ title: anchor.textContent?.trim() || anchor.href,
20
+ url: anchor.href,
21
+ }));
22
+ };
23
+ export const readFirefoxBookmarks = async (dbPath, folderName) => {
24
+ const tmpPath = join(tmpdir(), `digpile-${Date.now()}.sqlite`);
25
+ await copyFile(dbPath, tmpPath);
26
+ try {
27
+ const db = new Database(tmpPath, { readonly: true });
28
+ const folderRows = db
29
+ .prepare("SELECT id FROM moz_bookmarks WHERE type = 2 AND LOWER(title) = LOWER(?)")
30
+ .all(folderName);
31
+ if (folderRows.length === 0) {
32
+ db.close();
33
+ return [];
34
+ }
35
+ const folderIds = folderRows.map((row) => row.id);
36
+ const placeholders = folderIds.map(() => "?").join(",");
37
+ const rows = db
38
+ .prepare(`WITH RECURSIVE subtree(id, type, fk, title) AS (
39
+ SELECT id, type, fk, title FROM moz_bookmarks
40
+ WHERE parent IN (${placeholders})
41
+ UNION ALL
42
+ SELECT b.id, b.type, b.fk, b.title
43
+ FROM moz_bookmarks b JOIN subtree s ON b.parent = s.id
44
+ )
45
+ SELECT s.title, p.url
46
+ FROM subtree s JOIN moz_places p ON s.fk = p.id
47
+ WHERE s.type = 1 AND p.url NOT LIKE 'place:%'`)
48
+ .all(...folderIds);
49
+ db.close();
50
+ return rows.map((row) => ({
51
+ title: row.title || row.url,
52
+ url: row.url,
53
+ }));
54
+ }
55
+ finally {
56
+ try {
57
+ await unlink(tmpPath);
58
+ }
59
+ catch { }
60
+ }
61
+ };
62
+ const ChromiumBookmarkNode = v.union([
63
+ v.object({
64
+ type: v.literal("folder"),
65
+ name: v.string(),
66
+ children: v.array(v.lazy(() => ChromiumBookmarkNode)),
67
+ }),
68
+ v.object({ type: v.literal("url"), name: v.string(), url: v.string() }),
69
+ ]);
70
+ const ChromiumBookmarksFile = v.object({
71
+ roots: v.record(v.string(), ChromiumBookmarkNode),
72
+ });
73
+ export const readChromiumBookmarks = async (bookmarksPath, folderName) => {
74
+ const raw = await v.parseAsync(ChromiumBookmarksFile, JSON.parse(await readFile(bookmarksPath, "utf8")));
75
+ const bookmarks = [];
76
+ const collectAll = (node) => {
77
+ if (node.type === "url") {
78
+ bookmarks.push({ title: node.name, url: node.url });
79
+ return;
80
+ }
81
+ for (const child of node.children) {
82
+ collectAll(child);
83
+ }
84
+ };
85
+ const findFolder = (node) => {
86
+ if (node.type !== "folder")
87
+ return false;
88
+ if (node.name.toLowerCase() === folderName.toLowerCase()) {
89
+ collectAll(node);
90
+ return true;
91
+ }
92
+ return node.children.some(findFolder);
93
+ };
94
+ for (const root of Object.values(raw.roots)) {
95
+ if (findFolder(root))
96
+ break;
97
+ }
98
+ return bookmarks;
99
+ };
100
+ export const printResult = (bookmarks, total, folder) => {
101
+ box(bookmarks
102
+ .map((bookmark) => `${chalk.bold(bookmark.title)}\n${chalk.cyan(` ${bookmark.url}`)}`)
103
+ .join("\n\n"), "", {
104
+ rounded: true,
105
+ formatBorder: (text) => chalk.dim(text),
106
+ });
107
+ log.success(chalk.dim(`${bookmarks.length} of ${total} bookmarks surfaced from ` + chalk.italic(`"${folder}"`)));
108
+ };
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import { join } from "node:path";
3
+ import { BROWSERS } from "../constants.js";
4
+ import { expandHome } from "./index.js";
5
+ import { detectPlatform } from "./platform.js";
6
+ const findFirefoxProfiles = (basePath) => {
7
+ const dir = expandHome(basePath);
8
+ if (!fs.existsSync(dir))
9
+ return [];
10
+ return fs
11
+ .readdirSync(dir)
12
+ .filter((name) => {
13
+ try {
14
+ return (fs.statSync(join(dir, name)).isDirectory() &&
15
+ fs.existsSync(join(dir, name, "places.sqlite")));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ })
21
+ .map((name) => ({
22
+ name,
23
+ path: join(dir, name, "places.sqlite"),
24
+ }));
25
+ };
26
+ const findChromiumProfiles = (basePath) => {
27
+ const dir = expandHome(basePath);
28
+ if (!fs.existsSync(dir))
29
+ return [];
30
+ const profiles = [];
31
+ const defaultBookmarksPath = join(dir, "Default", "Bookmarks");
32
+ if (fs.existsSync(defaultBookmarksPath)) {
33
+ profiles.push({ name: "Default", path: defaultBookmarksPath });
34
+ }
35
+ try {
36
+ fs.readdirSync(dir)
37
+ .filter((name) => /^Profile \d+$/.test(name))
38
+ .forEach((name) => {
39
+ const bookmarksPath = join(dir, name, "Bookmarks");
40
+ if (fs.existsSync(bookmarksPath)) {
41
+ profiles.push({ name, path: bookmarksPath });
42
+ }
43
+ });
44
+ }
45
+ catch { }
46
+ return profiles;
47
+ };
48
+ export const detectAvailableBrowsers = () => {
49
+ const platform = detectPlatform();
50
+ if (!platform)
51
+ return [];
52
+ const found = [];
53
+ for (const [key, browser] of BROWSERS) {
54
+ const basePath = browser.paths[platform];
55
+ if (!basePath)
56
+ continue;
57
+ const profiles = browser.type === "sqlite" ? findFirefoxProfiles(basePath) : findChromiumProfiles(basePath);
58
+ if (profiles.length > 0) {
59
+ found.push({ key, browser, profiles });
60
+ }
61
+ }
62
+ return found;
63
+ };
@@ -0,0 +1,22 @@
1
+ import { cancel } from "@clack/prompts";
2
+ import { stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ export const expandHome = (path) => path.replace(/^~/, homedir());
5
+ export const fatal = (message) => {
6
+ cancel(message);
7
+ process.exit(1);
8
+ };
9
+ export const nonFatal = (message) => {
10
+ cancel(message);
11
+ process.exit(0);
12
+ };
13
+ export const exists = async (f) => {
14
+ try {
15
+ await stat(f);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ };
22
+ export const getRandom = (arr, n) => arr.toSorted(() => Math.random() - 0.5).slice(0, n);
@@ -0,0 +1,7 @@
1
+ export const detectPlatform = () => {
2
+ if (process.platform === "darwin")
3
+ return "darwin";
4
+ if (process.platform === "linux")
5
+ return "linux";
6
+ return null;
7
+ };
@@ -0,0 +1,51 @@
1
+ import chalk from "chalk";
2
+ import { isCancel, log, select } from "@clack/prompts";
3
+ import { fatal, nonFatal } from "./index.js";
4
+ export const promptBrowserAndProfile = async (available) => {
5
+ if (available.length === 0) {
6
+ return fatal("No supported browsers found. Supported: Firefox, Chrome, Brave, Chromium.");
7
+ }
8
+ let selectedBrowser = available[0];
9
+ if (available.length === 1) {
10
+ log.info(chalk.dim(`Using ${selectedBrowser.browser.icon} ${selectedBrowser.browser.name}`));
11
+ }
12
+ else {
13
+ const key = await select({
14
+ message: "Browser",
15
+ options: available.map(({ browser, key }) => ({
16
+ label: `${browser.icon} ${browser.name}`,
17
+ value: key,
18
+ })),
19
+ });
20
+ if (isCancel(key)) {
21
+ return nonFatal("Operation cancelled");
22
+ }
23
+ const browser = available.find((entry) => entry.key === key);
24
+ if (!browser) {
25
+ return fatal(`Unknown browser selection: ${key}`);
26
+ }
27
+ selectedBrowser = browser;
28
+ }
29
+ let selectedProfile = selectedBrowser.profiles[0];
30
+ if (selectedBrowser.profiles.length === 1) {
31
+ log.info(chalk.dim(`Profile: ${selectedProfile.name}`));
32
+ }
33
+ else {
34
+ const profileName = await select({
35
+ message: "Profile",
36
+ options: selectedBrowser.profiles.map(({ name }) => ({
37
+ label: name,
38
+ value: name,
39
+ })),
40
+ });
41
+ if (isCancel(profileName)) {
42
+ return nonFatal("Operation cancelled");
43
+ }
44
+ const profile = selectedBrowser.profiles.find((entry) => entry.name === profileName);
45
+ if (!profile) {
46
+ return fatal(`Unknown profile selection: ${profileName}`);
47
+ }
48
+ selectedProfile = profile;
49
+ }
50
+ return { browser: selectedBrowser, profile: selectedProfile };
51
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "digpile",
3
+ "version": "1.0.0",
4
+ "description": "Dig a random link out of your bookmark pile",
5
+ "keywords": [
6
+ "bookmarks",
7
+ "chrome",
8
+ "cli",
9
+ "firefox",
10
+ "productivity",
11
+ "random"
12
+ ],
13
+ "homepage": "github:masiama/digpile",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/masiama/digpile.git"
18
+ },
19
+ "bin": {
20
+ "digpile": "dist/index.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "os": [
27
+ "darwin",
28
+ "linux"
29
+ ],
30
+ "type": "module",
31
+ "scripts": {
32
+ "build": "rm -rf ./dist && tsc -p tsconfig.build.json",
33
+ "prepack": "pnpm build",
34
+ "start": "node ./src/index.ts",
35
+ "start:dist": "node ./dist/index.js",
36
+ "lint": "oxlint",
37
+ "lint:fix": "oxlint --fix",
38
+ "fmt": "oxfmt",
39
+ "fmt:check": "oxfmt --check",
40
+ "typecheck": "tsc --noEmit"
41
+ },
42
+ "dependencies": {
43
+ "@clack/prompts": "^1.4.0",
44
+ "@commander-js/extra-typings": "^14.0.0",
45
+ "better-sqlite3": "12.10.0",
46
+ "chalk": "^5.6.2",
47
+ "commander": "^14.0.3",
48
+ "jsdom": "^29.1.1",
49
+ "valibot": "^1.4.0"
50
+ },
51
+ "devDependencies": {
52
+ "@types/better-sqlite3": "^7.6.13",
53
+ "@types/jsdom": "^28.0.3",
54
+ "@types/node": "^25.7.0",
55
+ "oxfmt": "^0.49.0",
56
+ "oxlint": "^1.64.0",
57
+ "typescript": "^6.0.3"
58
+ },
59
+ "engines": {
60
+ "node": ">=22"
61
+ }
62
+ }