333 lines
11 KiB
JavaScript
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, '&')
|
|
.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`;
|
|
|
|
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);
|
|
}
|