From 100e2dacee780e3f60b556b673e4bc95f47515dc Mon Sep 17 00:00:00 2001 From: Paul Spenke Date: Sat, 14 Mar 2026 21:21:53 +0100 Subject: [PATCH] Initial commit --- README.md | 47 ++++++ background.js | 5 + content.js | 266 ++++++++++++++++++++++++++++++ icons/icon128.png | Bin 0 -> 341 bytes icons/icon16.png | Bin 0 -> 99 bytes icons/icon48.png | Bin 0 -> 145 bytes manifest.json | 38 +++++ marked.min.js | 146 +++++++++++++++++ popup.css | 404 ++++++++++++++++++++++++++++++++++++++++++++++ popup.html | 134 +++++++++++++++ popup.js | 384 +++++++++++++++++++++++++++++++++++++++++++ settings.css | 157 ++++++++++++++++++ settings.html | 77 +++++++++ settings.js | 63 ++++++++ 14 files changed, 1721 insertions(+) create mode 100644 README.md create mode 100644 background.js create mode 100644 content.js create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 manifest.json create mode 100644 marked.min.js create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js create mode 100644 settings.css create mode 100644 settings.html create mode 100644 settings.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..01da263 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Memos Clipper — Chrome Extension + +Clip any web page or selection as Markdown directly to your [usememos](https://usememos.com) instance. Images are uploaded as attachments. Preview and edit before sending. + +## Features + +- **Full page or selection** — clip the whole article (auto-extracted) or just what you've selected +- **Markdown preview** — live rendered preview with syntax highlighting +- **Edit before send** — tweak the Markdown in the built-in editor +- **Image attachments** — images found on the page are uploaded as resources; toggle per-image +- **Visibility control** — Private / Protected / Public per memo +- **Source attribution** — automatically prepends a blockquote with the source URL and title + +## Installation + +1. Open Chrome and go to `chrome://extensions/` +2. Enable **Developer mode** (top right toggle) +3. Click **Load unpacked** +4. Select this folder (`memos-extension/`) + +## Configuration + +1. Click the extension icon → **⚙ settings gear** (or right-click → Options) +2. Enter your Memos instance URL, e.g. `https://memos.example.com` +3. Generate an access token in Memos: **Settings → Account → Access Tokens** +4. Paste the token and click **Save Settings** +5. Click **Test Connection** to verify + +## Usage + +1. Navigate to any web page +2. *(Optional)* Select text you want to clip +3. Click the Memos Clipper icon in the toolbar +4. The page (or selection) is extracted as Markdown +5. Edit if needed, check the **Preview** tab +6. Adjust visibility and image settings +7. Click **Send to Memos** + +## API compatibility + +Tested against usememos **v0.22+** (uses `/api/v1/memos` and `/api/v1/resources`). + +## Notes + +- Images on cross-origin pages may fail to upload due to CORS restrictions — these will be skipped gracefully +- The extension uses Chrome's `scripting` permission to inject the content extractor on demand +- No data is ever sent anywhere except your own Memos instance diff --git a/background.js b/background.js new file mode 100644 index 0000000..8e5e3f8 --- /dev/null +++ b/background.js @@ -0,0 +1,5 @@ +// background.js - service worker + +chrome.runtime.onInstalled.addListener(() => { + console.log("Memos Clipper installed."); +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..37b1805 --- /dev/null +++ b/content.js @@ -0,0 +1,266 @@ +// content.js - Extracts page content and converts to Markdown +// Guard against double-injection (MV3 scripting.executeScript can fire multiple times) +if (window.__memosClipperLoaded) { /* skip */ } else { +window.__memosClipperLoaded = true; + +(function () { + // ── Turndown-lite: a minimal but solid HTML→Markdown converter ────────────── + function htmlToMarkdown(element) { + const clone = element.cloneNode(true); + + // Remove unwanted elements — comprehensive list covering real-world sites + const removeSelectors = [ + // Semantic structural chrome + 'script', 'style', 'noscript', 'template', + 'nav', 'header', 'footer', 'aside', + // ARIA roles for chrome + '[role="navigation"]', '[role="banner"]', '[role="complementary"]', + '[role="contentinfo"]', '[role="search"]', '[role="toolbar"]', + '[role="menubar"]', '[role="menu"]', '[role="dialog"]', + // Common class/id patterns for site chrome + '[class*="navbar"]', '[class*="nav-bar"]', '[class*="site-nav"]', + '[class*="site-header"]', '[class*="site-footer"]', + '[class*="page-header"]', '[class*="page-footer"]', + '[id*="navbar"]', '[id*="site-nav"]', '[id*="site-header"]', '[id*="site-footer"]', + // Ads and tracking + '[class*="advertisement"]', '[class*="advert"]', '[class*=" ad-"]', + '[class*="google-ad"]', '[class*="sponsored"]', + '[id*="advertisement"]', '[id*="google_ad"]', + // Cookie banners, popups, overlays + '[class*="cookie"]', '[id*="cookie"]', + '[class*="consent"]', '[id*="consent"]', + '[class*="gdpr"]', '[id*="gdpr"]', + '[class*="popup"]', '[class*="modal"]', '[class*="overlay"]', + '[class*="banner"]', '[id*="banner"]', + // Social / share widgets + '[class*="share-bar"]', '[class*="social-bar"]', '[class*="share-buttons"]', + '[class*="sharing"]', + // Subscription / newsletter prompts + '[class*="newsletter"]', '[class*="subscribe"]', + // Comments sections + '[id="comments"]', '[class*="comments-section"]', '[id*="disqus"]', + // Related / recommended articles + '[class*="related-posts"]', '[class*="recommended"]', '[class*="more-articles"]', + // Sidebar + '[class*="sidebar"]', '[id*="sidebar"]', + // Print/hidden + '[hidden]', '[aria-hidden="true"]', + ].join(', '); + + clone.querySelectorAll(removeSelectors).forEach((el) => el.remove()); + + // Also remove elements that are visually hidden via inline style + clone.querySelectorAll('[style*="display:none"],[style*="display: none"],[style*="visibility:hidden"]') + .forEach((el) => el.remove()); + + return nodeToMd(clone).replace(/\n{3,}/g, "\n\n").trim(); + } + + function nodeToMd(node, ctx = { listDepth: 0, ordered: false, index: 0 }) { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent.replace(/\s+/g, " "); + } + if (node.nodeType !== Node.ELEMENT_NODE) return ""; + + const tag = node.tagName.toLowerCase(); + const children = () => + Array.from(node.childNodes) + .map((c) => nodeToMd(c, ctx)) + .join(""); + + switch (tag) { + case "h1": return `\n\n# ${children().trim()}\n\n`; + case "h2": return `\n\n## ${children().trim()}\n\n`; + case "h3": return `\n\n### ${children().trim()}\n\n`; + case "h4": return `\n\n#### ${children().trim()}\n\n`; + case "h5": return `\n\n##### ${children().trim()}\n\n`; + case "h6": return `\n\n###### ${children().trim()}\n\n`; + case "p": return `\n\n${children().trim()}\n\n`; + case "br": return " \n"; + case "hr": return "\n\n---\n\n"; + + case "strong": + case "b": return `**${children()}**`; + case "em": + case "i": return `_${children()}_`; + case "s": + case "del": return `~~${children()}~~`; + case "code": { + const text = node.textContent; + return text.includes("`") ? `\`\`${text}\`\`` : `\`${text}\``; + } + case "pre": { + const codeEl = node.querySelector("code"); + const lang = codeEl + ? (codeEl.className.match(/language-(\S+)/) || [])[1] || "" + : ""; + const text = (codeEl || node).textContent; + return `\n\n\`\`\`${lang}\n${text}\n\`\`\`\n\n`; + } + case "blockquote": return `\n\n${children() + .trim() + .split("\n") + .map((l) => `> ${l}`) + .join("\n")}\n\n`; + + case "a": { + const href = node.getAttribute("href") || ""; + const text = children().trim(); + if (!text) return href; + try { + const abs = new URL(href, location.href).href; + return `[${text}](${abs})`; + } catch { + return `[${text}](${href})`; + } + } + + case "img": { + const src = node.getAttribute("src") || ""; + const alt = node.getAttribute("alt") || ""; + try { + const abs = new URL(src, location.href).href; + return `![${alt}](${abs})`; + } catch { + return src ? `![${alt}](${src})` : ""; + } + } + + case "ul": { + const lines = Array.from(node.children) + .map((li) => `${" ".repeat(ctx.listDepth)}- ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }).trim()}`) + .join("\n"); + return `\n\n${lines}\n\n`; + } + case "ol": { + const lines = Array.from(node.children) + .map((li, i) => `${" ".repeat(ctx.listDepth)}${i + 1}. ${nodeToMd(li, { ...ctx, listDepth: ctx.listDepth + 1 }).trim()}`) + .join("\n"); + return `\n\n${lines}\n\n`; + } + case "li": return children(); + + case "table": return convertTable(node); + + case "figure": { + const img = node.querySelector("img"); + const caption = node.querySelector("figcaption"); + let md = img ? nodeToMd(img, ctx) : children(); + if (caption) md += `\n*${caption.textContent.trim()}*`; + return `\n\n${md}\n\n`; + } + + // skip presentational / hidden + case "svg": + case "canvas": + case "video": + case "audio": + case "iframe": + case "button": + case "input": + case "select": + case "textarea": + case "form": + case "nav": + case "header": + case "footer": + case "aside": + return ""; + + default: + return children(); + } + } + + function convertTable(table) { + const rows = Array.from(table.querySelectorAll("tr")); + if (!rows.length) return ""; + const toRow = (tr) => + "| " + + Array.from(tr.querySelectorAll("th,td")) + .map((c) => c.textContent.trim().replace(/\|/g, "\\|")) + .join(" | ") + + " |"; + const header = toRow(rows[0]); + const sep = + "| " + + Array.from(rows[0].querySelectorAll("th,td")) + .map(() => "---") + .join(" | ") + + " |"; + const body = rows.slice(1).map(toRow).join("\n"); + return `\n\n${header}\n${sep}\n${body}\n\n`; + } + + // ── Extract images from HTML ──────────────────────────────────────────────── + function extractImages(element) { + const seen = new Set(); + const imgs = Array.from(element.querySelectorAll("img")); + return imgs + .filter((img) => { + const src = img.getAttribute("src") || ""; + if (!src) return false; + // skip tiny icons / tracking pixels by rendered size + const w = img.naturalWidth || img.width || 0; + const h = img.naturalHeight || img.height || 0; + if (w > 0 && w < 32 && h > 0 && h < 32) return false; + // skip 1x1 gif trackers + if (src.startsWith("data:image/gif")) return false; + return true; + }) + .map((img) => { + const src = img.getAttribute("src") || ""; + let abs = src; + try { abs = new URL(src, location.href).href; } catch {} + return { src: abs, alt: img.getAttribute("alt") || "" }; + }) + .filter((img) => { + if (seen.has(img.src)) return false; + seen.add(img.src); + return true; + }); + } + + // ── Message handler ───────────────────────────────────────────────────────── + chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.action === "getContent") { + try { + let markdown = ""; + let images = []; + let title = document.title || location.href; + + if (msg.mode === "selection") { + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const frag = sel.getRangeAt(0).cloneContents(); + const div = document.createElement("div"); + div.appendChild(frag); + markdown = htmlToMarkdown(div); + images = extractImages(div); + } else { + markdown = ""; + } + } else { + // full page — prefer article/main, fall back to body + const root = + document.querySelector("article") || + document.querySelector("main") || + document.querySelector('[role="main"]') || + document.body; + markdown = htmlToMarkdown(root); + images = extractImages(root); + } + + // Prepend source line + const sourceNote = `> Source: [${title}](${location.href})\n\n`; + markdown = sourceNote + markdown; + + sendResponse({ ok: true, markdown, images, title, url: location.href }); + } catch (e) { + sendResponse({ ok: false, error: e.message }); + } + return true; // async + } + }); +})(); +} // end guard diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..e434d526895a42dedcbdc569968fc8bbd1a46950 GIT binary patch literal 341 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^V3hH6aSW-L^Y-RO-op+&ERLlL zJAAJ%*e82Smm|R8|9hEj3{1kGEY447vp%NZ!Oq}d&A@Prk>LU#Lqiz@!xmQ6 zm`*@8I-2vr`Te_re!U@`a2nNF4iXOhXpTqL36g4fSa(V(HbFpI6zD|;22WQ%mvv4F FO#q%{U2p&Z literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..ee769390b09a93b5e2af6688589b10e9467e8e59 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|TAnVBAr*6yA3PUV;WV7Ea-rI5 xYr~FzQvV*aEtdL_p{|19+Ch6 literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..b551fdff740856e99e7e5c0a35b7f8ea3a39d392 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1SD@H9QR&>qbXqI{Xa|F*tDnm{r-UW|$|Wu^ literal 0 HcmV?d00001 diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c5af39a --- /dev/null +++ b/manifest.json @@ -0,0 +1,38 @@ +{ + "manifest_version": 3, + "name": "Memos Clipper", + "version": "1.0.0", + "description": "Clip web pages or selections as Markdown to your usememos instance", + "permissions": [ + "activeTab", + "scripting", + "storage" + ], + "host_permissions": [ + "" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "background": { + "service_worker": "background.js" + }, + "options_page": "settings.html", + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ] +} diff --git a/marked.min.js b/marked.min.js new file mode 100644 index 0000000..0826999 --- /dev/null +++ b/marked.min.js @@ -0,0 +1,146 @@ +/*! + * marked-mini - minimal Markdown renderer for Memos Clipper + * Supports: headings, bold, italic, strike, code, pre, blockquote, + * links, images, ul, ol, hr, tables, line breaks + */ +(function(global){ +"use strict"; +function escape(s){return s.replace(/&/g,"&").replace(//g,">").replace(/"/g,""");} + +function parseInline(s){ + // code spans + s = s.replace(/`([^`]+)`/g,(_,c)=>`${escape(c)}`); + // bold+italic + s = s.replace(/\*\*\*(.+?)\*\*\*/g,"$1"); + // bold + s = s.replace(/\*\*(.+?)\*\*/g,"$1"); + s = s.replace(/__(.+?)__/g,"$1"); + // italic + s = s.replace(/\*(.+?)\*/g,"$1"); + s = s.replace(/_([^_]+)_/g,"$1"); + // strikethrough + s = s.replace(/~~(.+?)~~/g,"$1"); + // images + s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,(_,alt,src)=>`${escape(alt)}`); + // links + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,txt,href)=>`${txt}`); + // auto-link + s = s.replace(/(?)]+)/g,url=>`${escape(url)}`); + // hard break + s = s.replace(/ \n/g,"
"); + return s; +} + +function parse(src){ + const lines = src.split("\n"); + const out = []; + let i = 0; + + while(i < lines.length){ + const line = lines[i]; + + // Fenced code block + const fenceM = line.match(/^```(\w*)/); + if(fenceM){ + const lang = fenceM[1]||""; + const code = []; + i++; + while(i${escape(code.join("\n"))}`); + continue; + } + + // Headings + const hM = line.match(/^(#{1,6})\s+(.*)/); + if(hM){ + const lvl = hM[1].length; + out.push(`${parseInline(hM[2])}`); + i++; continue; + } + + // HR + if(/^[-*_]{3,}\s*$/.test(line)){ + out.push("
"); + i++; continue; + } + + // Blockquote + if(line.startsWith("> ")){ + const bq = []; + while(i ")){ + bq.push(lines[i].slice(2)); + i++; + } + out.push(`
${parse(bq.join("\n"))}
`); + continue; + } + + // Unordered list + if(/^[-*+]\s/.test(line)){ + const items = []; + while(i${parseInline(lines[i].replace(/^[-*+]\s/,""))}`); + i++; + } + out.push(`
    ${items.join("")}
