Initial commit
This commit is contained in:
47
README.md
Normal file
47
README.md
Normal file
@@ -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
|
||||||
5
background.js
Normal file
5
background.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// background.js - service worker
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
console.log("Memos Clipper installed.");
|
||||||
|
});
|
||||||
266
content.js
Normal file
266
content.js
Normal file
@@ -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 ``;
|
||||||
|
} catch {
|
||||||
|
return 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
|
||||||
BIN
icons/icon128.png
Normal file
BIN
icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 B |
BIN
icons/icon16.png
Normal file
BIN
icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 B |
BIN
icons/icon48.png
Normal file
BIN
icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 B |
38
manifest.json
Normal file
38
manifest.json
Normal file
@@ -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": [
|
||||||
|
"<all_urls>"
|
||||||
|
],
|
||||||
|
"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": ["<all_urls>"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
146
marked.min.js
vendored
Normal file
146
marked.min.js
vendored
Normal file
@@ -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,">").replace(/"/g,""");}
|
||||||
|
|
||||||
|
function parseInline(s){
|
||||||
|
// code spans
|
||||||
|
s = s.replace(/`([^`]+)`/g,(_,c)=>`<code>${escape(c)}</code>`);
|
||||||
|
// bold+italic
|
||||||
|
s = s.replace(/\*\*\*(.+?)\*\*\*/g,"<strong><em>$1</em></strong>");
|
||||||
|
// bold
|
||||||
|
s = s.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>");
|
||||||
|
s = s.replace(/__(.+?)__/g,"<strong>$1</strong>");
|
||||||
|
// italic
|
||||||
|
s = s.replace(/\*(.+?)\*/g,"<em>$1</em>");
|
||||||
|
s = s.replace(/_([^_]+)_/g,"<em>$1</em>");
|
||||||
|
// strikethrough
|
||||||
|
s = s.replace(/~~(.+?)~~/g,"<del>$1</del>");
|
||||||
|
// images
|
||||||
|
s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g,(_,alt,src)=>`<img src="${escape(src)}" alt="${escape(alt)}">`);
|
||||||
|
// links
|
||||||
|
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,txt,href)=>`<a href="${escape(href)}" target="_blank">${txt}</a>`);
|
||||||
|
// auto-link
|
||||||
|
s = s.replace(/(?<!["\(])(https?:\/\/[^\s<>)]+)/g,url=>`<a href="${escape(url)}" target="_blank">${escape(url)}</a>`);
|
||||||
|
// hard break
|
||||||
|
s = s.replace(/ \n/g,"<br>");
|
||||||
|
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<lines.length && !lines[i].startsWith("```")){
|
||||||
|
code.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
i++; // skip closing ```
|
||||||
|
out.push(`<pre><code class="language-${escape(lang)}">${escape(code.join("\n"))}</code></pre>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
const hM = line.match(/^(#{1,6})\s+(.*)/);
|
||||||
|
if(hM){
|
||||||
|
const lvl = hM[1].length;
|
||||||
|
out.push(`<h${lvl}>${parseInline(hM[2])}</h${lvl}>`);
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HR
|
||||||
|
if(/^[-*_]{3,}\s*$/.test(line)){
|
||||||
|
out.push("<hr>");
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockquote
|
||||||
|
if(line.startsWith("> ")){
|
||||||
|
const bq = [];
|
||||||
|
while(i<lines.length && lines[i].startsWith("> ")){
|
||||||
|
bq.push(lines[i].slice(2));
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(`<blockquote>${parse(bq.join("\n"))}</blockquote>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list
|
||||||
|
if(/^[-*+]\s/.test(line)){
|
||||||
|
const items = [];
|
||||||
|
while(i<lines.length && /^[-*+]\s/.test(lines[i])){
|
||||||
|
items.push(`<li>${parseInline(lines[i].replace(/^[-*+]\s/,""))}</li>`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(`<ul>${items.join("")}</ul>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list
|
||||||
|
if(/^\d+\.\s/.test(line)){
|
||||||
|
const items = [];
|
||||||
|
while(i<lines.length && /^\d+\.\s/.test(lines[i])){
|
||||||
|
items.push(`<li>${parseInline(lines[i].replace(/^\d+\.\s/,""))}</li>`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(`<ol>${items.join("")}</ol>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table (| col | col |)
|
||||||
|
if(line.startsWith("|") && i+1<lines.length && /^\|[-| :]+\|/.test(lines[i+1])){
|
||||||
|
const headers = line.split("|").filter((_,idx,a)=>idx>0&&idx<a.length-1).map(h=>`<th>${parseInline(h.trim())}</th>`);
|
||||||
|
i+=2; // skip header + separator
|
||||||
|
const rows = [];
|
||||||
|
while(i<lines.length && lines[i].startsWith("|")){
|
||||||
|
const cells = lines[i].split("|").filter((_,idx,a)=>idx>0&&idx<a.length-1).map(c=>`<td>${parseInline(c.trim())}</td>`);
|
||||||
|
rows.push(`<tr>${cells.join("")}</tr>`);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
out.push(`<table><thead><tr>${headers.join("")}</tr></thead><tbody>${rows.join("")}</tbody></table>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line
|
||||||
|
if(line.trim()===""){
|
||||||
|
i++; continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph — collect until blank line
|
||||||
|
const para = [];
|
||||||
|
while(i<lines.length && lines[i].trim()!==""){
|
||||||
|
// stop on block elements
|
||||||
|
if(/^(#{1,6}\s|```|>|[-*+]\s|\d+\.\s|[-*_]{3,}\s*$)/.test(lines[i])) break;
|
||||||
|
para.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if(para.length) out.push(`<p>${parseInline(para.join(" "))}</p>`);
|
||||||
|
}
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
global.marked = {
|
||||||
|
parse: function(src, opts) {
|
||||||
|
const breaks = opts && opts.breaks;
|
||||||
|
// With breaks:true, single newlines in paragraphs become <br>
|
||||||
|
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);
|
||||||
404
popup.css
Normal file
404
popup.css
Normal file
@@ -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); }
|
||||||
134
popup.html
Normal file
134
popup.html
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Memos Clipper</title>
|
||||||
|
<link rel="stylesheet" href="popup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- ── Loading ── -->
|
||||||
|
<div id="view-loading" class="view">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<span>Extracting content…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── No settings ── -->
|
||||||
|
<div id="view-setup" class="view hidden">
|
||||||
|
<div class="setup-box">
|
||||||
|
<div class="setup-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>Configure your Memos instance first.</p>
|
||||||
|
<button id="open-settings-btn">Open Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Main editor ── -->
|
||||||
|
<div id="view-main" class="view hidden">
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span id="page-title" class="page-title">Clip to Memos</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="mode-toggle" class="icon-btn" title="Switch clip mode">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button id="settings-btn" class="icon-btn" title="Settings">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- tab bar -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="edit">Edit</button>
|
||||||
|
<button class="tab" data-tab="preview">Preview</button>
|
||||||
|
<div class="tab-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- edit panel -->
|
||||||
|
<div id="tab-edit" class="tab-panel">
|
||||||
|
<textarea id="md-editor" spellcheck="false" placeholder="Markdown content…"></textarea>
|
||||||
|
<div id="char-counter" class="char-counter">0 chars</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- preview panel -->
|
||||||
|
<div id="tab-preview" class="tab-panel hidden">
|
||||||
|
<div id="md-preview" class="preview-body"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- images section -->
|
||||||
|
<div id="images-section">
|
||||||
|
<div class="images-header">
|
||||||
|
<span id="images-label">Images <span id="img-count" class="badge">0</span></span>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" id="attach-images" checked />
|
||||||
|
upload as attachments
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="images-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tag input -->
|
||||||
|
<div id="tags-row">
|
||||||
|
<input id="tags-input" type="text" placeholder="#tag1 #tag2 …" spellcheck="false" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- footer -->
|
||||||
|
<footer>
|
||||||
|
<div class="footer-left">
|
||||||
|
<select id="visibility-select">
|
||||||
|
<option value="PRIVATE">🔒 Private</option>
|
||||||
|
<option value="PROTECTED">🔗 Protected</option>
|
||||||
|
<option value="PUBLIC">🌐 Public</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="footer-right">
|
||||||
|
<button id="reload-btn" class="secondary-btn" title="Re-clip page">↺</button>
|
||||||
|
<button id="send-btn" class="send-btn">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
Send to Memos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Success ── -->
|
||||||
|
<div id="view-success" class="view hidden">
|
||||||
|
<div class="success-box">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<p>Memo saved!</p>
|
||||||
|
<a id="memo-link" href="#" target="_blank">Open in Memos →</a>
|
||||||
|
<button id="new-clip-btn" class="secondary-btn" style="margin-top:8px">Clip another</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Error ── -->
|
||||||
|
<div id="view-error" class="view hidden">
|
||||||
|
<div class="setup-box">
|
||||||
|
<div class="setup-icon err">✗</div>
|
||||||
|
<p id="error-msg">Something went wrong.</p>
|
||||||
|
<button id="retry-btn">Retry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="marked.min.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
384
popup.js
Normal file
384
popup.js
Normal file
@@ -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 = `<pre style="white-space:pre-wrap">${escHtml(state.markdown)}</pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g, "&").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 `})`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
contentWithImages = finalContent + "\n\n" + imgLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
content: contentWithImages,
|
||||||
|
visibility,
|
||||||
|
};
|
||||||
|
// Also set resources array for native attachment display (v0.22+)
|
||||||
|
if (resourceNames.length) {
|
||||||
|
body.resources = resourceNames.map((name) => {
|
||||||
|
// Normalise: API wants just "resources/abc123" not a full path
|
||||||
|
const norm = name.startsWith("resources/") ? name : `resources/${name}`;
|
||||||
|
return { name: norm };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/v1/memos`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`API error ${res.status}: ${txt.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memo = await res.json();
|
||||||
|
// Handle both v0.22+ (memo.name = "memos/123") and older (memo.id = "123" or memo.uid)
|
||||||
|
let memoId = "";
|
||||||
|
if (memo.name) {
|
||||||
|
memoId = memo.name.replace(/^memos\//, "");
|
||||||
|
} else if (memo.uid) {
|
||||||
|
memoId = memo.uid;
|
||||||
|
} else if (memo.id) {
|
||||||
|
memoId = memo.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("memo-link").href = memoId ? `${baseUrl}/memos/${memoId}` : baseUrl;
|
||||||
|
showView("success");
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.textContent = e.message;
|
||||||
|
showView("error");
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> Send to Memos`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Upload a single image resource ────────────────────────────────────────────
|
||||||
|
async function uploadImage(baseUrl, token, img) {
|
||||||
|
const response = await fetch(img.src);
|
||||||
|
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Derive a filename with a valid extension
|
||||||
|
let filename = img.src.split("/").pop().split("?")[0].split("#")[0];
|
||||||
|
// Strip non-filename characters
|
||||||
|
filename = filename.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80);
|
||||||
|
// If no extension, infer from MIME type
|
||||||
|
if (!/\.\w{2,5}$/.test(filename)) {
|
||||||
|
const mimeToExt = {
|
||||||
|
"image/jpeg": "jpg", "image/jpg": "jpg", "image/png": "png",
|
||||||
|
"image/gif": "gif", "image/webp": "webp", "image/svg+xml": "svg",
|
||||||
|
"image/avif": "avif","image/tiff": "tiff",
|
||||||
|
};
|
||||||
|
const ext = mimeToExt[blob.type] || "jpg";
|
||||||
|
filename = (filename || "image") + "." + ext;
|
||||||
|
}
|
||||||
|
if (!filename || filename === ".") filename = "image.jpg";
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", blob, filename);
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/v1/resources`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text();
|
||||||
|
throw new Error(`Resource upload failed ${res.status}: ${txt.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
const resource = await res.json();
|
||||||
|
// Normalise the returned name — could be "resources/abc", "abc", or an int id
|
||||||
|
if (resource.name) return resource;
|
||||||
|
if (resource.id) return { ...resource, name: `resources/${resource.id}` };
|
||||||
|
throw new Error("Resource upload returned unexpected shape: " + JSON.stringify(resource).slice(0, 80));
|
||||||
|
}
|
||||||
157
settings.css
Normal file
157
settings.css
Normal file
@@ -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); }
|
||||||
77
settings.html
Normal file
77
settings.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Memos Clipper — Settings</title>
|
||||||
|
<link rel="stylesheet" href="settings.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10 9 9 9 8 9"/>
|
||||||
|
</svg>
|
||||||
|
Memos Clipper Settings
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Connection</h2>
|
||||||
|
<label>
|
||||||
|
<span>Memos Instance URL</span>
|
||||||
|
<input type="url" id="memos-url" placeholder="https://memos.example.com" />
|
||||||
|
<small>The base URL of your usememos instance (no trailing slash)</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>API Token</span>
|
||||||
|
<input type="password" id="api-token" placeholder="Your access token" />
|
||||||
|
<small>Settings → Account → Access Tokens in your Memos instance</small>
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="test-btn" class="secondary">Test Connection</button>
|
||||||
|
<button id="save-btn">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="status hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Defaults</h2>
|
||||||
|
<label>
|
||||||
|
<span>Default visibility</span>
|
||||||
|
<select id="visibility">
|
||||||
|
<option value="PRIVATE">Private</option>
|
||||||
|
<option value="PROTECTED">Protected</option>
|
||||||
|
<option value="PUBLIC">Public</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Default clip mode</span>
|
||||||
|
<select id="clip-mode">
|
||||||
|
<option value="page">Full page (article extraction)</option>
|
||||||
|
<option value="selection">Selection only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="include-images" checked />
|
||||||
|
<span>Upload page images as attachments</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label">
|
||||||
|
<input type="checkbox" id="include-tags" />
|
||||||
|
<span>Automatically add #clipped tag</span>
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="save-defaults-btn">Save Defaults</button>
|
||||||
|
</div>
|
||||||
|
<div id="defaults-status" class="status hidden"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
settings.js
Normal file
63
settings.js
Normal file
@@ -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();
|
||||||
Reference in New Issue
Block a user