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

399 lines
16 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";
// ── 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));
}