// popup.js "use strict"; import { marked } from "marked"; // ── State ─────────────────────────────────────────────────────────────────── const state = { markdown: "", images: [], // {src, alt, keep: bool} mode: "page", // "page" | "selection" settings: null, }; // ── DOM refs ───────────────────────────────────────────────────────────────── const views = { loading: document.getElementById("view-loading"), setup: document.getElementById("view-setup"), main: document.getElementById("view-main"), success: document.getElementById("view-success"), error: document.getElementById("view-error"), }; const mdEditor = document.getElementById("md-editor"); const mdPreview = document.getElementById("md-preview"); const imagesList = document.getElementById("images-list"); const imgCount = document.getElementById("img-count"); const pageTitle = document.getElementById("page-title"); const visibilityEl = document.getElementById("visibility-select"); const attachCheck = document.getElementById("attach-images"); const stripLinksCheck = document.getElementById("strip-links"); const modeToggle = document.getElementById("mode-toggle"); const sendBtn = document.getElementById("send-btn"); const reloadBtn = document.getElementById("reload-btn"); const errorMsg = document.getElementById("error-msg"); // ── Helpers ─────────────────────────────────────────────────────────────────── function showView(name) { Object.values(views).forEach((v) => v.classList.add("hidden")); views[name].classList.remove("hidden"); } async function getSettings() { return chrome.storage.sync.get([ "memosUrl", "apiToken", "visibility", "clipMode", "includeImages", "includeTags" ]); } async function getActiveTab() { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); return tab; } // ── Boot ────────────────────────────────────────────────────────────────────── (async () => { showView("loading"); state.settings = await getSettings(); if (!state.settings.memosUrl || !state.settings.apiToken) { showView("setup"); return; } // Apply saved defaults state.mode = state.settings.clipMode || "page"; if (state.settings.visibility) visibilityEl.value = state.settings.visibility; if (state.settings.includeImages === false) attachCheck.checked = false; if (state.settings.includeTags) tagsInput.value = "#clipped"; updateModeButton(); await clip(); })(); // ── Clip page ───────────────────────────────────────────────────────────────── async function clip() { showView("loading"); try { const tab = await getActiveTab(); pageTitle.textContent = tab.title || tab.url; // Ensure content script is available (MV3 — inject on demand) try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"], }); } catch (_) { /* already injected or restricted page */ } const response = await new Promise((resolve, reject) => { chrome.tabs.sendMessage(tab.id, { action: "getContent", mode: state.mode, stripLinks: stripLinksCheck.checked }, (res) => { if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); else resolve(res); }); }); if (!response || !response.ok) { throw new Error(response?.error || "Content script did not respond. Try reloading the page."); } state.markdown = response.markdown; // Warn if selection mode returned nothing if (state.mode === "selection" && !response.markdown.replace(/^>.*\n\n/, "").trim()) { state.markdown = response.markdown + "\n\n> ⚠️ No text was selected. Select some text on the page first, or switch to full-page mode."; } state.images = response.images.map((img) => ({ ...img, keep: true })); mdEditor.value = state.markdown; updateCharCounter(); renderImages(); showView("main"); switchTab("edit"); } catch (e) { errorMsg.textContent = e.message; showView("error"); } } // ── Tabs ────────────────────────────────────────────────────────────────────── function switchTab(name) { document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); document.querySelectorAll(".tab-panel").forEach((p) => p.classList.add("hidden")); document.querySelector(`.tab[data-tab="${name}"]`).classList.add("active"); document.getElementById(`tab-${name}`).classList.remove("hidden"); if (name === "preview") renderPreview(); } document.querySelectorAll(".tab").forEach((btn) => { btn.addEventListener("click", () => switchTab(btn.dataset.tab)); }); // ── Preview ─────────────────────────────────────────────────────────────────── function sanitizeHtml(html) { const doc = new DOMParser().parseFromString(html, "text/html"); const allowed = new Set([ "p","br","b","strong","i","em","u","s","del","ins","code","pre","blockquote", "h1","h2","h3","h4","h5","h6","ul","ol","li","a","img","table","thead", "tbody","tr","th","td","hr","span","div", ]); const allowedAttrs = { a: ["href","title"], img: ["src","alt","title","width","height"], td: ["colspan","rowspan"], th: ["colspan","rowspan"], code:["class"], pre: ["class"], }; function clean(node) { for (const child of [...node.childNodes]) { if (child.nodeType === Node.ELEMENT_NODE) { const tag = child.tagName.toLowerCase(); if (!allowed.has(tag)) { // Replace disallowed element with its text content child.replaceWith(document.createTextNode(child.textContent)); continue; } // Strip disallowed attributes for (const attr of [...child.attributes]) { const ok = (allowedAttrs[tag] || []).includes(attr.name); if (!ok) { child.removeAttribute(attr.name); continue; } // Block javascript: and data: in href/src if (attr.name === "href" || attr.name === "src") { const v = attr.value.trim().toLowerCase(); if (v.startsWith("javascript:") || v.startsWith("data:text")) { child.removeAttribute(attr.name); } } } clean(child); } } } clean(doc.body); return doc.body.innerHTML; } function renderPreview() { state.markdown = mdEditor.value; mdPreview.innerHTML = sanitizeHtml(marked.parse(state.markdown, { breaks: true })); } function escHtml(s) { return s.replace(/&/g, "&").replace(//g, ">"); } // ── Images ───────────────────────────────────────────────────────────────────── function renderImages() { // Deduplicate by src, exclude oversized data: URIs const seen = new Set(); const visible = state.images.filter((img) => { if (seen.has(img.src)) return false; seen.add(img.src); if (img.src.startsWith("data:") && img.src.length >= 500_000) return false; return true; }); // Sync keep flags back (dedup may have removed some) state.images = visible; const kept = visible.filter((img) => img.keep).length; imgCount.textContent = kept; imagesList.innerHTML = ""; if (!visible.length) { document.getElementById("images-section").style.display = "none"; return; } document.getElementById("images-section").style.display = ""; visible.forEach((img, i) => { const isDataUri = img.src.startsWith("data:"); const chip = document.createElement("div"); chip.className = "img-chip" + (img.keep ? "" : " skipped"); const thumb = document.createElement("img"); thumb.src = img.src; thumb.onerror = () => { thumb.style.display = "none"; }; const label = document.createElement("span"); const name = isDataUri ? `inline-image-${i + 1}` : (img.src.split("/").pop().split("?")[0].slice(0, 28) || `image-${i + 1}`); label.textContent = name; const remove = document.createElement("button"); remove.className = "remove-img"; remove.textContent = "×"; remove.title = img.keep ? "Exclude this image" : "Include this image"; remove.addEventListener("click", () => { img.keep = !img.keep; chip.classList.toggle("skipped", !img.keep); remove.title = img.keep ? "Exclude this image" : "Include this image"; const kept = state.images.filter((im) => im.keep).length; imgCount.textContent = kept; }); chip.append(thumb, label, remove); imagesList.appendChild(chip); }); } // ── Char counter ───────────────────────────────────────────────────────────── const charCounter = document.getElementById("char-counter"); function updateCharCounter() { const n = mdEditor.value.length; charCounter.textContent = `${n.toLocaleString()} char${n !== 1 ? "s" : ""}`; } mdEditor.addEventListener("input", updateCharCounter); // ── Tags input ──────────────────────────────────────────────────────────────── const tagsInput = document.getElementById("tags-input"); // ── Mode toggle ─────────────────────────────────────────────────────────────── const modeLabel = document.getElementById("mode-label"); function updateModeButton() { const isSelection = state.mode === "selection"; modeToggle.title = isSelection ? "Currently: selection — click for full page mode" : "Currently: full page — click for selection mode"; modeToggle.classList.toggle("mode-selection", isSelection); modeLabel.textContent = isSelection ? "Selection" : "Full page"; } modeToggle.addEventListener("click", async () => { state.mode = state.mode === "page" ? "selection" : "page"; updateModeButton(); await clip(); }); // ── Strip links toggle ──────────────────────────────────────────────────────── stripLinksCheck.addEventListener("change", () => clip()); // ── Reload ──────────────────────────────────────────────────────────────────── reloadBtn.addEventListener("click", () => clip()); // ── Settings buttons ─────────────────────────────────────────────────────────── document.getElementById("settings-btn").addEventListener("click", () => { chrome.runtime.openOptionsPage(); }); document.getElementById("open-settings-btn").addEventListener("click", () => { chrome.runtime.openOptionsPage(); }); document.getElementById("retry-btn").addEventListener("click", () => clip()); document.getElementById("new-clip-btn").addEventListener("click", () => clip()); // ── Send to Memos ───────────────────────────────────────────────────────────── sendBtn.addEventListener("click", async () => { state.markdown = mdEditor.value; // Append tags from tag bar if any const tagStr = tagsInput.value.trim(); const finalContent = tagStr ? `${state.markdown}\n\n${tagStr}` : state.markdown; const settings = await getSettings(); const baseUrl = settings.memosUrl.replace(/\/$/, ""); const token = settings.apiToken; const visibility = visibilityEl.value; sendBtn.disabled = true; try { // Validate Memos URL scheme to prevent accidental non-HTTP destinations try { const u = new URL(baseUrl); if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(); } catch { throw new Error("Invalid Memos URL in settings. Must start with http:// or https://"); } // 1. Create the memo first (with original image URLs) sendBtn.textContent = "Creating memo…"; const res = await fetch(`${baseUrl}/api/v1/memos`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ content: finalContent, visibility }), }); if (!res.ok) { const txt = await res.text(); throw new Error(`API error ${res.status}: ${txt.slice(0, 200)}`); } const memo = await res.json(); // memo.name is "memos/{id}" const memoName = memo.name; // 2. Upload images as attachments referencing the created memo const imageMap = new Map(); // originalUrl -> attachment name ("attachments/{id}") if (attachCheck.checked) { const toUpload = state.images.filter((img) => img.keep); let uploaded = 0; for (const img of toUpload) { sendBtn.textContent = `Uploading image ${uploaded + 1}/${toUpload.length}…`; try { const attachment = await uploadAttachment(baseUrl, token, img, memoName); imageMap.set(img.src, attachment.name); uploaded++; } catch (e) { console.warn("Failed to upload image:", img.src, e.message); } } } // 3. If images were uploaded, update the memo content to reference attachment URLs if (imageMap.size > 0) { sendBtn.textContent = "Updating memo…"; let contentWithImages = finalContent; for (const [origUrl, attachName] of imageMap.entries()) { const attachmentUrl = `${baseUrl}/file/${attachName}`; const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g'); // Escape $ in replacement to prevent backreference interpretation const safeReplacement = `![$1](${attachmentUrl.replace(/\$/g, '$$$$')})`; contentWithImages = contentWithImages.replace(re, safeReplacement); } // Append any uploaded attachments not already referenced in the markdown for (const attachName of imageMap.values()) { const attachmentUrl = `${baseUrl}/file/${attachName}`; if (!contentWithImages.includes(attachmentUrl)) { contentWithImages += `\n\n![attachment](${attachmentUrl})`; } } const patchRes = await fetch(`${baseUrl}/api/v1/${memoName}`, { method: "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ content: contentWithImages }), }); if (!patchRes.ok) { const txt = await patchRes.text(); console.warn(`Memo patch failed ${patchRes.status}: ${txt.slice(0, 200)}`); } } let memoId = ""; if (memoName) { memoId = memoName.replace(/^memos\//, ""); } else if (memo.uid) { memoId = memo.uid; } else if (memo.id) { memoId = memo.id; } document.getElementById("memo-link").href = memoId ? `${baseUrl}/memos/${memoId}` : baseUrl; showView("success"); } catch (e) { errorMsg.textContent = e.message; showView("error"); } finally { sendBtn.disabled = false; sendBtn.innerHTML = ` Send to Memos`; } }); // ── Upload a single image as an attachment ──────────────────────────────────── async function uploadAttachment(baseUrl, token, img, memoName) { // Validate URL scheme to prevent SSRF via crafted page image URLs if (!img.src.startsWith("data:")) { let parsedUrl; try { parsedUrl = new URL(img.src); } catch (_) { throw new Error(`Invalid image URL`); } if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { throw new Error(`Unsupported image URL scheme: ${parsedUrl.protocol}`); } } const response = await fetch(img.src); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); const blob = await response.blob(); // Derive a filename with a valid extension let filename = "image"; if (!img.src.startsWith("data:")) { filename = img.src.split("/").pop().split("?")[0].split("#")[0] || "image"; } filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80); if (!/\.\w{2,5}$/.test(filename)) { const mimeToExt = { "image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/webp": "webp", "image/svg+xml": "svg", "image/avif": "avif","image/tiff": "tiff", }; const ext = mimeToExt[blob.type] || "jpg"; filename = (filename || "image") + "." + ext; } if (!filename || filename === ".") filename = "image.jpg"; // Encode blob as base64 for the JSON body (protobuf bytes → base64 in JSON) // Chunked to avoid stack overflow when spreading large Uint8Arrays as arguments const arrayBuffer = await blob.arrayBuffer(); const uint8 = new Uint8Array(arrayBuffer); let binary = ""; const CHUNK = 8192; for (let i = 0; i < uint8.length; i += CHUNK) { binary += String.fromCharCode(...uint8.subarray(i, i + CHUNK)); } const base64 = btoa(binary); const res = await fetch(`${baseUrl}/api/v1/attachments`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ filename, type: blob.type || "application/octet-stream", content: base64, memo: memoName, }), }); if (!res.ok) { const txt = await res.text(); throw new Error(`Attachment upload failed ${res.status}: ${txt.slice(0, 120)}`); } const attachment = await res.json(); if (!attachment.name) { throw new Error("Attachment upload returned unexpected shape: " + JSON.stringify(attachment).slice(0, 80)); } return attachment; }