Erster Blueprint

This commit is contained in:
Andre
2026-03-28 11:21:49 +01:00
commit a8eaa1f9ce
12 changed files with 2143 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1d5f55"/>
<stop offset="100%" stop-color="#9f4b22"/>
</linearGradient>
</defs>
<rect width="96" height="96" rx="22" fill="url(#bg)"/>
<path d="M27 24h42c4.4 0 8 3.6 8 8v39c0 4.4-3.6 8-8 8H40l-13 10V32c0-4.4 3.6-8 8-8Z" fill="#fff6e7"/>
<path d="M40 38h20M40 49h18M40 60h12" stroke="#1f1a14" stroke-linecap="round" stroke-width="6"/>
<path d="M63 18l3 9 9 3-9 3-3 9-3-9-9-3 9-3 3-9Z" fill="#ffd66b"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B

+268
View File
@@ -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");
}
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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
};
})();
+88
View File
@@ -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);
}
+50
View File
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anytype Web Clipper Einstellungen</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<main class="settings">
<h1>Anytype Web Clipper</h1>
<p class="lead">Firefox speichert Webseiten nach Anytype und übernimmt dabei Text, Struktur und erkannte Bilder direkt in das Zielobjekt.</p>
<label class="field">
<span>Anytype API-URL</span>
<input id="apiBase" type="url" placeholder="http://localhost:31009">
</label>
<label class="field">
<span>Anytype API-Key</span>
<input id="apiKey" type="password" placeholder="API-Key">
</label>
<label class="field">
<span>Standard-Space</span>
<div class="row">
<select id="spaceId"></select>
<button id="refreshSpaces" type="button" class="ghost">Neu laden</button>
</div>
</label>
<label class="field">
<span>Standard-Objekttyp</span>
<select id="objectType">
<option value="page">Page</option>
<option value="bookmark">Bookmark</option>
<option value="note">Note</option>
</select>
</label>
<div class="actions">
<button id="save" type="button">Speichern</button>
</div>
<p id="status" class="status"></p>
</main>
<script src="options.js"></script>
</body>
</html>
+81
View File
@@ -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));
}
+90
View File
@@ -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;
}
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Anytype Web Clipper</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<main class="popup">
<h1>Anytype Web Clipper</h1>
<label class="field">
<span>API-URL</span>
<input id="apiBase" type="url" placeholder="http://localhost:31009">
</label>
<label class="field">
<span>API-Key</span>
<input id="apiKey" type="password" placeholder="Anytype API Key">
</label>
<label class="field">
<span>Space</span>
<div class="row">
<select id="spaceId"></select>
<button id="refreshSpaces" type="button" class="ghost">Aktualisieren</button>
</div>
</label>
<label class="field">
<span>Objekttyp</span>
<select id="objectType">
<option value="page">Page</option>
<option value="bookmark">Bookmark</option>
<option value="note">Note</option>
</select>
</label>
<div class="actions">
<button id="savePage" type="button">Ganze Seite sichern</button>
<button id="saveSelection" type="button" class="ghost">Auswahl sichern</button>
</div>
<p id="status" class="status"></p>
</main>
<script src="popup.js"></script>
</body>
</html>
+109
View File
@@ -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;
}