diff --git a/README.md b/README.md index 8bdc557..acd83d0 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,19 @@ > **How long?** Main pages request up to 100 bookmarks with 2.5–3.75 seconds between pages. Folder pages, network time, and retries add to this. The progress panel shows collected counts and elapsed time; the total is not known in advance. +## Full-page bookmark library + +Click the extension toolbar icon to open `library.html`, a full-tab library that reads the extension's existing cumulative archives directly. The original collection/export page remains available through **同步与备份** (Sync and backup), with a link back to the library. + +- Table and card layouts with a separate full-text, quote, image, and video preview pane. +- Case-insensitive search across text, authors, quoted text, folders, and URLs; quoted phrases and `-excluded` terms are supported. Press `/` to focus search. +- Folder, media-type, and availability filters; chronological and like-count sorting; 50 results per page. +- Switch between saved accounts or refresh the local archive after a scan. +- Open an xarchive JSON file as a read-only preview without replacing the saved archive. Imported previews are not persisted across reloads. +- Download the filtered results as a JSON snapshot explicitly marked as partial coverage. + +The initial library UI is in Simplified Chinese. No new extension permissions, hosted services, or build step are required. Remote media loads from the URLs already present in the archive; unavailable media cannot be recovered. The existing standalone viewer remains available. + ## Updating the extension ```bash diff --git a/background.js b/background.js index 7b0dbb8..168a766 100644 --- a/background.js +++ b/background.js @@ -61,9 +61,9 @@ chrome.webRequest.onSendHeaders.addListener( ['requestHeaders', 'extraHeaders'] ); -/** Open the options page when the extension icon is clicked. */ +/** Open the full-page bookmark library when the extension icon is clicked. */ chrome.action.onClicked.addListener(() => { - chrome.runtime.openOptionsPage(); + chrome.tabs.create({ url: chrome.runtime.getURL('library.html') }); }); /** diff --git a/lib/library-search.mjs b/lib/library-search.mjs new file mode 100644 index 0000000..c919de8 --- /dev/null +++ b/lib/library-search.mjs @@ -0,0 +1,68 @@ +export const escapeHTML = (s) => + String(s ?? "").replace( + /[&<>"']/g, + (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + c + ], + ); +export function safeURL(s) { + try { + const u = new URL(s); + return ["https:", "http:"].includes(u.protocol) ? u.href : ""; + } catch { + return ""; + } +} +export function filterBookmarks( + all, + { query = "", folder = "", media = "", status = "", sort = "new" } = {}, +) { + const terms = (query.match(/-?"[^"]+"|\S+/g) || []) + .map((t) => ({ + negative: t.startsWith("-"), + text: (t.startsWith("-") ? t.slice(1) : t) + .replaceAll('"', "") + .toLowerCase(), + })) + .filter((t) => t.text); + return all + .filter((b) => { + if (folder === "__unsorted" && b.folders?.length) return false; + if (folder && folder !== "__unsorted" && !b.folders?.includes(folder)) + return false; + if (status && b.status !== status) return false; + const m = b.media || []; + if (media === "text" && m.length) return false; + if (media === "photo" && !m.some((x) => x.type === "photo")) return false; + if ( + media === "video" && + !m.some((x) => ["video", "animated_gif"].includes(x.type)) + ) + return false; + const text = [ + b.full_text, + b.author?.name, + b.author?.screen_name, + b.quoted_tweet?.full_text, + ...(b.folders || []), + ...(b.entities?.urls || []).flatMap((u) => [ + u.url, + u.expanded_url, + u.display_url, + ]), + ] + .join(" ") + .toLowerCase(); + return terms.every((t) => + t.negative ? !text.includes(t.text) : text.includes(t.text), + ); + }) + .sort((a, b) => + sort === "likes" + ? (b.metrics?.likes || 0) - (a.metrics?.likes || 0) + : sort === "old" + ? (Date.parse(a.created_at) || 0) - (Date.parse(b.created_at) || 0) + : (Date.parse(b.created_at) || 0) - (Date.parse(a.created_at) || 0), + ); +} diff --git a/library.css b/library.css new file mode 100644 index 0000000..dbcfb8a --- /dev/null +++ b/library.css @@ -0,0 +1,496 @@ +:root { + color-scheme: dark; + --bg: #101113; + --panel: #17191c; + --line: #2b2e33; + --muted: #9299a4; + --ink: #eef0f3; + --accent: #c8f36b; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font: + 14px/1.6 -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + height: 100vh; + display: flex; + overflow: hidden; +} +button, +input, +select { + font: inherit; +} +button, +a, +select { + transition: background 0.15s; +} +button, +select { + border: 1px solid var(--line); + border-radius: 8px; + background: #1b1e22; + color: var(--ink); + padding: 8px 12px; +} +button { + cursor: pointer; +} +button:hover { + background: #30353b; +} +button:disabled { + opacity: 0.35; + cursor: default; +} +a { + color: var(--accent); + text-decoration: none; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.sidebar { + width: 218px; + flex-shrink: 0; + border-right: 1px solid var(--line); + padding: 27px 18px; + display: flex; + flex-direction: column; + background: #141618; +} +.brand { + display: flex; + gap: 9px; + align-items: center; + color: var(--ink); + font-size: 22px; +} +.brand span { + font-size: 8px; + letter-spacing: 1px; + border: 1px solid #535b45; + padding: 2px 4px; + color: var(--accent); +} +.local { + color: var(--accent); + font-size: 11px; + margin: 17px 0 32px; +} +.caption, +h3 { + font-size: 11px; + color: var(--muted); + letter-spacing: 1px; +} +h3 { + margin-top: 28px; +} +.sidebar select { + width: 100%; + margin-top: 9px; +} +nav { + overflow: auto; +} +nav button { + display: flex; + justify-content: space-between; + width: 100%; + border: 0; + background: none; + text-align: left; + margin-bottom: 5px; +} +nav button.active { + background: #293221; + color: var(--accent); +} +nav button span:first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +nav button small { + margin-left: 8px; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 20px; +} +.sidebar-bottom button, +.sidebar-bottom a { + display: block; + width: 100%; + margin: 8px 0; + text-align: left; +} +.sidebar-bottom a { + padding: 8px 12px; +} +.sidebar-bottom p { + font-size: 11px; + color: var(--muted); + padding: 12px; +} +main { + flex: 1; + min-width: 0; + padding: 25px 28px 0; + display: flex; + flex-direction: column; +} +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} +.eyebrow { + font-size: 10px; + color: var(--muted); + letter-spacing: 2px; +} +h1 { + font-size: 27px; + line-height: 1.3; + margin: 6px 0; +} +h1 span { + font-size: 14px; + color: var(--accent); + vertical-align: middle; + margin-left: 9px; + background: #293221; + padding: 3px 9px; + border-radius: 20px; +} +.searchbox { + display: flex; + align-items: center; + gap: 12px; + background: var(--panel); + border: 1px solid #41473c; + border-radius: 10px; + padding: 0 16px; +} +.searchbox > span { + font-size: 28px; + color: var(--accent); +} +input[type="search"] { + width: 100%; + height: 49px; + background: none; + border: none; + color: var(--ink); + outline: none; +} +.searchbox:focus-within { + border-color: var(--accent); +} +kbd { + color: var(--muted); + border: 1px solid var(--line); + padding: 0 6px; + border-radius: 4px; +} +.filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} +.view-switch { + display: flex; + gap: 3px; + margin-right: auto; +} +.view-switch button[aria-pressed="true"] { + color: var(--accent); + background: #293221; +} +#notice { + font-size: 11px; + color: var(--muted); + margin: 12px 0; + min-height: 18px; +} +.workspace { + display: flex; + flex: 1; + min-height: 0; + border-top: 1px solid var(--line); +} +.results { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} +#list { + overflow: auto; + flex: 1; +} +table { + border-collapse: collapse; + width: 100%; + table-layout: fixed; +} +th { + position: sticky; + top: 0; + background: #17191c; + text-align: left; + font-weight: 500; + color: var(--muted); + font-size: 11px; + padding: 12px 10px; + z-index: 1; +} +td { + padding: 16px 10px; + border-bottom: 1px solid #24272b; + vertical-align: top; +} +th:first-child { + width: 45px; +} +th:nth-child(2) { + width: 130px; +} +th:last-child { + width: 84px; +} +tr[data-id] { + cursor: pointer; +} +tr[data-id]:hover { + background: #1d2125; +} +tr.selected { + background: #222a1d; +} +td .snippet { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.7; +} +td small, +.byline small { + color: var(--muted); + display: block; + font-size: 11px; +} +.author { + display: flex; + gap: 8px; + align-items: center; + overflow: hidden; +} +.avatar { + width: 30px; + height: 30px; + object-fit: cover; + border-radius: 50%; + background: #333; + flex-shrink: 0; +} +.author b { + font-size: 12px; + word-break: break-word; +} +mark { + background: #c8f36b; + color: #172108; + border-radius: 2px; +} +footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + border-top: 1px solid var(--line); + padding: 13px 8px; + color: var(--muted); + font-size: 11px; +} +footer div { + display: flex; + align-items: center; + gap: 9px; +} +footer button { + font-size: 11px; + padding: 5px 8px; +} +#detail { + width: 40%; + min-width: 320px; + max-width: 640px; + border-left: 1px solid var(--line); + padding: 24px; + overflow: auto; + background: #131517; + resize: horizontal; +} +.empty { + text-align: center; + color: var(--muted); + padding: 70px 20px; +} +.empty > span { + font-size: 40px; + color: var(--accent); +} +.empty h2 { + font-size: 18px; + color: var(--ink); +} +.detail-top { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 22px; +} +.detail-top .avatar { + width: 42px; + height: 42px; +} +.detail-top a { + margin-left: auto; + font-size: 12px; + white-space: nowrap; +} +.body-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 15px; + line-height: 1.95; +} +.media img, +.media video { + max-width: 100%; + border-radius: 10px; + display: block; + margin: 15px auto; +} +.quote { + border: 1px solid var(--line); + border-radius: 10px; + padding: 16px; + margin: 20px 0; +} +.metrics { + display: flex; + gap: 18px; + color: var(--muted); + font-size: 12px; + margin: 20px 0; +} +.tag { + display: inline-block; + padding: 2px 7px; + border-radius: 5px; + background: #262c23; + color: #b6c69c; + font-size: 10px; + margin: 5px 4px 0 0; +} +.cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(235px, 1fr)); + gap: 12px; + padding: 12px; +} +.card { + padding: 16px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + text-align: left; + cursor: pointer; +} +.card.selected { + border-color: var(--accent); +} +.card .snippet { + margin-top: 12px; + display: -webkit-box; + -webkit-line-clamp: 6; + -webkit-box-orient: vertical; + overflow: hidden; +} +.card img.cover { + width: 100%; + height: 150px; + object-fit: cover; + border-radius: 7px; + margin-top: 14px; +} +@media (max-width: 1100px) { + .sidebar { + width: 175px; + padding: 20px 12px; + } + main { + padding: 20px 16px 0; + } + #detail { + min-width: 290px; + width: 42%; + padding: 16px; + } + th:nth-child(2) { + width: 100px; + } + th:last-child { + width: 65px; + } +} +@media (max-width: 780px) { + .sidebar { + width: 145px; + } + #detail { + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 75%; + max-width: none; + z-index: 3; + box-shadow: -20px 0 80px #0009; + } + #detail:not(.open) { + display: none; + } + .filters { + gap: 5px; + } + header button { + font-size: 11px; + } +} +.close-detail { + float: right; + margin: 0 0 12px 12px; +} +.error { + color: #f2aa9e !important; +} diff --git a/library.html b/library.html new file mode 100644 index 0000000..ed30408 --- /dev/null +++ b/library.html @@ -0,0 +1,88 @@ + + + + + + xarchive · 书签库 + + + + +
+
+
+
YOUR PERSONAL ARCHIVE
+

