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