470 lines
19 KiB
JavaScript
470 lines
19 KiB
JavaScript
// 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, "<").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 = `})`;
|
||
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`;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|