Files
memos-chrome-extension/src/popup.js

470 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── 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 = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> 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;
}