commit a8eaa1f9ceeb47bc7fb870dd7c16390fcc44d3ac Author: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sat Mar 28 11:21:49 2026 +0100 Erster Blueprint diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce27798 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Anytype Web Clipper fuer Firefox + +Dieses Projekt stellt ein Firefox-WebExtension-Addon bereit, das Webseiten nach Anytype speichert. + +## Was das Addon kann + +- Aktuelle Seite oder nur markierte Auswahl aus Firefox nach Anytype senden +- Titel, URL, Beschreibung und extrahierten Inhalt als Anytype-Objekt speichern +- Relevante Bilder direkt im Anytype-Inhalt mitfuehren, soweit die Anytype-API diese uebernimmt +- Spaces ueber die lokale Anytype-API laden und direkt im Addon auswaehlen +- Ueber das Toolbar-Popup oder per Rechtsklick auf die Webseite clippen + +## Einrichtung + +1. Anytype Desktop starten. +2. In Anytype unter `Settings -> API Keys` einen API-Key erzeugen. +3. In Firefox `about:debugging` oeffnen. +4. `This Firefox` waehlen. +5. `Load Temporary Add-on...` anklicken. +6. Die Datei `manifest.json` aus diesem Projekt auswaehlen. +7. Im Firefox-Erweiterungsmenue das Addon oeffnen und mit dem Pin-Symbol an die Symbolleiste anheften. +8. Im Addon Popup API-URL und API-Key eintragen. +9. Spaces laden und einen Ziel-Space auswaehlen. + +Standard-API-URL: `http://localhost:31009` + +## Bedienung + +- Klick auf das Addon-Icon in der Firefox-Symbolleiste oder im Erweiterungsmenue +- Rechtsklick auf die Seite: `In Anytype sichern` +- Rechtsklick auf markierten Text: `Auswahl in Anytype sichern` + +## Hinweis zu Firefox + +Ob ein Addon direkt sichtbar in der Symbolleiste sitzt, entscheidet Firefox. Das Addon kann sich nicht selbst anheften. Falls es nicht sichtbar ist, findest du es im Erweiterungsmenue und kannst es dort anpinnen. + +## Projektstruktur + +- `manifest.json` +- `src/background.js` +- `src/content-script.js` +- `src/lib/anytype.js` +- `src/popup/popup.html` +- `src/options/options.html` + +## Weiterer Ausbau + +- Bessere Lesbarkeits-Extraktion mit Mozilla Readability +- Noch robustere domainspezifische Bild-/Abschnitts-Extractor fuer komplexe Websites diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..fb72d9b --- /dev/null +++ b/manifest.json @@ -0,0 +1,51 @@ +{ + "manifest_version": 2, + "name": "Anytype Web Clipper", + "version": "0.1.0", + "description": "Speichert Webseiten aus Firefox in Anytype.", + "permissions": [ + "activeTab", + "storage", + "tabs", + "menus", + "" + ], + "icons": { + "48": "src/assets/icon.svg", + "96": "src/assets/icon.svg" + }, + "browser_action": { + "default_title": "Anytype Web Clipper", + "default_popup": "src/popup/popup.html", + "default_icon": { + "48": "src/assets/icon.svg", + "96": "src/assets/icon.svg" + } + }, + "background": { + "scripts": [ + "src/lib/anytype.js", + "src/background.js" + ] + }, + "options_ui": { + "page": "src/options/options.html", + "open_in_tab": true + }, + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "src/content-script.js" + ], + "run_at": "document_idle" + } + ], + "applications": { + "gecko": { + "id": "anytype-web-clipper@example.local" + } + } +} diff --git a/src/assets/icon.svg b/src/assets/icon.svg new file mode 100644 index 0000000..b5d2419 --- /dev/null +++ b/src/assets/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..3aea4a2 --- /dev/null +++ b/src/background.js @@ -0,0 +1,268 @@ +const DEFAULT_SETTINGS = { + apiBase: self.AnytypeApi.DEFAULT_API_BASE, + apiKey: "", + spaceId: "", + objectType: "page" +}; + +browser.runtime.onInstalled.addListener(async () => { + const current = await browser.storage.local.get(Object.keys(DEFAULT_SETTINGS)); + const nextValues = {}; + + for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) { + if (typeof current[key] === "undefined") { + nextValues[key] = value; + } + } + + if (Object.keys(nextValues).length > 0) { + await browser.storage.local.set(nextValues); + } + + createContextMenus(); +}); + +browser.runtime.onStartup.addListener(() => { + createContextMenus(); +}); + +browser.runtime.onMessage.addListener((message) => { + switch (message?.type) { + case "load-settings": + return loadSettings(); + case "save-settings": + return saveSettings(message.payload || {}); + case "list-spaces": + return listSpaces(); + case "save-active-tab": + return saveActiveTab(message.payload || {}); + default: + return undefined; + } +}); + +browser.menus.onClicked.addListener(async (info, tab) => { + try { + if (info.menuItemId === "clip-page") { + await clipTabFromContext(tab, "full", "clip"); + } + + if (info.menuItemId === "clip-selection") { + await clipTabFromContext(tab, "selection", "clip"); + } + + if (info.menuItemId === "bookmark-page") { + await clipTabFromContext(tab, "full", "bookmark"); + } + } catch (error) { + console.error("Anytype clip failed", error); + } +}); + +async function loadSettings() { + return browser.storage.local.get(DEFAULT_SETTINGS); +} + +async function saveSettings(payload) { + const current = await loadSettings(); + const next = { + ...current, + ...payload + }; + + next.apiBase = self.AnytypeApi.normalizeApiBase(next.apiBase); + + await browser.storage.local.set(next); + return next; +} + +async function listSpaces() { + const settings = await loadSettings(); + const spaces = await self.AnytypeApi.listSpaces(settings); + + return { spaces }; +} + +async function saveActiveTab(payload) { + const settings = await saveSettings(payload.settings || {}); + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + + if (!tab?.id || !tab.url || /^about:|^moz-extension:/.test(tab.url)) { + throw new Error("Diese Seite kann nicht gecaptured werden."); + } + + const capture = await browser.tabs.sendMessage(tab.id, { + type: "capture-page", + mode: payload.mode || "full" + }); + + return createClipObjects({ capture, settings, intent: "clip" }); +} + +async function clipTabFromContext(tab, mode, intent) { + const settings = await loadSettings(); + + if (!settings.apiKey || !settings.spaceId) { + throw new Error("Im Addon fehlen API-Key oder Space. Bitte einmal das Popup oder die Optionen ausfuellen."); + } + + if (!tab?.id || !tab.url || /^about:|^moz-extension:/.test(tab.url)) { + throw new Error("Diese Seite kann nicht gecaptured werden."); + } + + const capture = await browser.tabs.sendMessage(tab.id, { + type: "capture-page", + mode + }); + + return createClipObjects({ capture, settings, intent }); +} + +async function createClipObjects({ capture, settings, intent = "clip" }) { + const payload = intent === "bookmark" + ? buildBookmarkPayload(capture) + : buildAnytypePayload({ capture, settings }); + + const created = await self.AnytypeApi.createObject({ + apiBase: settings.apiBase, + apiKey: settings.apiKey, + spaceId: settings.spaceId, + payload + }); + + return { created }; +} + +function createContextMenus() { + browser.menus.removeAll().then(() => { + browser.menus.create({ + id: "clip-page", + title: "In Anytype sichern", + contexts: ["page", "tab"] + }); + + browser.menus.create({ + id: "bookmark-page", + title: "In Anytype bookmarken", + contexts: ["page", "tab"] + }); + + browser.menus.create({ + id: "clip-selection", + title: "Auswahl in Anytype sichern", + contexts: ["selection"] + }); + }); +} + +function buildAnytypePayload({ capture, settings }) { + const isSelectionCapture = Boolean(capture.isSelectionCapture); + const baseTitle = capture.title || "Unbenannte Webseite"; + const title = isSelectionCapture ? `${baseTitle} (Textauszug)` : baseTitle; + const description = truncate(capture.description || capture.selectionText || capture.mainText, 240); + const savedAt = formatSavedAt(new Date()); + const primaryMarkdown = capture.sectionMarkdown || capture.markdown || "_Kein extrahierbarer Inhalt gefunden._"; + const primaryHasInlineImages = /!\[/.test(primaryMarkdown); + const imageMarkdown = primaryHasInlineImages ? "" : buildImageMarkdown(capture.images || []); + const sourceLink = `[${capture.url}](${capture.url})`; + const contentHeading = isSelectionCapture ? "## Textauszug" : "## Inhalt"; + const body = [ + `> Hinweis Quelle: ${sourceLink}`, + `> Hinweis Gespeichert am: ${savedAt}`, + "", + contentHeading, + "", + primaryMarkdown, + imageMarkdown ? "## Bilder" : "", + imageMarkdown + ].filter(Boolean).join("\n\n"); + + return { + name: title, + type_key: settings.objectType || "page", + body, + properties: compactProperties([ + buildUrlProperty("source", capture.url), + buildTextProperty("description", description) + ]), + ...(settings.objectType === "bookmark" ? { source_url: capture.url } : {}) + }; +} + +function buildBookmarkPayload(capture) { + const title = capture.title || capture.url || "Quelle"; + const description = truncate(capture.description || capture.selectionText || capture.mainText, 240); + + return { + name: title, + type_key: "bookmark", + source_url: capture.url, + body: [ + `[${capture.url}](${capture.url})`, + "", + description || "" + ].filter(Boolean).join("\n\n"), + properties: compactProperties([ + buildUrlProperty("source", capture.url), + buildTextProperty("description", description) + ]) + }; +} + +function buildImageMarkdown(images) { + if (!Array.isArray(images) || images.length === 0) { + return ""; + } + + return images + .map((image) => `![${escapeMarkdownInline(image.alt || "Bild")}](${image.src})`) + .join("\n\n"); +} + +function compactProperties(properties) { + return properties.filter(Boolean); +} + +function buildTextProperty(key, value) { + if (!value) { + return null; + } + + return { + key, + text: value + }; +} + +function buildUrlProperty(key, value) { + if (!value) { + return null; + } + + return { + key, + url: value + }; +} + +function formatSavedAt(date) { + const day = String(date.getDate()).padStart(2, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const year = String(date.getFullYear()); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + + return `${day}.${month}.${year} um ${hours}:${minutes} Uhr`; +} + +function truncate(value, maxLength) { + if (!value) { + return ""; + } + + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}...` : value; +} + +function escapeMarkdownInline(value) { + return String(value || "").replace(/([\\`*_{}[\]()#+\-.!])/g, "\\$1"); +} diff --git a/src/content-script.js b/src/content-script.js new file mode 100644 index 0000000..aaffcb3 --- /dev/null +++ b/src/content-script.js @@ -0,0 +1,1208 @@ +let lastSelectionHtml = ""; +let lastSelectionText = ""; + +browser.runtime.onMessage.addListener((message) => { + if (message?.type !== "capture-page") { + return undefined; + } + + return capturePage(message.mode || "full"); +}); + +document.addEventListener("selectionchange", storeCurrentSelection, true); +document.addEventListener("mouseup", storeCurrentSelection, true); +document.addEventListener("keyup", storeCurrentSelection, true); + +async function capturePage(mode) { + const selection = getSelectionFragment(); + const isSelectionCapture = mode === "selection" && Boolean(selection.element); + const sourceRoot = isSelectionCapture ? selection.element : getPrimaryContent(); + const cleanedRoot = sourceRoot ? cleanClone(sourceRoot) : cleanClone(document.body); + const markdown = toMarkdown(cleanedRoot).trim(); + const sectionMarkdown = isSelectionCapture ? "" : buildSectionAwareMarkdown(sourceRoot); + + let images = []; + + try { + images = collectRelevantImages({ sourceRoot, mode }); + } catch (error) { + images = []; + } + + return { + title: document.title || "", + url: location.href, + hostname: location.hostname, + description: getDescription(), + isSelectionCapture, + selectionText: selection.text, + mainText: cleanedRoot?.textContent?.replace(/\s+/g, " ").trim() || "", + markdown, + sectionMarkdown, + images + }; +} + +function getDescription() { + const meta = document.querySelector('meta[name="description"], meta[property="og:description"]'); + return meta?.getAttribute("content")?.trim() || ""; +} + +function getSelectionFragment() { + const liveSelection = buildSelectionFragment(window.getSelection()); + + if (liveSelection.text && liveSelection.element) { + return liveSelection; + } + + if (!lastSelectionText || !lastSelectionHtml) { + return { text: "", element: null }; + } + + const container = document.createElement("div"); + container.innerHTML = lastSelectionHtml; + + return { + text: lastSelectionText, + element: container + }; +} + +function storeCurrentSelection() { + const fragment = buildSelectionFragment(window.getSelection()); + + if (!fragment.text || !fragment.element) { + return; + } + + lastSelectionText = fragment.text; + lastSelectionHtml = fragment.element.innerHTML; +} + +function buildSelectionFragment(selection) { + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return { text: "", element: null }; + } + + const text = selection.toString().trim(); + + if (!text) { + return { text: "", element: null }; + } + + const container = document.createElement("div"); + + for (let i = 0; i < selection.rangeCount; i += 1) { + container.appendChild(selection.getRangeAt(i).cloneContents()); + } + + return { + text, + element: container + }; +} +function getPrimaryContent() { + const preferredSelectors = [ + '.body.body-text.article-text > section.text', + '.article-text > section.text', + '.article-text section.text', + '.entry .entry-inner', + '.entry-inner', + '.entry-content', + '.post-content', + '.entry', + 'article .body-text', + 'main article' + ]; + + for (const selector of preferredSelectors) { + const element = document.querySelector(selector); + if (element) { + return element; + } + } + + const candidates = Array.from(document.querySelectorAll(".entry .entry-inner, .entry-inner, .entry, article, main, [role='main'], .content, .post, .article, .entry-content, .post-content")); + + if (candidates.length === 0) { + return document.body; + } + + return candidates + .map((element) => ({ + element, + score: (element.innerText || "").trim().length + })) + .sort((left, right) => right.score - left.score)[0].element; +} + +function cleanClone(node) { + const clone = node.cloneNode(true); + const removableSelectors = [ + "script", + "style", + "noscript", + "iframe", + "svg script", + "button", + "form", + "nav", + "aside", + "footer", + "template", + "[aria-hidden='true']", + ".comments", + "#comments", + "[class*='comment-thread']", + "[class*='comment-list']", + ".aawp-product", + "[class*='aawp-']", + "[data-aawp-product-asin]", + "[data-aawp-tracking-id]", + ".aawp-disclaimer", + "[id^='div-gpt-ad-']", + "[class*='adslot']", + ".social-links", + ".social-tooltip", + "#shr-btns", + "[data-sharelink]", + "[data-network]" + ]; + + removableSelectors.forEach((selector) => { + clone.querySelectorAll(selector).forEach((element) => element.remove()); + }); + + removePromotionalBlocks(clone); + + clone.querySelectorAll("a[href]").forEach((link) => { + if (shouldDiscardLink(link)) { + link.remove(); + } + }); + + clone.querySelectorAll("*").forEach((element) => { + for (const attribute of Array.from(element.attributes)) { + if (/^on/i.test(attribute.name)) { + element.removeAttribute(attribute.name); + } + } + }); + + return clone; +} + +function buildSectionAwareMarkdown(sourceRoot) { + if (!sourceRoot || sourceRoot === document.body) { + return ""; + } + + const containerConfig = chooseSectionContainerConfig(sourceRoot); + + if (containerConfig) { + const { parent, containers } = containerConfig; + const parts = []; + const outerLeadNodes = collectLeadNodesUntil(sourceRoot, parent); + const leadNodes = collectLeadNodesUntil(parent, containers[0]); + const leadMarkdown = buildSectionBodyMarkdown([...outerLeadNodes, ...leadNodes], sourceRoot, { allowIntroImage: true }); + const trailingNodes = collectTrailingNodesAfter(parent, containers[containers.length - 1]); + const outerTrailingNodes = collectTrailingNodesAfter(sourceRoot, parent); + const trailingMarkdown = buildSectionBodyMarkdown([...trailingNodes, ...outerTrailingNodes], sourceRoot, { allowIntroImage: true }); + + if (leadMarkdown) { + parts.push(leadMarkdown); + } + + containers.forEach((container) => { + const heading = container.querySelector('h2, h3, h4'); + const headingText = normalizeInlineWhitespace(heading?.textContent || '').trim(); + + if (!headingText) { + return; + } + + const headingLevel = heading.tagName.toLowerCase() === 'h2' ? '##' : '###'; + const nodes = Array.from(container.children).filter((child) => child !== heading); + const bodyMarkdown = buildSectionBodyMarkdown(nodes, sourceRoot, { allowIntroImage: true }); + + parts.push([ + `${headingLevel} ${headingText}`, + bodyMarkdown + ].filter(Boolean).join("\n\n")); + }); + + if (trailingMarkdown) { + parts.push(trailingMarkdown); + } + + return parts.filter(Boolean).join("\n\n").trim(); + } + + const sectionConfig = chooseSectionHeadingConfig(sourceRoot); + + if (!sectionConfig) { + return ""; + } + + const { headings, tagName, parent } = sectionConfig; + const parts = []; + const leadNodes = collectLeadNodesUntil(parent, headings[0]); + const leadMarkdown = buildSectionBodyMarkdown(leadNodes, sourceRoot, { allowIntroImage: true }); + + if (leadMarkdown) { + parts.push(leadMarkdown); + } + + headings.forEach((heading) => { + const nodes = collectSectionNodesByHeading(heading, tagName); + const bodyMarkdown = buildSectionBodyMarkdown(nodes, sourceRoot, { allowIntroImage: true }); + const headingLevel = heading.tagName.toLowerCase() === "h2" ? "##" : "###"; + const headingText = normalizeInlineWhitespace(heading.textContent || "").trim(); + + if (!headingText) { + return; + } + + parts.push([ + `${headingLevel} ${headingText}`, + bodyMarkdown + ].filter(Boolean).join("\n\n")); + }); + + return parts.filter(Boolean).join("\n\n").trim(); +} + +function chooseSectionContainerConfig(sourceRoot) { + const containers = Array.from(sourceRoot.querySelectorAll('section.list-item, article.list-item, .list > section, .rankings > section')).filter((element) => element.parentElement); + + if (containers.length < 3) { + return null; + } + + const groupedByParent = new Map(); + + containers.forEach((container) => { + const list = groupedByParent.get(container.parentElement) || []; + list.push(container); + groupedByParent.set(container.parentElement, list); + }); + + for (const [parent, siblings] of groupedByParent.entries()) { + if (siblings.length >= 3) { + return { parent, containers: siblings }; + } + } + + return null; +} + +function chooseSectionHeadingConfig(sourceRoot) { + const tags = ["h3", "h2"]; + + for (const tagName of tags) { + const headings = Array.from(sourceRoot.querySelectorAll(tagName)).filter((heading) => heading.parentElement); + + if (headings.length < 3) { + continue; + } + + const groupedByParent = new Map(); + + headings.forEach((heading) => { + const list = groupedByParent.get(heading.parentElement) || []; + list.push(heading); + groupedByParent.set(heading.parentElement, list); + }); + + for (const [parent, siblings] of groupedByParent.entries()) { + if (siblings.length >= 3) { + return { headings: siblings, tagName: tagName.toUpperCase(), parent }; + } + } + } + + return null; +} + +function collectLeadNodesUntil(parent, firstNode) { + const nodes = []; + let current = parent.firstElementChild; + + while (current && current !== firstNode) { + nodes.push(current); + current = current.nextElementSibling; + } + + return nodes; +} + +function collectTrailingNodesAfter(parent, lastNode) { + const nodes = []; + let current = lastNode?.nextElementSibling || null; + + while (current) { + nodes.push(current); + current = current.nextElementSibling; + } + + return nodes; +} + +function collectSectionNodesByHeading(heading, tagName) { + const nodes = []; + let current = heading.nextElementSibling; + + while (current && current.tagName !== tagName) { + nodes.push(current); + current = current.nextElementSibling; + } + + return nodes; +} + +function buildSectionBodyMarkdown(nodes, sourceRoot, options = {}) { + if (!nodes || nodes.length === 0) { + return ""; + } + + const imageCandidate = options.allowIntroImage ? extractFirstSectionImageCandidate(nodes, sourceRoot) : null; + const wrapper = document.createElement("div"); + + nodes.forEach((node) => { + wrapper.appendChild(node.cloneNode(true)); + }); + + sanitizeSectionClone(wrapper); + + const contentMarkdown = toMarkdown(wrapper).trim(); + const imageMarkdown = imageCandidate ? `![${escapeMarkdownText(imageCandidate.alt || "Bild")}](${imageCandidate.src})` : ""; + + return [imageMarkdown, contentMarkdown].filter(Boolean).join("\n\n"); +} + +function extractFirstSectionImageCandidate(nodes, sourceRoot) { + for (const node of nodes) { + if (!(node instanceof Element)) { + continue; + } + + const candidates = [node, ...Array.from(node.querySelectorAll('*'))]; + + for (const element of candidates) { + if (element.tagName === 'IMG') { + const candidate = buildSectionImageCandidate(element, sourceRoot); + if (candidate) { + return candidate; + } + } + } + + for (const element of candidates) { + if (element.tagName === 'A') { + const candidate = extractSectionImageFromAnchor(element, sourceRoot); + if (candidate) { + return candidate; + } + } + } + + for (const element of candidates) { + const candidate = extractSectionImageFromBackground(element, sourceRoot); + if (candidate) { + return candidate; + } + } + } + + return null; +} + +function sanitizeSectionClone(wrapper) { + wrapper.querySelectorAll('img, picture, source, figure').forEach((element) => element.remove()); + wrapper.querySelectorAll("[class*='gallery'], [class*='carousel'], [class*='slider'], [class*='lightbox'], [class*='screenshots'], [class*='screenshot'], [class*='thumbs'], [class*='thumbnails'], [class*='media'], [class*='swiper'], [data-gallery], [data-carousel], [data-slider]").forEach((element) => element.remove()); + + wrapper.querySelectorAll("ul, ol").forEach((list) => { + const imageLikeLinks = Array.from(list.querySelectorAll("a[href]")); + const score = imageLikeLinks.filter((link) => isImageLikeLink(link)).length; + + if (score >= 2) { + list.remove(); + } + }); + + removePromotionalBlocks(wrapper); + + wrapper.querySelectorAll("a[href]").forEach((link) => { + if (isImageLikeLink(link) || shouldDiscardLink(link)) { + link.remove(); + } + }); + + wrapper.querySelectorAll('.comments, #comments, [class*="comment-thread"], [class*="comment-list"]').forEach((element) => element.remove()); +} + +function removePromotionalBlocks(root) { + if (!root || typeof root.querySelectorAll !== "function") { + return; + } + + const candidates = Array.from(root.querySelectorAll("div, section, aside, p, blockquote, ul")); + candidates.forEach((element) => { + const text = normalizeInlineWhitespace(element.textContent || "").trim().toLowerCase(); + const paragraphCount = element.querySelectorAll("p").length; + const listCount = element.querySelectorAll("ul, ol").length; + + if (!text || text.length < 18) { + return; + } + + if (text.length > 600 || paragraphCount > 2 || listCount > 1) { + return; + } + + if (isPromotionalTextBlock(text)) { + element.remove(); + } + }); +} + +function isPromotionalTextBlock(text) { + if (!text) { + return false; + } + + if (/kein bock auf werbung\?/.test(text)) { + return true; + } + + if (/gefällt dir der artikel\?/.test(text)) { + return true; + } + + const looksLikeShareBlock = text.length < 400 + && /tweet/.test(text) + && /verschicken/.test(text) + && /teilen/.test(text); + + if (looksLikeShareBlock) { + return true; + } + + if (/bestseller nr\.\s*\d+/.test(text) || /amazon\./.test(text) || /cascblog00-21/.test(text)) { + return true; + } + + const promoSignals = [ + /werbefrei/, + /ohne werbung/, + /unterst(├╝|u)tze/, + /spende/, + /unterst├╝tzung/, + /newsletter/, + /patreon/, + /steady/, + /paypal/, + /werbung schalten/, + /adblock/, + /deaktiviere.*werbung/, + /bestseller nr\.\s*\d+/, + /amazon\./, + /amazon-angebot/, + /produkt(?:empfehlung|werbung)?/, + /affiliate/, + /partnerlink/, + /anzeige/, + /gefällt dir der artikel\?/, + /teilen mit deinen freunden/ + ]; + + const hits = promoSignals.filter((pattern) => pattern.test(text)).length; + return hits >= 2; +} + +function isImageLikeLink(link) { + const href = toAbsoluteUrl(link.getAttribute('href') || ''); + const label = (link.textContent || '').trim().toLowerCase(); + + return isLikelyImageUrl(href) || /^image:/.test(label) || /screenshot \d+ of \d+/.test(label) || / trailer$/.test(label); +} + +function shouldDiscardLink(link) { + if (!link) { + return false; + } + + const href = toAbsoluteUrl(link.getAttribute('href') || ''); + const text = normalizeInlineWhitespace(link.textContent || '').trim(); + const title = (link.getAttribute('title') || '').trim(); + const ariaLabel = (link.getAttribute('aria-label') || '').trim(); + const label = `${text} ${title} ${ariaLabel}`.trim().toLowerCase(); + + if (!href) { + return true; + } + + if (!text && !title && !ariaLabel) { + return true; + } + + return isDiscardableActionUrl(href) || isDiscardableActionLabel(label); +} + +function isDiscardableActionUrl(href) { + const normalized = (href || '').toLowerCase(); + + return /medium\.com\/m\/signin/.test(normalized) + || /[?&]actionurl=/.test(normalized) + || /bookmark/.test(normalized) + || /post_audio_button/.test(normalized) + || /operation=register/.test(normalized) + || /amazon\.[a-z.]+\/(dp|gp)\//.test(normalized) + || /[?&]tag=/.test(normalized) + || /[?&]linkcode=/.test(normalized) + || /[?&]psc=/.test(normalized) + || /[?&]keywords=/.test(normalized); +} + +function isDiscardableActionLabel(label) { + return /(^|\s)(sign in|sign up|listen|share|bookmark)(\s|$)/.test(label || '') + || /bestseller nr\.\s*\d+/.test(label || '') + || /amazon/.test(label || '') + || /affiliate|partnerlink|anzeige/.test(label || ''); +} + +function hasDiscardableAncestor(element) { + if (!element || typeof element.closest !== "function") { + return false; + } + + return Boolean(element.closest(".aawp-product, [class*='aawp-'], [data-aawp-product-asin], [data-aawp-tracking-id], .aawp-disclaimer, [id^='div-gpt-ad-'], [class*='adslot'], [rel~='sponsored']")); +} + +function buildSectionImageCandidate(image, sourceRoot) { + const src = resolveImageSource(image); + + if (hasDiscardableAncestor(image)) { + return null; + } + + if (!src || !/^https?:|^data:/i.test(src) || isPlaceholderImageUrl(src)) { + return null; + } + + const rect = typeof image.getBoundingClientRect === 'function' ? image.getBoundingClientRect() : { width: 0, height: 0 }; + const width = Math.max(Math.round(rect.width || 0), image.naturalWidth || 0, parseDimension(image.getAttribute('width'))); + const height = Math.max(Math.round(rect.height || 0), image.naturalHeight || 0, parseDimension(image.getAttribute('height'))); + const alt = (image.getAttribute('alt') || image.getAttribute('title') || '').trim(); + + if (width < 48 && height < 48 && !/(artwork|cover|hero|screenshot|images\.nintendolife\.com)/i.test(src)) { + return null; + } + + return { + src, + alt: alt || deriveImageLabel(src), + width, + height, + inPrimaryContent: Boolean(sourceRoot && typeof sourceRoot.contains === 'function' && sourceRoot.contains(image)) + }; +} + +function extractSectionImageFromAnchor(anchor, sourceRoot) { + if (!anchor) { + return null; + } + + const href = toAbsoluteUrl(anchor.getAttribute('href') || ''); + const label = (anchor.getAttribute('title') || anchor.textContent || '').trim(); + + if (!isLikelyImageUrl(href) && !/image:|screenshot|artwork|cover/i.test(label)) { + return null; + } + + const src = isLikelyImageUrl(href) ? href : ''; + + if (!src) { + return null; + } + + const rect = anchor.getBoundingClientRect(); + return { + src, + alt: label || deriveImageLabel(src), + width: Math.round(rect.width || 0), + height: Math.round(rect.height || 0), + inPrimaryContent: Boolean(sourceRoot && typeof sourceRoot.contains === 'function' && sourceRoot.contains(anchor)) + }; +} + +function extractSectionImageFromBackground(element, sourceRoot) { + const src = extractBackgroundImageUrl(element); + + if (!src || isPlaceholderImageUrl(src)) { + return null; + } + + const rect = element.getBoundingClientRect(); + + if (Math.round(rect.width || 0) < 48 && Math.round(rect.height || 0) < 48 && !/(artwork|cover|hero|screenshot)/i.test(src)) { + return null; + } + + return { + src, + alt: (element.getAttribute('aria-label') || element.getAttribute('title') || deriveImageLabel(src)).trim(), + width: Math.round(rect.width || 0), + height: Math.round(rect.height || 0), + inPrimaryContent: Boolean(sourceRoot && typeof sourceRoot.contains === 'function' && sourceRoot.contains(element)) + }; +} + +function collectRelevantImages({ sourceRoot, mode }) { + if (mode === "selection") { + return collectSelectionImages(sourceRoot).slice(0, 20); + } + + const seen = new Set(); + const images = []; + + collectFromDocumentImages({ sourceRoot, mode, seen, images }); + collectFromGalleryContainers({ sourceRoot, mode, seen, images }); + + const ogImage = getOgImage(); + + if (ogImage && !seen.has(ogImage.src)) { + seen.add(ogImage.src); + images.unshift(ogImage); + } + + return images.slice(0, 80); +} + +function collectSelectionImages(sourceRoot) { + if (!sourceRoot || typeof sourceRoot.querySelectorAll !== "function") { + return []; + } + + const seen = new Set(); + const images = []; + + Array.from(sourceRoot.querySelectorAll("img")).forEach((image) => { + const src = resolveImageSource(image); + + if (!src || seen.has(src) || !/^https?:|^data:/i.test(src) || isPlaceholderImageUrl(src)) { + return; + } + + seen.add(src); + images.push({ + src, + alt: (image.getAttribute("alt") || image.getAttribute("title") || deriveImageLabel(src)).trim(), + width: parseDimension(image.getAttribute("width")), + height: parseDimension(image.getAttribute("height")), + inPrimaryContent: true + }); + }); + + return images; +} + +function collectFromDocumentImages({ sourceRoot, mode, seen, images }) { + Array.from(document.images).forEach((image) => { + const candidate = buildImageCandidate(image, sourceRoot, mode); + + if (!candidate || seen.has(candidate.src)) { + return; + } + + seen.add(candidate.src); + images.push(candidate); + }); +} + +function collectFromGalleryContainers({ sourceRoot, mode, seen, images }) { + const galleries = findGalleryContainers(sourceRoot, mode); + + galleries.forEach((gallery) => { + const candidate = extractFirstGalleryImage(gallery, sourceRoot, mode); + + if (!candidate || seen.has(candidate.src)) { + return; + } + + seen.add(candidate.src); + images.push(candidate); + }); +} + +function findGalleryContainers(sourceRoot, mode) { + const root = sourceRoot || document.body; + const selector = [ + '[class*="gallery"]', + '[class*="carousel"]', + '[class*="slider"]', + '[class*="lightbox"]', + '[class*="screenshots"]', + '[class*="screenshot"]', + '[class*="thumbs"]', + '[class*="thumbnails"]', + '[class*="media"]', + '[class*="swiper"]', + '[data-gallery]', + '[data-carousel]', + '[data-slider]' + ].join(','); + + const candidates = Array.from(root.querySelectorAll(selector)); + return candidates.filter((element) => isMeaningfulGallery(element)); +} + +function isMeaningfulGallery(element) { + const count = element.querySelectorAll('img, a[href], [style*="background-image"]').length; + return count >= 2; +} + +function extractFirstGalleryImage(gallery, sourceRoot, mode) { + const imageElement = gallery.querySelector('img'); + + if (imageElement) { + const imageCandidate = buildImageCandidate(imageElement, sourceRoot, mode); + if (imageCandidate) { + return imageCandidate; + } + } + + const linkCandidate = extractImageFromAnchor(gallery.querySelector('a[href]'), sourceRoot); + if (linkCandidate) { + return linkCandidate; + } + + const backgroundElement = Array.from(gallery.querySelectorAll('*')).find((element) => extractBackgroundImageUrl(element)); + if (backgroundElement) { + return extractImageFromBackground(backgroundElement, sourceRoot); + } + + return null; +} + +function buildImageCandidate(image, sourceRoot, mode) { + const src = resolveImageSource(image); + + if (hasDiscardableAncestor(image)) { + return null; + } + + if (!src || !/^https?:|^data:/i.test(src) || isPlaceholderImageUrl(src)) { + return null; + } + + const rect = typeof image.getBoundingClientRect === "function" ? image.getBoundingClientRect() : { width: 0, height: 0 }; + const width = Math.max(Math.round(rect.width || 0), image.naturalWidth || 0, parseDimension(image.getAttribute("width"))); + const height = Math.max(Math.round(rect.height || 0), image.naturalHeight || 0, parseDimension(image.getAttribute("height"))); + const area = width * height; + const inPrimaryContent = Boolean(sourceRoot && typeof sourceRoot.contains === "function" && sourceRoot.contains(image)); + const alt = (image.getAttribute("alt") || image.getAttribute("title") || "").trim(); + + if (!isRenderableImage(image)) { + return null; + } + + if (mode === "selection" && !isNearViewport(image) && area < 10000) { + return null; + } + + if (!inPrimaryContent && !passesGlobalImageHeuristic({ src, alt, width, height, area })) { + return null; + } + + return { + src, + alt: alt || deriveImageLabel(src), + width, + height, + inPrimaryContent + }; +} + +function extractImageFromAnchor(anchor, sourceRoot) { + if (!anchor) { + return null; + } + + const href = anchor.getAttribute('href'); + const src = href ? toAbsoluteUrl(href) : ''; + + if (!isLikelyImageUrl(src)) { + return null; + } + + const rect = anchor.getBoundingClientRect(); + const width = Math.round(rect.width || 0); + const height = Math.round(rect.height || 0); + const area = width * height; + const alt = (anchor.getAttribute('title') || anchor.textContent || '').trim(); + const inPrimaryContent = Boolean(sourceRoot && typeof sourceRoot.contains === 'function' && sourceRoot.contains(anchor)); + + if (!inPrimaryContent && !passesGlobalImageHeuristic({ src, alt, width, height, area })) { + return null; + } + + return { + src, + alt: alt || deriveImageLabel(src), + width, + height, + inPrimaryContent + }; +} + +function extractImageFromBackground(element, sourceRoot) { + const src = extractBackgroundImageUrl(element); + + if (!src || isPlaceholderImageUrl(src)) { + return null; + } + + const rect = element.getBoundingClientRect(); + const width = Math.round(rect.width || 0); + const height = Math.round(rect.height || 0); + const area = width * height; + const alt = (element.getAttribute('aria-label') || element.getAttribute('title') || '').trim(); + const inPrimaryContent = Boolean(sourceRoot && typeof sourceRoot.contains === 'function' && sourceRoot.contains(element)); + + if (!inPrimaryContent && !passesGlobalImageHeuristic({ src, alt, width, height, area })) { + return null; + } + + return { + src, + alt: alt || deriveImageLabel(src), + width, + height, + inPrimaryContent + }; +} + +function extractBackgroundImageUrl(element) { + const style = window.getComputedStyle(element); + const value = style.backgroundImage || ''; + const match = value.match(/url\(["']?(.*?)["']?\)/i); + + if (!match?.[1]) { + return ''; + } + + const src = toAbsoluteUrl(match[1]); + return isLikelyImageUrl(src) ? src : ''; +} + +function passesGlobalImageHeuristic({ src, alt, width, height, area }) { + const label = `${src} ${alt}`.toLowerCase(); + + if (/(avatar|icon|logo|sprite|emoji|badge)/.test(label) && area < 40000) { + return false; + } + + if (width >= 220 && height >= 120) { + return true; + } + + if (area >= 30000) { + return true; + } + + if (isLikelyImageUrl(src) && /screenshot|artwork|cover|hero|gallery|media/.test(label)) { + return true; + } + + return false; +} + +function isRenderableImage(image) { + const style = window.getComputedStyle(image); + + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { + return false; + } + + return true; +} + +function isNearViewport(image) { + const rect = image.getBoundingClientRect(); + return rect.bottom > -400 && rect.top < window.innerHeight + 400; +} + +function getOgImage() { + const meta = document.querySelector('meta[property="og:image"], meta[name="og:image"]'); + const src = meta?.getAttribute("content")?.trim(); + + if (!src) { + return null; + } + + return { + src: toAbsoluteUrl(src), + alt: document.title || "Vorschaubild", + width: 0, + height: 0, + inPrimaryContent: false + }; +} + +function resolveImageSource(image) { + const candidates = [ + image.currentSrc, + image.getAttribute("src"), + image.getAttribute("data-src"), + image.getAttribute("data-lazy-src"), + image.getAttribute("data-original"), + image.getAttribute("data-image"), + firstSrcFromSrcset(image.getAttribute("srcset")), + firstSrcFromSrcset(image.getAttribute("data-srcset")) + ].filter(Boolean); + + const resolved = candidates.find((value) => !value.startsWith("data:image/gif;base64,R0lGOD")) || candidates[0]; + return resolved ? toAbsoluteUrl(resolved) : ""; +} + +function firstSrcFromSrcset(value) { + if (!value) { + return ""; + } + + const first = value.split(",")[0]?.trim().split(/\s+/)[0]; + return first || ""; +} + +function toAbsoluteUrl(value) { + try { + return new URL(value, location.href).href; + } catch (error) { + return value; + } +} + +function isLikelyImageUrl(value) { + return /^https?:|^data:/i.test(value || '') && /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(value || ''); +} + +function isPlaceholderImageUrl(src) { + return /^data:image\/gif;base64,R0lGOD/i.test(src || ''); +} + +function parseDimension(value) { + const parsed = Number.parseInt(value || "0", 10); + return Number.isFinite(parsed) ? parsed : 0; +} + +function deriveImageLabel(src) { + try { + const url = new URL(src); + const lastSegment = url.pathname.split("/").filter(Boolean).pop() || "Bild"; + return lastSegment.replace(/[-_]+/g, " ").replace(/\.[a-z0-9]+$/i, ""); + } catch (error) { + return "Bild"; + } +} + +function toMarkdown(root) { + if (!root) { + return ""; + } + + return serializeNode(root).replace(/\n{3,}/g, "\n\n").trim(); +} + +function buildLinkLabel(node, content, absoluteHref) { + const visible = normalizeInlineWhitespace(content || "").trim(); + const title = normalizeInlineWhitespace(node.getAttribute("title") || "").trim(); + const ariaLabel = normalizeInlineWhitespace(node.getAttribute("aria-label") || "").trim(); + + if (visible && visible !== "]" && visible !== ")" && visible !== "](") { + return visible; + } + + if (title) { + return title; + } + + if (ariaLabel) { + return ariaLabel; + } + + if (!absoluteHref) { + return ""; + } + + try { + const url = new URL(absoluteHref); + const path = url.pathname.replace(/\/$/, ""); + const tail = path.split("/").filter(Boolean).pop() || url.hostname; + return `${url.hostname}/${tail}`; + } catch (error) { + return absoluteHref; + } +} + +function serializeNode(node, depth = 0) { + if (!node) { + return ""; + } + + if (node.nodeType === Node.TEXT_NODE) { + return escapeMarkdownText(node.textContent || ""); + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const tag = node.tagName.toLowerCase(); + const children = Array.from(node.childNodes).map((child) => serializeNode(child, depth)).join(""); + const content = normalizeInlineWhitespace(children); + + switch (tag) { + case "h1": return `# ${content}\n\n`; + case "h2": return `## ${content}\n\n`; + case "h3": return `### ${content}\n\n`; + case "h4": return `#### ${content}\n\n`; + case "h5": return `##### ${content}\n\n`; + case "h6": return `###### ${content}\n\n`; + case "p": return `${content}\n\n`; + case "br": return " \n"; + case "strong": + case "b": return `**${content}**`; + case "em": + case "i": return `*${content}*`; + case "code": return `\`${content.replace(/`/g, "\\`")}\``; + case "pre": return `\`\`\`\n${node.textContent?.trim() || ""}\n\`\`\`\n\n`; + case "blockquote": return `${content.split("\n").map((line) => line ? `> ${line}` : ">").join("\n")}\n\n`; + case "a": { + if (shouldDiscardLink(node)) { + return content && content !== node.getAttribute("href") ? content : ""; + } + + const href = node.getAttribute("href"); + const absoluteHref = href ? toAbsoluteUrl(href) : ""; + const label = buildLinkLabel(node, content, absoluteHref); + const heading = node.querySelector("h1, h2, h3, h4, h5, h6"); + const paragraphs = Array.from(node.querySelectorAll("p")) + .map((element) => normalizeInlineWhitespace(element.textContent || "").trim()) + .filter(Boolean); + const hasStructuredChildren = Boolean(heading) || paragraphs.length > 0; + + if (hasStructuredChildren) { + const parts = []; + const headingText = normalizeInlineWhitespace(heading?.textContent || "") + .replace(/^#{2,6}\s+/, "") + .replace(/\s+#{2,6}$/, "") + .trim(); + + if (headingText) { + parts.push(`### ${headingText}`); + } + + paragraphs.forEach((text) => { + if (text !== headingText) { + parts.push(text); + } + }); + + if (absoluteHref) { + parts.push(`[${escapeMarkdownText(buildLinkLabel(node, "", absoluteHref))}](${absoluteHref})`); + } + + return parts.filter(Boolean).join("\n\n"); + } + + if (!absoluteHref) { + return label; + } + + if (!label.trim()) { + return absoluteHref; + } + + return `[${escapeMarkdownText(label)}](${absoluteHref})`; + } + case "img": { + const alt = node.getAttribute("alt") || node.getAttribute("title") || "Bild"; + const absoluteSrc = resolveImageSource(node); + return absoluteSrc ? `![${escapeMarkdownText(alt)}](${absoluteSrc})\n\n` : ""; + } + case "ul": return `${serializeList(node, depth, false)}\n`; + case "ol": return `${serializeList(node, depth, true)}\n`; + case "li": return `${content}\n`; + case "hr": return `---\n\n`; + case "table": return tableToMarkdown(node); + case "div": + case "section": + case "article": + case "main": + case "header": return `${content}\n\n`; + default: return content; + } +} + +function serializeList(node, depth, ordered) { + const items = Array.from(node.children) + .filter((child) => child.tagName?.toLowerCase() === "li") + .map((child, index) => { + const marker = ordered ? `${index + 1}. ` : "- "; + const text = serializeNode(child, depth + 1).trim().replace(/\n/g, "\n "); + return `${" ".repeat(depth)}${marker}${text}`; + }); + + return items.join("\n"); +} + +function tableToMarkdown(table) { + const rows = Array.from(table.querySelectorAll("tr")).map((row) => + Array.from(row.children).map((cell) => normalizeInlineWhitespace(cell.textContent || "").trim()) + ); + + if (rows.length === 0) { + return ""; + } + + const [headerRow, ...bodyRows] = rows; + const separator = headerRow.map(() => "---"); + const allRows = [headerRow, separator, ...bodyRows]; + + return `${allRows.map((row) => `| ${row.join(" | ")} |`).join("\n")}\n\n`; +} + +function normalizeInlineWhitespace(value) { + return value.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").replace(/[ \t]{2,}/g, " "); +} + +function escapeMarkdownText(value) { + return value.replace(/\s+/g, " ").replace(/([\\`*_{}[\]])/g, "\\$1"); +} + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/lib/anytype.js b/src/lib/anytype.js new file mode 100644 index 0000000..a782fdf --- /dev/null +++ b/src/lib/anytype.js @@ -0,0 +1,88 @@ +(function () { + const DEFAULT_API_BASE = "http://localhost:31009"; + const API_VERSION = "2025-11-08"; + + function normalizeApiBase(apiBase) { + const value = (apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, ""); + return value || DEFAULT_API_BASE; + } + + async function anytypeRequest({ apiBase, apiKey, path, method = "GET", body }) { + if (!apiKey) { + throw new Error("Es fehlt ein Anytype API-Key."); + } + + const response = await fetch(`${normalizeApiBase(apiBase)}${path}`, { + method, + headers: { + Authorization: `Bearer ${apiKey}`, + "Anytype-Version": API_VERSION, + "Content-Type": "application/json" + }, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + let details = ""; + + try { + const payload = await response.json(); + details = payload.message || payload.error || JSON.stringify(payload); + } catch (error) { + details = await response.text(); + } + + throw new Error(`Anytype API Fehler (${response.status}): ${details || "Unbekannter Fehler"}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); + } + + async function listSpaces({ apiBase, apiKey }) { + const result = await anytypeRequest({ + apiBase, + apiKey, + path: "/v1/spaces" + }); + + if (Array.isArray(result)) { + return result; + } + + if (Array.isArray(result?.data)) { + return result.data; + } + + if (Array.isArray(result?.spaces)) { + return result.spaces; + } + + return []; + } + + async function createObject({ apiBase, apiKey, spaceId, payload }) { + if (!spaceId) { + throw new Error("Es wurde kein Anytype-Space ausgewählt."); + } + + return anytypeRequest({ + apiBase, + apiKey, + path: `/v1/spaces/${encodeURIComponent(spaceId)}/objects`, + method: "POST", + body: payload + }); + } + + self.AnytypeApi = { + DEFAULT_API_BASE, + API_VERSION, + normalizeApiBase, + listSpaces, + createObject + }; +})(); diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..89f3610 --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,88 @@ +:root { + --bg: #f2ede2; + --panel: #fff9f0; + --text: #211910; + --muted: #6a6158; + --accent: #1d5f55; + --accent-strong: #11443c; + --border: #d8ccb7; +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(160deg, rgba(29, 95, 85, 0.11), transparent 28%), + linear-gradient(180deg, #fbf7ef, var(--bg)); + color: var(--text); + font: 16px/1.55 "Segoe UI", "Helvetica Neue", sans-serif; +} + +.settings { + max-width: 720px; + margin: 0 auto; + padding: 40px 20px 64px; +} + +h1, +h2 { + margin-top: 0; +} + +.lead { + color: var(--muted); + margin-bottom: 24px; +} + +.field, +.toggle { + display: grid; + gap: 8px; + margin-bottom: 16px; +} + +.row, +.actions { + display: flex; + gap: 10px; +} + +input, +select, +button { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + font: inherit; +} + +input, +select { + width: 100%; + box-sizing: border-box; + background: var(--panel); +} + +button { + cursor: pointer; + background: var(--accent); + color: #fff; +} + +button.ghost { + background: transparent; + color: var(--accent); +} + +.status { + min-height: 24px; + color: var(--accent-strong); +} + +.panel { + margin-top: 24px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255, 249, 240, 0.8); +} diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..3f93a1d --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,50 @@ + + + + + + Anytype Web Clipper Einstellungen + + + +
+

