@v0idd0/tabsnap 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vøiddo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # tabsnap
2
+
3
+ > One-click browser extension that captures every open tab as plain text,
4
+ > markdown, JSON, or a readme file. Free, MIT, zero telemetry.
5
+
6
+ [Landing page](https://extensions.voiddo.com/tabsnap/) ·
7
+ [Privacy policy](https://extensions.voiddo.com/tabsnap/privacy/) ·
8
+ [Contact](https://extensions.voiddo.com/tabsnap/contact/)
9
+
10
+ ## What it does
11
+
12
+ Click the toolbar icon → tabsnap reads the title and URL of every tab you have
13
+ open and renders them in the popup as one of four formats:
14
+
15
+ - **markdown** — nested list grouped by window, hostnames as section headers
16
+ - **plain** — one tab per line, title + URL
17
+ - **json** — structured array, ready to feed into another tool
18
+ - **readme.md** — full markdown document with a domain-summary table at the top
19
+
20
+ Hit **copy** or **download** and you're done.
21
+
22
+ ## Privacy
23
+
24
+ tabsnap reads only `chrome.tabs` metadata (title, URL, windowId, pinned,
25
+ incognito) and only when you click the toolbar icon. **No backend. No
26
+ analytics. No telemetry.** Your tabs never leave your machine.
27
+
28
+ Permissions:
29
+
30
+ - `tabs` — list open tabs
31
+ - `downloads` — save the snapshot to a file (only on user click)
32
+ - `storage` — remember your popup preferences across sessions
33
+
34
+ No host permissions. No `activeTab`. No content scripts.
35
+
36
+ ## Repo layout
37
+
38
+ chrome/ Manifest V3 source for Chrome / Edge / Brave
39
+ firefox/ Manifest V3 source with browser_specific_settings.gecko
40
+ edge/ Manifest V3 source (mirrors chrome/)
41
+ dist/ Pre-built ZIPs ready for store submission
42
+ shared/ Files copied into each platform tree at build time
43
+
44
+ ## Build
45
+
46
+ The platform directories are already self-contained — you can load any of
47
+ `chrome/`, `firefox/`, or `edge/` as an unpacked extension in the matching
48
+ browser. The pre-built ZIPs in `dist/` are produced by zipping each platform
49
+ directory.
50
+
51
+ ## License
52
+
53
+ MIT — see `LICENSE`.
54
+
55
+ Built by [vøiddo](https://voiddo.com/), a small studio shipping AI-flavoured
56
+ tools, browser extensions and weird browser games.
package/bin/tabsnap.js ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /* @v0idd0/tabsnap CLI — read a tab list (JSON), print formatted snapshot.
3
+ Input shapes accepted:
4
+ - array of tab objects: [{title,url,windowId?,pinned?,incognito?}, ...]
5
+ - object with .tabs: {tabs: [...]}
6
+ - object with .windows: {windows: [{tabs:[...]}, ...]} (auto-flatten)
7
+ Reads from --file=<path>, or stdin if no file given.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const { formatTabs, FORMATS } = require('../src/index.js');
14
+
15
+ const HELP = `tabsnap — format a tab list as markdown / plain / json / readme
16
+
17
+ usage:
18
+ tabsnap [options] # reads JSON from stdin
19
+ tabsnap --file=tabs.json # reads from file
20
+ cat tabs.json | tabsnap -f readme
21
+
22
+ options:
23
+ -f, --format=<fmt> one of: ${FORMATS.join(', ')} (default: markdown)
24
+ -i, --file=<path> read JSON tab list from file instead of stdin
25
+ --no-group do not group by window (single flat list)
26
+ --include-pinned include pinned tabs (default: skip)
27
+ --include-incognito include incognito-window tabs (default: skip)
28
+ -h, --help this help
29
+ -v, --version print version
30
+
31
+ input shapes accepted:
32
+ [{"title":"…","url":"…","windowId":1,"pinned":false}, …]
33
+ {"tabs": [...]}
34
+ {"windows": [{"tabs":[...]}, ...]}
35
+ also: { "snapshot_at": "...", "windows": [...] } from a previous tabsnap export
36
+
37
+ examples:
38
+ tabsnap < tabs.json
39
+ tabsnap --file=tabs.json --format=json --no-group
40
+ curl -s api.example.com/tabs | tabsnap -f readme > snapshot.md
41
+
42
+ free, MIT, zero telemetry. https://extensions.voiddo.com/tabsnap/
43
+ `;
44
+
45
+ function parseArgs(argv) {
46
+ const opts = {
47
+ format: 'markdown',
48
+ file: null,
49
+ groupByWindow: true,
50
+ includePinned: false,
51
+ includeIncognito: false,
52
+ help: false,
53
+ version: false,
54
+ };
55
+ for (let i = 0; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === '-h' || a === '--help') opts.help = true;
58
+ else if (a === '-v' || a === '--version') opts.version = true;
59
+ else if (a === '--no-group') opts.groupByWindow = false;
60
+ else if (a === '--include-pinned') opts.includePinned = true;
61
+ else if (a === '--include-incognito') opts.includeIncognito = true;
62
+ else if (a.startsWith('--format=')) opts.format = a.slice(9);
63
+ else if (a === '-f' || a === '--format') opts.format = argv[++i];
64
+ else if (a.startsWith('--file=')) opts.file = a.slice(7);
65
+ else if (a === '-i' || a === '--file') opts.file = argv[++i];
66
+ else {
67
+ process.stderr.write('tabsnap: unknown argument "' + a + '"\nrun with --help for usage.\n');
68
+ process.exit(2);
69
+ }
70
+ }
71
+ if (!FORMATS.includes(opts.format)) {
72
+ process.stderr.write('tabsnap: invalid format "' + opts.format + '" — use one of: ' + FORMATS.join(', ') + '\n');
73
+ process.exit(2);
74
+ }
75
+ return opts;
76
+ }
77
+
78
+ function normalizeTabs(input) {
79
+ if (Array.isArray(input)) return input;
80
+ if (input && Array.isArray(input.tabs)) return input.tabs;
81
+ if (input && Array.isArray(input.windows)) {
82
+ return input.windows.flatMap((w, i) => {
83
+ const wid = (w.windowId !== undefined) ? w.windowId : (w.window_index !== undefined ? w.window_index : (i + 1));
84
+ return (w.tabs || []).map(t => ({ ...t, windowId: t.windowId !== undefined ? t.windowId : wid }));
85
+ });
86
+ }
87
+ throw new Error('tabsnap: input does not match any accepted shape (array / .tabs / .windows). got ' + typeof input);
88
+ }
89
+
90
+ async function readStdin() {
91
+ return new Promise((resolve, reject) => {
92
+ let buf = '';
93
+ process.stdin.setEncoding('utf8');
94
+ process.stdin.on('data', chunk => { buf += chunk; });
95
+ process.stdin.on('end', () => resolve(buf));
96
+ process.stdin.on('error', reject);
97
+ });
98
+ }
99
+
100
+ async function main() {
101
+ const opts = parseArgs(process.argv.slice(2));
102
+ if (opts.help) { process.stdout.write(HELP); return; }
103
+ if (opts.version) {
104
+ const pkg = require('../package.json');
105
+ process.stdout.write(pkg.version + '\n');
106
+ return;
107
+ }
108
+
109
+ let raw;
110
+ if (opts.file) {
111
+ raw = fs.readFileSync(opts.file, 'utf8');
112
+ } else if (process.stdin.isTTY) {
113
+ process.stderr.write('tabsnap: no input. pipe JSON in, or use --file=<path>. run --help for usage.\n');
114
+ process.exit(2);
115
+ } else {
116
+ raw = await readStdin();
117
+ }
118
+
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch (e) {
123
+ process.stderr.write('tabsnap: input is not valid JSON: ' + e.message + '\n');
124
+ process.exit(1);
125
+ }
126
+
127
+ let tabs;
128
+ try {
129
+ tabs = normalizeTabs(parsed);
130
+ } catch (e) {
131
+ process.stderr.write(e.message + '\n');
132
+ process.exit(1);
133
+ }
134
+
135
+ // backfill missing windowId so groupByWindow works on flat input
136
+ let nextWid = 1;
137
+ for (const t of tabs) {
138
+ if (t.windowId === undefined || t.windowId === null) t.windowId = nextWid;
139
+ }
140
+
141
+ const out = formatTabs(tabs, opts.format, {
142
+ groupByWindow: opts.groupByWindow,
143
+ includePinned: opts.includePinned,
144
+ includeIncognito: opts.includeIncognito,
145
+ });
146
+ process.stdout.write(out);
147
+ }
148
+
149
+ main().catch(e => {
150
+ process.stderr.write('tabsnap: unexpected error: ' + (e.stack || e.message || e) + '\n');
151
+ process.exit(1);
152
+ });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@v0idd0/tabsnap",
3
+ "version": "1.0.0",
4
+ "description": "tabsnap — format a tab list (or any tabs-shaped JSON) as markdown, plain text, JSON, or a readme file. Library + CLI. Same formatters that power the tabsnap browser extension. Zero deps. Free forever from vøiddo.",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "tabsnap": "./bin/tabsnap.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node test.js"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "keywords": [
19
+ "tabs",
20
+ "tab-snapshot",
21
+ "tab-list",
22
+ "markdown",
23
+ "browser-extension",
24
+ "chrome-tabs",
25
+ "firefox-tabs",
26
+ "cli",
27
+ "formatter",
28
+ "json-to-markdown",
29
+ "json-to-readme",
30
+ "snapshot",
31
+ "voiddo",
32
+ "free",
33
+ "zero-deps"
34
+ ],
35
+ "author": "vøiddo <support@voiddo.com> (https://voiddo.com)",
36
+ "license": "MIT",
37
+ "homepage": "https://extensions.voiddo.com/tabsnap/",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/voidd0/tabsnap.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/voidd0/tabsnap/issues",
44
+ "email": "support@voiddo.com"
45
+ },
46
+ "funding": {
47
+ "type": "individual",
48
+ "url": "https://voiddo.com/contact/"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "engines": {
54
+ "node": ">=14"
55
+ }
56
+ }
package/src/index.js ADDED
@@ -0,0 +1,156 @@
1
+ /* @v0idd0/tabsnap — pure-function tab-list serializers.
2
+ Exports: formatTabs, filterTabs, groupTabsByWindow, hostnameOf, countByDomain
3
+ No DOM. No node-only globals. Works in any JS runtime that has URL + Date. */
4
+
5
+ 'use strict';
6
+
7
+ function escapeMarkdown(s) {
8
+ if (!s) return '';
9
+ return String(s).replace(/[\[\]\\`*_]/g, ch => '\\' + ch);
10
+ }
11
+
12
+ function groupTabsByWindow(tabs) {
13
+ const groups = new Map();
14
+ for (const t of tabs) {
15
+ if (!groups.has(t.windowId)) groups.set(t.windowId, []);
16
+ groups.get(t.windowId).push(t);
17
+ }
18
+ return [...groups.entries()].map(([windowId, tabs], i) => ({
19
+ windowIndex: i + 1,
20
+ windowId,
21
+ tabs,
22
+ }));
23
+ }
24
+
25
+ function filterTabs(tabs, opts) {
26
+ let out = tabs.slice();
27
+ if (!opts.includePinned) out = out.filter(t => !t.pinned);
28
+ if (!opts.includeIncognito) out = out.filter(t => !t.incognito);
29
+ if (opts.currentWindowOnly && opts.currentWindowId !== undefined) {
30
+ out = out.filter(t => t.windowId === opts.currentWindowId);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function fmtMarkdown(tabs, opts) {
36
+ const t = filterTabs(tabs, opts);
37
+ const dt = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
38
+ const head = '# tab snapshot · ' + t.length + ' tab' + (t.length === 1 ? '' : 's') + '\n_' + dt + '_\n\n';
39
+ if (!opts.groupByWindow) {
40
+ return head + t.map(formatLineMd).join('\n') + '\n';
41
+ }
42
+ const groups = groupTabsByWindow(t);
43
+ return head + groups.map(g =>
44
+ '## window ' + g.windowIndex + ' (' + g.tabs.length + ')\n' +
45
+ g.tabs.map(formatLineMd).join('\n') + '\n'
46
+ ).join('\n');
47
+ }
48
+
49
+ function formatLineMd(tab) {
50
+ const title = escapeMarkdown(tab.title || '(untitled)');
51
+ return '- [' + title + '](' + tab.url + ')';
52
+ }
53
+
54
+ function fmtPlain(tabs, opts) {
55
+ const t = filterTabs(tabs, opts);
56
+ if (!opts.groupByWindow) {
57
+ return t.map(x => (x.title || '(untitled)') + '\n ' + x.url).join('\n\n') + '\n';
58
+ }
59
+ const groups = groupTabsByWindow(t);
60
+ return groups.map(g =>
61
+ 'window ' + g.windowIndex + ' (' + g.tabs.length + ')\n' +
62
+ '─'.repeat(40) + '\n' +
63
+ g.tabs.map(x => (x.title || '(untitled)') + '\n ' + x.url).join('\n\n')
64
+ ).join('\n\n') + '\n';
65
+ }
66
+
67
+ function fmtJson(tabs, opts) {
68
+ const t = filterTabs(tabs, opts);
69
+ const out = {
70
+ snapshot_at: new Date().toISOString(),
71
+ count: t.length,
72
+ tool: 'tabsnap',
73
+ };
74
+ if (opts.groupByWindow) {
75
+ out.windows = groupTabsByWindow(t).map(g => ({
76
+ window_index: g.windowIndex,
77
+ tab_count: g.tabs.length,
78
+ tabs: g.tabs.map(simplifyTab),
79
+ }));
80
+ } else {
81
+ out.tabs = t.map(simplifyTab);
82
+ }
83
+ return JSON.stringify(out, null, 2) + '\n';
84
+ }
85
+
86
+ function simplifyTab(t) {
87
+ return {
88
+ title: t.title || null,
89
+ url: t.url || null,
90
+ pinned: !!t.pinned,
91
+ active: !!t.active,
92
+ audible: !!t.audible,
93
+ domain: hostnameOf(t.url),
94
+ };
95
+ }
96
+
97
+ function fmtReadme(tabs, opts) {
98
+ const t = filterTabs(tabs, opts);
99
+ const dt = new Date().toISOString().slice(0, 10);
100
+ const head = '# tab graveyard\n\n' +
101
+ '> ' + t.length + ' tab' + (t.length === 1 ? '' : 's') +
102
+ ' captured ' + dt + ' via [tabsnap](https://extensions.voiddo.com/tabsnap/).\n\n';
103
+ const counts = countByDomain(t);
104
+ let domains = '## by domain\n\n';
105
+ domains += '| domain | count |\n|---|---|\n';
106
+ for (const [d, c] of counts) domains += '| ' + d + ' | ' + c + ' |\n';
107
+ domains += '\n';
108
+ let body;
109
+ if (!opts.groupByWindow) {
110
+ body = '## all tabs\n\n' + t.map(formatLineMd).join('\n') + '\n';
111
+ } else {
112
+ const groups = groupTabsByWindow(t);
113
+ body = groups.map(g =>
114
+ '## window ' + g.windowIndex + ' · ' + g.tabs.length + ' tab' + (g.tabs.length === 1 ? '' : 's') + '\n\n' +
115
+ g.tabs.map(formatLineMd).join('\n') + '\n'
116
+ ).join('\n');
117
+ }
118
+ return head + domains + body;
119
+ }
120
+
121
+ function hostnameOf(url) {
122
+ if (!url) return null;
123
+ try { return new URL(url).hostname; } catch (_) { return null; }
124
+ }
125
+
126
+ function countByDomain(tabs) {
127
+ const m = new Map();
128
+ for (const t of tabs) {
129
+ const d = hostnameOf(t.url) || '(local)';
130
+ m.set(d, (m.get(d) || 0) + 1);
131
+ }
132
+ return [...m.entries()].sort((a, b) => b[1] - a[1]);
133
+ }
134
+
135
+ const FORMATTERS = {
136
+ markdown: fmtMarkdown,
137
+ plain: fmtPlain,
138
+ json: fmtJson,
139
+ readme: fmtReadme,
140
+ };
141
+
142
+ const FORMATS = Object.keys(FORMATTERS);
143
+
144
+ function formatTabs(tabs, format, opts) {
145
+ const fn = FORMATTERS[format] || FORMATTERS.markdown;
146
+ return fn(tabs, opts || {});
147
+ }
148
+
149
+ module.exports = {
150
+ formatTabs,
151
+ filterTabs,
152
+ groupTabsByWindow,
153
+ hostnameOf,
154
+ countByDomain,
155
+ FORMATS,
156
+ };