`); + continue; + } + + // Ordered list + if(/^\d+\.\s/.test(line)){ + const items = []; + while(i${parseInline(lines[i].replace(/^\d+\.\s/,""))}`); + i++; + } + out.push(`
    ${items.join("")}
`); + continue; + } + + // Table (| col | col |) + if(line.startsWith("|") && i+1idx>0&&idx`${parseInline(h.trim())}`); + i+=2; // skip header + separator + const rows = []; + while(iidx>0&&idx`${parseInline(c.trim())}`); + rows.push(`${cells.join("")}`); + i++; + } + out.push(`${headers.join("")}${rows.join("")}
`); + continue; + } + + // Empty line + if(line.trim()===""){ + i++; continue; + } + + // Paragraph — collect until blank line + const para = []; + while(i|[-*+]\s|\d+\.\s|[-*_]{3,}\s*$)/.test(lines[i])) break; + para.push(lines[i]); + i++; + } + if(para.length) out.push(`

${parseInline(para.join(" "))}

`); + } + return out.join("\n"); +} + +global.marked = { + parse: function(src, opts) { + const breaks = opts && opts.breaks; + // With breaks:true, single newlines in paragraphs become
+ if (breaks) { + // Pre-process: single \n inside paragraph text → two spaces + \n (markdown hard break) + src = src.replace(/([^\n])\n([^\n])/g, "$1 \n$2"); + } + return parse(src); + } +};})(window); diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..fcf92fe --- /dev/null +++ b/popup.css @@ -0,0 +1,404 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap'); + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f11; + --surface: #1a1a1f; + --surface2: #22222a; + --border: #2a2a35; + --accent: #7c6af7; + --accent-dim: #7c6af720; + --accent-hover: #9585fa; + --text: #e8e8f0; + --text-dim: #777788; + --text-muted: #444455; + --success: #4ade80; + --error: #f87171; + --radius: 6px; + --font: 'IBM Plex Sans', sans-serif; + --mono: 'IBM Plex Mono', monospace; +} + +html, body { + width: 440px; + min-height: 200px; + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 13px; + line-height: 1.5; + overflow-x: hidden; +} + +/* ── Views ── */ +.view { display: flex; flex-direction: column; } +.view.hidden { display: none !important; } + +/* ── Loading ── */ +#view-loading { + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + color: var(--text-dim); + font-size: 13px; +} +.spinner { + width: 22px; height: 22px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin .7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Setup / error / success ── */ +.setup-box, .success-box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 40px 32px; + text-align: center; + color: var(--text-dim); + font-size: 13px; +} +.setup-icon { + width: 44px; height: 44px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: var(--surface); + border: 1px solid var(--border); +} +.setup-icon svg { stroke: var(--text-dim); } +.setup-icon.err { color: var(--error); font-size: 20px; font-weight: 700; } + +.success-icon { + width: 44px; height: 44px; + border-radius: 50%; + background: #4ade8015; + border: 1px solid #4ade8040; + color: var(--success); + font-size: 22px; + display: flex; align-items: center; justify-content: center; +} +.success-box p { font-size: 14px; font-weight: 600; color: var(--text); } +.success-box a { color: var(--accent); text-decoration: none; font-size: 12px; } +.success-box a:hover { text-decoration: underline; } + +/* ── Header ── */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px 10px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.logo { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 13px; + color: var(--text); +} +.logo svg { stroke: var(--accent); flex-shrink: 0; } +.page-title { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 400; + font-size: 12px; + color: var(--text-dim); +} +.header-actions { display: flex; gap: 4px; } +.icon-btn { + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 5px 6px; + cursor: pointer; + color: var(--text-dim); + display: flex; align-items: center; + transition: all .12s; +} +.icon-btn:hover { border-color: var(--border); color: var(--text); background: var(--surface2); } +.icon-btn svg { display: block; } + +/* ── Tabs ── */ +.tabs { + display: flex; + position: relative; + border-bottom: 1px solid var(--border); + background: var(--surface); + padding: 0 14px; + gap: 2px; +} +.tab { + background: none; + border: none; + color: var(--text-dim); + font-family: var(--font); + font-size: 12px; + font-weight: 500; + padding: 8px 12px; + cursor: pointer; + position: relative; + transition: color .12s; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* ── Tab panels ── */ +.tab-panel { flex: 1; } +.tab-panel.hidden { display: none; } + +#md-editor { + display: block; + width: 100%; + height: 260px; + background: var(--bg); + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + font-family: var(--mono); + font-size: 12px; + line-height: 1.7; + padding: 14px; + resize: none; + outline: none; +} +#md-editor::placeholder { color: var(--text-muted); } + +.preview-body { + height: 260px; + overflow-y: auto; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg); + font-size: 13px; + line-height: 1.7; +} + +/* preview markdown styles */ +.preview-body h1 { font-size: 18px; margin: 0 0 12px; border-bottom: 1px solid var(--border); padding-bottom: 6px; } +.preview-body h2 { font-size: 15px; margin: 16px 0 8px; } +.preview-body h3 { font-size: 13px; font-weight: 600; margin: 14px 0 6px; } +.preview-body p { margin: 0 0 10px; } +.preview-body blockquote { + border-left: 3px solid var(--accent); + margin: 10px 0; + padding: 4px 12px; + color: var(--text-dim); + background: var(--accent-dim); + border-radius: 0 4px 4px 0; +} +.preview-body code { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 3px; + padding: 1px 5px; + font-family: var(--mono); + font-size: 11px; +} +.preview-body pre { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + overflow-x: auto; + margin: 10px 0; +} +.preview-body pre code { background: none; border: none; padding: 0; } +.preview-body a { color: var(--accent); } +.preview-body img { max-width: 100%; border-radius: 4px; margin: 8px 0; } +.preview-body table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 12px; } +.preview-body th, .preview-body td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; } +.preview-body th { background: var(--surface2); } +.preview-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; } +.preview-body ul, .preview-body ol { padding-left: 20px; margin: 0 0 10px; } + +/* ── Images section ── */ +#images-section { + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.images-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 14px; + font-size: 12px; + color: var(--text-dim); +} +.badge { + display: inline-block; + background: var(--accent-dim); + color: var(--accent); + border-radius: 10px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + margin-left: 4px; +} +.toggle-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 11px; +} +.toggle-label input { accent-color: var(--accent); cursor: pointer; } + +#images-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0 14px 10px; + max-height: 90px; + overflow-y: auto; +} +.img-chip { + display: flex; + align-items: center; + gap: 5px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 4px; + padding: 3px 8px 3px 5px; + font-size: 11px; + color: var(--text-dim); + max-width: 160px; +} +.img-chip img { + width: 20px; height: 20px; + object-fit: cover; + border-radius: 2px; + flex-shrink: 0; +} +.img-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} +.img-chip .remove-img { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 13px; + line-height: 1; + padding: 0; + flex-shrink: 0; +} +.img-chip .remove-img:hover { color: var(--error); } +.img-chip.skipped { opacity: .4; } + +/* ── Tags row ── */ +#tags-row { + padding: 6px 14px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +#tags-input { + width: 100%; + background: transparent; + border: none; + outline: none; + color: var(--accent); + font-family: var(--mono); + font-size: 12px; + padding: 2px 0; +} +#tags-input::placeholder { color: var(--text-muted); font-family: var(--font); } + +/* ── Char counter ── */ +.char-counter { + padding: 3px 14px; + font-size: 10px; + color: var(--text-muted); + font-family: var(--mono); + text-align: right; + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +/* ── Footer ── */ +footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: var(--surface); + border-top: 1px solid var(--border); + gap: 8px; +} +.footer-left, .footer-right { + display: flex; + align-items: center; + gap: 8px; +} + +select { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-dim); + font-family: var(--font); + font-size: 12px; + padding: 5px 8px; + outline: none; + cursor: pointer; + transition: border-color .12s; +} +select:focus { border-color: var(--accent); } +select option { background: var(--surface); } + +.secondary-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-dim); + font-family: var(--font); + font-size: 12px; + padding: 6px 10px; + cursor: pointer; + transition: all .12s; +} +.secondary-btn:hover { border-color: var(--accent); color: var(--accent); } + +.send-btn { + display: flex; + align-items: center; + gap: 6px; + background: var(--accent); + border: none; + border-radius: var(--radius); + color: #fff; + font-family: var(--font); + font-size: 12px; + font-weight: 600; + padding: 7px 14px; + cursor: pointer; + transition: background .12s, transform .08s; +} +.send-btn:hover { background: var(--accent-hover); } +.send-btn:active { transform: scale(.97); } +.send-btn:disabled { opacity: .5; cursor: default; } +.send-btn svg { flex-shrink: 0; } + +/* scrollbar */ +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* mode badge */ +#mode-toggle.mode-selection { color: var(--accent); background: var(--accent-dim); border-color: var(--accent); } diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..dec80c5 --- /dev/null +++ b/popup.html @@ -0,0 +1,134 @@ + + + + + Memos Clipper + + + + +
+
+ Extracting content… +
+ + + + + + + + + + + + + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..fc3acc3 --- /dev/null +++ b/popup.js @@ -0,0 +1,384 @@ +// 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 = `
${escHtml(state.markdown)}
`; + } +} + +function escHtml(s) { + return s.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 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 = ` 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)); +} diff --git a/settings.css b/settings.css new file mode 100644 index 0000000..e7b6c9b --- /dev/null +++ b/settings.css @@ -0,0 +1,157 @@ +@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap'); + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0f0f11; + --surface: #1a1a1f; + --border: #2a2a35; + --accent: #7c6af7; + --accent-dim: #7c6af730; + --text: #e8e8f0; + --text-dim: #888899; + --success: #4ade80; + --error: #f87171; + --radius: 8px; + --font: 'IBM Plex Sans', sans-serif; + --mono: 'IBM Plex Mono', monospace; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + font-size: 14px; + line-height: 1.6; +} + +.page { max-width: 560px; margin: 0 auto; padding: 32px 24px; } + +header { + margin-bottom: 32px; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; + font-size: 16px; + font-weight: 600; + color: var(--text); +} +.logo svg { width: 22px; height: 22px; stroke: var(--accent); } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + margin-bottom: 20px; +} + +.card h2 { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--text-dim); + margin-bottom: 20px; +} + +label { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 16px; +} + +label > span { + font-size: 13px; + font-weight: 500; + color: var(--text); +} + +input[type="url"], +input[type="password"], +input[type="text"], +select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: var(--mono); + font-size: 13px; + padding: 9px 12px; + outline: none; + transition: border-color .15s; + width: 100%; +} + +input:focus, select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-dim); +} + +select option { background: var(--surface); } + +small { + font-size: 11px; + color: var(--text-dim); + font-family: var(--font); +} + +.checkbox-label { + flex-direction: row; + align-items: center; + gap: 10px; + cursor: pointer; +} +.checkbox-label input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} +.checkbox-label span { font-size: 13px; font-weight: 400; } + +.actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +button { + font-family: var(--font); + font-size: 13px; + font-weight: 500; + padding: 9px 18px; + border-radius: var(--radius); + border: none; + cursor: pointer; + transition: all .15s; +} + +button:not(.secondary) { + background: var(--accent); + color: #fff; +} +button:not(.secondary):hover { filter: brightness(1.15); } + +button.secondary { + background: transparent; + border: 1px solid var(--border); + color: var(--text-dim); +} +button.secondary:hover { border-color: var(--accent); color: var(--accent); } + +.status { + margin-top: 12px; + padding: 9px 12px; + border-radius: var(--radius); + font-size: 12px; + font-family: var(--mono); +} +.status.hidden { display: none; } +.status.ok { background: #4ade8015; border: 1px solid #4ade8040; color: var(--success); } +.status.err { background: #f8717115; border: 1px solid #f8717140; color: var(--error); } diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..3ccce36 --- /dev/null +++ b/settings.html @@ -0,0 +1,77 @@ + + + + + Memos Clipper — Settings + + + +
+
+ +
+ +
+
+