Anytype Web Clipper

+

Firefox speichert Webseiten nach Anytype und übernimmt dabei Text, Struktur und erkannte Bilder direkt in das Zielobjekt.

+ + + + + + + + + +
+ +
+ +

+
+ + + + diff --git a/src/options/options.js b/src/options/options.js new file mode 100644 index 0000000..f4de919 --- /dev/null +++ b/src/options/options.js @@ -0,0 +1,81 @@ +const optionsForm = { + apiBase: document.getElementById("apiBase"), + apiKey: document.getElementById("apiKey"), + spaceId: document.getElementById("spaceId"), + objectType: document.getElementById("objectType") +}; + +const optionsStatus = document.getElementById("status"); + +document.getElementById("refreshSpaces").addEventListener("click", refreshSpaces); +document.getElementById("save").addEventListener("click", saveSettings); + +init().catch(showError); + +async function init() { + const settings = await browser.runtime.sendMessage({ type: "load-settings" }); + optionsForm.apiBase.value = settings.apiBase || ""; + optionsForm.apiKey.value = settings.apiKey || ""; + optionsForm.objectType.value = settings.objectType || "page"; + await refreshSpaces(settings.spaceId); +} + +async function refreshSpaces(selectedSpaceId = optionsForm.spaceId.value) { + setStatus("Lade Spaces ..."); + await persist(); + + try { + const response = await browser.runtime.sendMessage({ type: "list-spaces" }); + const spaces = response?.spaces || []; + optionsForm.spaceId.innerHTML = ""; + + spaces.forEach((space) => { + const option = document.createElement("option"); + option.value = space.id || space.space_id || ""; + option.textContent = space.name || option.value; + option.selected = option.value === selectedSpaceId; + optionsForm.spaceId.appendChild(option); + }); + + if (!optionsForm.spaceId.value && spaces.length > 0) { + optionsForm.spaceId.selectedIndex = 0; + } + + setStatus(spaces.length > 0 ? "Spaces geladen." : "Keine Spaces gefunden."); + } catch (error) { + showError(error); + } +} + +async function saveSettings() { + try { + await persist(); + setStatus("Einstellungen gespeichert."); + } catch (error) { + showError(error); + } +} + +async function persist() { + await browser.runtime.sendMessage({ + type: "save-settings", + payload: readOptions() + }); +} + +function readOptions() { + return { + apiBase: optionsForm.apiBase.value.trim(), + apiKey: optionsForm.apiKey.value.trim(), + spaceId: optionsForm.spaceId.value, + objectType: optionsForm.objectType.value + }; +} + +function setStatus(message) { + optionsStatus.textContent = message; +} + +function showError(error) { + setStatus(error?.message || String(error)); +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..916acf6 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,90 @@ +:root { + color-scheme: light; + --bg: #f4efe4; + --panel: #fffaf0; + --text: #1f1a14; + --muted: #6b6258; + --accent: #9f4b22; + --accent-strong: #7e3715; + --border: #d7c6ae; +} + +body { + margin: 0; + min-width: 360px; + background: + radial-gradient(circle at top right, rgba(159, 75, 34, 0.12), transparent 34%), + linear-gradient(135deg, #f7f2e8, var(--bg)); + color: var(--text); + font: 14px/1.45 "Segoe UI", "Helvetica Neue", sans-serif; +} + +.popup { + padding: 16px; +} + +h1 { + margin: 0 0 14px; + font-size: 18px; +} + +.field, +.toggle { + display: grid; + gap: 6px; + margin-bottom: 12px; +} + +.row, +.actions { + display: flex; + gap: 8px; +} + +input, +select, +button { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + font: inherit; +} + +input, +select { + width: 100%; + box-sizing: border-box; + background: var(--panel); +} + +button { + cursor: pointer; + background: var(--accent); + color: #fff; + transition: background-color 0.15s ease; +} + +button:hover { + background: var(--accent-strong); +} + +button.ghost { + background: transparent; + color: var(--accent-strong); +} + +.actions button { + flex: 1; +} + +.status { + min-height: 20px; + margin: 12px 0 0; + color: var(--accent-strong); +} + +.note { + margin: 10px 0 0; + color: var(--muted); + font-size: 12px; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..db78cac --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,49 @@ + + + + + + Anytype Web Clipper + + + +
+

Anytype Web Clipper

+ + + + + + + + + +
+ + +
+ +

+
+ + + diff --git a/src/popup/popup.js b/src/popup/popup.js new file mode 100644 index 0000000..de02fe1 --- /dev/null +++ b/src/popup/popup.js @@ -0,0 +1,109 @@ +const form = { + apiBase: document.getElementById("apiBase"), + apiKey: document.getElementById("apiKey"), + spaceId: document.getElementById("spaceId"), + objectType: document.getElementById("objectType") +}; + +const status = document.getElementById("status"); + +document.getElementById("refreshSpaces").addEventListener("click", refreshSpaces); +document.getElementById("savePage").addEventListener("click", () => saveClip("full")); +document.getElementById("saveSelection").addEventListener("click", () => saveClip("selection")); + +bootstrap().catch(showError); + +async function bootstrap() { + const settings = await browser.runtime.sendMessage({ type: "load-settings" }); + populateForm(settings); + await refreshSpaces(settings.spaceId); +} + +function populateForm(settings) { + form.apiBase.value = settings.apiBase || ""; + form.apiKey.value = settings.apiKey || ""; + form.objectType.value = settings.objectType || "page"; +} + +async function refreshSpaces(selectedSpaceId = form.spaceId.value) { + setStatus("Lade Spaces ..."); + await persistForm(); + + try { + const response = await browser.runtime.sendMessage({ type: "list-spaces" }); + const spaces = response?.spaces || []; + + form.spaceId.innerHTML = ""; + + if (spaces.length === 0) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Keine Spaces gefunden"; + form.spaceId.appendChild(option); + setStatus("Keine Spaces verfügbar."); + return; + } + + spaces.forEach((space) => { + const option = document.createElement("option"); + option.value = space.id || space.space_id || ""; + option.textContent = space.name || option.value; + option.selected = option.value === selectedSpaceId; + form.spaceId.appendChild(option); + }); + + if (!form.spaceId.value) { + form.spaceId.selectedIndex = 0; + } + + await persistForm(); + setStatus("Spaces geladen."); + } catch (error) { + showError(error); + } +} + +async function saveClip(mode) { + setStatus("Sichere Webseite ..."); + + try { + const result = await browser.runtime.sendMessage({ + type: "save-active-tab", + payload: { + mode, + settings: readForm() + } + }); + + const objectId = result?.created?.id || result?.created?.object?.id; + const suffix = objectId ? ` Objekt-ID: ${objectId}` : ""; + setStatus(`In Anytype gespeichert.${suffix}`); + window.close(); + } catch (error) { + showError(error); + } +} + +async function persistForm() { + await browser.runtime.sendMessage({ + type: "save-settings", + payload: readForm() + }); +} + +function readForm() { + return { + apiBase: form.apiBase.value.trim(), + apiKey: form.apiKey.value.trim(), + spaceId: form.spaceId.value, + objectType: form.objectType.value + }; +} + +function showError(error) { + setStatus(error?.message || String(error)); +} + +function setStatus(message) { + status.textContent = message; +}