tabctl 0.3.0 → 0.4.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.
@@ -11,10 +11,15 @@ exports.listGroups = listGroups;
11
11
  exports.groupUpdate = groupUpdate;
12
12
  exports.groupUngroup = groupUngroup;
13
13
  exports.groupAssign = groupAssign;
14
+ exports.groupGather = groupGather;
14
15
  function getGroupTabs(windowSnapshot, groupId) {
15
16
  return windowSnapshot.tabs
16
17
  .filter((tab) => tab.groupId === groupId)
17
- .sort((a, b) => (Number(a.index) || 0) - (Number(b.index) || 0));
18
+ .sort((a, b) => {
19
+ const ai = Number(a.index);
20
+ const bi = Number(b.index);
21
+ return (Number.isFinite(ai) ? ai : 0) - (Number.isFinite(bi) ? bi : 0);
22
+ });
18
23
  }
19
24
  function listGroupSummaries(snapshot, buildWindowLabels, windowId) {
20
25
  const windowLabels = buildWindowLabels(snapshot);
@@ -83,8 +88,8 @@ function resolveGroupByTitle(snapshot, buildWindowLabels, groupTitle, windowId)
83
88
  if (matches.length > 1) {
84
89
  return {
85
90
  error: {
86
- message: "Group title is ambiguous. Provide a windowId.",
87
- hint: "Use --window to disambiguate group titles.",
91
+ message: `Ambiguous group title: found ${matches.length} groups named "${groupTitle}". Use group-gather to merge duplicates, --group-id to target by ID, or --window to narrow scope.`,
92
+ hint: "Use group-gather to merge duplicates, --group-id to target by ID, or --window to narrow scope.",
88
93
  matches: matches.map((match) => summarizeGroupMatch(match, windowLabels)),
89
94
  availableGroups,
90
95
  },
@@ -441,3 +446,84 @@ async function groupAssign(params, deps) {
441
446
  txid: params.txid || null,
442
447
  };
443
448
  }
449
+ async function groupGather(params, deps) {
450
+ const snapshot = await deps.getTabSnapshot();
451
+ const windows = snapshot.windows;
452
+ const windowIdParam = params.windowId != null ? deps.resolveWindowIdFromParams(snapshot, params.windowId) : null;
453
+ if (windowIdParam && !windows.some((win) => win.windowId === windowIdParam)) {
454
+ throw new Error("Window not found");
455
+ }
456
+ const groupTitleFilter = typeof params.groupTitle === "string" ? params.groupTitle.trim() : "";
457
+ const merged = [];
458
+ const undoEntries = [];
459
+ for (const win of windows) {
460
+ if (windowIdParam && win.windowId !== windowIdParam)
461
+ continue;
462
+ const byTitle = new Map();
463
+ for (const group of win.groups) {
464
+ const title = typeof group.title === "string" ? group.title : "";
465
+ if (!title)
466
+ continue;
467
+ if (groupTitleFilter && title !== groupTitleFilter)
468
+ continue;
469
+ if (!byTitle.has(title))
470
+ byTitle.set(title, []);
471
+ byTitle.get(title).push(group);
472
+ }
473
+ for (const [title, titleGroups] of byTitle) {
474
+ if (titleGroups.length < 2)
475
+ continue;
476
+ const groupsWithIndex = titleGroups.map((g) => {
477
+ const tabs = win.tabs.filter((t) => t.groupId === g.groupId);
478
+ const minIndex = Math.min(...tabs.map((t) => {
479
+ const idx = Number(t.index);
480
+ return Number.isFinite(idx) ? idx : Infinity;
481
+ }));
482
+ return { group: g, tabs, minIndex };
483
+ });
484
+ groupsWithIndex.sort((a, b) => a.minIndex - b.minIndex);
485
+ const primary = groupsWithIndex[0];
486
+ const duplicates = groupsWithIndex.slice(1);
487
+ let movedTabs = 0;
488
+ for (const dup of duplicates) {
489
+ const tabIds = dup.tabs
490
+ .map((t) => t.tabId)
491
+ .filter((id) => typeof id === "number");
492
+ if (tabIds.length > 0) {
493
+ for (const tab of dup.tabs) {
494
+ undoEntries.push({
495
+ tabId: tab.tabId,
496
+ windowId: win.windowId,
497
+ index: tab.index,
498
+ groupId: tab.groupId,
499
+ groupTitle: tab.groupTitle,
500
+ groupColor: tab.groupColor,
501
+ groupCollapsed: dup.group.collapsed ?? null,
502
+ });
503
+ }
504
+ await chrome.tabs.group({ groupId: primary.group.groupId, tabIds });
505
+ movedTabs += tabIds.length;
506
+ }
507
+ }
508
+ merged.push({
509
+ windowId: win.windowId,
510
+ groupTitle: title,
511
+ primaryGroupId: primary.group.groupId,
512
+ mergedGroupCount: duplicates.length,
513
+ movedTabs,
514
+ });
515
+ }
516
+ }
517
+ return {
518
+ merged,
519
+ summary: {
520
+ mergedGroups: merged.reduce((sum, m) => sum + m.mergedGroupCount, 0),
521
+ movedTabs: merged.reduce((sum, m) => sum + m.movedTabs, 0),
522
+ },
523
+ undo: {
524
+ action: "group-gather",
525
+ tabs: undoEntries,
526
+ },
527
+ txid: params.txid || null,
528
+ };
529
+ }
@@ -15,7 +15,7 @@ exports.scrollToPosition = scrollToPosition;
15
15
  exports.captureTabTiles = captureTabTiles;
16
16
  exports.screenshotTabs = screenshotTabs;
17
17
  exports.SCREENSHOT_TILE_MAX_DIM = 2000;
18
- exports.SCREENSHOT_MAX_BYTES = 2000000;
18
+ exports.SCREENSHOT_MAX_BYTES = 2_000_000;
19
19
  exports.SCREENSHOT_QUALITY = 80;
20
20
  exports.SCREENSHOT_SCROLL_DELAY_MS = 150;
21
21
  exports.SCREENSHOT_CAPTURE_DELAY_MS = 350;
@@ -288,7 +288,7 @@ async function screenshotTabs(params, requestId, deps) {
288
288
  const adjustedTileMaxDim = tileMaxDim < 50 ? 50 : tileMaxDim;
289
289
  const maxBytesRaw = Number(params.maxBytes);
290
290
  const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? Math.floor(maxBytesRaw) : exports.SCREENSHOT_MAX_BYTES;
291
- const adjustedMaxBytes = maxBytes < 50000 ? 50000 : maxBytes;
291
+ const adjustedMaxBytes = maxBytes < 50_000 ? 50_000 : maxBytes;
292
292
  const progressEnabled = params.progress === true;
293
293
  const tabs = selection.tabs;
294
294
  const entries = [];
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  // Tab operations — extracted from background.ts (pure structural refactor).
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
3
6
  Object.defineProperty(exports, "__esModule", { value: true });
4
7
  exports.getMostRecentFocusedWindowId = getMostRecentFocusedWindowId;
5
8
  exports.normalizeUrl = normalizeUrl;
@@ -8,6 +11,7 @@ exports.resolveOpenWindow = resolveOpenWindow;
8
11
  exports.focusTab = focusTab;
9
12
  exports.refreshTabs = refreshTabs;
10
13
  exports.openTabs = openTabs;
14
+ const normalize_url_1 = __importDefault(require("normalize-url"));
11
15
  function getMostRecentFocusedWindowId(windows) {
12
16
  let bestWindowId = null;
13
17
  let bestFocusedAt = -Infinity;
@@ -29,42 +33,26 @@ function normalizeUrl(rawUrl) {
29
33
  if (!rawUrl || typeof rawUrl !== "string") {
30
34
  return null;
31
35
  }
32
- let url;
33
36
  try {
34
- url = new URL(rawUrl);
37
+ return (0, normalize_url_1.default)(rawUrl, {
38
+ stripHash: true,
39
+ removeQueryParameters: [
40
+ /^utm_\w+$/i,
41
+ "fbclid",
42
+ "gclid",
43
+ "igshid",
44
+ "mc_cid",
45
+ "mc_eid",
46
+ "ref",
47
+ "ref_src",
48
+ "ref_url",
49
+ "si",
50
+ ],
51
+ });
35
52
  }
36
53
  catch {
37
54
  return null;
38
55
  }
39
- if (url.protocol !== "http:" && url.protocol !== "https:") {
40
- return null;
41
- }
42
- url.hash = "";
43
- const dropKeys = new Set([
44
- "fbclid",
45
- "gclid",
46
- "igshid",
47
- "mc_cid",
48
- "mc_eid",
49
- "ref",
50
- "ref_src",
51
- "ref_url",
52
- "utm_campaign",
53
- "utm_content",
54
- "utm_medium",
55
- "utm_source",
56
- "utm_term",
57
- "utm_name",
58
- "si",
59
- ]);
60
- for (const key of Array.from(url.searchParams.keys())) {
61
- if (key.startsWith("utm_") || dropKeys.has(key)) {
62
- url.searchParams.delete(key);
63
- }
64
- }
65
- const search = url.searchParams.toString();
66
- url.search = search ? `?${search}` : "";
67
- return url.toString();
68
56
  }
69
57
  function normalizeTabIndex(value) {
70
58
  const index = Number(value);
@@ -200,6 +188,8 @@ async function openTabs(params, deps) {
200
188
  throw new Error("Only one target position is allowed");
201
189
  }
202
190
  const newWindow = params.newWindow === true;
191
+ const forceNewGroup = params.newGroup === true;
192
+ const allowDuplicates = params.allowDuplicates === true;
203
193
  if (!urls.length && !newWindow) {
204
194
  throw new Error("No URLs provided");
205
195
  }
@@ -287,6 +277,13 @@ async function openTabs(params, deps) {
287
277
  }
288
278
  const snapshot = await deps.getTabSnapshot();
289
279
  let openParams = params;
280
+ // Auto-resolve window by group name when no explicit window selector is provided
281
+ if (groupTitle && !forceNewGroup && openParams.windowId == null && !openParams.windowGroupTitle && !openParams.windowTabId && !openParams.windowUrl) {
282
+ const groupWindows = snapshot.windows.filter((win) => win.groups.some((g) => g.title === groupTitle));
283
+ if (groupWindows.length === 1) {
284
+ openParams = { ...openParams, windowId: groupWindows[0].windowId };
285
+ }
286
+ }
290
287
  if (params.windowId == null && (beforeTabId != null || afterTabId != null)) {
291
288
  const anchorId = beforeTabId != null ? beforeTabId : afterTabId;
292
289
  const anchorWindow = snapshot.windows
@@ -304,6 +301,25 @@ async function openTabs(params, deps) {
304
301
  if (!windowSnapshot) {
305
302
  throw new Error("Window snapshot unavailable");
306
303
  }
304
+ // Resolve existing group for reuse
305
+ let existingGroupId = null;
306
+ const existingUrlSet = new Set();
307
+ if (groupTitle && !forceNewGroup) {
308
+ const matchingGroups = windowSnapshot.groups.filter((g) => g.title === groupTitle);
309
+ if (matchingGroups.length > 1) {
310
+ throw new Error(`Ambiguous group title "${groupTitle}": found ${matchingGroups.length} groups with the same name. Use --new-group to force a new group, group-gather to merge, or --group-id to target by ID.`);
311
+ }
312
+ if (matchingGroups.length === 1) {
313
+ existingGroupId = matchingGroups[0].groupId;
314
+ const existingTabs = windowSnapshot.tabs.filter((tab) => tab.groupId === existingGroupId);
315
+ for (const tab of existingTabs) {
316
+ const norm = normalizeUrl(tab.url);
317
+ if (norm) {
318
+ existingUrlSet.add(norm);
319
+ }
320
+ }
321
+ }
322
+ }
307
323
  const created = [];
308
324
  const skipped = [];
309
325
  let insertIndex = null;
@@ -339,8 +355,25 @@ async function openTabs(params, deps) {
339
355
  }
340
356
  insertIndex = beforeTabId != null ? anchorIndex : anchorIndex + 1;
341
357
  }
358
+ // Default insert position: append after existing group tabs
359
+ if (existingGroupId != null && insertIndex == null && beforeTabId == null && afterTabId == null) {
360
+ const groupTabs = windowSnapshot.tabs.filter((tab) => tab.groupId === existingGroupId);
361
+ const indices = groupTabs
362
+ .map((tab) => normalizeTabIndex(tab.index))
363
+ .filter((value) => value != null);
364
+ if (indices.length) {
365
+ insertIndex = Math.max(...indices) + 1;
366
+ }
367
+ }
342
368
  let nextIndex = insertIndex;
343
369
  for (const url of urls) {
370
+ if (!allowDuplicates && existingGroupId != null) {
371
+ const norm = normalizeUrl(url);
372
+ if (norm && existingUrlSet.has(norm)) {
373
+ skipped.push({ url, reason: "duplicate" });
374
+ continue;
375
+ }
376
+ }
344
377
  try {
345
378
  const createOptions = { windowId, url, active: false };
346
379
  if (nextIndex != null) {
@@ -365,12 +398,18 @@ async function openTabs(params, deps) {
365
398
  try {
366
399
  const tabIds = created.map((tab) => tab.tabId).filter((id) => typeof id === "number");
367
400
  if (tabIds.length > 0) {
368
- groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } });
369
- const update = { title: groupTitle };
370
- if (groupColor) {
371
- update.color = groupColor;
401
+ if (existingGroupId != null) {
402
+ // Reuse existing group
403
+ groupId = await chrome.tabs.group({ groupId: existingGroupId, tabIds });
404
+ }
405
+ else {
406
+ groupId = await chrome.tabs.group({ tabIds, createProperties: { windowId } });
407
+ const update = { title: groupTitle };
408
+ if (groupColor) {
409
+ update.color = groupColor;
410
+ }
411
+ await chrome.tabGroups.update(groupId, update);
372
412
  }
373
- await chrome.tabGroups.update(groupId, update);
374
413
  }
375
414
  }
376
415
  catch (error) {
@@ -378,6 +417,28 @@ async function openTabs(params, deps) {
378
417
  groupId = null;
379
418
  }
380
419
  }
420
+ // All-dupes case: report existing group even when no new tabs were created
421
+ if (groupTitle && created.length === 0 && existingGroupId != null) {
422
+ groupId = existingGroupId;
423
+ }
424
+ // Reorder: groups before ungrouped tabs
425
+ try {
426
+ const freshTabs = await chrome.tabs.query({ windowId });
427
+ freshTabs.sort((a, b) => a.index - b.index);
428
+ const firstUngroupedIndex = freshTabs.findIndex(t => (t.groupId ?? -1) === -1);
429
+ if (firstUngroupedIndex >= 0) {
430
+ const groupedAfterUngrouped = freshTabs.filter((t, i) => i > firstUngroupedIndex && (t.groupId ?? -1) !== -1);
431
+ if (groupedAfterUngrouped.length > 0) {
432
+ const tabIdsToMove = groupedAfterUngrouped.map(t => t.id).filter(id => typeof id === "number");
433
+ if (tabIdsToMove.length > 0) {
434
+ await chrome.tabs.move(tabIdsToMove, { index: firstUngroupedIndex });
435
+ }
436
+ }
437
+ }
438
+ }
439
+ catch (err) {
440
+ deps.log("Failed to reorder groups before ungrouped tabs", err);
441
+ }
381
442
  return {
382
443
  windowId,
383
444
  groupId,
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.undoGroupUpdate = undoGroupUpdate;
5
5
  exports.undoGroupUngroup = undoGroupUngroup;
6
6
  exports.undoGroupAssign = undoGroupAssign;
7
+ exports.undoGroupGather = undoGroupGather;
7
8
  exports.undoMoveTab = undoMoveTab;
8
9
  exports.undoMoveGroup = undoMoveGroup;
9
10
  exports.undoMergeWindow = undoMergeWindow;
@@ -197,6 +198,10 @@ async function undoGroupAssign(undo, deps) {
197
198
  const tabs = undo.tabs || [];
198
199
  return await restoreTabsFromUndo(tabs, deps);
199
200
  }
201
+ async function undoGroupGather(undo, deps) {
202
+ const tabs = undo.tabs || [];
203
+ return await restoreTabsFromUndo(tabs, deps);
204
+ }
200
205
  async function undoMoveTab(undo, deps) {
201
206
  const from = undo.from || {};
202
207
  const entry = {
@@ -426,6 +431,9 @@ async function undoTransaction(params, deps) {
426
431
  if (undo.action === "group-assign") {
427
432
  return await undoGroupAssign(undo, deps);
428
433
  }
434
+ if (undo.action === "group-gather") {
435
+ return await undoGroupGather(undo, deps);
436
+ }
429
437
  if (undo.action === "move-tab") {
430
438
  return await undoMoveTab(undo, deps);
431
439
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Tab Control",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "description": "Archive and manage browser tabs with CLI support",
6
6
  "permissions": [
7
7
  "tabs",
@@ -19,5 +19,5 @@
19
19
  "background": {
20
20
  "service_worker": "background.js"
21
21
  },
22
- "version_name": "0.3.0"
22
+ "version_name": "0.4.0"
23
23
  }