/** * Groups tabs by domain. * Forum link: https://forum.vivaldi.net/topic/98184/group-tabs-by-domain?lang=en-US&page=1 */ (async () => { 'use strict'; const config = { // Use the base domain for tab stacks (true: enabled, false: disabled) base_domain: false, // Automatically change the name of the tab stack (0: disabled, 1: use hostname, 2: generate from base domain) rename_stack: 0, // Workspaces that allow automatic tab stacking (exact match or ) // * If not set, automatic tab stacking is allowed in all workspaces allow_workspaces: [ // "Shopping", ], // Domains that allow automatic tab stacking (exact match or regular expression) // * If not set, automatic tab stacking is allowed for all domains allow_domains: [ // "www.example.com", // /^(.+\.)?example\.net$/, ], // Domains to exclude from automatic tab stacking (exact match or regular expression) block_domains: [ // "www.example.com", // /^(.+\.)?example\.net$/, ], }; const mergeArrays = (...arrays) => [...new Set(arrays.flat())]; const getUrlFragments = (url) => vivaldi.utilities.getUrlFragments(url); const getBaseDomain = (url) => { const {hostForSecurityDisplay, tld} = getUrlFragments(url); return hostForSecurityDisplay.match(`([^.]+\\.${ tld })$`)?.[1] || hostForSecurityDisplay; }; const getHostname = (url) => { const {hostForSecurityDisplay} = getUrlFragments(url); return config.base_domain ? getBaseDomain(url) : hostForSecurityDisplay; }; const matchHostRule = (url, rule) => { const {hostForSecurityDisplay} = getUrlFragments(url); return rule instanceof RegExp ? rule.test(hostForSecurityDisplay) : hostForSecurityDisplay === rule; }; const getTab = async (tabId) => { const tab = await chrome.tabs.get(tabId); if (tab.vivExtData) { tab.vivExtData = JSON.parse(tab.vivExtData); return tab; } }; const getTabIndex = async (tabId) => (await getTab(tabId)).index; const getWorkspaceName = async (workspaceId) => { if (!workspaceId) { return ''; } const workspaceList = await vivaldi.prefs.get('vivaldi.workspaces.list'); return workspaceList.find(item => item.id === workspaceId).name; }; const getTabsByWorkspace = async () => { const tabs = (await chrome.tabs.query({ currentWindow: true })) .filter(tab => tab.id !== -1 && tab.vivExtData) .map(tab => Object.assign(tab, { vivExtData: JSON.parse(tab.vivExtData) })) .filter(tab => !tab.pinned && !tab.vivExtData.panelId) .filter(tab => !config.allow_domains.length || config.allow_domains.find(rule => matchHostRule(tab.url, rule))) .filter(tab => !config.block_domains.length || !config.block_domains.find(rule => matchHostRule(tab.url, rule))); return Object.groupBy(tabs, tab => tab.vivExtData.workspaceId); }; const getTabsByStack = (tabs) => Object.groupBy(tabs, tab => tab.vivExtData.group); const getTabsByHost = (tabs) => Object.groupBy(tabs, tab => getHostname(tab.url)); const getMaxTabsStackId = (tabsByStack, targetHost) => { const counts = {}; for (const [stackId, tabs] of Object.entries(tabsByStack)) { if (stackId !== 'undefined') { const tabsByHost = getTabsByHost(tabs); const count = tabsByHost[targetHost]?.length || 0; delete tabsByHost[targetHost]; counts[stackId] = Object.values(tabsByHost) .reduce((acc, tabs) => { return acc > tabs.length ? acc : 0; }, count); } } return Object.entries(counts) .reduce((acc, [stackId, count]) => { return acc[1] < count ? [stackId, count] : acc; }, [, 0])[0]; }; const getTabStackName = (url) => { let stackName; switch (config.rename_stack) { case 1: stackName = getHostname(url); break; case 2: stackName = getBaseDomain(url).split('.')[0]; stackName = stackName.charAt(0).toUpperCase() + stackName.slice(1); break; } return stackName; }; const addTabStack = async (tabId, stackId, stackName) => { const {vivExtData} = await getTab(tabId); if (stackName) { vivExtData.fixedGroupTitle = stackName; } vivExtData.group = stackId; chrome.tabs.update(tabId, { vivExtData: JSON.stringify(vivExtData) }); }; const stackingTabs = async (workspaceId) => { const workspaceName = await getWorkspaceName(workspaceId); if (!config.allow_workspaces.length || config.allow_workspaces.includes(workspaceName)) { const tabsByWorkspace = await getTabsByWorkspace(); const tabsByStack = getTabsByStack(tabsByWorkspace[workspaceId]); const tabsByHost = getTabsByHost(tabsByWorkspace[workspaceId]); for (const [host, tabs] of Object.entries(tabsByHost)) { const targetStackId = getMaxTabsStackId(tabsByStack, host) || crypto.randomUUID(); const targetStackTabs = tabsByStack[targetStackId] ? getTabsByHost(tabsByStack[targetStackId])[host] : []; const targetTabs = mergeArrays(targetStackTabs, tabs); const targetStackName = getTabStackName(tabs[0].pendingUrl || tabs[0].url); let tabIndex = await getTabIndex(targetTabs[0].id); for (const tab of targetTabs) { addTabStack(tab.id, targetStackId, targetStackName); chrome.tabs.move(tab.id, { index: tabIndex }); tabIndex++; } } } }; chrome.webNavigation.onCommitted.addListener(async details => { if (details.tabId !== -1) { const tab = await getTab(details.tabId); if (tab && !tab.pinned && !tab.vivExtData.panelId && details.frameType === 'outermost_frame') { const workspaceId = tab.vivExtData.workspaceId; stackingTabs(workspaceId); } } }); })();