Auto-commited changes

This commit is contained in:
2026-03-18 19:58:05 +01:00
parent 4355fa78fa
commit 2b8659499a
4 changed files with 115 additions and 30 deletions

View File

@@ -5,7 +5,7 @@ window.__memosClipperLoaded = true;
(function () { (function () {
// ── Turndown-lite: a minimal but solid HTML→Markdown converter ────────────── // ── Turndown-lite: a minimal but solid HTML→Markdown converter ──────────────
function htmlToMarkdown(element, isSelection = false) { function htmlToMarkdown(element, isSelection = false, stripLinks = false) {
const clone = element.cloneNode(true); const clone = element.cloneNode(true);
// Remove unwanted elements — comprehensive list covering real-world sites // Remove unwanted elements — comprehensive list covering real-world sites
@@ -54,15 +54,40 @@ window.__memosClipperLoaded = true;
// Also remove elements that are visually hidden via inline style // Also remove elements that are visually hidden via inline style
clone.querySelectorAll('[style*="display:none"],[style*="display: none"],[style*="visibility:hidden"]') clone.querySelectorAll('[style*="display:none"],[style*="display: none"],[style*="visibility:hidden"]')
.forEach((el) => el.remove()); .forEach((el) => el.remove());
// Remove link-dense blocks (navigation menus, ad link lists, etc.)
// Collect candidates first to avoid mid-iteration detached-node issues.
// Only target outer chrome elements (nav, aside, header, footer, div, section)
// not content containers like article/main, to avoid stripping TOCs in prose.
const linkDenseCandidates = Array.from(
clone.querySelectorAll('nav, aside, header, footer, div, section')
).filter((el) => {
// Skip if inside the primary content container
if (el.closest('article, main, [role="main"]')) return false;
const totalText = (el.textContent || '').trim().length;
if (totalText < 30) return false; // too short to judge
const linkText = Array.from(el.querySelectorAll('a'))
.reduce((sum, a) => sum + (a.textContent || '').trim().length, 0);
if (linkText / totalText <= 0.65) return false;
// Require that the element has little direct (non-link) text of its own
const directText = Array.from(el.childNodes)
.filter((n) => n.nodeType === Node.TEXT_NODE)
.reduce((sum, n) => sum + n.textContent.trim().length, 0);
return directText < totalText * 0.25;
});
// Remove outermost candidates only (skip those already inside a removed ancestor)
linkDenseCandidates.forEach((el) => {
if (el.isConnected) el.remove();
});
} else { } else {
// In selection mode, we still want to remove script/style tags if any // In selection mode, we still want to remove script/style tags if any
clone.querySelectorAll('script, style, noscript, template').forEach((el) => el.remove()); clone.querySelectorAll('script, style, noscript, template').forEach((el) => el.remove());
} }
return nodeToMd(clone).replace(/\n{3,}/g, "\n\n").trim(); return nodeToMd(clone, { listDepth: 0, ordered: false, index: 0 }, stripLinks).replace(/\n{3,}/g, "\n\n").trim();
} }
function nodeToMd(node, ctx = { listDepth: 0, ordered: false, index: 0 }) { function nodeToMd(node, ctx = { listDepth: 0, ordered: false, index: 0 }, stripLinks = false) {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.replace(/\s+/g, " "); return node.textContent.replace(/\s+/g, " ");
} }
@@ -71,7 +96,7 @@ window.__memosClipperLoaded = true;
const tag = node.tagName.toLowerCase(); const tag = node.tagName.toLowerCase();
const children = () => const children = () =>
Array.from(node.childNodes) Array.from(node.childNodes)
.map((c) => nodeToMd(c, ctx)) .map((c) => nodeToMd(c, ctx, stripLinks))
.join(""); .join("");
switch (tag) { switch (tag) {
@@ -93,7 +118,8 @@ window.__memosClipperLoaded = true;
case "del": return `~~${children()}~~`; case "del": return `~~${children()}~~`;
case "code": { case "code": {
const text = node.textContent; const text = node.textContent;
return text.includes("`") ? `\`\`${text}\`\`` : `\`${text}\``; if (text.includes("`")) return `\`\` ${text} \`\``;
return `\`${text}\``;
} }
case "pre": { case "pre": {
const codeEl = node.querySelector("code"); const codeEl = node.querySelector("code");
@@ -110,8 +136,9 @@ window.__memosClipperLoaded = true;
.join("\n")}\n\n`; .join("\n")}\n\n`;
case "a": { case "a": {
const href = node.getAttribute("href") || "";
const text = children().trim(); const text = children().trim();
if (stripLinks) return text; // just the anchor text, no URL
const href = node.getAttribute("href") || "";
if (!text) return href; if (!text) return href;
try { try {
const abs = new URL(href, location.href).href; const abs = new URL(href, location.href).href;
@@ -134,24 +161,24 @@ window.__memosClipperLoaded = true;
case "ul": { case "ul": {
const lines = Array.from(node.children) const lines = Array.from(node.children)
.map((li) => `${" ".repeat(ctx.listDepth)}- ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }).trim()}`) .map((li) => `${" ".repeat(ctx.listDepth)}- ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }, stripLinks).trim()}`)
.join("\n"); .join("\n");
return `\n\n${lines}\n\n`; return `\n\n${lines}\n\n`;
} }
case "ol": { case "ol": {
const lines = Array.from(node.children) const lines = Array.from(node.children)
.map((li, i) => `${" ".repeat(ctx.listDepth)}${i + 1}. ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }).trim()}`) .map((li, i) => `${" ".repeat(ctx.listDepth)}${i + 1}. ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }, stripLinks).trim()}`)
.join("\n"); .join("\n");
return `\n\n${lines}\n\n`; return `\n\n${lines}\n\n`;
} }
case "li": return children(); case "li": return children();
case "table": return convertTable(node); case "table": return convertTable(node, stripLinks);
case "figure": { case "figure": {
const img = node.querySelector("img"); const img = node.querySelector("img");
const caption = node.querySelector("figcaption"); const caption = node.querySelector("figcaption");
let md = img ? nodeToMd(img, ctx) : children(); let md = img ? nodeToMd(img, ctx, stripLinks) : children();
if (caption) md += `\n*${caption.textContent.trim()}*`; if (caption) md += `\n*${caption.textContent.trim()}*`;
return `\n\n${md}\n\n`; return `\n\n${md}\n\n`;
} }
@@ -178,13 +205,16 @@ window.__memosClipperLoaded = true;
} }
} }
function convertTable(table) { function convertTable(table, stripLinks = false) {
const rows = Array.from(table.querySelectorAll("tr")); const rows = Array.from(table.querySelectorAll("tr"));
if (!rows.length) return ""; if (!rows.length) return "";
const toRow = (tr) => const toRow = (tr) =>
"| " + "| " +
Array.from(tr.querySelectorAll("th,td")) Array.from(tr.querySelectorAll("th,td"))
.map((c) => c.textContent.trim().replace(/\|/g, "\\|")) .map((c) => {
const text = stripLinks ? c.textContent.trim() : nodeToMd(c).trim();
return text.replace(/\|/g, "\\|");
})
.join(" | ") + .join(" | ") +
" |"; " |";
const header = toRow(rows[0]); const header = toRow(rows[0]);
@@ -234,6 +264,7 @@ window.__memosClipperLoaded = true;
let markdown = ""; let markdown = "";
let images = []; let images = [];
let title = document.title || location.href; let title = document.title || location.href;
const stripLinks = !!msg.stripLinks;
if (msg.mode === "selection") { if (msg.mode === "selection") {
const sel = window.getSelection(); const sel = window.getSelection();
@@ -241,7 +272,7 @@ window.__memosClipperLoaded = true;
const frag = sel.getRangeAt(0).cloneContents(); const frag = sel.getRangeAt(0).cloneContents();
const div = document.createElement("div"); const div = document.createElement("div");
div.appendChild(frag); div.appendChild(frag);
markdown = htmlToMarkdown(div, true); markdown = htmlToMarkdown(div, true, stripLinks);
images = extractImages(div); images = extractImages(div);
} else { } else {
markdown = ""; markdown = "";
@@ -253,7 +284,7 @@ window.__memosClipperLoaded = true;
document.querySelector("main") || document.querySelector("main") ||
document.querySelector('[role="main"]') || document.querySelector('[role="main"]') ||
document.body; document.body;
markdown = htmlToMarkdown(root); markdown = htmlToMarkdown(root, false, stripLinks);
images = extractImages(root); images = extractImages(root);
} }

View File

@@ -315,6 +315,16 @@ header {
.img-chip .remove-img:hover { color: var(--error); } .img-chip .remove-img:hover { color: var(--error); }
.img-chip.skipped { opacity: .4; } .img-chip.skipped { opacity: .4; }
/* ── Options row ── */
#options-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 14px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
/* ── Tags row ── */ /* ── Tags row ── */
#tags-row { #tags-row {
padding: 6px 14px; padding: 6px 14px;
@@ -414,5 +424,21 @@ select option { background: var(--surface); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* mode badge */ /* mode button */
#mode-toggle.mode-selection { color: var(--accent); background: var(--accent-dim); border-color: var(--accent); } .mode-btn {
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 4px 8px;
cursor: pointer;
color: var(--text-dim);
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font);
font-size: 11px;
font-weight: 500;
transition: all .12s;
}
.mode-btn:hover { border-color: var(--accent); color: var(--text); }
.mode-btn.mode-selection { color: var(--accent); background: var(--accent-dim); border-color: var(--accent); }

View File

@@ -34,10 +34,11 @@
<span id="page-title" class="page-title font-semibold text-gray-800 dark:text-gray-200 truncate max-w-[180px]">Clip to Memos</span> <span id="page-title" class="page-title font-semibold text-gray-800 dark:text-gray-200 truncate max-w-[180px]">Clip to Memos</span>
</div> </div>
<div class="header-actions flex space-x-1"> <div class="header-actions flex space-x-1">
<button id="mode-toggle" class="icon-btn p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" title="Switch clip mode"> <button id="mode-toggle" class="mode-btn flex items-center space-x-1 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition text-xs" title="Switch clip mode">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="13" height="13">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/> <path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
</svg> </svg>
<span id="mode-label">Full page</span>
</button> </button>
<button id="settings-btn" class="icon-btn p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" title="Settings"> <button id="settings-btn" class="icon-btn p-1.5 rounded hover:bg-gray-100 text-gray-500 transition" title="Settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
@@ -78,6 +79,14 @@
<div id="images-list"></div> <div id="images-list"></div>
</div> </div>
<!-- options row -->
<div id="options-row">
<label class="toggle-label" title="Remove hyperlinks from the clipped text, keeping only the link labels">
<input type="checkbox" id="strip-links" />
strip links
</label>
</div>
<!-- tag input --> <!-- tag input -->
<div id="tags-row"> <div id="tags-row">
<input id="tags-input" type="text" placeholder="#tag1 #tag2 …" spellcheck="false" /> <input id="tags-input" type="text" placeholder="#tag1 #tag2 …" spellcheck="false" />
@@ -124,7 +133,7 @@
</div> </div>
</div> </div>
<script src="/marked.min.js" type="module"></script> <script src="/marked.min.js"></script>
<script src="/popup.js" type="module"></script> <script src="/popup.js" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -25,6 +25,7 @@ const imgCount = document.getElementById("img-count");
const pageTitle = document.getElementById("page-title"); const pageTitle = document.getElementById("page-title");
const visibilityEl = document.getElementById("visibility-select"); const visibilityEl = document.getElementById("visibility-select");
const attachCheck = document.getElementById("attach-images"); const attachCheck = document.getElementById("attach-images");
const stripLinksCheck = document.getElementById("strip-links");
const modeToggle = document.getElementById("mode-toggle"); const modeToggle = document.getElementById("mode-toggle");
const sendBtn = document.getElementById("send-btn"); const sendBtn = document.getElementById("send-btn");
const reloadBtn = document.getElementById("reload-btn"); const reloadBtn = document.getElementById("reload-btn");
@@ -61,6 +62,7 @@ async function getActiveTab() {
state.mode = state.settings.clipMode || "page"; state.mode = state.settings.clipMode || "page";
if (state.settings.visibility) visibilityEl.value = state.settings.visibility; if (state.settings.visibility) visibilityEl.value = state.settings.visibility;
if (state.settings.includeImages === false) attachCheck.checked = false; if (state.settings.includeImages === false) attachCheck.checked = false;
if (state.settings.includeTags) tagsInput.value = "#clipped";
updateModeButton(); updateModeButton();
await clip(); await clip();
@@ -82,7 +84,7 @@ async function clip() {
} catch (_) { /* already injected or restricted page */ } } catch (_) { /* already injected or restricted page */ }
const response = await new Promise((resolve, reject) => { const response = await new Promise((resolve, reject) => {
chrome.tabs.sendMessage(tab.id, { action: "getContent", mode: state.mode }, (res) => { 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)); if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
else resolve(res); else resolve(res);
}); });
@@ -249,17 +251,17 @@ mdEditor.addEventListener("input", updateCharCounter);
// ── Tags input ──────────────────────────────────────────────────────────────── // ── Tags input ────────────────────────────────────────────────────────────────
const tagsInput = document.getElementById("tags-input"); const tagsInput = document.getElementById("tags-input");
// Load saved tags default
getSettings().then((s) => {
if (s.includeTags) tagsInput.value = "#clipped";
});
// ── Mode toggle ─────────────────────────────────────────────────────────────── // ── Mode toggle ───────────────────────────────────────────────────────────────
const modeLabel = document.getElementById("mode-label");
function updateModeButton() { function updateModeButton() {
modeToggle.title = state.mode === "page" const isSelection = state.mode === "selection";
? "Currently: full page — click for selection mode" modeToggle.title = isSelection
: "Currently: selection — click for full page mode"; ? "Currently: selection — click for full page mode"
modeToggle.classList.toggle("mode-selection", state.mode === "selection"); : "Currently: full page — click for selection mode";
modeToggle.classList.toggle("mode-selection", isSelection);
modeLabel.textContent = isSelection ? "Selection" : "Full page";
} }
modeToggle.addEventListener("click", async () => { modeToggle.addEventListener("click", async () => {
@@ -268,6 +270,9 @@ modeToggle.addEventListener("click", async () => {
await clip(); await clip();
}); });
// ── Strip links toggle ────────────────────────────────────────────────────────
stripLinksCheck.addEventListener("change", () => clip());
// ── Reload ──────────────────────────────────────────────────────────────────── // ── Reload ────────────────────────────────────────────────────────────────────
reloadBtn.addEventListener("click", () => clip()); reloadBtn.addEventListener("click", () => clip());
@@ -295,6 +300,14 @@ sendBtn.addEventListener("click", async () => {
sendBtn.disabled = true; sendBtn.disabled = true;
try { 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) // 1. Create the memo first (with original image URLs)
sendBtn.textContent = "Creating memo…"; sendBtn.textContent = "Creating memo…";
@@ -342,7 +355,9 @@ sendBtn.addEventListener("click", async () => {
const attachmentUrl = `${baseUrl}/file/${attachName}`; const attachmentUrl = `${baseUrl}/file/${attachName}`;
const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedUrl = origUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g'); const re = new RegExp(`!\\[(.*?)\\]\\(${escapedUrl}\\)`, 'g');
contentWithImages = contentWithImages.replace(re, `![$1](${attachmentUrl})`); // 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 // Append any uploaded attachments not already referenced in the markdown
@@ -353,7 +368,7 @@ sendBtn.addEventListener("click", async () => {
} }
} }
await fetch(`${baseUrl}/api/v1/${memoName}`, { const patchRes = await fetch(`${baseUrl}/api/v1/${memoName}`, {
method: "PATCH", method: "PATCH",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -361,6 +376,10 @@ sendBtn.addEventListener("click", async () => {
}, },
body: JSON.stringify({ content: contentWithImages }), 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 = ""; let memoId = "";