Files
catcher-of-tabs/popup.js

333 lines
11 KiB
JavaScript

// 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 buildSelectorData();
renderSelector();
updateStats();
setupEventListeners();
});
// Fetch and cache all window/tab/group data
async function buildSelectorData() {
try {
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 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() {
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();
checkbox.checked = !checkbox.checked;
}
});
});
}
// 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 {
if (includeDescriptions) {
statusEl.textContent = `Fetching descriptions (0/${tabs.length})...`;
await fetchDescriptions(tabs, statusEl);
}
const markdown = await generateMarkdown(tabs, {
includeDescriptions,
groupByWindow,
includeTimestamp
});
downloadMarkdown(markdown);
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 {
exportBtn.disabled = false;
exportBtn.innerHTML = originalContent;
}
}
// Fetch descriptions from tabs
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 {
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 doesn't support script injection (e.g. about:, chrome://, moz:// pages)
tab.description = null;
}
}
}
// 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) ||
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', {
dateStyle: 'full',
timeStyle: 'short'
});
markdown += `**Exported:** ${timestamp}\n\n`;
}
markdown += `**Total Tabs:** ${tabs.length}\n\n`;
markdown += '---\n\n';
if (options.groupByWindow) {
// Group selected tabs by their window, using cached window data order
const tabsByWindow = {};
tabs.forEach(tab => {
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`;
markdown += formatTabs(tabsByWindow[windowId], options.includeDescriptions);
markdown += '---\n\n';
windowIndex++;
}
} else {
markdown += formatTabs(tabs, options.includeDescriptions);
}
return markdown;
}
// Format tabs as markdown list
function formatTabs(tabs, includeDescriptions) {
let content = '';
tabs.forEach(tab => {
content += `- **${escapeMarkdown(tab.title)}**\n`;
content += ` - URL: ${tab.url}\n`;
if (includeDescriptions && tab.description) {
content += ` - Description: ${escapeMarkdown(tab.description)}\n`;
}
content += '\n';
});
return content;
}
// Escape markdown special characters
function escapeMarkdown(text) {
if (!text) return '';
return text.replace(/[\\`*_{}[\]()#+\-.!]/g, '\\$&');
}
// Escape HTML for safe DOM injection
function escapeHtml(text) {
if (!text) return '';
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// 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`;
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);
}