Connection

+ + +
+ + +
+ +
+ +
+

Defaults

+ + + + +
+ +
+ +
+
+
+ + + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..900e10c --- /dev/null +++ b/settings.js @@ -0,0 +1,63 @@ +// settings.js +const $ = (id) => document.getElementById(id); + +async function load() { + const s = await chrome.storage.sync.get([ + "memosUrl", "apiToken", "visibility", "clipMode", "includeImages", "includeTags" + ]); + if (s.memosUrl) $("memos-url").value = s.memosUrl; + if (s.apiToken) $("api-token").value = s.apiToken; + if (s.visibility) $("visibility").value = s.visibility; + if (s.clipMode) $("clip-mode").value = s.clipMode; + if (s.includeImages !== undefined) $("include-images").checked = s.includeImages; + if (s.includeTags !== undefined) $("include-tags").checked = s.includeTags; +} + +function showStatus(el, ok, msg) { + el.textContent = msg; + el.className = `status ${ok ? "ok" : "err"}`; +} + +$("save-btn").addEventListener("click", async () => { + const url = $("memos-url").value.trim().replace(/\/$/, ""); + const token = $("api-token").value.trim(); + if (!url || !token) { + showStatus($("status"), false, "URL and token are required."); + return; + } + await chrome.storage.sync.set({ memosUrl: url, apiToken: token }); + showStatus($("status"), true, "✓ Saved."); +}); + +$("save-defaults-btn").addEventListener("click", async () => { + await chrome.storage.sync.set({ + visibility: $("visibility").value, + clipMode: $("clip-mode").value, + includeImages: $("include-images").checked, + includeTags: $("include-tags").checked, + }); + showStatus($("defaults-status"), true, "✓ Defaults saved."); +}); + +$("test-btn").addEventListener("click", async () => { + const url = $("memos-url").value.trim().replace(/\/$/, ""); + const token = $("api-token").value.trim(); + $("test-btn").textContent = "Testing…"; + try { + const res = await fetch(`${url}/api/v1/memos?pageSize=1`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + showStatus($("status"), true, `✓ Connected! (HTTP ${res.status})`); + } else { + const txt = await res.text(); + showStatus($("status"), false, `✗ HTTP ${res.status}: ${txt.slice(0, 120)}`); + } + } catch (e) { + showStatus($("status"), false, `✗ ${e.message}`); + } finally { + $("test-btn").textContent = "Test Connection"; + } +}); + +load();