asked the machines to make some improvement. window select. other browser support. I think. I haven't tested it yet. YOLO.
This commit is contained in:
265
popup.js
265
popup.js
@@ -1,30 +1,129 @@
|
||||
// Browser API compatibility shim (Chrome uses chrome.*, Firefox exposes browser.*)
|
||||
const api = (typeof browser !== 'undefined' && browser.tabs) ? browser : chrome;
|
||||
|
||||
// Cached window/tab/group data built at load time
|
||||
let windowSelectorData = []; // [{windowId, focused, tabs, groups}]
|
||||
|
||||
// Tab group color lookup (Chrome's string enum → CSS hex)
|
||||
const GROUP_COLORS = {
|
||||
grey: '#5f6368', blue: '#1a73e8', red: '#d93025',
|
||||
yellow: '#f9ab00', green: '#1e8e3e', pink: '#d01884',
|
||||
purple: '#a142f4', cyan: '#007b83', orange: '#fa903e'
|
||||
};
|
||||
|
||||
// Initialize extension
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await updateStats();
|
||||
await buildSelectorData();
|
||||
renderSelector();
|
||||
updateStats();
|
||||
setupEventListeners();
|
||||
});
|
||||
|
||||
// Update tab and window count statistics
|
||||
async function updateStats() {
|
||||
// Fetch and cache all window/tab/group data
|
||||
async function buildSelectorData() {
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
const windows = await chrome.windows.getAll();
|
||||
|
||||
document.getElementById('tab-count').textContent = tabs.length;
|
||||
document.getElementById('window-count').textContent = windows.length;
|
||||
const [allTabs, allWindows] = await Promise.all([
|
||||
api.tabs.query({}),
|
||||
api.windows.getAll()
|
||||
]);
|
||||
|
||||
windowSelectorData = await Promise.all(allWindows.map(async (win) => {
|
||||
const tabs = allTabs.filter(t => t.windowId === win.id);
|
||||
let groups = [];
|
||||
|
||||
if (api.tabGroups && typeof api.tabGroups.query === 'function') {
|
||||
try {
|
||||
groups = await api.tabGroups.query({ windowId: win.id });
|
||||
} catch (_) {
|
||||
// tabGroups API present but query failed — treat as no groups
|
||||
}
|
||||
}
|
||||
|
||||
return { windowId: win.id, focused: win.focused, tabs, groups };
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error updating stats:', error);
|
||||
console.error('Error building selector data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update tab and window count statistics from cached data
|
||||
function updateStats() {
|
||||
const totalTabs = windowSelectorData.reduce((sum, w) => sum + w.tabs.length, 0);
|
||||
document.getElementById('tab-count').textContent = totalTabs;
|
||||
document.getElementById('window-count').textContent = windowSelectorData.length;
|
||||
}
|
||||
|
||||
// Render window/group checkboxes into #selector-list
|
||||
function renderSelector() {
|
||||
const list = document.getElementById('selector-list');
|
||||
|
||||
if (windowSelectorData.length === 0) {
|
||||
list.innerHTML = '<div class="selector-loading">No windows found.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
windowSelectorData.forEach((win, index) => {
|
||||
const label = `Window ${index + 1}${win.focused ? ' (active)' : ''}`;
|
||||
const tabCount = win.tabs.length;
|
||||
|
||||
html += `
|
||||
<div class="selector-item" data-type="window" data-id="${win.windowId}">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" class="window-checkbox"
|
||||
data-window-id="${win.windowId}" checked
|
||||
aria-describedby="win-meta-${win.windowId}">
|
||||
<span class="selector-item-title">${label}</span>
|
||||
<span class="selector-item-meta" id="win-meta-${win.windowId}">${tabCount} tab${tabCount !== 1 ? 's' : ''}</span>
|
||||
</label>`;
|
||||
|
||||
if (win.groups.length > 0) {
|
||||
win.groups.forEach(group => {
|
||||
const color = GROUP_COLORS[group.color] || '#888888';
|
||||
const groupTitle = group.title || 'Unnamed group';
|
||||
const groupTabCount = win.tabs.filter(t => t.groupId === group.id).length;
|
||||
|
||||
html += `
|
||||
<div class="selector-item selector-item--group" data-type="group" data-id="${group.id}">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" class="group-checkbox"
|
||||
data-group-id="${group.id}" data-window-id="${win.windowId}" checked
|
||||
aria-describedby="grp-meta-${group.id}">
|
||||
<span class="selector-item-dot" style="background:${color};" aria-hidden="true"></span>
|
||||
<span class="selector-item-title">${escapeHtml(groupTitle)}</span>
|
||||
<span class="selector-item-meta" id="grp-meta-${group.id}">${groupTabCount} tab${groupTabCount !== 1 ? 's' : ''}</span>
|
||||
</label>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
list.innerHTML = html;
|
||||
|
||||
// Stagger animation delays on rendered items
|
||||
list.querySelectorAll('.selector-item:not(.selector-item--group)').forEach((el, i) => {
|
||||
el.style.animationDelay = `${i * 50}ms`;
|
||||
});
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
function setupEventListeners() {
|
||||
const exportBtn = document.getElementById('export-btn');
|
||||
exportBtn.addEventListener('click', handleExport);
|
||||
|
||||
// Keyboard accessibility for options
|
||||
const checkboxes = document.querySelectorAll('input[type="checkbox"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
document.getElementById('export-btn').addEventListener('click', handleExport);
|
||||
|
||||
document.getElementById('select-all-btn').addEventListener('click', () => {
|
||||
document.querySelectorAll('.window-checkbox, .group-checkbox')
|
||||
.forEach(cb => { cb.checked = true; });
|
||||
});
|
||||
|
||||
document.getElementById('deselect-all-btn').addEventListener('click', () => {
|
||||
document.querySelectorAll('.window-checkbox, .group-checkbox')
|
||||
.forEach(cb => { cb.checked = false; });
|
||||
});
|
||||
|
||||
// Keyboard accessibility for option checkboxes
|
||||
document.querySelectorAll('.options input[type="checkbox"]').forEach(checkbox => {
|
||||
checkbox.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -34,54 +133,83 @@ function setupEventListeners() {
|
||||
});
|
||||
}
|
||||
|
||||
// Return only the tabs from selected windows/groups
|
||||
function getSelectedTabs() {
|
||||
const checkedWindowIds = new Set(
|
||||
[...document.querySelectorAll('.window-checkbox:checked')]
|
||||
.map(cb => parseInt(cb.dataset.windowId))
|
||||
);
|
||||
const checkedGroupIds = new Set(
|
||||
[...document.querySelectorAll('.group-checkbox:checked')]
|
||||
.map(cb => parseInt(cb.dataset.groupId))
|
||||
);
|
||||
|
||||
const result = [];
|
||||
for (const win of windowSelectorData) {
|
||||
if (!checkedWindowIds.has(win.windowId)) continue;
|
||||
|
||||
for (const tab of win.tabs) {
|
||||
if (win.groups.length > 0 && tab.groupId && tab.groupId > 0) {
|
||||
// Grouped tab: only include if its group is checked
|
||||
if (checkedGroupIds.has(tab.groupId)) result.push(tab);
|
||||
} else {
|
||||
// Ungrouped tab (Chrome sentinel -1 or Firefox undefined): always include
|
||||
result.push(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Main export handler
|
||||
async function handleExport() {
|
||||
const exportBtn = document.getElementById('export-btn');
|
||||
const statusEl = document.getElementById('export-status');
|
||||
|
||||
|
||||
// Get user options
|
||||
const includeDescriptions = document.getElementById('include-descriptions').checked;
|
||||
const groupByWindow = document.getElementById('group-by-window').checked;
|
||||
const includeTimestamp = document.getElementById('include-timestamp').checked;
|
||||
|
||||
|
||||
// Get filtered tabs
|
||||
const tabs = getSelectedTabs();
|
||||
|
||||
if (tabs.length === 0) {
|
||||
statusEl.textContent = '! Select at least one window to export.';
|
||||
statusEl.className = 'status-message error';
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable button and show loading state
|
||||
exportBtn.disabled = true;
|
||||
const originalContent = exportBtn.innerHTML;
|
||||
exportBtn.innerHTML = '<span class="btn-icon loading" aria-hidden="true">⏳</span>Exporting...';
|
||||
|
||||
|
||||
statusEl.textContent = 'Collecting tab information...';
|
||||
statusEl.className = 'status-message loading';
|
||||
|
||||
|
||||
try {
|
||||
// Get all tabs
|
||||
const tabs = await chrome.tabs.query({});
|
||||
|
||||
// Fetch descriptions if needed
|
||||
if (includeDescriptions) {
|
||||
statusEl.textContent = `Fetching descriptions (0/${tabs.length})...`;
|
||||
await fetchDescriptions(tabs, statusEl);
|
||||
}
|
||||
|
||||
// Generate markdown content
|
||||
|
||||
const markdown = await generateMarkdown(tabs, {
|
||||
includeDescriptions,
|
||||
groupByWindow,
|
||||
includeTimestamp
|
||||
});
|
||||
|
||||
// Trigger download
|
||||
|
||||
downloadMarkdown(markdown);
|
||||
|
||||
// Show success message
|
||||
statusEl.textContent = '✓ Successfully exported ' + tabs.length + ' tabs!';
|
||||
|
||||
statusEl.textContent = '✓ Exported ' + tabs.length + ' tabs successfully.';
|
||||
statusEl.className = 'status-message success';
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
statusEl.textContent = '✗ Error: ' + error.message;
|
||||
statusEl.className = 'status-message error';
|
||||
} finally {
|
||||
// Re-enable button
|
||||
exportBtn.disabled = false;
|
||||
exportBtn.innerHTML = originalContent;
|
||||
}
|
||||
@@ -92,37 +220,36 @@ async function fetchDescriptions(tabs, statusEl) {
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tab = tabs[i];
|
||||
statusEl.textContent = `Fetching descriptions (${i + 1}/${tabs.length})...`;
|
||||
|
||||
|
||||
try {
|
||||
// Try to execute script to get description
|
||||
const results = await chrome.scripting.executeScript({
|
||||
const results = await api.scripting.executeScript({
|
||||
target: { tabId: tab.id },
|
||||
func: extractDescription
|
||||
});
|
||||
|
||||
|
||||
if (results && results[0] && results[0].result) {
|
||||
tab.description = results[0].result;
|
||||
}
|
||||
} catch (error) {
|
||||
// Tab might not support script injection (e.g., chrome:// pages)
|
||||
// Tab doesn't support script injection (e.g. about:, chrome://, moz:// pages)
|
||||
tab.description = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to extract description (runs in tab context)
|
||||
// Function to extract description — runs in tab context
|
||||
function extractDescription() {
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
const ogDesc = document.querySelector('meta[property="og:description"]');
|
||||
return (metaDesc && metaDesc.content) ||
|
||||
(ogDesc && ogDesc.content) ||
|
||||
return (metaDesc && metaDesc.content) ||
|
||||
(ogDesc && ogDesc.content) ||
|
||||
null;
|
||||
}
|
||||
|
||||
// Generate markdown content
|
||||
async function generateMarkdown(tabs, options) {
|
||||
let markdown = '# Browser Tabs Export\n\n';
|
||||
|
||||
|
||||
if (options.includeTimestamp) {
|
||||
const now = new Date();
|
||||
const timestamp = now.toLocaleString('en-US', {
|
||||
@@ -131,22 +258,18 @@ async function generateMarkdown(tabs, options) {
|
||||
});
|
||||
markdown += `**Exported:** ${timestamp}\n\n`;
|
||||
}
|
||||
|
||||
|
||||
markdown += `**Total Tabs:** ${tabs.length}\n\n`;
|
||||
markdown += '---\n\n';
|
||||
|
||||
|
||||
if (options.groupByWindow) {
|
||||
// Group tabs by window
|
||||
const windows = await chrome.windows.getAll();
|
||||
// Group selected tabs by their window, using cached window data order
|
||||
const tabsByWindow = {};
|
||||
|
||||
tabs.forEach(tab => {
|
||||
if (!tabsByWindow[tab.windowId]) {
|
||||
tabsByWindow[tab.windowId] = [];
|
||||
}
|
||||
if (!tabsByWindow[tab.windowId]) tabsByWindow[tab.windowId] = [];
|
||||
tabsByWindow[tab.windowId].push(tab);
|
||||
});
|
||||
|
||||
|
||||
let windowIndex = 1;
|
||||
for (const windowId in tabsByWindow) {
|
||||
markdown += `## Window ${windowIndex}\n\n`;
|
||||
@@ -155,30 +278,27 @@ async function generateMarkdown(tabs, options) {
|
||||
windowIndex++;
|
||||
}
|
||||
} else {
|
||||
// List all tabs together
|
||||
markdown += formatTabs(tabs, options.includeDescriptions);
|
||||
}
|
||||
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
// Format tabs as markdown
|
||||
// Format tabs as markdown list
|
||||
function formatTabs(tabs, includeDescriptions) {
|
||||
let content = '';
|
||||
|
||||
tabs.forEach((tab, index) => {
|
||||
// List item with title and URL
|
||||
|
||||
tabs.forEach(tab => {
|
||||
content += `- **${escapeMarkdown(tab.title)}**\n`;
|
||||
content += ` - URL: ${tab.url}\n`;
|
||||
|
||||
// Description if available
|
||||
|
||||
if (includeDescriptions && tab.description) {
|
||||
content += ` - Description: ${escapeMarkdown(tab.description)}\n`;
|
||||
}
|
||||
|
||||
|
||||
content += '\n';
|
||||
});
|
||||
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -188,20 +308,25 @@ function escapeMarkdown(text) {
|
||||
return text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Escape HTML for safe DOM injection
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Download markdown file
|
||||
function downloadMarkdown(content) {
|
||||
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
||||
const filename = `tabs-export-${timestamp}.md`;
|
||||
|
||||
chrome.downloads.download({
|
||||
url: url,
|
||||
filename: filename,
|
||||
saveAs: true
|
||||
}, (downloadId) => {
|
||||
// Cleanup object URL after download starts
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
});
|
||||
|
||||
api.downloads.download({ url, filename, saveAs: true });
|
||||
|
||||
// Always clean up — Firefox's browser.downloads returns a Promise and ignores callbacks
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user