Files
memos-chrome-extension/popup.js
2026-03-14 21:21:53 +01:00

385 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
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
resourceNames.push(resource.name || `resources/${resource.id}`);
}
uploaded++;
} catch (e) {
console.warn("Failed to upload image:", img.src, e.message);
}
}
}
sendBtn.textContent = "Creating memo…";
// 2. Create memo
// Build content: append uploaded image markdown links so they appear inline
// even on instances where resource attachment display is not supported.
let contentWithImages = finalContent;
if (resourceNames.length) {
const imgLinks = resourceNames
.map((name, i) => {
// v0.22+ resource URL pattern: /file/{name}/filename
const urlName = name.startsWith("resources/") ? name : `resources/${name}`;
return `![attachment-${i + 1}](${baseUrl}/file/${urlName.replace("resources/", "")})`;
})
.join("\n");
contentWithImages = finalContent + "\n\n" + imgLinks;
}
const body = {
content: contentWithImages,
visibility,
};
// Also set resources array for native attachment display (v0.22+)
if (resourceNames.length) {
body.resources = resourceNames.map((name) => {
// Normalise: API wants just "resources/abc123" not a full path
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
return { name: norm };
});
}
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));
}