Add Memos Clipper extension with Markdown content extraction and browser integration.

This commit is contained in:
2026-03-15 09:34:59 +01:00
parent 2acd843b2b
commit a2f0a0e262
18 changed files with 3774 additions and 0 deletions

398
src/popup.js Normal file
View File

@@ -0,0 +1,398 @@
// popup.js
"use strict";
// ── 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 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;
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 }, (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 renderPreview() {
state.markdown = mdEditor.value;
if (typeof marked !== "undefined") {
mdPreview.innerHTML = marked.parse(state.markdown, { breaks: true });
} else {
// Fallback: basic escaping
mdPreview.innerHTML = `<pre style="white-space:pre-wrap">${escHtml(state.markdown)}</pre>`;
}
}
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 uploadable = visible.filter((img) => !img.src.startsWith("data:")).length;
imgCount.textContent = uploadable;
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;
if (isDataUri) label.title = "Embedded data URI — cannot be uploaded";
const remove = document.createElement("button");
remove.className = "remove-img";
remove.textContent = "×";
remove.title = img.keep ? "Exclude this image" : "Include this image";
if (isDataUri) {
remove.style.display = "none"; // data URIs can't be uploaded anyway
}
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 && !im.src.startsWith("data:")).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");
// Load saved tags default
getSettings().then((s) => {
if (s.includeTags) tagsInput.value = "#clipped";
});
// ── Mode toggle ───────────────────────────────────────────────────────────────
function updateModeButton() {
modeToggle.title = state.mode === "page"
? "Currently: full page — click for selection mode"
: "Currently: selection — click for full page mode";
modeToggle.classList.toggle("mode-selection", state.mode === "selection");
}
modeToggle.addEventListener("click", async () => {
state.mode = state.mode === "page" ? "selection" : "page";
updateModeButton();
await 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 {
let resourceNames = [];
// 1. Upload images if requested
const imageMap = new Map(); // originalUrl -> resourceName
if (attachCheck.checked) {
const toUpload = state.images.filter((img) => img.keep && !img.src.startsWith("data:"));
let uploaded = 0;
for (const img of toUpload) {
sendBtn.textContent = `Uploading image ${uploaded + 1}/${toUpload.length}`;
try {
const resource = await uploadImage(baseUrl, token, img);
if (resource) {
// v0.22+ uses resource.name; older APIs return resource.id
const name = resource.name || `resources/${resource.id}`;
resourceNames.push(name);
imageMap.set(img.src, name);
}
uploaded++;
} catch (e) {
console.warn("Failed to upload image:", img.src, e.message);
}
}
}
sendBtn.textContent = "Creating memo…";
// 2. Create memo
// Try to replace original image URLs with local attachment URLs in the markdown
let contentWithImages = finalContent;
for (const [origUrl, resName] of imageMap.entries()) {
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
const attachmentUrl = `${baseUrl}/file/${urlPart}`;
// Escape for regex
const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g');
contentWithImages = contentWithImages.replace(re, `![$1](${attachmentUrl})`);
}
// Append any uploaded images that weren't already in the markdown (e.g. from gallery)
for (const resName of imageMap.values()) {
const urlPart = resName.startsWith("resources/") ? resName : `resources/${resName}`;
if (!contentWithImages.includes(`/file/${urlPart}`)) {
contentWithImages += `\n\n![attachment](${baseUrl}/file/${urlPart})`;
}
}
const body = {
content: contentWithImages,
visibility,
};
// Also set resources array for native attachment display (v0.22+)
if (resourceNames.length) {
body.resources = resourceNames.map((name) => {
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
return { name: norm };
});
// Backward compatibility: some versions use resourceIdList (array of ints)
const resourceIds = resourceNames
.map(n => parseInt(n.replace("resources/", "")))
.filter(id => !isNaN(id));
if (resourceIds.length === resourceNames.length) {
body.resourceIdList = resourceIds;
}
}
const res = await fetch(`${baseUrl}/api/v1/memos`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`API error ${res.status}: ${txt.slice(0, 200)}`);
}
const memo = await res.json();
// Handle both v0.22+ (memo.name = "memos/123") and older (memo.id = "123" or memo.uid)
let memoId = "";
if (memo.name) {
memoId = memo.name.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 resource ────────────────────────────────────────────
async function uploadImage(baseUrl, token, img) {
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 = img.src.split("/").pop().split("?")[0].split("#")[0];
// Strip non-filename characters
filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
// If no extension, infer from MIME type
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";
const formData = new FormData();
formData.append("file", blob, filename);
const res = await fetch(`${baseUrl}/api/v1/resources`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Resource upload failed ${res.status}: ${txt.slice(0, 120)}`);
}
const resource = await res.json();
// Normalise the returned name — could be "resources/abc", "abc", or an int id
if (resource.name) return resource;
if (resource.id) return { ...resource, name: `resources/${resource.id}` };
throw new Error("Resource upload returned unexpected shape: " + JSON.stringify(resource).slice(0, 80));
}