/* Portage by pvrz https://github.com/pvrzz/Portage/ File Name: dashboard.js */ const api = typeof browser !== "undefined" ? browser : chrome; const IS_FIREFOX = navigator.userAgent.includes("Firefox"); const SOURCE = IS_FIREFOX ? "firefox" : "chrome"; const SOURCE_LABEL = IS_FIREFOX ? "Firefox" : "Chrome"; const HAS_READINGLIST = typeof api.readingList !== "undefined" && typeof api.readingList?.query === "function"; document.getElementById("srcBadge").textContent = SOURCE_LABEL; document.getElementById("exHeroTitle").textContent = `Scan ${SOURCE_LABEL}, then carry your data across`; document.getElementById("scanLabel").textContent = `Scan ${SOURCE_LABEL}`; const SVG = { bookmarks: '', history: '', cookies: '', tabs: '', sessions: '', reading: '', extensions: '', check: '', arrow: '', download: '', lock: '', eye: '', }; const iconSvg = (i) => `${i}`; const tick = () => new Promise((r) => setTimeout(r, 0)); const IMPORTABLE = /^(https?|ftp):/i; function saveFile(filename, text, mime) { const url = URL.createObjectURL(new Blob([text], { type: mime })); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); } function humanSize(b) { if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(0) + " KB"; return (b / 1048576).toFixed(1) + " MB"; } function stamp() { const d = new Date(), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; } function countBookmarks(tree) { let n = 0; (function w(a) { for (const x of a) { if (x.url) n++; if (x.children) w(x.children); } })(tree); return n; } function countSessions(list) { let n = 0; for (const s of list) n += s.type === "window" ? (s.tabs?.length || 0) : 1; return n; } function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (m) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[m])); } function extensionsTxt(list) { const L = []; L.push("Portage — extension reinstall checklist"); L.push(`Exported from ${SOURCE_LABEL} · ${new Date().toLocaleString()} · ${list.length} extensions`); L.push(""); L.push("Tip: paste this whole list into an AI assistant and ask it to find each one as an"); L.push("add-on for your new browser — or search the names yourself with the links below."); L.push(""); list.forEach((e, i) => { L.push(`${i + 1}. ${e.name} (v${e.version || "?"})`); if (e.description) L.push(` ${e.description}`); if (e.homepageUrl) L.push(` Home: ${e.homepageUrl}`); L.push(` Firefox: https://addons.mozilla.org/firefox/search/?q=${encodeURIComponent(e.name)}`); L.push(` Chrome: https://chromewebstore.google.com/search/${encodeURIComponent(e.name)}`); L.push(""); }); return L.join("\n"); } function toast(msg, icon) { const wrap = document.getElementById("toasts"); const el = document.createElement("div"); el.className = "toast"; el.innerHTML = iconSvg(SVG[icon] || SVG.check); const span = document.createElement("span"); span.textContent = msg; el.appendChild(span); wrap.appendChild(el); setTimeout(() => { el.style.transition = "opacity .3s, transform .3s"; el.style.opacity = "0"; el.style.transform = "translateX(20px)"; }, 3400); setTimeout(() => el.remove(), 3800); } function bufToB64(buf) { const bytes = new Uint8Array(buf); let bin = ""; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); return btoa(bin); } function b64ToBuf(b64) { const bin = atob(b64); const len = bin.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; } async function deriveKey(passphrase, salt, iterations) { const baseKey = await crypto.subtle.importKey("raw", new TextEncoder().encode(passphrase), "PBKDF2", false, ["deriveKey"]); return crypto.subtle.deriveKey({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, baseKey, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } async function encryptBundle(obj, passphrase) { const iterations = 250000; const salt = crypto.getRandomValues(new Uint8Array(16)); const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await deriveKey(passphrase, salt, iterations); const plaintext = new TextEncoder().encode(JSON.stringify({ meta: obj.meta, data: obj.data })); const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext); return { portage: { format: "portage-bundle-encrypted", version: 1, source: obj.portage.source, generator: obj.portage.generator, exportedAt: obj.portage.exportedAt, cipher: "AES-GCM", keyLength: 256, kdf: "PBKDF2", hash: "SHA-256", iterations, salt: bufToB64(salt), iv: bufToB64(iv), }, ciphertext: bufToB64(ct), }; } async function decryptBundle(wrapper, passphrase) { const p = wrapper.portage; const salt = new Uint8Array(b64ToBuf(p.salt)); const iv = new Uint8Array(b64ToBuf(p.iv)); const key = await deriveKey(passphrase, salt, p.iterations || 250000); const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, b64ToBuf(wrapper.ciphertext)); const inner = JSON.parse(new TextDecoder().decode(pt)); return { portage: { format: "portage-bundle", version: 1, source: p.source, generator: p.generator, exportedAt: p.exportedAt }, meta: inner.meta, data: inner.data }; } function passStrength(p) { if (!p) return { pct: 0, label: "—" }; let s = Math.min(p.length * 4, 40); if (/[a-z]/.test(p)) s += 10; if (/[A-Z]/.test(p)) s += 10; if (/[0-9]/.test(p)) s += 10; if (/[^A-Za-z0-9]/.test(p)) s += 15; if (p.length >= 16) s += 15; s = Math.min(s, 100); return { pct: s, label: s < 35 ? "Weak" : s < 60 ? "Fair" : s < 80 ? "Good" : "Strong" }; } async function collectBookmarks() { const tree = await api.bookmarks.getTree(); return { data: tree, count: countBookmarks(tree) }; } async function collectHistory() { const items = await api.history.search({ text: "", startTime: 0, maxResults: 1000000 }); const data = items.map((i) => ({ url: i.url, title: i.title || "", lastVisitTime: i.lastVisitTime || 0, visitCount: i.visitCount || 0, typedCount: i.typedCount || 0 })); return { data, count: data.length }; } async function collectCookies() { const all = await api.cookies.getAll({}); const data = all.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, secure: c.secure, httpOnly: c.httpOnly, sameSite: c.sameSite, expirationDate: c.expirationDate, hostOnly: c.hostOnly, session: c.session })); return { data, count: data.length }; } async function collectTabs() { const tabs = await api.tabs.query({}); const data = tabs.map((t) => ({ url: t.url || t.pendingUrl, title: t.title || "", pinned: t.pinned, index: t.index, windowId: t.windowId })).filter((t) => t.url); return { data, count: data.length }; } async function collectSessions() { const s = await api.sessions.getRecentlyClosed({ maxResults: 25 }); const data = s.map((x) => { if (x.tab) return { type: "tab", url: x.tab.url, title: x.tab.title || "" }; if (x.window) return { type: "window", tabs: (x.window.tabs || []).map((t) => ({ url: t.url, title: t.title || "" })) }; return null; }).filter(Boolean); return { data, count: countSessions(data) }; } async function collectReadingList() { const items = await api.readingList.query({}); const data = items.map((i) => ({ url: i.url, title: i.title || "", hasBeenRead: i.hasBeenRead, creationTime: i.creationTime, lastUpdateTime: i.lastUpdateTime })); return { data, count: data.length }; } async function collectExtensions() { const all = await api.management.getAll(); const data = all.filter((e) => e.type === "extension" && e.id !== api.runtime.id) .map((e) => ({ id: e.id, name: e.name, version: e.version, enabled: e.enabled, description: e.description, homepageUrl: e.homepageUrl, installType: e.installType })); return { data, count: data.length }; } async function importBookmarks(tree, onProg) { const total = countBookmarks(tree); let done = 0, imported = 0, skipped = 0; const root = await api.bookmarks.create({ title: `Portage Import — ${new Date().toLocaleString()}` }); async function walk(nodes, parentId) { for (const node of nodes) { if (node.url) { try { if (!IMPORTABLE.test(node.url)) skipped++; else { await api.bookmarks.create({ parentId, title: node.title || node.url, url: node.url }); imported++; } } catch { skipped++; } if (++done % 50 === 0) { onProg(done, total); await tick(); } } else if (node.children) { let pid = parentId; if (node.title) { const f = await api.bookmarks.create({ parentId, title: node.title }); pid = f.id; } await walk(node.children, pid); } } } await walk(tree, root.id); onProg(total, total); return { imported, skipped }; } async function importHistory(items, onProg) { const total = items.length; let imported = 0, skipped = 0; for (let i = 0; i < total; i++) { const it = items[i]; try { if (!IMPORTABLE.test(it.url)) skipped++; else { const details = IS_FIREFOX ? { url: it.url, title: it.title || undefined, visitTime: it.lastVisitTime || Date.now() } : { url: it.url }; await api.history.addUrl(details); imported++; } } catch { skipped++; } if (i % 100 === 0) { onProg(i, total); await tick(); } } onProg(total, total); return { imported, skipped }; } function mapSameSite(s) { return s === "strict" ? "strict" : s === "lax" ? "lax" : "no_restriction"; } async function importCookies(items, onProg) { const total = items.length; let imported = 0, skipped = 0; for (let i = 0; i < total; i++) { const c = items[i]; try { const host = (c.domain || "").replace(/^\./, ""); if (!host) skipped++; else { const url = (c.secure ? "https" : "http") + "://" + host + (c.path || "/"); const d = { url, name: c.name, value: c.value, path: c.path || "/", secure: !!c.secure, httpOnly: !!c.httpOnly, sameSite: mapSameSite(c.sameSite) }; if (!c.hostOnly) d.domain = c.domain; if (!c.session && c.expirationDate) d.expirationDate = c.expirationDate; await api.cookies.set(d); imported++; } } catch { skipped++; } if (i % 100 === 0) { onProg(i, total); await tick(); } } onProg(total, total); return { imported, skipped }; } async function importTabs(items, onProg) { const total = items.length; let imported = 0, skipped = 0; for (let i = 0; i < total; i++) { const t = items[i]; try { if (!IMPORTABLE.test(t.url)) skipped++; else { await api.tabs.create({ url: t.url, pinned: !!t.pinned, active: false }); imported++; } } catch { skipped++; } onProg(i + 1, total); if (i % 10 === 0) await tick(); } return { imported, skipped }; } async function importSessions(list, onProg) { const flat = []; for (const s of list) { if (s.type === "window") for (const t of (s.tabs || [])) flat.push(t); else flat.push(s); } return importTabs(flat, onProg); } async function importReadingList(items, onProg) { const total = items.length; let imported = 0, skipped = 0; if (HAS_READINGLIST && api.readingList.addEntry) { for (let i = 0; i < total; i++) { const it = items[i]; try { if (!IMPORTABLE.test(it.url)) skipped++; else { await api.readingList.addEntry({ url: it.url, title: it.title || it.url, hasBeenRead: !!it.hasBeenRead }); imported++; } } catch { skipped++; } onProg(i + 1, total); if (i % 50 === 0) await tick(); } } else { const folder = await api.bookmarks.create({ title: "Reading List (from Portage)" }); for (let i = 0; i < total; i++) { const it = items[i]; try { if (!IMPORTABLE.test(it.url)) skipped++; else { await api.bookmarks.create({ parentId: folder.id, title: it.title || it.url, url: it.url }); imported++; } } catch { skipped++; } onProg(i + 1, total); if (i % 50 === 0) await tick(); } } return { imported, skipped }; } const CATS = { bookmarks: { label: "Bookmarks", unit: "bookmarks", icon: SVG.bookmarks, level: "full", desc: "Full bookmark tree, folders and all", importFx: "Created in a 'Portage Import' folder", collect: collectBookmarks, count: countBookmarks, importRun: importBookmarks }, history: { label: "History", unit: "pages", icon: SVG.history, level: "full", desc: "Pages you've visited, with timestamps", importFx: IS_FIREFOX ? "Re-added with titles & times" : "Re-added as visits", collect: collectHistory, count: (d) => d.length, importRun: importHistory }, cookies: { label: "Cookies", unit: "cookies", icon: SVG.cookies, level: "full", desc: "Site cookies — keeps you signed in", importFx: "Re-set so logins carry over", collect: collectCookies, count: (d) => d.length, importRun: importCookies }, tabs: { label: "Open tabs", unit: "tabs", icon: SVG.tabs, level: "full", desc: "Tabs open right now, every window", importFx: "Reopened in this window", collect: collectTabs, count: (d) => d.length, importRun: importTabs }, sessions: { label: "Recently closed", unit: "sessions", icon: SVG.sessions, level: "partial", desc: "Tabs & windows you closed recently", importFx: "Reopened as tabs", collect: collectSessions, count: countSessions, importRun: importSessions }, readingList: { label: "Reading list", unit: "entries", icon: SVG.reading, level: HAS_READINGLIST ? "full" : "partial", desc: "Pages saved to read later", importFx: HAS_READINGLIST ? "Added to the reading list" : "Saved to a bookmark folder", collect: collectReadingList, count: (d) => d.length, importRun: importReadingList, needsReadingList: true }, extensions: { label: "Extensions", unit: "installed", icon: SVG.extensions, level: "checklist", desc: "Installed add-ons → reinstall checklist", importFx: "Can't auto-install — use the .txt checklist", collect: collectExtensions, count: (d) => d.length, importRun: null, special: true }, }; const ORDER = ["bookmarks", "history", "cookies", "tabs", "sessions", "readingList", "extensions"]; const exState = {}; let scanned = false; const exGrid = document.getElementById("exportGrid"); function exportCategoryIds() { return ORDER.filter((id) => !(CATS[id].needsReadingList && !HAS_READINGLIST)); } function makeCard(id, c, opts) { const el = document.createElement("div"); el.className = "card"; el.dataset.id = id; el.innerHTML = `
${iconSvg(c.icon)}

${c.label}

${opts.desc}
${opts.count ?? 0}${c.unit}
${opts.msg}
${iconSvg(SVG.arrow)}${opts.fx}
`; return el; } function renderExportCards() { exGrid.innerHTML = ""; exportCategoryIds().forEach((id, idx) => { const c = CATS[id]; exState[id] = { data: null, count: 0, selected: true, status: "idle" }; const el = makeCard(id, c, { checked: true, toggleDisabled: true, desc: c.desc, count: 0, dot: "empty", msg: "Not scanned yet", fx: c.special ? "Saved as a .txt checklist" : c.importFx }); el.style.animationDelay = idx * 45 + "ms"; exGrid.appendChild(el); }); exGrid.addEventListener("change", (e) => { const id = e.target.dataset?.toggle; if (!id) return; exState[id].selected = e.target.checked; exGrid.querySelector(`.card[data-id="${id}"]`).classList.toggle("disabled", !e.target.checked); updateExportTotals(); }); } function setBar(id, pct) { const e = document.querySelector(`[data-bar="${id}"]`); if (e) e.style.width = pct + "%"; } function setDot(id, cls) { const e = document.querySelector(`[data-dot="${id}"]`); if (e) e.className = "dot " + cls; } function setMsg(id, m) { const e = document.querySelector(`[data-msg="${id}"]`); if (e) e.textContent = m; } function countUp(id, to) { const el = document.querySelector(`[data-n="${id}"]`); const t0 = performance.now(); (function f(t) { const p = Math.min(1, (t - t0) / 600); el.textContent = Math.round((1 - Math.pow(1 - p, 3)) * to).toLocaleString(); if (p < 1) requestAnimationFrame(f); })(performance.now()); } async function scanAll() { document.getElementById("scan").disabled = true; document.getElementById("scanbarWrap").classList.remove("hidden"); document.getElementById("exStats").classList.remove("hidden"); document.getElementById("actionbar").classList.remove("show"); const ids = exportCategoryIds(); let done = 0; for (const id of ids) { const c = CATS[id], st = exState[id]; setDot(id, "scanning"); setMsg(id, "Scanning…"); setBar(id, 18); document.getElementById("scanStage").textContent = "Scanning " + c.label.toLowerCase() + "…"; try { const { data, count } = await c.collect(); st.data = data; st.count = count; st.status = "done"; setBar(id, 100); countUp(id, count); if (count === 0) { setDot(id, "empty"); setMsg(id, "Nothing found"); } else { setDot(id, "done"); setMsg(id, `Collected ${count.toLocaleString()} ${c.unit}`); } if (c.special && count > 0) addExtTxtButton(id, data); } catch (err) { st.status = "error"; st.data = null; st.count = 0; setBar(id, 100); setDot(id, "error"); setMsg(id, "Unavailable — " + err.message); st.selected = false; } const cb = document.querySelector(`[data-toggle="${id}"]`); const ok = st.status === "done" && st.count > 0; cb.disabled = !ok; cb.checked = ok; st.selected = ok; document.querySelector(`.card[data-id="${id}"]`).classList.toggle("disabled", !ok); done++; const pct = Math.round((done / ids.length) * 100); document.getElementById("scanFill").style.width = pct + "%"; document.getElementById("scanPct").textContent = pct + "%"; } scanned = true; document.getElementById("scanStage").textContent = "Scan complete"; document.getElementById("scan").disabled = false; document.getElementById("scanLabel").textContent = "Re-scan"; document.getElementById("actionbar").classList.add("show"); updateExportTotals(); toast("Scan complete — review and export", "check"); } function addExtTxtButton(id, list) { const slot = document.querySelector(`[data-extra="${id}"]`); slot.innerHTML = `
`; document.getElementById("extTxtNow").addEventListener("click", () => { saveFile(`portage-${SOURCE}-extensions-${stamp()}.txt`, extensionsTxt(list), "text/plain"); toast("Extension checklist saved", "download"); }); } function buildBundle() { const data = {}, counts = {}; for (const id of exportCategoryIds()) { const st = exState[id]; if (st.selected && st.data != null) { data[id] = st.data; counts[id] = st.count; } } return { portage: { format: "portage-bundle", version: 1, source: SOURCE, generator: `Portage ${SOURCE_LABEL} 2.0.0`, exportedAt: new Date().toISOString(), userAgent: navigator.userAgent }, meta: { counts }, data }; } function updateExportTotals() { let items = 0, migratable = 0, cats = 0; for (const id of exportCategoryIds()) { const st = exState[id], c = CATS[id]; if (st.selected && st.count > 0) { cats++; items += st.count; if (c.importRun) migratable += st.count; } } document.getElementById("totItems").textContent = items.toLocaleString(); document.getElementById("totMigratable").textContent = migratable.toLocaleString(); document.getElementById("totSelected").textContent = cats; document.getElementById("abItems").textContent = items.toLocaleString(); document.getElementById("abCats").textContent = cats; document.getElementById("totSize").textContent = humanSize(new Blob([JSON.stringify(buildBundle())]).size); document.getElementById("export").disabled = cats === 0; } function clearScannedData() { for (const id of exportCategoryIds()) { const st = exState[id]; st.data = null; st.count = 0; st.selected = false; st.status = "cleared"; setBar(id, 0); setDot(id, "empty"); setMsg(id, "Cleared from memory — re-scan to export again"); const n = document.querySelector(`[data-n="${id}"]`); if (n) n.textContent = "0"; const cb = document.querySelector(`[data-toggle="${id}"]`); if (cb) { cb.checked = false; cb.disabled = true; } const card = document.querySelector(`.card[data-id="${id}"]`); if (card) card.classList.add("disabled"); const extra = document.querySelector(`[data-extra="${id}"]`); if (extra) extra.innerHTML = ""; } scanned = false; document.getElementById("scanLabel").textContent = `Scan ${SOURCE_LABEL}`; document.getElementById("actionbar").classList.remove("show"); updateExportTotals(); } async function exportBundle() { const encOn = document.getElementById("encToggle").checked; const pass = document.getElementById("encPass").value; const pass2 = document.getElementById("encPass2").value; if (encOn) { if (!pass) { toast("Enter a passphrase, or switch off encryption", "lock"); document.getElementById("encPass").focus(); return; } if (pass.length < 6) { toast("Use a longer passphrase (6+ characters)", "lock"); return; } if (pass !== pass2) { toast("Passphrases don't match", "lock"); document.getElementById("encPass2").focus(); return; } } const btn = document.getElementById("export"); const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' ' + (encOn ? "Encrypting…" : "Building…"); let ok = false, extra = ""; try { const obj = buildBundle(); const json = encOn ? JSON.stringify(await encryptBundle(obj, pass)) : JSON.stringify(obj, null, 2); saveFile(`portage-${SOURCE}-${stamp()}.portage.json`, json, "application/json"); const ext = exState.extensions; if (ext && ext.selected && ext.count > 0) { saveFile(`portage-${SOURCE}-extensions-${stamp()}.txt`, extensionsTxt(ext.data), "text/plain"); extra = " + extension checklist"; } ok = true; } catch (err) { toast("Export failed: " + err.message, "lock"); } btn.innerHTML = orig; if (ok) { document.getElementById("encPass").value = ""; document.getElementById("encPass2").value = ""; document.getElementById("encStrengthBar").style.width = "0%"; document.getElementById("encStrengthLabel").textContent = "—"; clearScannedData(); toast(`Bundle saved${encOn ? " (encrypted)" : ""}${extra} — data wiped from memory`, "check"); } else { btn.disabled = false; } } async function copyJson() { try { await navigator.clipboard.writeText(JSON.stringify(buildBundle(), null, 2)); } catch (err) { toast("Copy failed: " + err.message, "check"); return; } clearScannedData(); toast("Plaintext bundle copied — data wiped from memory", "check"); } function initEncryption() { const toggle = document.getElementById("encToggle"); const body = document.getElementById("encBody"); const pass = document.getElementById("encPass"); const bar = document.getElementById("encStrengthBar"); const label = document.getElementById("encStrengthLabel"); toggle.addEventListener("change", () => body.classList.toggle("hidden", !toggle.checked)); document.getElementById("encReveal").addEventListener("click", () => { pass.type = pass.type === "password" ? "text" : "password"; }); pass.addEventListener("input", () => { const s = passStrength(pass.value); bar.style.width = s.pct + "%"; label.textContent = s.label; }); } let bundle = null; const imState = {}; const imGrid = document.getElementById("importGrid"); const dropzone = document.getElementById("dropzone"); const fileInput = document.getElementById("file"); dropzone.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", (e) => { if (e.target.files[0]) loadFile(e.target.files[0]); }); ["dragenter", "dragover"].forEach((ev) => dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.add("drag"); })); ["dragleave", "drop"].forEach((ev) => dropzone.addEventListener(ev, (e) => { e.preventDefault(); dropzone.classList.remove("drag"); })); dropzone.addEventListener("drop", (e) => { const f = e.dataTransfer.files[0]; if (f) loadFile(f); }); async function loadFile(file) { let parsed; try { parsed = JSON.parse(await file.text()); } catch { toast("Couldn't read that file — is it a Portage bundle?", "check"); return; } const fmt = parsed?.portage?.format; if (fmt === "portage-bundle") { bundle = parsed; showReview(); toast("Bundle loaded — review and import", "check"); } else if (fmt === "portage-bundle-encrypted") { openDecryptModal(parsed); } else { toast("Not a Portage bundle.", "check"); } } function openDecryptModal(wrapper) { const overlay = document.createElement("div"); overlay.className = "modal-overlay"; const src = (wrapper.portage.source || "another browser").replace(/^./, (m) => m.toUpperCase()); overlay.innerHTML = ` `; document.body.appendChild(overlay); const input = overlay.querySelector("#decPass"); const err = overlay.querySelector("#decErr"); const go = overlay.querySelector("#decGo"); setTimeout(() => input.focus(), 50); overlay.querySelector("#decReveal").addEventListener("click", () => { input.type = input.type === "password" ? "text" : "password"; }); overlay.querySelector("#decCancel").addEventListener("click", () => overlay.remove()); overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); }); async function attempt() { if (!input.value) return; err.classList.remove("show"); go.disabled = true; const orig = go.innerHTML; go.innerHTML = ' Decrypting…'; try { bundle = await decryptBundle(wrapper, input.value); overlay.remove(); showReview(); toast("Bundle decrypted — review and import", "check"); } catch { go.disabled = false; go.innerHTML = orig; err.classList.add("show"); input.select(); } } go.addEventListener("click", attempt); input.addEventListener("keydown", (e) => { if (e.key === "Enter") attempt(); }); } function showReview() { document.getElementById("loadStep").classList.add("hidden"); document.getElementById("reviewStep").classList.remove("hidden"); const p = bundle.portage; document.getElementById("bundleTitle").textContent = `Bundle from ${(p.source || "?").replace(/^./, (m) => m.toUpperCase())}`; document.getElementById("bundleMeta").textContent = `exported ${new Date(p.exportedAt).toLocaleString()} · ${p.generator || "Portage"}`; imGrid.innerHTML = ""; let totalItems = 0; ORDER.forEach((id, idx) => { const c = CATS[id]; const data = bundle.data?.[id]; const present = Array.isArray(data) ? data.length > 0 : !!data; if (!present) return; const count = c.count(data); totalItems += count; const importable = !!c.importRun; imState[id] = { count, selected: importable }; const el = makeCard(id, c, { checked: importable, toggleDisabled: !importable, desc: c.desc, count: count.toLocaleString(), dot: importable ? "done" : "empty", msg: importable ? "Ready to import" : "Reinstall manually", fx: c.importFx, }); el.style.animationDelay = idx * 45 + "ms"; if (!importable) el.classList.add("disabled"); imGrid.appendChild(el); if (c.special) renderExtList(id, data); }); imGrid.addEventListener("change", (e) => { const id = e.target.dataset?.toggle; if (!id || !imState[id]) return; imState[id].selected = e.target.checked; imGrid.querySelector(`.card[data-id="${id}"]`).classList.toggle("disabled", !e.target.checked); updateImportTotals(); }); document.getElementById("imItems").textContent = totalItems.toLocaleString(); updateImportTotals(); } function renderExtList(id, list) { const card = imGrid.querySelector(`.card[data-id="${id}"]`); card.classList.add("wide"); const slot = card.querySelector(`[data-extra="${id}"]`); const rows = list.map((e, i) => `
${i + 1} ${escapeHtml(e.name)} v${escapeHtml(e.version || "?")} Firefox Chrome
`).join(""); slot.innerHTML = `
${rows}
`; document.getElementById("extTxtDl").addEventListener("click", () => { saveFile(`portage-extensions-${stamp()}.txt`, extensionsTxt(list), "text/plain"); toast("Extension checklist saved", "download"); }); } function updateImportTotals() { let sel = 0; for (const id in imState) if (imState[id].selected) sel += imState[id].count; document.getElementById("imSelected").textContent = sel.toLocaleString(); document.getElementById("importBtn").disabled = sel === 0; } async function runImport() { const btn = document.getElementById("importBtn"); const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Importing…'; document.getElementById("impbarWrap").classList.remove("hidden"); const todo = ORDER.filter((id) => imState[id]?.selected && CATS[id].importRun); let imported = 0, skipped = 0, ci = 0; for (const id of todo) { const c = CATS[id]; setDot(id, "scanning"); setMsg(id, "Importing…"); document.getElementById("impStage").textContent = "Importing " + c.label.toLowerCase() + "…"; try { const res = await c.importRun(bundle.data[id], (d, t) => setBar(id, t ? Math.round((d / t) * 100) : 100)); imported += res.imported; skipped += res.skipped; setBar(id, 100); setDot(id, "done"); setMsg(id, `Imported ${res.imported.toLocaleString()}` + (res.skipped ? ` · ${res.skipped} skipped` : "")); } catch (err) { setDot(id, "error"); setMsg(id, "Failed — " + err.message); } ci++; const pct = Math.round((ci / todo.length) * 100); document.getElementById("impFill").style.width = pct + "%"; document.getElementById("impPct").textContent = pct + "%"; document.getElementById("imImported").textContent = imported.toLocaleString(); document.getElementById("imSkipped").textContent = skipped.toLocaleString(); } bundle = null; document.getElementById("impStage").textContent = "Import complete · bundle wiped from memory"; btn.innerHTML = orig; btn.disabled = true; toast(`Done — ${imported.toLocaleString()} items imported into ${SOURCE_LABEL}; bundle wiped`, "check"); } function resetImport() { bundle = null; fileInput.value = ""; document.getElementById("reviewStep").classList.add("hidden"); document.getElementById("loadStep").classList.remove("hidden"); document.getElementById("impbarWrap").classList.add("hidden"); } function setMode(mode) { document.querySelectorAll("#modeSeg button").forEach((b) => b.classList.toggle("active", b.dataset.mode === mode)); const exporting = mode === "export"; document.getElementById("exportPanel").classList.toggle("hidden", !exporting); document.getElementById("importPanel").classList.toggle("hidden", exporting); document.getElementById("actionbar").classList.toggle("show", exporting && scanned); } document.getElementById("modeSeg").addEventListener("click", (e) => { const b = e.target.closest("button"); if (b) setMode(b.dataset.mode); }); document.getElementById("scan").addEventListener("click", scanAll); document.getElementById("export").addEventListener("click", exportBundle); document.getElementById("copyJson").addEventListener("click", copyJson); document.getElementById("importBtn").addEventListener("click", runImport); document.getElementById("reset").addEventListener("click", resetImport); renderExportCards(); initEncryption();