init
This commit is contained in:
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 552 B |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 B |
17
manifest.json
Normal file
17
manifest.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Tab Archive",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Save all your open tabs to your self-hosted Tab Archive backend.",
|
||||||
|
"permissions": ["tabs", "storage"],
|
||||||
|
"host_permissions": ["http://localhost/*", "https://*/*", "http://*/*"],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "Tab Archive"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
popup.css
Normal file
47
popup.css
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: #0f1115;
|
||||||
|
color: #e6e9ef;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.logo { font-size: 18px; }
|
||||||
|
.title { font-weight: 600; font-size: 15px; flex: 1; }
|
||||||
|
.link { background: none; border: none; color: #8b93a5; cursor: pointer; font-size: 15px; }
|
||||||
|
.link:hover { color: #e6e9ef; }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
label { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; color: #8b93a5; font-size: 12px; }
|
||||||
|
input {
|
||||||
|
background: #22262f; border: 1px solid #2c313c; border-radius: 7px;
|
||||||
|
color: #e6e9ef; padding: 8px 10px; font-size: 13px; outline: none;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #4f8cff; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #22262f; border: 1px solid #2c313c; color: #e6e9ef;
|
||||||
|
padding: 9px 12px; border-radius: 8px; cursor: pointer; font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #2b303b; }
|
||||||
|
.btn.primary { background: #4f8cff; border-color: #4f8cff; color: #fff; }
|
||||||
|
.btn.primary:hover { background: #3b73e0; }
|
||||||
|
.btn.block { display: block; width: 100%; margin-top: 8px; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.row { display: flex; gap: 8px; }
|
||||||
|
.row .btn { flex: 1; }
|
||||||
|
|
||||||
|
.settings { border-bottom: 1px solid #2c313c; padding-bottom: 12px; margin-bottom: 12px; }
|
||||||
|
.hint { color: #8b93a5; font-size: 11px; margin: 6px 0 0; }
|
||||||
|
|
||||||
|
.status-line { color: #8b93a5; margin: 0 0 4px; }
|
||||||
|
#tabCount { color: #e6e9ef; font-weight: 600; }
|
||||||
|
|
||||||
|
.message { margin-top: 12px; padding: 9px 11px; border-radius: 7px; font-size: 12px; }
|
||||||
|
.message.success { background: rgba(47,174,102,.15); border: 1px solid #2fae66; }
|
||||||
|
.message.error { background: rgba(229,72,77,.15); border: 1px solid #e5484d; }
|
||||||
42
popup.html
Normal file
42
popup.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<span class="logo">📑</span>
|
||||||
|
<span class="title">Tab Archive</span>
|
||||||
|
<button id="toggleSettings" class="link" title="Settings">⚙️</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings" class="settings hidden">
|
||||||
|
<label>Backend URL
|
||||||
|
<input type="text" id="backendUrl" placeholder="http://localhost:3000">
|
||||||
|
</label>
|
||||||
|
<label>API Token
|
||||||
|
<input type="password" id="apiToken" placeholder="cta_...">
|
||||||
|
</label>
|
||||||
|
<label>Folder name (optional)
|
||||||
|
<input type="text" id="folderName" placeholder="e.g. Saved 2026-06-07">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="testBtn" class="btn">Test connection</button>
|
||||||
|
<button id="saveSettings" class="btn primary">Save</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Get a token from the backend UI → API Tokens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<p class="status-line"><span id="tabCount">–</span> open tabs in this window</p>
|
||||||
|
<button id="saveAll" class="btn primary block">💾 Save all opened tabs</button>
|
||||||
|
<button id="saveClose" class="btn block">💾 Save & close all opened tabs</button>
|
||||||
|
<button id="openBackend" class="btn block">🌐 Open Tab Archive</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="message" class="message hidden"></div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
165
popup.js
Normal file
165
popup.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DEFAULTS = { backendUrl: 'http://localhost:3000', apiToken: '', folderName: '' };
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
settings: document.getElementById('settings'),
|
||||||
|
toggleSettings: document.getElementById('toggleSettings'),
|
||||||
|
backendUrl: document.getElementById('backendUrl'),
|
||||||
|
apiToken: document.getElementById('apiToken'),
|
||||||
|
folderName: document.getElementById('folderName'),
|
||||||
|
testBtn: document.getElementById('testBtn'),
|
||||||
|
saveSettings: document.getElementById('saveSettings'),
|
||||||
|
tabCount: document.getElementById('tabCount'),
|
||||||
|
saveAll: document.getElementById('saveAll'),
|
||||||
|
saveClose: document.getElementById('saveClose'),
|
||||||
|
openBackend: document.getElementById('openBackend'),
|
||||||
|
message: document.getElementById('message'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSettings() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get(DEFAULTS, (s) => resolve(s));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSettings(values) {
|
||||||
|
return new Promise((resolve) => chrome.storage.local.set(values, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, kind) {
|
||||||
|
els.message.textContent = text;
|
||||||
|
els.message.className = 'message ' + (kind || '');
|
||||||
|
els.message.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBase(url) {
|
||||||
|
return (url || '').trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs worth archiving: skip internal chrome:// / extension pages.
|
||||||
|
function archivable(tab) {
|
||||||
|
return tab.url && /^https?:\/\//i.test(tab.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentWindowTabs() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.tabs.query({ currentWindow: true }, (tabs) => resolve(tabs));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTabCount() {
|
||||||
|
const tabs = await getCurrentWindowTabs();
|
||||||
|
els.tabCount.textContent = tabs.filter(archivable).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postBookmarks(settings, bookmarks) {
|
||||||
|
const base = normalizeBase(settings.backendUrl);
|
||||||
|
const res = await fetch(base + '/api/bookmarks/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + settings.apiToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
bookmarks,
|
||||||
|
folder_name: settings.folderName ? settings.folderName.trim() : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || ('HTTP ' + res.status));
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(closeAfter) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.apiToken) {
|
||||||
|
showMessage('Set your API token in settings (⚙️) first.', 'error');
|
||||||
|
els.settings.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
els.saveAll.disabled = true;
|
||||||
|
els.saveClose.disabled = true;
|
||||||
|
showMessage('Saving…', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allTabs = await getCurrentWindowTabs();
|
||||||
|
const tabs = allTabs.filter(archivable);
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
showMessage('No saveable tabs (only http/https tabs are archived).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookmarks = tabs.map((t) => ({ title: t.title, url: t.url }));
|
||||||
|
const result = await postBookmarks(settings, bookmarks);
|
||||||
|
const notes = [];
|
||||||
|
if (result.skipped) notes.push(`${result.skipped} duplicate(s) skipped`);
|
||||||
|
// `limited` is returned by the hosted backend when the free-plan cap is hit.
|
||||||
|
if (result.limited) notes.push(`${result.limited} not saved — free plan limit reached`);
|
||||||
|
const noteStr = notes.length ? ` (${notes.join('; ')})` : '';
|
||||||
|
showMessage(`Saved ${result.created} tab(s).${noteStr}`, result.limited ? 'error' : 'success');
|
||||||
|
|
||||||
|
// Only close once the save has been confirmed above.
|
||||||
|
if (closeAfter) {
|
||||||
|
await new Promise((resolve) => chrome.tabs.remove(tabs.map((t) => t.id), resolve));
|
||||||
|
}
|
||||||
|
await refreshTabCount();
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('Failed: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
els.saveAll.disabled = false;
|
||||||
|
els.saveClose.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wire up UI ---
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const s = await getSettings();
|
||||||
|
els.backendUrl.value = s.backendUrl;
|
||||||
|
els.apiToken.value = s.apiToken;
|
||||||
|
els.folderName.value = s.folderName;
|
||||||
|
if (!s.apiToken) els.settings.classList.remove('hidden');
|
||||||
|
await refreshTabCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
els.toggleSettings.addEventListener('click', () => els.settings.classList.toggle('hidden'));
|
||||||
|
|
||||||
|
els.saveSettings.addEventListener('click', async () => {
|
||||||
|
await setSettings({
|
||||||
|
backendUrl: normalizeBase(els.backendUrl.value) || DEFAULTS.backendUrl,
|
||||||
|
apiToken: els.apiToken.value.trim(),
|
||||||
|
folderName: els.folderName.value.trim(),
|
||||||
|
});
|
||||||
|
showMessage('Settings saved.', 'success');
|
||||||
|
els.settings.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
els.testBtn.addEventListener('click', async () => {
|
||||||
|
const base = normalizeBase(els.backendUrl.value);
|
||||||
|
const token = els.apiToken.value.trim();
|
||||||
|
if (!base || !token) { showMessage('Enter backend URL and token first.', 'error'); return; }
|
||||||
|
showMessage('Testing…', '');
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + '/api/me', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
showMessage('Connected as ' + data.user.email, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showMessage('Connection failed: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.saveAll.addEventListener('click', () => handleSave(false));
|
||||||
|
els.saveClose.addEventListener('click', () => handleSave(true));
|
||||||
|
|
||||||
|
els.openBackend.addEventListener('click', async () => {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const url = normalizeBase(settings.backendUrl) || DEFAULTS.backendUrl;
|
||||||
|
chrome.tabs.create({ url });
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
Reference in New Issue
Block a user