书签库 0

+
+ +
+ +
+
+ +
+ +
+

+
+
+
+
+ +
+ +
+
+
+ +
+
+ + + + + diff --git a/library.js b/library.js new file mode 100644 index 0000000..b15a292 --- /dev/null +++ b/library.js @@ -0,0 +1,308 @@ +import { filterBookmarks, escapeHTML, safeURL } from "./lib/library-search.mjs"; +const $ = (id) => document.getElementById(id), + store = new XArchive.ArchiveStore(); +let data = null, + all = [], + filtered = [], + page = 0, + folder = "", + selected = null, + view = "table"; +const size = 50; +const esc = escapeHTML; +function highlight(s) { + const words = [ + ...new Set( + ($("search").value.match(/"[^"]+"|\S+/g) || []) + .filter((x) => !x.startsWith("-")) + .map((x) => x.replaceAll('"', "")) + .filter(Boolean), + ), + ].sort((a, b) => b.length - a.length); + if (!words.length) return esc(s || ""); + const pattern = words + .map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join("|"); + const regex = new RegExp(pattern, "gi"); + const text = String(s || ""); + let result = "", + last = 0; + for (const match of text.matchAll(regex)) { + result += + esc(text.slice(last, match.index)) + "" + esc(match[0]) + ""; + last = match.index + match[0].length; + } + return result + esc(text.slice(last)); +} +function avatar(b) { + const u = safeURL(b.author?.profile_image_url); + return u + ? `` + : ''; +} +function author(b) { + return `
${avatar(b)}
${esc(b.author?.name || "未知作者")}@${esc(b.author?.screen_name || "unknown")}
`; +} +function date(b) { + const d = new Date(b.created_at); + return Number.isFinite(+d) ? d.toLocaleDateString("zh-CN") : ""; +} +function setData(d, label) { + XArchive.validate(d); + data = d; + all = d.bookmarks; + selected = null; + $("detail").classList.remove("open"); + $("detail").innerHTML = + '

选择一条书签查看全文

'; + page = 0; + folder = ""; + $("total").textContent = all.length.toLocaleString(); + $("notice").classList.remove("error"); + $("notice").textContent = + `${label} · ${all.filter((b) => b.status === "unavailable").length} 条不可用 · 搜索正文、作者、引用内容及链接`; + renderFolders(); + search(); +} +function renderFolders() { + const counts = new Map(); + for (const b of all) + for (const f of new Set(b.folders || [])) + counts.set(f, (counts.get(f) || 0) + 1); + const entries = [ + ["", "全部书签", all.length], + ["__unsorted", "未分类", all.filter((b) => !b.folders?.length).length], + ...Array.from(counts, ([f, n]) => [f, f, n]), + ]; + $("folders").replaceChildren(); + for (const [key, label, n] of entries) { + const b = document.createElement("button"); + b.className = folder === key ? "active" : ""; + b.innerHTML = `${esc(label)}${n}`; + b.onclick = () => { + folder = key; + page = 0; + renderFolders(); + search(); + }; + $("folders").append(b); + } +} +function search() { + filtered = filterBookmarks(all, { + query: $("search").value, + folder, + media: $("media").value, + status: $("status").value, + sort: $("sort").value, + }); + if (selected && !filtered.some((b) => b.tweet_id === selected.tweet_id)) { + selected = null; + $("detail").classList.remove("open"); + $("detail").innerHTML = + '

选择一条书签查看全文

'; + } + page = Math.min(page, Math.max(0, Math.ceil(filtered.length / size) - 1)); + render(); + if (selected) detail(selected); +} +function render() { + const rows = filtered.slice(page * size, (page + 1) * size); + $("list").innerHTML = + view === "table" + ? `${rows.map((b, i) => ``).join("")}
#作者推文发布日期
${page * size + i + 1}${author(b)}
${highlight(b.full_text || b.unavailable_reason || "推文不可用")}
${(b.folders || []).map((f) => `${esc(f)}`).join("")}
${date(b)}
` + : `
${rows.map((b) => ``).join("")}
`; + if (!rows.length) + $("list").innerHTML = + '
⌕

没有匹配的书签

试试更短的关键词,或重置筛选条件

'; + $("count").textContent = + `${filtered.length.toLocaleString()} 条结果 / 共 ${all.length.toLocaleString()} 条`; + $("page").textContent = + `${page + 1} / ${Math.max(1, Math.ceil(filtered.length / size))}`; + $("prev").disabled = page === 0; + $("next").disabled = (page + 1) * size >= filtered.length; + $("export").disabled = !filtered.length; + for (const el of $("list").querySelectorAll("[data-id]")) { + const open = () => { + selected = all.find((b) => b.tweet_id === el.dataset.id); + detail(selected); + render(); + }; + el.onclick = open; + el.onkeydown = (e) => { + if (e.key === "Enter") open(); + }; + } +} +function detail(b) { + if (!b) return; + const url = `https://x.com/i/status/${encodeURIComponent(b.tweet_id)}`; + $("detail").classList.add("open"); + $("detail").innerHTML = + `
${avatar(b)}
${esc(b.author?.name || "未知作者")}@${esc(b.author?.screen_name || "unknown")} · ${date(b)}
在 X 查看 ↗
${highlight(b.full_text || b.unavailable_reason || "推文不可用")}
${b.quoted_tweet ? `
${esc(b.quoted_tweet.author?.name || "引用推文")}
${highlight(b.quoted_tweet.full_text || "原文不可用")}
` : ""}
♡ ${b.metrics?.likes || 0}↻ ${b.metrics?.retweets || 0}◉ ${b.metrics?.views || 0}
${(b.folders || []).map((f) => `${esc(f)}`).join("")}`; + $("detail").querySelector(".close-detail").onclick = () => { + $("detail").classList.remove("open"); + $("detail").innerHTML = + '

选择一条书签查看全文

'; + selected = null; + render(); + }; + for (const m of b.media || []) { + const video = m.type === "video" || m.type === "animated_gif"; + const u = safeURL(video ? m.variants?.[0]?.url : m.url); + if (!u) continue; + const el = document.createElement(video ? "video" : "img"); + el.src = u; + if (video) { + el.controls = true; + el.preload = "none"; + el.poster = safeURL(m.thumbnail_url) || ""; + } else { + el.alt = m.alt_text || "推文图片"; + el.loading = "lazy"; + el.referrerPolicy = "no-referrer"; + } + $("detail").querySelector(".media").append(el); + } + for (const u of b.entities?.urls || []) { + const href = safeURL(u.expanded_url || u.url); + if (!href) continue; + const a = document.createElement("a"); + a.href = href; + a.textContent = u.display_url || href; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.style.display = "block"; + a.style.overflowWrap = "anywhere"; + $("detail").querySelector(".links").append(a); + } +} +async function load() { + const list = await store.list(); + $("account").replaceChildren(); + for (const r of list) { + const o = new Option( + (r.screenName ? "@" + r.screenName : r.userId || "导入资料") + + ` · ${r.count}`, + r.key, + ); + $("account").add(o); + } + if (list.length) { + const active = await store.get("active"); + if (list.some((r) => r.key === active?.value)) + $("account").value = active.value; + await choose(); + } else { + $("notice").textContent = + "还没有存档:导入 xarchive JSON,或前往「同步与备份」采集书签。"; + search(); + } +} +async function choose() { + const r = await store.get($("account").value); + if (r) setData(r.data, "已读取插件本地存档"); +} +function fail(e) { + $("notice").textContent = "未能读取:" + e.message; + $("notice").classList.add("error"); +} +$("account").onchange = () => choose().catch(fail); +$("refresh").onclick = () => load().catch(fail); +$("import").onclick = () => $("file").click(); +$("file").onchange = async () => { + try { + const f = $("file").files[0]; + if (!f) return; + if (f.size > 100 * 1024 * 1024) throw Error("文件超过 100 MB"); + const d = JSON.parse(await f.text()); + XArchive.validate(d); + setData(d, "已打开文件 " + f.name + "(只读预览,未覆盖存档)"); + for (const option of [...$("account").options]) + if (option.value === "file") option.remove(); + $("account").add(new Option("导入文件 · " + f.name, "file", true, true)); + } catch (e) { + fail(e); + } finally { + $("file").value = ""; + } +}; +let timer; +$("search").oninput = () => { + clearTimeout(timer); + timer = setTimeout(() => { + page = 0; + search(); + }, 150); +}; +for (const id of ["media", "status", "sort"]) + $(id).onchange = () => { + page = 0; + search(); + }; +$("clear").onclick = () => { + $("search").value = ""; + $("media").value = ""; + $("status").value = ""; + $("sort").value = "new"; + folder = ""; + page = 0; + renderFolders(); + search(); +}; +$("prev").onclick = () => { + page--; + render(); + $("list").scrollTop = 0; +}; +$("next").onclick = () => { + page++; + render(); + $("list").scrollTop = 0; +}; +for (const [id, v] of [ + ["tableView", "table"], + ["gridView", "grid"], +]) + $(id).onclick = () => { + view = v; + $("tableView").setAttribute("aria-pressed", v === "table"); + $("gridView").setAttribute("aria-pressed", v === "grid"); + render(); + }; +$("export").onclick = () => { + const out = { + ...data, + bookmarks: filtered, + export_metadata: { + ...data.export_metadata, + kind: "snapshot", + collection: { + bookmarks: "partial", + folders: "unknown", + reason: "search_selection", + }, + stats: { total_bookmarks: filtered.length }, + }, + }; + const u = URL.createObjectURL( + new Blob([JSON.stringify(out, null, 2)], { type: "application/json" }), + ); + const a = document.createElement("a"); + a.href = u; + a.download = + "xarchive-search-" + new Date().toISOString().slice(0, 10) + ".json"; + a.click(); + setTimeout(() => URL.revokeObjectURL(u), 10000); +}; +document.onkeydown = (e) => { + if ( + e.key === "/" && + !["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName) + ) { + e.preventDefault(); + $("search").focus(); + } + if (e.key === "Escape") $("detail").classList.remove("open"); +}; +load().catch(fail); diff --git a/options.html b/options.html index 7334a55..66077e1 100644 --- a/options.html +++ b/options.html @@ -9,7 +9,7 @@
-

xarchive

+

xarchive

打开全页书签库 →

Keep a growing archive of your X.com bookmarks

diff --git a/tests/library-search.test.mjs b/tests/library-search.test.mjs new file mode 100644 index 0000000..f4c834d --- /dev/null +++ b/tests/library-search.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + filterBookmarks, + safeURL, + escapeHTML, +} from "../lib/library-search.mjs"; +const rows = [ + { + tweet_id: "1", + full_text: "Claude Code 教程", + author: { name: "小明" }, + folders: ["AI"], + media: [{ type: "photo" }], + status: "available", + created_at: "2026-01-01", + metrics: { likes: 9 }, + }, + { + tweet_id: "2", + full_text: "Claude tools", + folders: [], + media: [], + status: "unavailable", + created_at: "2026-02-01", + }, +]; +test("phrase, exclusions, author and combined facets", () => { + assert.deepEqual( + filterBookmarks(rows, { query: '"Claude Code" -广告' }).map( + (x) => x.tweet_id, + ), + ["1"], + ); + assert.equal( + filterBookmarks(rows, { query: "小明", media: "photo", folder: "AI" }) + .length, + 1, + ); + assert.equal( + filterBookmarks(rows, { query: "Claude -Code" })[0].tweet_id, + "2", + ); + assert.equal( + filterBookmarks(rows, { folder: "__unsorted", status: "unavailable" }) + .length, + 1, + ); +}); +test("sorting does not mutate original archive", () => { + assert.equal(filterBookmarks(rows, { sort: "new" })[0].tweet_id, "2"); + assert.equal(rows[0].tweet_id, "1"); + assert.equal(filterBookmarks(rows, { sort: "likes" })[0].tweet_id, "1"); +}); +test("untrusted links and content cannot become executable HTML", () => { + assert.equal(safeURL("javascript:alert(1)"), ""); + assert.equal(safeURL("data:text/html,test"), ""); + assert.equal(escapeHTML("