├── .gitignore
├── LICENSE
├── README.md
├── clear.uc.js
├── image.jpg
├── preferences.json
├── tab_sort_clear.uc.js
└── theme.json
/.gitignore:
--------------------------------------------------------------------------------
1 | api.txt
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Darsh A
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.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ✨ AI Tab Groups for Zen Browser ✨
2 | ‼️ Breaks for Zen version <1.12.6b, Please use the latest script.
3 |
4 | https://github.com/user-attachments/assets/fc792843-b1da-448e-ba00-63322a3d9c99
5 |
6 | ## Pre-requisites
7 | - Enable userChrome Customizations:
8 | In `about:config` go to `toolkit.legacyUserProfileCustomizations.stylesheets` and set it to `true`.
9 | - Install and Setup the userChrome.js Loader from [Autoconfig](https://github.com/MrOtherGuy/fx-autoconfig/tree/master)
10 | - Install the Tab Groups config from [Advanced Tab Groups](https://github.com/Anoms12/Advanced-Tab-Groups)
11 | If you already have a Tab Groups config you can skip this
12 |
13 | ## Setup and Install
14 | - Copy and paste the `tab_sort_clear.uc.js` file to your `chrome/JS` folder.
15 | ### AI Setup
16 | 1. For Gemini (RECOMMENDED)
17 | - Set `gemini { enabled:true }` in `apiConfig` and `ollama { enabled:false }` in `apiConfig`
18 | - Get an API Key from [AI Studios](https://aistudio.google.com)
19 | - Replace `YOUR_GEMINI-API-KEY` with the copied API key
20 | - Don't change the Gemini model since 2.0 has very low rate limits (unless you are rich ig)
21 | 2. For Ollama
22 | - Download and install [Ollama](https://ollama.com/)
23 | - Install your preferred model. The script uses `llama3.1` by default
24 | - Set `ollama { enabled:true }` in `apiConfig` and `gemini { enabled:false }` in `apiConfig`
25 | - Set the model you downloaded in ollama.model: in the config (you can see the models by doing `ollama list` in terminal)
26 | - Make sure `browser.tabs.groups.smart.enabled` is set to `false` in `about:config`
27 | - Open Zen browser, go to `about:support` and clear the startup cache.
28 | - Done. Enjoy ^^
29 |
30 | ## How it works?
31 | - The script has two phases, first it manually sorts tabs that have common words in their title and URL, second it uses the AI to sort rest of the tabs and checks if they fit in the existing groups (that were manually created) or if it should create a new group.
32 | - The script only fetches the tabs full URL and title, thus it prioritizes the title first for main context and URL for sub context.
33 | - The sort function only works when there is two or more tabs to sort into a group.
34 | - You can also have a selected group of tabs sorted as well, this allows you to have fine-grained control over the sorting (works for tabs that are already grouped as well, they may be re-sorted).
35 | - You are free to change the AI prompt to your suitable workflow. The prompt is at the top in `apiConfig`.
36 | - The `Clear` button only clears un-grouped non-pinned tabs.
37 |
38 | **Peace <3**
39 |
40 |
--------------------------------------------------------------------------------
/clear.uc.js:
--------------------------------------------------------------------------------
1 | (() => {
2 | const CONFIG = {
3 | styles: `
4 | .vertical-pinned-tabs-container-separator,
5 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator {
6 | display: flex !important;
7 | flex-direction: column;
8 | margin-left: 0 !important;
9 | min-height: 1px !important;
10 | background-color: var(--lwt-toolbarbutton-border-color, rgba(200, 200, 200, 0.1));
11 | transition: width 0.1s ease-in-out, margin-right 0.1s ease-in-out, background-color 0.3s ease-out;
12 | margin-top: 5px !important;
13 | margin-bottom: 8px !important;
14 | }
15 | #clear-button {
16 | opacity: 0;
17 | transition: opacity 0.1s ease-in-out;
18 | position: absolute;
19 | right: 0;
20 | font-size: 12px;
21 | width: 60px;
22 | pointer-events: auto;
23 | align-self: end;
24 | appearance: none;
25 | margin-top: -8px;
26 | padding: 1px;
27 | color: grey;
28 | label { display: block; }
29 | }
30 | #clear-button:hover {
31 | opacity: 1;
32 | color: white;
33 | border-radius: 4px;
34 | }
35 | .vertical-pinned-tabs-container-separator:has(#clear-button):hover,
36 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:has(#clear-button):hover {
37 | width: calc(100% - 60px);
38 | margin-right: auto;
39 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2));
40 | }
41 | .vertical-pinned-tabs-container-separator:hover #clear-button,
42 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:hover #clear-button {
43 | opacity: 1;
44 | }
45 | .tab-closing {
46 | animation: fadeUp 0.5s forwards;
47 | }
48 | @keyframes fadeUp {
49 | 0% { opacity: 1; transform: translateY(0); }
50 | 100% { opacity: 0; transform: translateY(-20px); max-height: 0px; padding: 0; margin: 0; border: 0; }
51 | }
52 | .tabbrowser-tab {
53 | transition: transform 0.3s ease-out, opacity 0.3s ease-out, max-height 0.5s ease-out, margin 0.5s ease-out, padding 0.5s ease-out;
54 | }
55 | `
56 | };
57 |
58 | let commandListenerAdded = false;
59 |
60 | const injectStyles = () => {
61 | let styleElement = document.getElementById('tab-clear-styles');
62 | if (!styleElement) {
63 | styleElement = Object.assign(document.createElement('style'), {
64 | id: 'tab-clear-styles',
65 | textContent: CONFIG.styles
66 | });
67 | document.head.appendChild(styleElement);
68 | } else if (styleElement.textContent !== CONFIG.styles) {
69 | styleElement.textContent = CONFIG.styles;
70 | }
71 | };
72 |
73 | const clearTabs = () => {
74 | try {
75 | const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace;
76 | if (!currentWorkspaceId) return;
77 | const groupSelector = `tab-group:has(tab[zen-workspace-id="${currentWorkspaceId}"])`;
78 | const tabsToClose = [];
79 | for (const tab of gBrowser.tabs) {
80 | const isSameWorkSpace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId;
81 | const groupParent = tab.closest('tab-group');
82 | const isInGroupInCorrectWorkspace = groupParent ? groupParent.matches(groupSelector) : false;
83 | const isEmptyZenTab = tab.hasAttribute("zen-empty-tab");
84 | if (isSameWorkSpace && !tab.selected && !tab.pinned && !isInGroupInCorrectWorkspace && !isEmptyZenTab && tab.isConnected) {
85 | tabsToClose.push(tab);
86 | }
87 | }
88 | if (tabsToClose.length === 0) return;
89 |
90 | tabsToClose.forEach(tab => {
91 | tab.classList.add('tab-closing');
92 | setTimeout(() => {
93 | if (tab && tab.isConnected) {
94 | try {
95 | gBrowser.removeTab(tab, { animate: false, skipSessionStore: false, closeWindowWithLastTab: false });
96 | } catch (removeError) {
97 | if (tab && tab.isConnected) {
98 | tab.classList.remove('tab-closing');
99 | }
100 | }
101 | }
102 | }, 500);
103 | });
104 | } catch (error) {
105 | console.log(error);
106 | }
107 | };
108 |
109 | function ensureButtonsExist(container) {
110 | if (!container || !container.isConnected) return;
111 | if (!container.querySelector('#clear-button')) {
112 | try {
113 | const buttonFragment = window.MozXULElement.parseXULToFragment(
114 | ``
115 | );
116 | if (container.isConnected) {
117 | container.appendChild(buttonFragment.firstChild.cloneNode(true));
118 | }
119 | } catch (e) { console.log(e); }
120 | }
121 | }
122 |
123 | function addButtonsToAllSeparators() {
124 | const separators = document.querySelectorAll(".vertical-pinned-tabs-container-separator");
125 | if (separators.length > 0) {
126 | separators.forEach(ensureButtonsExist);
127 | } else {
128 | const periphery = document.querySelector('#tabbrowser-arrowscrollbox-periphery');
129 | if (periphery && !periphery.querySelector('#clear-button')) {
130 | ensureButtonsExist(periphery);
131 | }
132 | }
133 | }
134 |
135 | function setupCommandsAndListener() {
136 | const zenCommands = document.querySelector("commandset#zenCommandSet");
137 | if (!zenCommands) return;
138 | if (!zenCommands.querySelector("#cmd_zenClearTabs")) {
139 | try {
140 | const cmd = window.MozXULElement.parseXULToFragment(``).firstChild;
141 | zenCommands.appendChild(cmd);
142 | } catch (e) { console.log(e); }
143 | }
144 | if (!commandListenerAdded) {
145 | try {
146 | zenCommands.addEventListener('command', (event) => {
147 | if (event.target.id === "cmd_zenClearTabs") {
148 | clearTabs();
149 | }
150 | });
151 | commandListenerAdded = true;
152 | } catch (e) { console.log(e); }
153 | }
154 | }
155 |
156 | function setupZenWorkspaceHooks() {
157 | if (typeof gZenWorkspaces === 'undefined' || typeof gZenWorkspaces.clearButtonHooksApplied !== 'undefined') {
158 | return;
159 | }
160 | gZenWorkspaces.originalClearButtonHooks = {
161 | onTabBrowserInserted: gZenWorkspaces.onTabBrowserInserted,
162 | updateTabsContainers: gZenWorkspaces.updateTabsContainers,
163 | };
164 | gZenWorkspaces.clearButtonHooksApplied = true;
165 |
166 | gZenWorkspaces.onTabBrowserInserted = function(event) {
167 | if (typeof gZenWorkspaces.originalClearButtonHooks.onTabBrowserInserted === 'function') {
168 | try { gZenWorkspaces.originalClearButtonHooks.onTabBrowserInserted.call(gZenWorkspaces, event); } catch (e) { /* Error handling removed */ }
169 | }
170 | setTimeout(addButtonsToAllSeparators, 150);
171 | };
172 |
173 | gZenWorkspaces.updateTabsContainers = function(...args) {
174 | if (typeof gZenWorkspaces.originalClearButtonHooks.updateTabsContainers === 'function') {
175 | try { gZenWorkspaces.originalClearButtonHooks.updateTabsContainers.apply(gZenWorkspaces, args); } catch (e) { /* Error handling removed */ }
176 | }
177 | setTimeout(addButtonsToAllSeparators, 150);
178 | };
179 | }
180 |
181 | function initializeScript() {
182 | let checkCount = 0;
183 | const maxChecks = 30;
184 | const checkInterval = 1000;
185 |
186 | const initCheckInterval = setInterval(() => {
187 | checkCount++;
188 | const separatorExists = !!document.querySelector(".vertical-pinned-tabs-container-separator");
189 | const peripheryExists = !!document.querySelector('#tabbrowser-arrowscrollbox-periphery');
190 | const commandSetExists = !!document.querySelector("commandset#zenCommandSet");
191 | const gBrowserReady = typeof gBrowser !== 'undefined' && gBrowser.tabContainer;
192 | const gZenWorkspacesReady = typeof gZenWorkspaces !== 'undefined' && typeof gZenWorkspaces.activeWorkspace !== 'undefined';
193 | const ready = gBrowserReady && commandSetExists && (separatorExists || peripheryExists) && gZenWorkspacesReady;
194 |
195 | if (ready) {
196 | clearInterval(initCheckInterval);
197 | const finalSetup = () => {
198 | try {
199 | injectStyles();
200 | setupCommandsAndListener();
201 | addButtonsToAllSeparators();
202 | setupZenWorkspaceHooks();
203 | } catch (e) { console.log(e); }
204 | };
205 | if ('requestIdleCallback' in window) {
206 | requestIdleCallback(finalSetup, { timeout: 2000 });
207 | } else {
208 | setTimeout(finalSetup, 500);
209 | }
210 | } else if (checkCount > maxChecks) {
211 | clearInterval(initCheckInterval);
212 | }
213 | }, checkInterval);
214 | }
215 |
216 | if (document.readyState === "complete" || document.readyState === "interactive") {
217 | initializeScript();
218 | } else {
219 | window.addEventListener("load", initializeScript, { once: true });
220 | }
221 |
222 | })();
223 |
--------------------------------------------------------------------------------
/image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Darsh-A/Ai-TabGroups-ZenBrowser/488dc5c202745447fa08836e0719e4b04063efc7/image.jpg
--------------------------------------------------------------------------------
/preferences.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "property": "extensions.tabgroups.ai_model",
4 | "label": "Choose AI - Gemini or Ollama",
5 | "type": "dropdown",
6 | "placeholder": false,
7 | "value": "number",
8 | "default": 1,
9 | "options": [
10 | {
11 | "value": 1,
12 | "label": "Gemini"
13 | },
14 | {
15 | "value": 2,
16 | "label": "Ollama"
17 | }
18 | ]
19 | },
20 | {
21 | "type": "separator",
22 | "label": "Gemini",
23 | "id": "gemini-separator",
24 | "conditions": [
25 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 1 } }
26 | ]
27 | },
28 | {
29 | "property": "extensions.tabgroups.gemini_api_key",
30 | "label": "Gemini API Key",
31 | "type": "string",
32 | "placeholder": "Enter your Gemini API key here",
33 | "conditions": [
34 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 1 } }
35 | ]
36 | },
37 | {
38 | "property": "extensions.tabgroups.gemini_model",
39 | "label": "Gemini Model to Use For Renaming",
40 | "default": "gemini-1.5-flash-latest",
41 | "type": "string",
42 | "placeholder": "Enter your Gemini Model here",
43 | "conditions": [
44 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 1 } }
45 | ]
46 | },
47 | {
48 | "type": "separator",
49 | "label": "Ollama",
50 | "id": "ollama-separator",
51 | "conditions": [
52 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 2 } }
53 | ]
54 | },
55 | {
56 | "property": "extensions.tabgroups.ollama_endpoint",
57 | "label": "Ollama Endpoint URL",
58 | "default": "http://localhost:11434/api/generate",
59 | "type": "string",
60 | "placeholder": "Enter your Ollama Endpoint URL here",
61 | "conditions": [
62 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 2 } }
63 | ]
64 | },
65 | {
66 | "property": "extensions.tabgroups.ollama_model",
67 | "label": "Ollama Model to Use For Renaming",
68 | "default": "llama3.1:latest",
69 | "type": "string",
70 | "placeholder": "Enter your Ollama Model here",
71 | "conditions": [
72 | { "if": { "property": "extensions.tabgroups.ai_model", "value": 2 } }
73 | ]
74 | }
75 | ]
76 |
--------------------------------------------------------------------------------
/tab_sort_clear.uc.js:
--------------------------------------------------------------------------------
1 | // VERSION 4.10.0 (Added tab multi-selection sort capability)
2 | (() => {
3 | // --- Configuration ---
4 |
5 | // Preference Key for AI Model Selection
6 | const AI_MODEL_PREF = "extensions.tabgroups.ai_model"; // '1' for Gemini, '2' for Ollama
7 | // Preference Keys for AI Config
8 | const OLLAMA_ENDPOINT_PREF = "extensions.tabgroups.ollama_endpoint";
9 | const OLLAMA_MODEL_PREF = "extensions.tabgroups.ollama_model";
10 | const GEMINI_API_KEY_PREF = "extensions.tabgroups.gemini_api_key";
11 | const GEMINI_MODEL_PREF = "extensions.tabgroups.gemini_model";
12 |
13 | // Helper function to read preferences with fallbacks
14 | const getPref = (prefName, defaultValue = "") => {
15 | try {
16 | const prefService = Services.prefs;
17 | if (prefService.prefHasUserValue(prefName)) {
18 | switch (prefService.getPrefType(prefName)) {
19 | case prefService.PREF_STRING:
20 | return prefService.getStringPref(prefName);
21 | case prefService.PREF_INT:
22 | return prefService.getIntPref(prefName);
23 | case prefService.PREF_BOOL:
24 | return prefService.getBoolPref(prefName);
25 | }
26 | }
27 | } catch (e) {
28 | console.warn(`Failed to read preference ${prefName}:`, e);
29 | }
30 | return defaultValue;
31 | };
32 |
33 | // Read preference values
34 | const AI_MODEL_VALUE = getPref(AI_MODEL_PREF, "1"); // Default to Gemini
35 | const OLLAMA_ENDPOINT_VALUE = getPref(OLLAMA_ENDPOINT_PREF, "http://localhost:11434/api/generate");
36 | const OLLAMA_MODEL_VALUE = getPref(OLLAMA_MODEL_PREF, "llama3.2");
37 | const GEMINI_API_KEY_VALUE = getPref(GEMINI_API_KEY_PREF, "");
38 | const GEMINI_MODEL_VALUE = getPref(GEMINI_MODEL_PREF, "gemini-1.5-flash");
39 |
40 | const CONFIG = {
41 | apiConfig: {
42 | ollama: {
43 | endpoint: OLLAMA_ENDPOINT_VALUE,
44 | enabled: AI_MODEL_VALUE == "2",
45 | model: OLLAMA_MODEL_VALUE,
46 | promptTemplateBatch: `Analyze the following numbered list of tab data (Title, URL, Description) and assign a concise category (1-2 words, Title Case) for EACH tab.
47 |
48 | Existing Categories (Use these EXACT names if a tab fits):
49 | {EXISTING_CATEGORIES_LIST}
50 |
51 | ---
52 | Instructions for Assignment:
53 | 1. **Prioritize Existing:** For each tab below, determine if it clearly belongs to one of the 'Existing Categories'. Base this primarily on the URL/Domain, then Title/Description. If it fits, you MUST use the EXACT category name provided in the 'Existing Categories' list. DO NOT create a minor variation (e.g., if 'Project Docs' exists, use that, don't create 'Project Documentation').
54 | 2. **Assign New Category (If Necessary):** Only if a tab DOES NOT fit an existing category, assign the best NEW concise category (1-2 words, Title Case).
55 | * PRIORITIZE the URL/Domain (e.g., 'GitHub', 'YouTube', 'StackOverflow').
56 | * Use Title/Description for specifics or generic domains.
57 | 3. **Consistency is CRITICAL:** Use the EXACT SAME category name for all tabs belonging to the same logical group (whether assigned an existing or a new category). If multiple tabs point to 'google.com/search?q=recipes', categorize them consistently (e.g., 'Google Search' or 'Recipes', but use the same one for all).
58 | 4. **Format:** 1-2 words, Title Case.
59 |
60 | ---
61 | Input Tab Data:
62 | {TAB_DATA_LIST}
63 |
64 | ---
65 | Instructions for Output:
66 | 1. Output ONLY the category names.
67 | 2. Provide EXACTLY ONE category name per line.
68 | 3. The number of lines in your output MUST EXACTLY MATCH the number of tabs in the Input Tab Data list above.
69 | 4. DO NOT include numbering, explanations, apologies, markdown formatting, or any surrounding text like "Output:" or backticks.
70 | 5. Just the list of categories, separated by newlines.
71 | ---
72 |
73 | Output:`
74 | },
75 | gemini: {
76 | enabled: AI_MODEL_VALUE == "1",
77 | apiKey: GEMINI_API_KEY_VALUE,
78 | model: GEMINI_MODEL_VALUE,
79 | // Endpoint structure: https://generativelanguage.googleapis.com/v1beta/models/{model}:{method}
80 | apiBaseUrl: 'https://generativelanguage.googleapis.com/v1beta/models/',
81 | promptTemplateBatch: `Analyze the following numbered list of tab data (Title, URL, Description) and assign a concise category (1-2 words, Title Case) for EACH tab.
82 |
83 | Existing Categories (Use these EXACT names if a tab fits):
84 | {EXISTING_CATEGORIES_LIST}
85 |
86 | ---
87 | Instructions for Assignment:
88 | 1. **Prioritize Existing:** For each tab below, determine if it clearly belongs to one of the 'Existing Categories'. Base this primarily on the URL/Domain, then Title/Description. If it fits, you MUST use the EXACT category name provided in the 'Existing Categories' list. DO NOT create a minor variation (e.g., if 'Project Docs' exists, use that, don't create 'Project Documentation').
89 | 2. **Assign New Category (If Necessary):** Only if a tab DOES NOT fit an existing category, assign the best NEW concise category (1-2 words, Title Case).
90 | * PRIORITIZE the URL/Domain (e.g., 'GitHub', 'YouTube', 'StackOverflow').
91 | * Use Title/Description for specifics or generic domains.
92 | 3. **Consistency is CRITICAL:** Use the EXACT SAME category name for all tabs belonging to the same logical group (whether assigned an existing or a new category). If multiple tabs point to 'google.com/search?q=recipes', categorize them consistently (e.g., 'Google Search' or 'Recipes', but use the same one for all).
93 | 4. **Format:** 1-2 words, Title Case.
94 |
95 | ---
96 | Input Tab Data:
97 | {TAB_DATA_LIST}
98 |
99 | ---
100 | Instructions for Output:
101 | 1. Output ONLY the category names.
102 | 2. Provide EXACTLY ONE category name per line.
103 | 3. The number of lines in your output MUST EXACTLY MATCH the number of tabs in the Input Tab Data list above.
104 | 4. DO NOT include numbering, explanations, apologies, markdown formatting, or any surrounding text like "Output:" or backticks.
105 | 5. Just the list of categories, separated by newlines.
106 | ---
107 |
108 | Output:`,
109 | generationConfig: {
110 | temperature: 0.1, // Low temp for consistency
111 | // maxOutputTokens: calculated dynamically based on tab count
112 | candidateCount: 1, // Only need one best answer
113 | // stopSequences: ["---"] // Optional: define sequences to stop generation
114 | }
115 | },
116 | customApi: {
117 | enabled: false,
118 | // ... (custom API config if needed)
119 | }
120 | },
121 | groupColors: [
122 | "var(--tab-group-color-blue)", "var(--tab-group-color-red)", "var(--tab-group-color-yellow)",
123 | "var(--tab-group-color-green)", "var(--tab-group-color-pink)", "var(--tab-group-color-purple)",
124 | "var(--tab-group-color-orange)", "var(--tab-group-color-cyan)", "var(--tab-group-color-gray)"
125 | ],
126 | groupColorNames: [
127 | "blue", "red", "yellow", "green", "pink", "purple", "orange", "cyan", "gray"
128 | ],
129 | preGroupingThreshold: 2, // Min tabs for keyword/hostname pre-grouping
130 | titleKeywordStopWords: new Set([
131 | 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'with', 'by', 'of',
132 | 'is', 'am', 'are', 'was', 'were', 'be', 'being', 'been', 'has', 'have', 'had', 'do', 'does', 'did',
133 | 'how', 'what', 'when', 'where', 'why', 'which', 'who', 'whom', 'whose',
134 | 'new', 'tab', 'untitled', 'page', 'home', 'com', 'org', 'net', 'io', 'dev', 'app',
135 | 'get', 'set', 'list', 'view', 'edit', 'create', 'update', 'delete',
136 | 'my', 'your', 'his', 'her', 'its', 'our', 'their', 'me', 'you', 'him', 'her', 'it', 'us', 'them',
137 | 'about', 'search', 'results', 'posts', 'index', 'dashboard', 'profile', 'settings',
138 | 'official', 'documentation', 'docs', 'wiki', 'help', 'support', 'faq', 'guide',
139 | 'error', 'login', 'signin', 'sign', 'up', 'out', 'welcome', 'loading', 'vs', 'using', 'code',
140 | 'microsoft', 'google', 'apple', 'amazon', 'facebook', 'twitter'
141 | ]),
142 | minKeywordLength: 3,
143 | consolidationDistanceThreshold: 2, // Max Levenshtein distance to merge similar group names
144 | styles: `
145 | #sort-button {
146 | opacity: 0;
147 | transition: opacity 0.1s ease-in-out;
148 | position: absolute;
149 | right: 55px; /* Positioned to the left of the clear button */
150 | font-size: 12px;
151 | width: 60px;
152 | pointer-events: auto;
153 | align-self: end;
154 | appearance: none;
155 | margin-top: -8px;
156 | padding: 1px;
157 | color: gray;
158 | label { display: block; }
159 | }
160 | #sort-button:hover {
161 | opacity: 1;
162 | color: white;
163 | border-radius: 4px;
164 | }
165 |
166 | #clear-button {
167 | opacity: 0;
168 | transition: opacity 0.1s ease-in-out;
169 | position: absolute;
170 | right: 0;
171 | font-size: 12px;
172 | width: 60px;
173 | pointer-events: auto;
174 | align-self: end;
175 | appearance: none;
176 | margin-top: -8px;
177 | padding: 1px;
178 | color: grey;
179 | label { display: block; }
180 | }
181 | #clear-button:hover {
182 | opacity: 1;
183 | color: white;
184 | border-radius: 4px;
185 | }
186 | /* Separator Base Style (Ensures background is animatable) */
187 | .vertical-pinned-tabs-container-separator {
188 | display: flex !important;
189 | flex-direction: column;
190 | margin-left: 0;
191 | min-height: 1px;
192 | background-color: var(--lwt-toolbarbutton-border-color, rgba(200, 200, 200, 0.1)); /* Subtle base color */
193 | transition: width 0.1s ease-in-out, margin-right 0.1s ease-in-out, background-color 0.3s ease-out; /* Add background transition */
194 | }
195 | /* Separator Hover Logic */
196 | .vertical-pinned-tabs-container-separator:has(#sort-button):has(#clear-button):hover {
197 | width: calc(100% - 115px); /* 60px (clear) + 55px (sort) */
198 | margin-right: auto;
199 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2)); /* Slightly lighter on hover */
200 | }
201 | /* Hover when ONLY SORT is present */
202 | .vertical-pinned-tabs-container-separator:has(#sort-button):not(:has(#clear-button)):hover {
203 | width: calc(100% - 65px); /* Only space for sort + margin */
204 | margin-right: auto;
205 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2));
206 | }
207 | /* Hover when ONLY CLEAR is present */
208 | .vertical-pinned-tabs-container-separator:not(:has(#sort-button)):has(#clear-button):hover {
209 | width: calc(100% - 60px); /* Only space for clear */
210 | margin-right: auto;
211 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2));
212 | }
213 | /* Show BOTH buttons on separator hover */
214 | .vertical-pinned-tabs-container-separator:hover #sort-button,
215 | .vertical-pinned-tabs-container-separator:hover #clear-button {
216 | opacity: 1;
217 | }
218 |
219 | /* When theres no Pinned Tabs */
220 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator {
221 | display: flex !important;
222 | flex-direction: column !important;
223 | margin-left: 0 !important;
224 | margin-top: 5px !important;
225 | margin-bottom: 8px !important;
226 | min-height: 1px !important;
227 | background-color: var(--lwt-toolbarbutton-border-color, rgba(200, 200, 200, 0.1)); /* Subtle base color */
228 | transition: width 0.1s ease-in-out, margin-right 0.1s ease-in-out, background-color 0.3s ease-out; /* Add background transition */
229 | }
230 | /* Hover when BOTH buttons are potentially visible (No Pinned) */
231 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:has(#sort-button):has(#clear-button):hover {
232 | width: calc(100% - 115px); /* 60px (clear) + 55px (sort) */
233 | margin-right: auto;
234 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2));
235 | }
236 | /* Hover when ONLY SORT is present (No Pinned) */
237 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:has(#sort-button):not(:has(#clear-button)):hover {
238 | width: calc(100% - 65px); /* Only space for sort + margin */
239 | margin-right: auto;
240 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2));
241 | }
242 | /* Hover when ONLY CLEAR is present (No Pinned) */
243 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:not(:has(#sort-button)):has(#clear-button):hover {
244 | width: calc(100% - 60px); /* Only space for clear */
245 | margin-right: auto;
246 | background-color: var(--lwt-toolbarbutton-hover-background, rgba(200, 200, 200, 0.2));
247 | }
248 | /* Show BOTH buttons on separator hover (No Pinned) */
249 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:hover #sort-button,
250 | .zen-workspace-tabs-section[hide-separator] .vertical-pinned-tabs-container-separator:hover #clear-button {
251 | opacity: 1;
252 | }
253 |
254 | /* Separator Pulsing Animation */
255 | @keyframes pulse-separator-bg {
256 | 0% { background-color: var(--lwt-toolbarbutton-border-color, rgb(255, 141, 141)); }
257 | 50% { background-color: var(--lwt-toolbarbutton-hover-background, rgba(137, 178, 255, 0.91)); } /* Brighter pulse color */
258 | 100% { background-color: var(--lwt-toolbarbutton-border-color, rgb(142, 253, 238)); }
259 | }
260 |
261 | .separator-is-sorting {
262 | animation: pulse-separator-bg 1.5s ease-in-out infinite;
263 | will-change: background-color;
264 | }
265 |
266 | /* Tab Animations */
267 | .tab-closing {
268 | animation: fadeUp 0.5s forwards;
269 | }
270 | @keyframes fadeUp {
271 | 0% { opacity: 1; transform: translateY(0); }
272 | 100% { opacity: 0; transform: translateY(-20px); max-height: 0px; padding: 0; margin: 0; border: 0; } /* Add max-height */
273 | }
274 | @keyframes loading-pulse-tab {
275 | 0%, 100% { opacity: 0.6; }
276 | 50% { opacity: 1; }
277 | }
278 | .tab-is-sorting .tab-icon-image,
279 | .tab-is-sorting .tab-label {
280 | animation: loading-pulse-tab 1.5s ease-in-out infinite;
281 | will-change: opacity;
282 | }
283 | .tabbrowser-tab {
284 | transition: transform 0.3s ease-out, opacity 0.3s ease-out, max-height 0.5s ease-out, margin 0.5s ease-out, padding 0.5s ease-out; /* Add transition for closing */
285 | }
286 | `
287 | };
288 |
289 | // --- Globals & State ---
290 | let groupColorIndex = 0;
291 | let isSorting = false;
292 | let commandListenerAdded = false;
293 |
294 | // --- Helper Functions ---
295 |
296 | const injectStyles = () => {
297 | let styleElement = document.getElementById('tab-sort-clear-styles');
298 | if (styleElement) {
299 | if (styleElement.textContent !== CONFIG.styles) {
300 | styleElement.textContent = CONFIG.styles;
301 | console.log("BUTTONS: Styles updated.");
302 | }
303 | return;
304 | }
305 | styleElement = Object.assign(document.createElement('style'), {
306 | id: 'tab-sort-clear-styles',
307 | textContent: CONFIG.styles
308 | });
309 | document.head.appendChild(styleElement);
310 | console.log("BUTTONS: Styles injected.");
311 | };
312 |
313 | const getTabData = (tab) => {
314 | if (!tab || !tab.isConnected) {
315 | return { title: 'Invalid Tab', url: '', hostname: '', description: '' };
316 | }
317 | let title = 'Untitled Page';
318 | let fullUrl = '';
319 | let hostname = '';
320 | let description = '';
321 |
322 | try {
323 | const originalTitle = tab.getAttribute('label') || tab.querySelector('.tab-label, .tab-text')?.textContent || '';
324 | const browser = tab.linkedBrowser || tab._linkedBrowser || gBrowser?.getBrowserForTab?.(tab);
325 |
326 | if (browser?.currentURI?.spec && !browser.currentURI.spec.startsWith('about:')) {
327 | try {
328 | const currentURL = new URL(browser.currentURI.spec);
329 | fullUrl = currentURL.href;
330 | hostname = currentURL.hostname.replace(/^www\./, '');
331 | } catch (e) {
332 | hostname = 'Invalid URL';
333 | fullUrl = browser?.currentURI?.spec || 'Invalid URL';
334 | }
335 | } else if (browser?.currentURI?.spec) {
336 | fullUrl = browser.currentURI.spec;
337 | hostname = 'Internal Page';
338 | }
339 |
340 | if (!originalTitle || originalTitle === 'New Tab' || originalTitle === 'about:blank' || originalTitle === 'Loading...' || originalTitle.startsWith('http:') || originalTitle.startsWith('https:')) {
341 | if (hostname && hostname !== 'Invalid URL' && hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== 'Internal Page') {
342 | title = hostname;
343 | } else {
344 | try {
345 | const pathSegment = new URL(fullUrl).pathname.split('/')[1];
346 | if (pathSegment) {
347 | title = pathSegment;
348 | }
349 | } catch { /* ignore */ }
350 | }
351 | } else {
352 | title = originalTitle.trim();
353 | }
354 | title = title || 'Untitled Page';
355 |
356 | try {
357 | if (browser && browser.contentDocument) {
358 | const metaDescElement = browser.contentDocument.querySelector('meta[name="description"]');
359 | if (metaDescElement) {
360 | description = metaDescElement.getAttribute('content')?.trim() || '';
361 | description = description.substring(0, 200);
362 | }
363 | }
364 | } catch (contentError) {
365 | /* ignore permission errors */
366 | }
367 | } catch (e) {
368 | console.error('Error getting tab data for tab:', tab, e);
369 | title = 'Error Processing Tab';
370 | }
371 | return { title: title, url: fullUrl, hostname: hostname || 'N/A', description: description || 'N/A' };
372 | };
373 |
374 | const toTitleCase = (str) => {
375 | if (!str) return ""; // Added guard for null/undefined input
376 | return str.toLowerCase()
377 | .split(' ')
378 | .map(word => word.charAt(0).toUpperCase() + word.slice(1))
379 | .join(' ');
380 | };
381 |
382 | const processTopic = (text) => {
383 | if (!text) return "Uncategorized";
384 |
385 | const originalTextTrimmedLower = text.trim().toLowerCase();
386 | const normalizationMap = {
387 | 'github.com': 'GitHub', 'github': 'GitHub',
388 | 'stackoverflow.com': 'Stack Overflow', 'stack overflow': 'Stack Overflow', 'stackoverflow': 'Stack Overflow',
389 | 'google docs': 'Google Docs', 'docs.google.com': 'Google Docs',
390 | 'google drive': 'Google Drive', 'drive.google.com': 'Google Drive',
391 | 'youtube.com': 'YouTube', 'youtube': 'YouTube',
392 | 'reddit.com': 'Reddit', 'reddit': 'Reddit',
393 | 'chatgpt': 'ChatGPT', 'openai.com': 'OpenAI',
394 | 'gmail': 'Gmail', 'mail.google.com': 'Gmail',
395 | 'aws': 'AWS', 'amazon web services': 'AWS',
396 | 'pinterest.com': 'Pinterest', 'pinterest': 'Pinterest',
397 | 'developer.mozilla.org': 'MDN Web Docs', 'mdn': 'MDN Web Docs', 'mozilla': 'Mozilla'
398 | };
399 |
400 | if (normalizationMap[originalTextTrimmedLower]) {
401 | return normalizationMap[originalTextTrimmedLower];
402 | }
403 |
404 | let processedText = text.replace(/^(Category is|The category is|Topic:)\s*"?/i, '');
405 | processedText = processedText.replace(/^\s*[\d.\-*]+\s*/, '');
406 | let words = processedText.trim().split(/\s+/);
407 | let category = words.slice(0, 2).join(' ');
408 | category = category.replace(/["'*().:;,]/g, '');
409 |
410 | return toTitleCase(category).substring(0, 40) || "Uncategorized";
411 | };
412 |
413 | const extractTitleKeywords = (title) => {
414 | if (!title || typeof title !== 'string') {
415 | return new Set();
416 | }
417 | const cleanedTitle = title.toLowerCase()
418 | .replace(/[-_]/g, ' ')
419 | .replace(/[^\w\s]/g, '')
420 | .replace(/\s+/g, ' ')
421 | .trim();
422 | const words = cleanedTitle.split(' ');
423 | const keywords = new Set();
424 |
425 | for (const word of words) {
426 | if (word.length >= CONFIG.minKeywordLength && !CONFIG.titleKeywordStopWords.has(word) && !/^\d+$/.test(word)) {
427 | keywords.add(word);
428 | }
429 | }
430 | return keywords;
431 | };
432 |
433 | const getNextGroupColorName = () => {
434 | const colorName = CONFIG.groupColorNames[groupColorIndex % CONFIG.groupColorNames.length];
435 | groupColorIndex++;
436 | return colorName;
437 | };
438 |
439 | const findGroupElement = (topicName, workspaceId) => {
440 | const sanitizedTopicName = topicName.trim();
441 | if (!sanitizedTopicName) return null;
442 |
443 | // Escape special characters for CSS selector
444 | const safeSelectorTopicName = sanitizedTopicName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
445 |
446 | // Use :has() to find the group by label that CONTAINS a tab with the correct workspace ID
447 | const selector = `tab-group[label="${safeSelectorTopicName}"]:has(tab[zen-workspace-id="${workspaceId}"])`;
448 |
449 | try {
450 | // console.log(`findGroupElement: Searching with selector: ${selector}`); // Optional debug log
451 | return document.querySelector(selector);
452 | } catch (e) {
453 | console.error(`Error finding group with selector: ${selector}`, e);
454 | return null;
455 | }
456 | };
457 |
458 | const levenshteinDistance = (a, b) => {
459 | if (!a || !b) return Math.max(a?.length ?? 0, b?.length ?? 0);
460 | a = a.toLowerCase();
461 | b = b.toLowerCase();
462 | if (a.length === 0) return b.length;
463 | if (b.length === 0) return a.length;
464 |
465 | const matrix = [];
466 | for (let i = 0; i <= b.length; i++) {
467 | matrix[i] = [i];
468 | }
469 | for (let j = 0; j <= a.length; j++) {
470 | matrix[0][j] = j;
471 | }
472 |
473 | for (let i = 1; i <= b.length; i++) {
474 | for (let j = 1; j <= a.length; j++) {
475 | const cost = a[i - 1] === b[j - 1] ? 0 : 1;
476 | matrix[i][j] = Math.min(
477 | matrix[i - 1][j] + 1, // Deletion
478 | matrix[i][j - 1] + 1, // Insertion
479 | matrix[i - 1][j - 1] + cost // Substitution
480 | );
481 | }
482 | }
483 | return matrix[b.length][a.length];
484 | };
485 | // --- End Helper Functions ---
486 |
487 |
488 | // --- AI Interaction ---
489 | const askAIForMultipleTopics = async (tabs, existingCategoryNames = []) => {
490 | const validTabs = tabs.filter(tab => tab && tab.isConnected);
491 | if (!validTabs || validTabs.length === 0) {
492 | return [];
493 | }
494 |
495 | const { gemini, ollama } = CONFIG.apiConfig;
496 | let result = [];
497 | let apiChoice = "None";
498 |
499 | validTabs.forEach(tab => tab.classList.add('tab-is-sorting'));
500 |
501 | try {
502 | if (gemini.enabled) {
503 | apiChoice = "Gemini";
504 | if (!gemini.apiKey) {
505 | throw new Error("Gemini API key is missing or not set. Please paste your key in the CONFIG section.");
506 | }
507 | console.log(`Batch AI (Gemini): Requesting categories for ${validTabs.length} tabs, considering ${existingCategoryNames.length} existing categories...`);
508 |
509 | const tabDataArray = validTabs.map(getTabData);
510 | const formattedTabDataList = tabDataArray.map((data, index) =>
511 | `${index + 1}.\nTitle: "${data.title}"\nURL: "${data.url}"\nDescription: "${data.description}"`
512 | ).join('\n\n');
513 | const formattedExistingCategories = existingCategoryNames.length > 0
514 | ? existingCategoryNames.map(name => `- ${name}`).join('\n')
515 | : "None";
516 |
517 | const prompt = gemini.promptTemplateBatch
518 | .replace("{EXISTING_CATEGORIES_LIST}", formattedExistingCategories)
519 | .replace("{TAB_DATA_LIST}", formattedTabDataList);
520 |
521 | const apiUrl = `${gemini.apiBaseUrl}${gemini.model}:generateContent?key=${gemini.apiKey}`;
522 | const headers = { 'Content-Type': 'application/json' };
523 | const estimatedOutputTokens = Math.max(256, validTabs.length * 16); // Dynamic estimation
524 |
525 | const requestBody = {
526 | contents: [{ parts: [{ text: prompt }] }],
527 | generationConfig: {
528 | ...gemini.generationConfig,
529 | maxOutputTokens: estimatedOutputTokens
530 | }
531 | };
532 |
533 | const response = await fetch(apiUrl, {
534 | method: 'POST',
535 | headers: headers,
536 | body: JSON.stringify(requestBody)
537 | });
538 |
539 | if (!response.ok) {
540 | let errorText = `API Error ${response.status}`;
541 | try {
542 | const errorData = await response.json();
543 | errorText += `: ${errorData?.error?.message || response.statusText}`;
544 | console.error("Gemini API Error Response:", errorData);
545 | } catch (parseError) {
546 | errorText += `: ${response.statusText}`;
547 | const rawText = await response.text().catch(() => '');
548 | console.error("Gemini API Error Raw Response:", rawText);
549 | }
550 | if (response.status === 400 && errorText.includes("API key not valid")) {
551 | throw new Error(`Gemini API Error: API key is not valid. Please check the key in the script configuration. (${errorText})`);
552 | }
553 | if (response.status === 403) {
554 | throw new Error(`Gemini API Error: Permission denied. Ensure the API key has the 'generativelanguage.models.generateContent' permission enabled. (${errorText})`);
555 | }
556 | throw new Error(errorText);
557 | }
558 |
559 | const data = await response.json();
560 | const aiText = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
561 |
562 | if (!aiText) {
563 | console.error("Gemini API: Empty or unexpected response structure.", data);
564 | if (data?.promptFeedback?.blockReason) {
565 | throw new Error(`Gemini API Error: Request blocked due to ${data.promptFeedback.blockReason}. Check safety ratings: ${JSON.stringify(data.promptFeedback.safetyRatings)}`);
566 | }
567 | if (data?.candidates?.[0]?.finishReason && data.candidates[0].finishReason !== "STOP") {
568 | throw new Error(`Gemini API Error: Generation finished unexpectedly due to ${data.candidates[0].finishReason}.`);
569 | }
570 | throw new Error("Gemini API response content is missing or empty.");
571 | }
572 |
573 | console.log("Gemini Raw Response Text:\n---\n", aiText, "\n---");
574 | const lines = aiText.split('\n').map(line => line.trim()).filter(Boolean);
575 |
576 | if (lines.length !== validTabs.length) {
577 | console.warn(`Batch AI (Gemini): Mismatch! Expected ${validTabs.length} topics, received ${lines.length}.`);
578 | if (validTabs.length === 1 && lines.length > 0) {
579 | const firstLineTopic = processTopic(lines[0]);
580 | console.warn(` -> Mismatch Correction (Single Tab): Using first line "${lines[0]}" -> Topic: "${firstLineTopic}"`);
581 | result = [{ tab: validTabs[0], topic: firstLineTopic }];
582 | } else if (lines.length > validTabs.length) {
583 | console.warn(` -> Mismatch Correction (Too Many Lines): Truncating response to ${validTabs.length} lines.`);
584 | const processedTopics = lines.slice(0, validTabs.length).map(processTopic);
585 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] }));
586 | } else {
587 | console.warn(` -> Fallback (Too Few Lines): Assigning remaining tabs "Uncategorized".`);
588 | const processedTopics = lines.map(processTopic);
589 | result = validTabs.map((tab, index) => ({
590 | tab: tab,
591 | topic: index < processedTopics.length ? processedTopics[index] : "Uncategorized"
592 | }));
593 | }
594 | } else {
595 | const processedTopics = lines.map(processTopic);
596 | console.log("Batch AI (Gemini): Processed Topics:", processedTopics);
597 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] }));
598 | }
599 |
600 | } else if (ollama.enabled) {
601 | // --- OLLAMA LOGIC ---
602 | apiChoice = "Ollama";
603 | console.log(`Batch AI (Ollama): Requesting categories for ${validTabs.length} tabs, considering ${existingCategoryNames.length} existing categories...`);
604 | let apiUrl = ollama.endpoint;
605 | let headers = { 'Content-Type': 'application/json' };
606 |
607 | const tabDataArray = validTabs.map(getTabData);
608 | const formattedTabDataList = tabDataArray.map((data, index) =>
609 | `${index + 1}.\nTitle: "${data.title}"\nURL: "${data.url}"\nDescription: "${data.description}"`
610 | ).join('\n\n');
611 | const formattedExistingCategories = existingCategoryNames.length > 0
612 | ? existingCategoryNames.map(name => `- ${name}`).join('\n')
613 | : "None";
614 |
615 | const prompt = ollama.promptTemplateBatch
616 | .replace("{EXISTING_CATEGORIES_LIST}", formattedExistingCategories)
617 | .replace("{TAB_DATA_LIST}", formattedTabDataList);
618 |
619 | const requestBody = {
620 | model: ollama.model,
621 | prompt: prompt,
622 | stream: false,
623 | options: { temperature: 0.1, num_predict: validTabs.length * 15 } // Dynamic estimation
624 | };
625 |
626 | const response = await fetch(apiUrl, {
627 | method: 'POST',
628 | headers: headers,
629 | body: JSON.stringify(requestBody)
630 | });
631 |
632 | if (!response.ok) {
633 | const errorText = await response.text().catch(() => 'Unknown API error reason');
634 | throw new Error(`Ollama API Error ${response.status}: ${errorText}`);
635 | }
636 |
637 | const data = await response.json();
638 | let aiText = data.response?.trim();
639 |
640 | if (!aiText) {
641 | throw new Error("Ollama: Empty API response");
642 | }
643 |
644 | const lines = aiText.split('\n').map(line => line.trim()).filter(Boolean);
645 |
646 | if (lines.length !== validTabs.length) {
647 | console.warn(`Batch AI (Ollama): Mismatch! Expected ${validTabs.length} topics, received ${lines.length}. AI Response:\n${aiText}`);
648 | if (validTabs.length === 1 && lines.length > 0) {
649 | const firstLineTopic = processTopic(lines[0]);
650 | console.warn(` -> Mismatch Correction (Single Tab): Using first line "${lines[0]}" -> Topic: "${firstLineTopic}"`);
651 | result = [{ tab: validTabs[0], topic: firstLineTopic }];
652 | } else if (lines.length > validTabs.length) {
653 | console.warn(` -> Mismatch Correction (Too Many Lines): Truncating response to ${validTabs.length} lines.`);
654 | const processedTopics = lines.slice(0, validTabs.length).map(processTopic);
655 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] }));
656 | } else {
657 | console.warn(` -> Fallback (Too Few Lines): Assigning remaining tabs "Uncategorized".`);
658 | const processedTopics = lines.map(processTopic);
659 | result = validTabs.map((tab, index) => ({
660 | tab: tab,
661 | topic: index < processedTopics.length ? processedTopics[index] : "Uncategorized"
662 | }));
663 | }
664 | } else {
665 | const processedTopics = lines.map(processTopic);
666 | console.log("Batch AI (Ollama): Processed Topics:", processedTopics);
667 | result = validTabs.map((tab, index) => ({ tab: tab, topic: processedTopics[index] }));
668 | }
669 | } else {
670 | throw new Error("No AI API is enabled in the configuration (Gemini or Ollama).");
671 | }
672 | return result;
673 | } catch (error) {
674 | console.error(`Batch AI (${apiChoice}): Error getting topics:`, error);
675 | // Return "Uncategorized" for all tabs on error
676 | return validTabs.map(tab => ({ tab, topic: "Uncategorized" }));
677 | } finally {
678 | // Remove sorting indicator after a short delay
679 | setTimeout(() => {
680 | validTabs.forEach(tab => {
681 | if (tab && tab.isConnected) {
682 | tab.classList.remove('tab-is-sorting');
683 | }
684 | });
685 | }, 200);
686 | }
687 | };
688 | // --- End AI Interaction ---
689 |
690 | // --- Main Sorting Function ---
691 | const sortTabsByTopic = async () => {
692 | if (isSorting) {
693 | console.log("Sorting already in progress.");
694 | return;
695 | }
696 | isSorting = true;
697 |
698 | // Check for multiple tab selection
699 | const selectedTabs = gBrowser.selectedTabs;
700 | const isSortingSelectedTabs = selectedTabs.length > 1;
701 | const actionType = isSortingSelectedTabs ? "selected tabs" : "all ungrouped tabs";
702 |
703 | console.log(`Starting tab sort (${actionType} mode) - (v4.10.0 - Flexible Group Selector)...`);
704 |
705 | let separatorsToSort = []; // Keep track of separators to remove class later
706 | try {
707 | separatorsToSort = document.querySelectorAll('.vertical-pinned-tabs-container-separator');
708 | if(separatorsToSort.length > 0) {
709 | console.log("Applying sorting indicator to separator(s)...");
710 | separatorsToSort.forEach(sep => sep.classList.add('separator-is-sorting'));
711 | } else {
712 | console.warn("Could not find separator element to apply sorting indicator.");
713 | }
714 |
715 | const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace;
716 | if (!currentWorkspaceId) {
717 | console.error("Cannot get current workspace ID.");
718 | // No need to set isSorting = false here, finally block handles it
719 | return; // Exit early
720 | }
721 |
722 | // --- Step 1: Get ALL Existing Group Names for Context ---
723 | const allExistingGroupNames = new Set();
724 | // CORRECTED SELECTOR using :has()
725 | const groupSelector = `tab-group:has(tab[zen-workspace-id="${currentWorkspaceId}"])`;
726 | console.log("Querying for groups using selector:", groupSelector);
727 |
728 | document.querySelectorAll(groupSelector).forEach(groupEl => {
729 | const label = groupEl.getAttribute('label');
730 | if (label) {
731 | allExistingGroupNames.add(label);
732 | } else {
733 | console.log("Group element found, but missing label attribute:", groupEl);
734 | }
735 | });
736 | console.log(`Found ${allExistingGroupNames.size} existing group names for context:`, Array.from(allExistingGroupNames));
737 |
738 | // Determine if we are sorting multiple selected tabs
739 | let initialTabsToSort = [];
740 | if (isSortingSelectedTabs) {
741 | console.log(` -> Sorting ${selectedTabs.length} selected tabs.`);
742 | initialTabsToSort = selectedTabs.filter(tab => {
743 | const isInCorrectWorkspace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId;
744 | // For multi-selected tabs, we allow sorting even if they are already in a group.
745 | return (
746 | isInCorrectWorkspace &&
747 | !tab.pinned &&
748 | !tab.hasAttribute('zen-empty-tab') &&
749 | tab.isConnected
750 | );
751 | });
752 | } else {
753 | // Sort all ungrouped tabs - ensure they aren't already in a group matched by the NEW selector
754 | console.log(" -> Sorting all ungrouped tabs in the current workspace.");
755 | initialTabsToSort = Array.from(gBrowser.tabs).filter(tab => {
756 | const isInCorrectWorkspace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId;
757 | const groupParent = tab.closest('tab-group');
758 | const isInGroupInCorrectWorkspace = groupParent ? groupParent.matches(groupSelector) : false;
759 | return (
760 | isInCorrectWorkspace && // Must be in the target workspace
761 | !tab.pinned && // Not pinned
762 | !tab.hasAttribute('zen-empty-tab') && // Not an empty zen tab
763 | !isInGroupInCorrectWorkspace && // Not already in a group belonging to this workspace
764 | tab.isConnected // Tab is connected
765 | );
766 | });
767 | }
768 |
769 | if (initialTabsToSort.length === 0) {
770 | console.log(`No tabs to sort in this workspace (${actionType} mode).`);
771 | // No need to set isSorting = false here, finally block handles it
772 | return; // Exit early
773 | }
774 | console.log(`Found ${initialTabsToSort.length} tabs to process for sorting.`);
775 |
776 | // --- Pre-Grouping Logic (Keywords & Hostnames) ---
777 | const preGroups = {};
778 | const handledTabs = new Set();
779 | const tabDataCache = new Map();
780 | const tabKeywordsCache = new Map();
781 |
782 | initialTabsToSort.forEach(tab => {
783 | const data = getTabData(tab);
784 | tabDataCache.set(tab, data);
785 | tabKeywordsCache.set(tab, data.title ? extractTitleKeywords(data.title) : new Set());
786 | });
787 |
788 | // Keyword pre-grouping
789 | const keywordToTabsMap = new Map();
790 | initialTabsToSort.forEach(tab => {
791 | const keywords = tabKeywordsCache.get(tab);
792 | if (keywords) {
793 | keywords.forEach(keyword => {
794 | if (!keywordToTabsMap.has(keyword)) {
795 | keywordToTabsMap.set(keyword, new Set());
796 | }
797 | keywordToTabsMap.get(keyword).add(tab);
798 | });
799 | }
800 | });
801 |
802 | const potentialKeywordGroups = [];
803 | keywordToTabsMap.forEach((tabsSet, keyword) => {
804 | if (tabsSet.size >= CONFIG.preGroupingThreshold) {
805 | potentialKeywordGroups.push({ keyword: keyword, tabs: tabsSet, size: tabsSet.size });
806 | }
807 | });
808 | potentialKeywordGroups.sort((a, b) => b.size - a.size); // Process larger groups first
809 |
810 | potentialKeywordGroups.forEach(({ keyword, tabs }) => {
811 | const finalTabsForGroup = new Set();
812 | tabs.forEach(tab => {
813 | if (!handledTabs.has(tab)) {
814 | finalTabsForGroup.add(tab);
815 | }
816 | });
817 | if (finalTabsForGroup.size >= CONFIG.preGroupingThreshold) {
818 | const categoryName = processTopic(keyword);
819 | console.log(` - Pre-Grouping by Title Keyword: "${keyword}" (Count: ${finalTabsForGroup.size}) -> Category: "${categoryName}"`);
820 | preGroups[categoryName] = Array.from(finalTabsForGroup);
821 | finalTabsForGroup.forEach(tab => handledTabs.add(tab));
822 | }
823 | });
824 |
825 | // Hostname pre-grouping (for remaining tabs)
826 | const hostnameCounts = {};
827 | initialTabsToSort.forEach(tab => {
828 | if (!handledTabs.has(tab)) {
829 | const data = tabDataCache.get(tab);
830 | if (data?.hostname && data.hostname !== 'N/A' && data.hostname !== 'Invalid URL' && data.hostname !== 'Internal Page') {
831 | hostnameCounts[data.hostname] = (hostnameCounts[data.hostname] || 0) + 1;
832 | }
833 | }
834 | });
835 |
836 | const sortedHostnames = Object.keys(hostnameCounts).sort((a, b) => hostnameCounts[b] - hostnameCounts[a]);
837 |
838 | for (const hostname of sortedHostnames) {
839 | if (hostnameCounts[hostname] >= CONFIG.preGroupingThreshold) {
840 | const categoryName = processTopic(hostname);
841 | // Avoid creating a hostname group if a keyword group with the same processed name already exists
842 | if (preGroups[categoryName]) {
843 | console.log(` - Skipping Hostname Group for "${hostname}" -> Category "${categoryName}" (already exists from keywords).`);
844 | continue;
845 | }
846 |
847 | const tabsForHostnameGroup = [];
848 | initialTabsToSort.forEach(tab => {
849 | if (!handledTabs.has(tab)) {
850 | const data = tabDataCache.get(tab);
851 | if (data?.hostname === hostname) {
852 | tabsForHostnameGroup.push(tab);
853 | }
854 | }
855 | });
856 |
857 | if (tabsForHostnameGroup.length >= CONFIG.preGroupingThreshold) {
858 | console.log(` - Pre-Grouping by Hostname: "${hostname}" (Count: ${tabsForHostnameGroup.length}) -> Category: "${categoryName}"`);
859 | preGroups[categoryName] = tabsForHostnameGroup;
860 | tabsForHostnameGroup.forEach(tab => handledTabs.add(tab));
861 | }
862 | }
863 | }
864 | // --- End Pre-Grouping Logic ---
865 |
866 | // --- AI Grouping for Remaining Tabs ---
867 | const tabsForAI = initialTabsToSort.filter(tab => !handledTabs.has(tab) && tab.isConnected);
868 | let aiTabTopics = [];
869 | const comprehensiveExistingNames = new Set([...allExistingGroupNames, ...Object.keys(preGroups)]);
870 | const existingNamesForAIContext = Array.from(comprehensiveExistingNames);
871 |
872 | if (tabsForAI.length > 0) {
873 | console.log(` -> ${tabsForAI.length} tabs remaining for AI analysis. Providing ${existingNamesForAIContext.length} existing categories as context.`);
874 | aiTabTopics = await askAIForMultipleTopics(tabsForAI, existingNamesForAIContext); // Pass comprehensive names
875 | } else {
876 | console.log(" -> No tabs remaining for AI analysis.");
877 | }
878 | // --- End AI Grouping ---
879 |
880 | // --- Combine Groups ---
881 | const finalGroups = { ...preGroups };
882 | aiTabTopics.forEach(({ tab, topic }) => {
883 | if (!topic || topic === "Uncategorized" || !tab || !tab.isConnected) {
884 | if (topic && topic !== "Uncategorized") {
885 | console.warn(` -> AI suggested category "${topic}" but associated tab is invalid/disconnected.`);
886 | }
887 | return; // Skip invalid/uncategorized/disconnected
888 | }
889 | if (!finalGroups[topic]) {
890 | finalGroups[topic] = [];
891 | }
892 | // Double-check if tab was somehow handled between AI request and processing
893 | if (!handledTabs.has(tab)) {
894 | finalGroups[topic].push(tab);
895 | handledTabs.add(tab); // Mark as handled now
896 | } else {
897 | const originalGroup = Object.keys(preGroups).find(key => preGroups[key].includes(tab));
898 | console.warn(` -> AI suggested category "${topic}" for tab "${getTabData(tab).title}", but it was already pre-grouped under "${originalGroup || 'Unknown Pre-Group'}". Keeping pre-grouped assignment.`);
899 | }
900 | });
901 | // --- End Combine Groups ---
902 |
903 | // --- Consolidate Similar Category Names (Levenshtein distance) ---
904 | console.log(" -> Consolidating potential duplicate categories...");
905 | const originalKeys = Object.keys(finalGroups);
906 | const mergedKeys = new Set();
907 | const consolidationMap = {}; // To track merges: mergedKey -> canonicalKey
908 |
909 | for (let i = 0; i < originalKeys.length; i++) {
910 | let keyA = originalKeys[i];
911 | if (mergedKeys.has(keyA)) continue; // Already merged into another key
912 |
913 | // Resolve transitive merges for keyA if it was already targeted
914 | while (consolidationMap[keyA]) {
915 | keyA = consolidationMap[keyA];
916 | }
917 | if (mergedKeys.has(keyA)) continue; // Check again after resolving transitive merges
918 |
919 | for (let j = i + 1; j < originalKeys.length; j++) {
920 | let keyB = originalKeys[j];
921 | if (mergedKeys.has(keyB)) continue;
922 |
923 | // Resolve transitive merges for keyB
924 | while (consolidationMap[keyB]) {
925 | keyB = consolidationMap[keyB];
926 | }
927 | if (mergedKeys.has(keyB) || keyA === keyB) continue; // Already merged or identical after resolving
928 |
929 | const distance = levenshteinDistance(keyA, keyB);
930 | const threshold = CONFIG.consolidationDistanceThreshold;
931 |
932 | if (distance <= threshold && distance > 0) { // Only merge if similar but not identical
933 | // Determine which key to keep (prioritize existing, then pre-grouped, then shorter)
934 | let canonicalKey = keyA;
935 | let mergedKey = keyB;
936 |
937 | const keyAIsActuallyExisting = allExistingGroupNames.has(keyA);
938 | const keyBIsActuallyExisting = allExistingGroupNames.has(keyB);
939 | const keyAIsPreGroup = keyA in preGroups;
940 | const keyBIsPreGroup = keyB in preGroups;
941 |
942 | // Priority: Existing > Pre-Group > Shorter Length
943 | if (keyBIsActuallyExisting && !keyAIsActuallyExisting) {
944 | [canonicalKey, mergedKey] = [keyB, keyA]; // B is existing, A is not
945 | } else if (keyAIsActuallyExisting && keyBIsActuallyExisting) {
946 | // Both exist, prefer pre-group, then shorter
947 | if (keyBIsPreGroup && !keyAIsPreGroup) [canonicalKey, mergedKey] = [keyB, keyA];
948 | else if (keyA.length > keyB.length) [canonicalKey, mergedKey] = [keyB, keyA];
949 | } else if (!keyAIsActuallyExisting && !keyBIsActuallyExisting) {
950 | // Neither exist, prefer pre-group, then shorter
951 | if (keyBIsPreGroup && !keyAIsPreGroup) [canonicalKey, mergedKey] = [keyB, keyA];
952 | else if (keyA.length > keyB.length) [canonicalKey, mergedKey] = [keyB, keyA];
953 | }
954 | // Handle the case where keyA exists, keyB doesn't (already default)
955 |
956 | console.log(` - Consolidating: Merging "${mergedKey}" into "${canonicalKey}" (Distance: ${distance})`);
957 |
958 | // Merge tabs from mergedKey into canonicalKey
959 | if (finalGroups[mergedKey]) {
960 | if (!finalGroups[canonicalKey]) finalGroups[canonicalKey] = [];
961 | const uniqueTabsToAdd = finalGroups[mergedKey].filter(tab =>
962 | tab && tab.isConnected && !finalGroups[canonicalKey].some(existingTab => existingTab === tab)
963 | );
964 | finalGroups[canonicalKey].push(...uniqueTabsToAdd);
965 | }
966 |
967 | mergedKeys.add(mergedKey); // Mark B as merged
968 | consolidationMap[mergedKey] = canonicalKey; // Track the merge target
969 | delete finalGroups[mergedKey]; // Remove the merged group
970 |
971 | // If keyA was the one being merged, update keyA to the canonical key for subsequent checks in the inner loop
972 | if (mergedKey === keyA) {
973 | keyA = canonicalKey;
974 | break; // Break inner loop as keyA has changed, restart comparison from outer loop perspective
975 | }
976 | }
977 | }
978 | }
979 | console.log(" -> Consolidation complete.");
980 | // --- End Consolidation ---
981 |
982 | console.log(" -> Final Consolidated groups:", Object.keys(finalGroups).map(k => `${k} (${finalGroups[k]?.length ?? 0})`).join(', '));
983 | if (Object.keys(finalGroups).length === 0) {
984 | console.log("No valid groups identified after consolidation. Sorting finished.");
985 | // No need to set isSorting = false here, finally block handles it
986 | return; // Exit early
987 | }
988 |
989 | // --- Step 2: Get existing group ELEMENTS once before the loop ---
990 | const existingGroupElementsMap = new Map();
991 | document.querySelectorAll(groupSelector).forEach(groupEl => { // Use the same corrected selector
992 | const label = groupEl.getAttribute('label');
993 | if (label) {
994 | existingGroupElementsMap.set(label, groupEl);
995 | }
996 | });
997 |
998 | // Reset color index AFTER consolidation, before creating new groups
999 | groupColorIndex = 0;
1000 |
1001 | // --- Process each final, consolidated group ---
1002 | for (const topic in finalGroups) {
1003 | // Filter AGAIN for valid, connected tabs right before moving/grouping
1004 | const tabsForThisTopic = finalGroups[topic].filter(t => {
1005 | if (!t || !t.isConnected) return false;
1006 | // Always allow valid tabs through; group membership will be checked at move time
1007 | return true;
1008 | });
1009 |
1010 | if (tabsForThisTopic.length === 0) {
1011 | console.log(` -> Skipping group "${topic}" as no valid, unsorted tabs remain in this workspace.`);
1012 | continue; // Skip empty or already correctly sorted collections
1013 | }
1014 |
1015 | // --- Step 3: Use the Map for lookup ---
1016 | const existingGroupElement = existingGroupElementsMap.get(topic);
1017 |
1018 | if (existingGroupElement && existingGroupElement.isConnected) { // Check if the element is still in the DOM
1019 | // Move tabs to EXISTING group
1020 | console.log(` -> Moving ${tabsForThisTopic.length} tabs to existing group "${topic}".`);
1021 | try {
1022 | // Ensure group is expanded before moving tabs into it
1023 | if (existingGroupElement.getAttribute("collapsed") === "true") {
1024 | existingGroupElement.setAttribute("collapsed", "false");
1025 | const groupLabelElement = existingGroupElement.querySelector('.tab-group-label');
1026 | if (groupLabelElement) {
1027 | groupLabelElement.setAttribute('aria-expanded', 'true'); // Ensure visually expanded too
1028 | }
1029 | }
1030 | // Move tabs one by one
1031 | for (const tab of tabsForThisTopic) {
1032 | if (!tab || !tab.isConnected) continue;
1033 | const groupParent = tab.closest('tab-group');
1034 | // Only skip if already in the *target* group
1035 | const isAlreadyInTargetGroup = groupParent === existingGroupElement;
1036 | if (!isAlreadyInTargetGroup) {
1037 | gBrowser.moveTabToGroup(tab, existingGroupElement);
1038 | } else {
1039 | console.log(` -> Tab "${getTabData(tab)?.title || 'Unknown'}" is already in the correct group "${topic}", skipping move.`);
1040 | }
1041 | }
1042 | } catch (e) {
1043 | console.error(`Error moving tabs to existing group "${topic}":`, e, existingGroupElement);
1044 | }
1045 | } else {
1046 | // Create NEW group
1047 | if (existingGroupElement && !existingGroupElement.isConnected) {
1048 | console.warn(` -> Existing group element for "${topic}" was found in map but is no longer connected to DOM. Will create a new group.`);
1049 | }
1050 |
1051 | const wasOriginallyPreGroup = topic in preGroups;
1052 | const wasDirectlyFromAI = aiTabTopics.some(ait => ait.topic === topic && tabsForThisTopic.includes(ait.tab));
1053 |
1054 | // Create group if it meets threshold OR came from pre-grouping OR came directly from AI
1055 | if (tabsForThisTopic.length >= CONFIG.preGroupingThreshold || wasDirectlyFromAI || wasOriginallyPreGroup) {
1056 | console.log(` -> Creating new group "${topic}" with ${tabsForThisTopic.length} tabs.`);
1057 | const firstValidTabForGroup = tabsForThisTopic[0]; // Need a reference tab for insertion point
1058 | const groupOptions = {
1059 | label: topic,
1060 | color: getNextGroupColorName(),
1061 | insertBefore: firstValidTabForGroup // Insert before the first tab being added
1062 | };
1063 | try {
1064 | // Create the group with all tabs at once
1065 | const newGroup = gBrowser.addTabGroup(tabsForThisTopic, groupOptions);
1066 |
1067 | if (newGroup && newGroup.isConnected) { // Check if group was created and is in DOM
1068 | console.log(` -> Successfully created group element for "${topic}".`);
1069 | existingGroupElementsMap.set(topic, newGroup); // Add to map for potential later reuse in this run
1070 | } else {
1071 | console.warn(` -> addTabGroup didn't return a connected element for "${topic}". Attempting fallback find.`);
1072 | // Use the CORRECTED findGroupElement helper
1073 | const newGroupElFallback = findGroupElement(topic, currentWorkspaceId);
1074 | if (newGroupElFallback && newGroupElFallback.isConnected) {
1075 | console.log(` -> Found new group element for "${topic}" via fallback.`);
1076 | existingGroupElementsMap.set(topic, newGroupElFallback); // Add to map via fallback
1077 | } else {
1078 | console.error(` -> Failed to find the newly created group element for "${topic}" even with fallback.`);
1079 | }
1080 | }
1081 | } catch (e) {
1082 | console.error(`Error calling gBrowser.addTabGroup for topic "${topic}":`, e);
1083 | // Attempt to find the group even after an error, it might have partially succeeded
1084 | const groupAfterError = findGroupElement(topic, currentWorkspaceId);
1085 | if (groupAfterError && groupAfterError.isConnected) {
1086 | console.warn(` -> Group "${topic}" might exist despite error. Found via findGroupElement.`);
1087 | existingGroupElementsMap.set(topic, groupAfterError); // Update map
1088 | } else {
1089 | console.error(` -> Failed to find group "${topic}" after creation error.`);
1090 | }
1091 | }
1092 | } else {
1093 | console.log(` -> Skipping creation of small group "${topic}" (${tabsForThisTopic.length} tabs) - didn't meet threshold and wasn't a pre-group or directly from AI.`);
1094 | }
1095 | }
1096 | } // End loop through final groups
1097 |
1098 | console.log("--- Tab sorting process complete ---");
1099 |
1100 | } catch (error) {
1101 | console.error("Error during overall sorting process:", error);
1102 | } finally {
1103 | isSorting = false; // Ensure sorting flag is reset
1104 |
1105 | // Remove loading indicator class
1106 | if (separatorsToSort.length > 0) {
1107 | console.log("Removing sorting indicator from separator(s)...");
1108 | separatorsToSort.forEach(sep => {
1109 | // Check if element still exists before removing class
1110 | if (sep && sep.isConnected) {
1111 | sep.classList.remove('separator-is-sorting');
1112 | }
1113 | });
1114 | }
1115 |
1116 | // Remove tab loading indicators after a delay
1117 | setTimeout(() => {
1118 | Array.from(gBrowser.tabs).forEach(tab => {
1119 | if (tab && tab.isConnected) {
1120 | tab.classList.remove('tab-is-sorting');
1121 | }
1122 | });
1123 | }, 500); // Keep existing delay for tabs
1124 | }
1125 | };
1126 | // --- End Sorting Function ---
1127 |
1128 |
1129 | // --- Clear Tabs Functionality ---
1130 | const clearTabs = () => {
1131 | console.log("Clearing tabs...");
1132 | let closedCount = 0;
1133 | try {
1134 | const currentWorkspaceId = window.gZenWorkspaces?.activeWorkspace;
1135 | if (!currentWorkspaceId) {
1136 | console.error("CLEAR BTN: Cannot get current workspace ID.");
1137 | return;
1138 | }
1139 | // Define the group selector for the current workspace *once*
1140 | const groupSelector = `tab-group:has(tab[zen-workspace-id="${currentWorkspaceId}"])`;
1141 |
1142 | const tabsToClose = [];
1143 | for (const tab of gBrowser.tabs) {
1144 | const isSameWorkSpace = tab.getAttribute('zen-workspace-id') === currentWorkspaceId;
1145 | const groupParent = tab.closest('tab-group');
1146 | // Check if the parent group matches the selector for the *current* workspace
1147 | const isInGroupInCorrectWorkspace = groupParent ? groupParent.matches(groupSelector) : false;
1148 | const isEmptyZenTab = tab.hasAttribute("zen-empty-tab");
1149 |
1150 | if (isSameWorkSpace && // In the correct workspace
1151 | !tab.selected && // Not the active tab
1152 | !tab.pinned && // Not pinned
1153 | !isInGroupInCorrectWorkspace && // Not in a group belonging to this workspace
1154 | !isEmptyZenTab && // Not an empty Zen tab
1155 | tab.isConnected) { // Is connected
1156 | tabsToClose.push(tab);
1157 | }
1158 | }
1159 |
1160 | if (tabsToClose.length === 0) {
1161 | console.log("CLEAR BTN: No ungrouped, non-pinned, non-active tabs found to clear in this workspace.");
1162 | return;
1163 | }
1164 |
1165 | console.log(`CLEAR BTN: Closing ${tabsToClose.length} tabs.`);
1166 | tabsToClose.forEach(tab => {
1167 | tab.classList.add('tab-closing'); // Add animation class
1168 | closedCount++;
1169 | // Delay removal to allow animation to play
1170 | setTimeout(() => {
1171 | if (tab && tab.isConnected) {
1172 | try {
1173 | gBrowser.removeTab(tab, {
1174 | animate: false, // Animation handled by CSS
1175 | skipSessionStore: false,
1176 | closeWindowWithLastTab: false,
1177 | });
1178 | } catch (removeError) {
1179 | console.warn(`CLEAR BTN: Error removing tab: ${removeError}`, tab);
1180 | // Attempt to remove animation class if removal fails
1181 | tab.classList.remove('tab-closing');
1182 | }
1183 | }
1184 | }, 500); // Match CSS animation duration
1185 | });
1186 | } catch (error) {
1187 | console.error("CLEAR BTN: Error during tab clearing:", error);
1188 | } finally {
1189 | console.log(`CLEAR BTN: Initiated closing for ${closedCount} tabs.`);
1190 | }
1191 | };
1192 |
1193 |
1194 | // --- Button Initialization & Workspace Handling ---
1195 |
1196 | function ensureButtonsExist(container) {
1197 | if (!container) return;
1198 |
1199 | // Ensure Sort Button
1200 | if (!container.querySelector('#sort-button')) {
1201 | try {
1202 | const buttonFragment = window.MozXULElement.parseXULToFragment(
1203 | ``
1204 | );
1205 | container.appendChild(buttonFragment.firstChild.cloneNode(true));
1206 | console.log("BUTTONS: Sort button added to container:", container.id || container.className);
1207 | } catch (e) {
1208 | console.error("BUTTONS: Error creating/appending sort button:", e);
1209 | }
1210 | }
1211 |
1212 | // Ensure Clear Button
1213 | if (!container.querySelector('#clear-button')) {
1214 | try {
1215 | const buttonFragment = window.MozXULElement.parseXULToFragment(
1216 | ``
1217 | );
1218 | container.appendChild(buttonFragment.firstChild.cloneNode(true));
1219 | console.log("BUTTONS: Clear button added to container:", container.id || container.className);
1220 | } catch (e) {
1221 | console.error("BUTTONS: Error creating/appending clear button:", e);
1222 | }
1223 | }
1224 | }
1225 |
1226 | function addButtonsToAllSeparators() {
1227 | const separators = document.querySelectorAll(".vertical-pinned-tabs-container-separator");
1228 | if (separators.length > 0) {
1229 | separators.forEach(ensureButtonsExist);
1230 | } else {
1231 | // Fallback if no separators are found (e.g., different Zen Tab config or error)
1232 | const periphery = document.querySelector('#tabbrowser-arrowscrollbox-periphery');
1233 | if (periphery && !periphery.querySelector('#sort-button') && !periphery.querySelector('#clear-button')) {
1234 | console.warn("BUTTONS: No separators found, attempting fallback append to periphery.");
1235 | ensureButtonsExist(periphery);
1236 | } else if (!periphery) {
1237 | console.error("BUTTONS: No separators or fallback periphery container found.");
1238 | }
1239 | }
1240 | }
1241 |
1242 | function setupCommandsAndListener() {
1243 | const zenCommands = document.querySelector("commandset#zenCommandSet");
1244 | if (!zenCommands) {
1245 | console.error("BUTTONS INIT: Could not find 'commandset#zenCommandSet'. Zen Tab Organizer might not be fully loaded.");
1246 | return;
1247 | }
1248 |
1249 | // Add Sort Command if missing
1250 | if (!zenCommands.querySelector("#cmd_zenSortTabs")) {
1251 | try {
1252 | const cmd = window.MozXULElement.parseXULToFragment(``).firstChild;
1253 | zenCommands.appendChild(cmd);
1254 | console.log("BUTTONS INIT: Command 'cmd_zenSortTabs' added.");
1255 | } catch (e) {
1256 | console.error("BUTTONS INIT: Error adding command 'cmd_zenSortTabs':", e);
1257 | }
1258 | }
1259 |
1260 | // Add Clear Command if missing
1261 | if (!zenCommands.querySelector("#cmd_zenClearTabs")) {
1262 | try {
1263 | const cmd = window.MozXULElement.parseXULToFragment(``).firstChild;
1264 | zenCommands.appendChild(cmd);
1265 | console.log("BUTTONS INIT: Command 'cmd_zenClearTabs' added.");
1266 | } catch (e) {
1267 | console.error("BUTTONS INIT: Error adding command 'cmd_zenClearTabs':", e);
1268 | }
1269 | }
1270 |
1271 | // Add listener only once
1272 | if (!commandListenerAdded) {
1273 | try {
1274 | zenCommands.addEventListener('command', (event) => {
1275 | if (event.target.id === "cmd_zenSortTabs") {
1276 | sortTabsByTopic();
1277 | } else if (event.target.id === "cmd_zenClearTabs") {
1278 | clearTabs();
1279 | }
1280 | });
1281 | commandListenerAdded = true;
1282 | console.log("BUTTONS INIT: Command listener added for Sort and Clear.");
1283 | } catch (e) {
1284 | console.error("BUTTONS INIT: Error adding command listener:", e);
1285 | }
1286 | }
1287 | }
1288 |
1289 |
1290 | // --- gZenWorkspaces Hooks ---
1291 |
1292 | function setupZenWorkspaceHooks() {
1293 | if (typeof gZenWorkspaces === 'undefined') {
1294 | console.warn("BUTTONS: gZenWorkspaces object not found. Skipping hook setup. Ensure Zen Tab Organizer loads first.");
1295 | return;
1296 | }
1297 | // Avoid applying hooks multiple times
1298 | if (typeof gZenWorkspaces.originalHooks !== 'undefined') {
1299 | console.log("BUTTONS HOOK: Hooks already seem to be applied. Skipping re-application.");
1300 | return;
1301 | }
1302 |
1303 | console.log("BUTTONS HOOK: Applying gZenWorkspaces hooks...");
1304 | // Store original functions before overwriting
1305 | gZenWorkspaces.originalHooks = {
1306 | onTabBrowserInserted: gZenWorkspaces.onTabBrowserInserted,
1307 | updateTabsContainers: gZenWorkspaces.updateTabsContainers,
1308 | };
1309 |
1310 | // Hook into onTabBrowserInserted (called when workspace elements are likely created/updated)
1311 | gZenWorkspaces.onTabBrowserInserted = function(event) {
1312 | // Call the original function first
1313 | if (typeof gZenWorkspaces.originalHooks.onTabBrowserInserted === 'function') {
1314 | try {
1315 | gZenWorkspaces.originalHooks.onTabBrowserInserted.call(gZenWorkspaces, event);
1316 | } catch (e) {
1317 | console.error("BUTTONS HOOK: Error in original onTabBrowserInserted:", e);
1318 | }
1319 | }
1320 | // Add buttons after a short delay to ensure elements are ready
1321 | setTimeout(addButtonsToAllSeparators, 150); // Slightly increased delay for safety
1322 | };
1323 |
1324 | // Hook into updateTabsContainers (called on various workspace/tab changes)
1325 | gZenWorkspaces.updateTabsContainers = function(...args) {
1326 | // Call the original function first
1327 | if (typeof gZenWorkspaces.originalHooks.updateTabsContainers === 'function') {
1328 | try {
1329 | gZenWorkspaces.originalHooks.updateTabsContainers.apply(gZenWorkspaces, args);
1330 | } catch (e) {
1331 | console.error("BUTTONS HOOK: Error in original updateTabsContainers:", e);
1332 | }
1333 | }
1334 | // Add buttons after a short delay
1335 | setTimeout(addButtonsToAllSeparators, 150); // Slightly increased delay for safety
1336 | };
1337 | console.log("BUTTONS HOOK: gZenWorkspaces hooks applied successfully.");
1338 | }
1339 |
1340 |
1341 | // --- Initial Setup Trigger ---
1342 |
1343 | function initializeScript() {
1344 | console.log("INIT: Sort & Clear Tabs Script (v4.10.0 - Gemini - Structured) loading...");
1345 | let checkCount = 0;
1346 | const maxChecks = 30; // Check for up to ~30 seconds
1347 | const checkInterval = 1000; // Check every second
1348 |
1349 | const initCheckInterval = setInterval(() => {
1350 | checkCount++;
1351 |
1352 | // Check for necessary conditions
1353 | const separatorExists = !!document.querySelector(".vertical-pinned-tabs-container-separator");
1354 | const peripheryExists = !!document.querySelector('#tabbrowser-arrowscrollbox-periphery');
1355 | const commandSetExists = !!document.querySelector("commandset#zenCommandSet");
1356 | const gBrowserReady = typeof gBrowser !== 'undefined' && gBrowser.tabContainer;
1357 | const gZenWorkspacesReady = typeof gZenWorkspaces !== 'undefined' && typeof gZenWorkspaces.activeWorkspace !== 'undefined';
1358 |
1359 | const ready = gBrowserReady && commandSetExists && (separatorExists || peripheryExists) && gZenWorkspacesReady;
1360 |
1361 | if (ready) {
1362 | console.log(`INIT: Required elements found after ${checkCount} checks. Initializing...`);
1363 | clearInterval(initCheckInterval); // Stop checking
1364 |
1365 | // Defer final setup slightly to ensure everything is stable
1366 | const finalSetup = () => {
1367 | try {
1368 | injectStyles();
1369 | setupCommandsAndListener();
1370 | addButtonsToAllSeparators(); // Initial add
1371 | setupZenWorkspaceHooks(); // Setup hooks for future updates
1372 | console.log("INIT: Sort & Clear Button setup and hooks complete.");
1373 | } catch (e) {
1374 | console.error("INIT: Error during deferred final setup:", e);
1375 | }
1376 | };
1377 |
1378 | // Use requestIdleCallback if available for less impact, otherwise fallback to setTimeout
1379 | if ('requestIdleCallback' in window) {
1380 | requestIdleCallback(finalSetup, { timeout: 2000 });
1381 | } else {
1382 | setTimeout(finalSetup, 500);
1383 | }
1384 |
1385 | } else if (checkCount > maxChecks) {
1386 | clearInterval(initCheckInterval); // Stop checking after timeout
1387 | console.error(`INIT: Failed to find required elements after ${maxChecks} checks. Script might not function correctly.`);
1388 | console.error("INIT Status:", {
1389 | gBrowserReady,
1390 | commandSetExists,
1391 | separatorExists,
1392 | peripheryExists,
1393 | gZenWorkspacesReady
1394 | });
1395 | // Provide specific feedback
1396 | if (!gZenWorkspacesReady) console.error(" -> gZenWorkspaces might not be fully initialized yet (activeWorkspace missing?). Ensure Zen Tab Organizer extension is loaded and enabled BEFORE this script runs.");
1397 | if (!separatorExists && !peripheryExists) console.error(" -> Neither separator element '.vertical-pinned-tabs-container-separator' nor fallback periphery '#tabbrowser-arrowscrollbox-periphery' found in the DOM.");
1398 | if (!commandSetExists) console.error(" -> Command set '#zenCommandSet' not found. Ensure Zen Tab Organizer extension is loaded and enabled.");
1399 | if (!gBrowserReady) console.error(" -> Global 'gBrowser' object not ready.");
1400 | }
1401 | }, checkInterval);
1402 | }
1403 |
1404 | // --- Start Initialization ---
1405 | // Wait for the window to load before trying to access DOM elements
1406 | if (document.readyState === "complete" || document.readyState === "interactive") {
1407 | // If already loaded, initialize directly
1408 | initializeScript();
1409 | } else {
1410 | // Otherwise, wait for the 'load' event
1411 | window.addEventListener("load", initializeScript, { once: true });
1412 | }
1413 |
1414 | })(); // End script wrapper
1415 |
--------------------------------------------------------------------------------
/theme.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "ai-tabgroups-zenbrowser",
3 | "name": "Tab Sort Clear AI",
4 | "description": "The script sorts the temporary tabs into tabgroups using ai for a more cleaner organized browsing experience. Inspired by Arc Max Tidy feature. ",
5 | "homepage": "https://github.com/Darsh-A/Ai-TabGroups-ZenBrowser",
6 | "preferences": "https://raw.githubusercontent.com/Darsh-A/Ai-TabGroups-ZenBrowser/main/preferences.json",
7 | "image": "https://github.com/Darsh-A/Ai-TabGroups-ZenBrowser/blob/main/image.jpg?raw=true",
8 | "js": true,
9 | "readme": "https://raw.githubusercontent.com/Darsh-A/Ai-TabGroups-ZenBrowser/refs/heads/main/README.md",
10 | "author": "Darsh A",
11 | "version": "1.0.0",
12 | "tags": [
13 | "sine",
14 | "Tab Sort Clear",
15 | "Tab Groups",
16 | "fx-autoconfig",
17 | "JavaScript",
18 | "Darsh A"
19 | ],
20 | "createdAt": "2025-05-26",
21 | "updatedAt": "2025-05-26"
22 | }
23 |
--------------------------------------------------------------------------------