commit 100e2dacee780e3f60b556b673e4bc95f47515dc Author: Paul Spenke Date: Sat Mar 14 21:21:53 2026 +0100 Initial commit 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 0000000..e434d52 Binary files /dev/null and b/icons/icon128.png differ diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000..ee76939 Binary files /dev/null and b/icons/icon16.png differ diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000..b551fdf Binary files /dev/null and b/icons/icon48.png differ 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();