feat(extension): save a right-clicked image as a new note

This commit is contained in:
tianma-if committed 2026-09-26 19:33:13 +08:00
1 parent aaf3dacfb6
commit 8a4df2a5f2
20 files changed
+1479 -31

No files matched your search

+7 -4
View File
@@ -11,9 +11,10 @@
## Description
EdgeEver Web Clipper saves the current webpage or selected text directly to the self-hosted EdgeEver instance you configure.
EdgeEver Web Clipper saves the current webpage, selected text, or a right-clicked image directly to the self-hosted EdgeEver instance you configure.
- Extract readable article content or preserve the current selection.
- Right-click an image and save that image file as a new note.
- Convert captured HTML to Markdown locally.
- Choose a default EdgeEver notebook.
- Send content directly to your instance without an EdgeEver-operated relay.
@@ -25,7 +26,7 @@ Before using the extension, enter your EdgeEver instance URL and API token in th
- `authenticationInfo`: the API token is sent only to the EdgeEver instance configured by the user.
- `browsingActivity`: the current page URL is included in the note created by the user.
- `websiteContent`: the selected text or extracted article body is included in the note created by the user.
- `websiteContent`: the selected text, extracted article body, or image file is included in the note created by the user.
The project maintainers do not receive or retain this data. Instance settings are stored in the browser's local extension storage.
@@ -36,7 +37,8 @@ The project maintainers do not receive or retain this data. Instance settings ar
3. Choose **Test connection**, select a notebook, and save.
4. Open a normal HTTP or HTTPS webpage.
5. Open the extension, choose **Clip current page**, and verify that the success message appears.
6. Verify the created note in the review EdgeEver instance.
6. On a normal webpage, right-click an image, choose **Save image to EdgeEver**, and verify the new note contains the image.
7. Verify the created notes in the review EdgeEver instance.
Restricted browser pages, extension stores, built-in PDF viewers, and other privileged pages cannot be captured.
@@ -60,9 +62,10 @@ The reviewable source is the repository source before Vite bundling. Third-party
## 中文商店说明
EdgeEver 网页裁剪插件可将当前网页或选中的文字直接保存到用户配置的自托管 EdgeEver 实例。
EdgeEver 网页裁剪插件可将当前网页、选中的文字,或右键选中的图片直接保存到用户配置的自托管 EdgeEver 实例。
- 提取适合阅读的文章正文,也可只保存当前选区。
- 在图片上右键,把图片文件存成一条新笔记。
- 在浏览器本地将 HTML 转换为 Markdown。
- 可选择默认 EdgeEver 笔记本。
- 数据直接发送到用户自己的实例,不经过 EdgeEver 中转服务。
+4 -2
View File
@@ -1,6 +1,6 @@
# EdgeEver Web Clipper
Chrome, Edge, and Firefox Manifest V3 extension for saving the current webpage or selected text to a user's self-hosted EdgeEver instance.
Chrome, Edge, and Firefox Manifest V3 extension for saving the current webpage, selected text, or a right-clicked image to a user's self-hosted EdgeEver instance.
## Current MVP
@@ -8,6 +8,8 @@ Chrome, Edge, and Firefox Manifest V3 extension for saving the current webpage o
- Test the connection and select a default notebook.
- Click the extension action to capture the selected content, or extract the article body with Mozilla Readability when there is no selection.
- Convert the extracted article HTML into Markdown with Turndown before uploading it.
- Right-click an image and choose **Save image to EdgeEver**. The image file is uploaded into a new note in the default notebook, with the source page linked underneath.
- When the page cannot hand over the image bytes, the extension asks once for access to that image's site. After that, later images from the same site save directly.
- Create a searchable EdgeEver memo with the source URL and a `web-clip` tag.
The extension does not use a central relay service. The page content is sent directly to the EdgeEver instance configured by the user.
@@ -55,7 +57,7 @@ The Firefox package declares the data types required by its user-triggered clipp
- `authenticationInfo`: the API token sent directly to the user's EdgeEver instance.
- `browsingActivity`: the URL of the page the user chooses to clip.
- `websiteContent`: the selected text or extracted article content the user chooses to clip.
- `websiteContent`: the selected text, extracted article content, or image the user chooses to clip.
No data is sent to an EdgeEver-operated relay, analytics service, or advertising service.
+13 -10
View File
@@ -27,21 +27,22 @@ Select the matching language in the Chrome Web Store developer dashboard and ent
#### Summary
将当前网页或选中内容保存到你自托管的 EdgeEver 实例。
将当前网页、选中内容或右键图片保存到你自托管的 EdgeEver 实例。
#### Detailed description
EdgeEver Web Clipper 可以把当前网页或你选中的内容保存到自托管的 EdgeEver 实例。
EdgeEver Web Clipper 可以把当前网页、你选中的内容,或右键选中的图片保存到自托管的 EdgeEver 实例。
主要功能:
- 自动提取文章正文,并转换为便于搜索和编辑的 Markdown。
- 优先保存你在页面中选中的内容。
- 在图片上右键,选择“保存图片到 EdgeEver”,把图片文件存成一条新笔记。
- 在笔记中保留原始标题、来源网址和剪藏时间。
- 可选择默认笔记本,并自动添加 `web-clip` 标签。
- 网页内容直接发送到你配置的 EdgeEver 实例,不经过开发者的中转服务器。
使用前,请在插件设置中填写 EdgeEver 实例地址和 API Token。插件只会在你点击“剪藏当前网页”后读取当前标签页,并仅向你授权的 EdgeEver 实例申请网络访问权限。
使用前,请在插件设置中填写 EdgeEver 实例地址和 API Token。插件只会在你点击“剪藏当前网页”或选择“保存图片到 EdgeEver”后读取当前标签页。图片若无法由页面直接交出,才会再向你请求该图片所在网站的访问权限。
EdgeEver 是开源、自托管的现代笔记工作区。项目主页与源代码:https://github.com/tianma-if/edgeever
@@ -49,21 +50,22 @@ EdgeEver 是开源、自托管的现代笔记工作区。项目主页与源代
#### Summary
Save the current webpage or selected content to your self-hosted EdgeEver instance.
Save the current webpage, selected content, or a right-clicked image to your self-hosted EdgeEver instance.
#### Detailed description
EdgeEver Web Clipper saves the current webpage or selected content to your self-hosted EdgeEver instance.
EdgeEver Web Clipper saves the current webpage, selected content, or a right-clicked image to your self-hosted EdgeEver instance.
Key features:
- Extract article content automatically and convert it to searchable, editable Markdown.
- Prefer content selected on the page when a selection is available.
- Right-click an image and choose “Save image to EdgeEver” to store the image file as a new note.
- Preserve the original title, source URL, and clipping time in the note.
- Select a default notebook and add the `web-clip` tag automatically.
- Send webpage content directly to your configured EdgeEver instance without a developer-operated relay server.
Before using the extension, enter your EdgeEver instance URL and API token in the extension settings. The extension reads the current tab only after you click “Clip current page” and requests network access only for the EdgeEver instance you authorize.
Before using the extension, enter your EdgeEver instance URL and API token in the extension settings. The extension reads the current tab only after you click “Clip current page” or choose “Save image to EdgeEver”. It asks for access to an image's site only when that page cannot provide the image file.
EdgeEver is an open-source, self-hosted modern notes workspace. Project homepage and source code: https://github.com/tianma-if/edgeever
@@ -71,14 +73,15 @@ EdgeEver is an open-source, self-hosted modern notes workspace. Project homepage
### Single purpose
Save the current webpage or user-selected content to the self-hosted EdgeEver instance explicitly configured by the user.
Save the current webpage, user-selected content, or a user-chosen image to the self-hosted EdgeEver instance explicitly configured by the user.
### Permission justifications
- `activeTab`: Read the active page only after the user clicks the extension's save action.
- `scripting`: Inject the packaged content extraction script into the active page after the user initiates a capture.
- `activeTab`: Read the active page only after the user clicks the extension's save action or chooses Save image to EdgeEver from the image menu.
- `contextMenus`: Add a Save image to EdgeEver item when the user right-clicks an image. It runs only after the user selects that item.
- `scripting`: Inject the packaged capture script into the active page after the user initiates a capture.
- `storage`: Store the user's EdgeEver instance URL, API token, and default notebook ID locally.
- Optional host permissions: Send API requests only to the EdgeEver instance origin configured and approved by the user.
- Optional host permissions: Send API requests only to the EdgeEver instance origin the user approves. If a page cannot provide an image file, the extension can also ask for access to that image's site, or to all sites when the user explicitly chooses that option, and uses it only to download the image the user chose to save.
### Data disclosures
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, test } from "bun:test";
import {
filenameForImage,
imageAltText,
imageFromBytes,
imageHostName,
imageMemoMarkdown,
imageOriginPattern,
MAX_IMAGE_BYTES,
noteTitleForImage,
preferredImageUrls,
saveCapturedImageNote,
sniffImageMimeType,
} from "./src/image-clip.ts";
const source = {
pageUrl: "https://x.com/huoshan007/status/1",
capturedAt: "2026-09-26T00:00:00.000Z",
sourceLabel: "来源",
capturedAtLabel: "抓取时间",
};
describe("preferred image URLs", () => {
test("asks Twitter for the original asset before the clicked preview", () => {
const urls = preferredImageUrls("https://pbs.twimg.com/media/Abc123?format=jpg&name=small");
expect(urls).toHaveLength(3);
expect(new URL(urls[0]).searchParams.get("name")).toBe("orig");
expect(new URL(urls[1]).searchParams.get("name")).toBe("large");
expect(urls[2]).toBe("https://pbs.twimg.com/media/Abc123?format=jpg&name=small");
});
test("keeps an original Twitter URL as a single candidate", () => {
const src = "https://pbs.twimg.com/media/Abc123?format=png&name=orig";
expect(preferredImageUrls(src)).toEqual([src]);
});
test("upgrades the legacy Twitter size suffix and leaves other sites unchanged", () => {
const legacy = preferredImageUrls("https://pbs.twimg.com/media/Abc.jpg:small");
expect(new URL(legacy[0]).pathname.endsWith(":orig")).toBe(true);
expect(legacy[1]).toBe("https://pbs.twimg.com/media/Abc.jpg:small");
expect(preferredImageUrls("https://images.example.com/photo.png?name=small")).toEqual([
"https://images.example.com/photo.png?name=small",
]);
});
});
describe("image note content", () => {
test("embeds the uploaded resource and the source page", () => {
const markdown = imageMemoMarkdown({
...source,
resourceId: "res_1",
alt: "Jianying ] Headless",
altFallback: "图片",
});
expect(markdown.startsWith("![Jianying Headless](/api/v1/resources/res_1/blob)")).toBe(true);
expect(markdown).toContain("[https://x.com/huoshan007/status/1](https://x.com/huoshan007/status/1)");
expect(markdown).not.toContain("pbs.twimg.com");
});
test("encodes parentheses in the source link and falls back when alt is empty", () => {
const markdown = imageMemoMarkdown({
...source,
pageUrl: "https://example.com/a(b)",
resourceId: "res_2",
alt: " ",
altFallback: "图片",
});
expect(markdown).toContain("![图片](/api/v1/resources/res_2/blob)");
expect(markdown).toContain("(https://example.com/a%28b%29)");
expect(imageAltText("]", "图片")).toBe("图片");
});
test("names the file from the media id and clips the note title", () => {
expect(filenameForImage("https://pbs.twimg.com/media/Abc123?format=jpg&name=small", "image/jpeg")).toBe("Abc123.jpg");
expect(filenameForImage("https://pbs.twimg.com/media/Abc.jpg:large", "image/png")).toBe("Abc.png");
expect(noteTitleForImage(` ${"标".repeat(180)} `, "", "图片")).toHaveLength(160);
expect(noteTitleForImage(" ", "卡片", "图片")).toBe("卡片");
expect(noteTitleForImage(" ", " ", "图片")).toBe("图片");
});
test("reads an http image origin and ignores blob URLs", () => {
expect(imageOriginPattern("https://pbs.twimg.com/media/Abc")).toBe("https://pbs.twimg.com/*");
expect(imageHostName("https://pbs.twimg.com/media/Abc")).toBe("pbs.twimg.com");
expect(imageOriginPattern("blob:https://x.com/8fb53d6f")).toBeNull();
expect(imageHostName("data:image/png;base64,aaaa")).toBe("");
});
});
describe("image bytes", () => {
test("recognizes common image signatures and rejects other payloads", () => {
expect(sniffImageMimeType(Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d))).toBe("image/png");
expect(sniffImageMimeType(Uint8Array.of(0xff, 0xd8, 0xff, 0x00))).toBe("image/jpeg");
expect(sniffImageMimeType(Uint8Array.of(0x47, 0x49, 0x46, 0x38))).toBe("image/gif");
const webp = new Uint8Array(12);
webp.set([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]);
expect(sniffImageMimeType(webp)).toBe("image/webp");
const avif = new Uint8Array(12);
avif.set([0, 0, 0, 0x18, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66]);
expect(sniffImageMimeType(avif)).toBe("image/avif");
expect(imageFromBytes(Uint8Array.of(0x89, 0x50, 0x4e, 0x47), "").mimeType).toBe("image/png");
expect(imageFromBytes(Uint8Array.of(0x3c, 0x68, 0x74, 0x6d, 0x6c), "text/html")).toEqual({ error: "unsupported" });
expect(imageFromBytes(new Uint8Array(), "image/png")).toEqual({ error: "empty" });
expect(imageFromBytes(new Uint8Array(MAX_IMAGE_BYTES + 1), "image/png")).toEqual({ error: "too-large" });
expect(imageFromBytes(Uint8Array.of(1, 2, 3), "image/jpg").mimeType).toBe("image/jpeg");
});
});
describe("saveCapturedImageNote", () => {
const image = {
notebookId: "nb_inbox",
title: "火山哥 on X",
alt: "卡片",
filename: "Abc123.jpg",
mimeType: "image/jpeg",
bytes: Uint8Array.of(0xff, 0xd8, 0xff),
...source,
altFallback: "图片",
};
const client = (overrides = {}) => {
const order = [];
const calls = { deleted: [], saved: null, created: null };
return {
order,
calls,
api: {
listNotebooks: async () => {
order.push("list");
return { notebooks: [{ id: "nb_first" }] };
},
createMemo: async (body) => {
order.push("create");
calls.created = body;
return { memo: { id: "memo_1" } };
},
uploadImage: async () => {
order.push("upload");
return { id: "res_1" };
},
createEditSession: async () => {
order.push("session");
return { editSession: { id: "edit_1", baseRevision: 0, baseContentHash: "a".repeat(64) } };
},
saveMemo: async (_memoId, body) => {
order.push("save");
calls.saved = body;
},
deleteMemo: async (memoId) => {
order.push("delete");
calls.deleted.push(memoId);
},
...overrides,
},
};
};
test("creates a note, uploads the image, then saves it into the note", async () => {
const fixture = client();
const saved = await saveCapturedImageNote(fixture.api, image);
expect(saved).toEqual({ memoId: "memo_1", resourceId: "res_1" });
expect(fixture.order).toEqual(["create", "upload", "session", "save"]);
expect(fixture.calls.created.notebookId).toBe("nb_inbox");
expect(fixture.calls.created.tags).toEqual(["web-clip"]);
expect(fixture.calls.created.contentMarkdown.startsWith("![")).toBe(false);
expect(fixture.calls.saved.editSessionId).toBe("edit_1");
expect(fixture.calls.saved.expectedRevision).toBe(0);
expect(fixture.calls.saved.expectedContentHash).toBe("a".repeat(64));
expect(fixture.calls.saved.contentMarkdown).toContain("](/api/v1/resources/res_1/blob)");
expect(fixture.calls.deleted).toEqual([]);
});
test("uses the first notebook when none is selected", async () => {
const fixture = client();
await saveCapturedImageNote(fixture.api, { ...image, notebookId: "" });
expect(fixture.order[0]).toBe("list");
expect(fixture.calls.created.notebookId).toBe("nb_first");
});
test("deletes the new note when the image upload fails", async () => {
const fixture = client({
uploadImage: async () => {
throw new Error("Missing required scope: write:resources");
},
});
await expect(saveCapturedImageNote(fixture.api, image)).rejects.toThrow("write:resources");
expect(fixture.calls.deleted).toEqual(["memo_1"]);
expect(fixture.order).toEqual(["create", "delete"]);
});
test("does not delete anything when no notebook exists", async () => {
const fixture = client({
listNotebooks: async () => ({ notebooks: [] }),
});
await expect(saveCapturedImageNote(fixture.api, { ...image, notebookId: "" })).rejects.toThrow("no-notebook");
expect(fixture.calls.deleted).toEqual([]);
});
});
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="imageSaveWindowTitle">Save image to EdgeEver</title>
</head>
<body>
<main class="image-save-shell">
<h1 data-i18n="imageSaveWindowTitle">Save image to EdgeEver</h1>
<p class="hint" data-i18n="imagePermissionIntro">This page cannot read the image file. After you allow the site that hosts it, EdgeEver saves it as a new note in the clipper's default notebook. Only the image you chose is read.</p>
<p id="host" class="hint"></p>
<div class="image-save-actions">
<button id="allow" type="button" disabled data-i18n="allowAndSaveImage">Allow and save</button>
<button id="allow-all" class="secondary" type="button" disabled data-i18n="allowAllSitesAndSave">Allow all sites and save future images directly</button>
</div>
<p id="status" class="status" role="status"></p>
</main>
<script type="module" src="/src/image-save.ts"></script>
</body>
</html>
+2 -2
View File
@@ -9,8 +9,8 @@
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": ["activeTab", "scripting", "storage"],
"optional_host_permissions": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"permissions": ["activeTab", "contextMenus", "scripting", "storage"],
"optional_host_permissions": ["https://*/*", "http://*/*", "http://localhost/*", "http://127.0.0.1/*"],
"action": {
"default_title": "__MSG_actionTitle__",
"default_icon": {
+7 -2
View File
@@ -12,8 +12,13 @@ describe("extension manifests", () => {
for (const target of ["chromium", "firefox"]) {
const manifest = buildExtensionManifest(target, extensionPackage.version);
expect(manifest.version).toBe(extensionPackage.version);
expect(manifest.permissions).toEqual(["activeTab", "scripting", "storage"]);
expect(manifest.optional_host_permissions).toContain("https://*/*");
expect(manifest.permissions).toEqual(["activeTab", "contextMenus", "scripting", "storage"]);
expect(manifest.optional_host_permissions).toEqual([
"https://*/*",
"http://*/*",
"http://localhost/*",
"http://127.0.0.1/*",
]);
}
});
+1
View File
@@ -8,6 +8,7 @@
<body>
<main class="options-shell">
<h1 data-i18n="extensionName">EdgeEver Web Clipper</h1>
<p class="hint" data-i18n="imageSaveHint">Right-click an image on a webpage and choose “Save image to EdgeEver”. It is saved into the default notebook selected below.</p>
<form id="settings-form">
<label>
<span data-i18n="instanceAddress">EdgeEver instance URL</span>
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@edgeever/extension",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"type": "module",
"scripts": {
@@ -9,7 +9,7 @@
"dev": "vite --config vite.config.ts",
"lint:firefox": "web-ext lint --source-dir dist-firefox --output text",
"package:firefox": "bun run build:firefox && bun run lint:firefox && web-ext build --source-dir dist-firefox --artifacts-dir web-ext-artifacts --overwrite-dest",
"test": "bun test manifest.test.mjs"
"test": "bun test manifest.test.mjs image-clip.test.mjs"
},
"dependencies": {
"@mozilla/readability": "^0.6.0",
+1 -1
View File
@@ -14,7 +14,7 @@
<p class="hint" data-i18n="webClipper">Web Clipper</p>
</div>
</header>
<p class="popup-description" data-i18n="popupDescription">Save the current webpage or selected content to your EdgeEver.</p>
<p class="popup-description" data-i18n="popupDescription">Clip the current page or selection. Right-click an image to save it as a note.</p>
<button id="save" class="save-button" type="button">
<svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18">
<path d="M5 4.75A1.75 1.75 0 0 1 6.75 3h8.836c.464 0 .91.184 1.238.513l1.663 1.663c.329.328.513.774.513 1.238v12.836A1.75 1.75 0 0 1 17.25 21H6.75A1.75 1.75 0 0 1 5 19.25V4.75Z" />
@@ -3,7 +3,7 @@
"message": "EdgeEver Web Clipper"
},
"extensionDescription": {
"message": "Save the current webpage or selected content to your self-hosted EdgeEver instance."
"message": "Save the current webpage, selected content, or a right-clicked image to your self-hosted EdgeEver instance."
},
"actionTitle": {
"message": "Save to EdgeEver"
@@ -12,7 +12,7 @@
"message": "Web Clipper"
},
"popupDescription": {
"message": "Clip the current webpage or selected content to your EdgeEver."
"message": "Clip the current page or selection. Right-click an image to save it as a note."
},
"saveCurrentPage": {
"message": "Clip current page"
@@ -195,5 +195,64 @@
},
"emptyPageContent": {
"message": "(No readable content was found on this page)"
},
"saveImageToEdgeEver": {
"message": "Save image to EdgeEver"
},
"imageSaveHint": {
"message": "Right-click an image on a webpage and choose “Save image to EdgeEver”. It is saved as a new note in the default notebook selected below."
},
"savingImage": {
"message": "Saving the image…"
},
"imageSaved": {
"message": "Saved as a new note."
},
"imageUnreadable": {
"message": "The image file could not be read, so nothing was saved. It may be drawn by the page, or it may require a signed-in download."
},
"imageTooLarge": {
"message": "This image is larger than 20 MB, so it was not saved."
},
"imageUnsupportedType": {
"message": "This image format cannot be saved into a note yet. PNG, JPEG, GIF, WebP, and AVIF are supported."
},
"imagePermissionIntro": {
"message": "This page cannot read the image file. After you allow the site that hosts it, EdgeEver saves it as a new note in the clipper's default notebook. Only the image you chose is read."
},
"imagePermissionHost": {
"message": "Site: $HOST$",
"placeholders": {
"host": {
"content": "$1"
}
}
},
"imagePermissionToast": {
"message": "Allow access to this image's site to save it."
},
"allowAndSaveImage": {
"message": "Allow and save"
},
"allowAllSitesAndSave": {
"message": "Allow all sites, then save future images directly"
},
"imagePermissionDenied": {
"message": "Site access was not allowed, so the image was not saved."
},
"imageResourceScopeRequired": {
"message": "This API token cannot write attachments. Create a full-access token in EdgeEver settings."
},
"imageNoteFallbackTitle": {
"message": "Image"
},
"imageAltFallback": {
"message": "Image"
},
"imageSaveWindowTitle": {
"message": "Save image to EdgeEver"
},
"imageSaveExpired": {
"message": "This save has expired. Right-click the image and try again."
}
}
@@ -3,7 +3,7 @@
"message": "EdgeEver Web Clipper"
},
"extensionDescription": {
"message": "表示中のページまたは選択した内容を、自分で運用する EdgeEver へ保存します。"
"message": "表示中のページ、選択した内容、または右クリックした画像を、自分で運用する EdgeEver に保存します。"
},
"actionTitle": {
"message": "EdgeEver に保存"
@@ -12,7 +12,7 @@
"message": "Web Clipper"
},
"popupDescription": {
"message": "表示中のページまたは選択した内容を EdgeEver に取り込みます。"
"message": "表示中のページや選択した内容を取り込みます。画像を右クリックしてノートとして保存することもできます。"
},
"saveCurrentPage": {
"message": "このページを取り込む"
@@ -195,5 +195,64 @@
},
"emptyPageContent": {
"message": "(このページから読み取れる本文はありませんでした)"
},
"saveImageToEdgeEver": {
"message": "画像を EdgeEver に保存"
},
"imageSaveHint": {
"message": "ウェブページ上の画像を右クリックし、「画像を EdgeEver に保存」を選ぶと、上で選んだ既定のノートブックに新しいノートとして保存されます。"
},
"savingImage": {
"message": "画像を保存しています…"
},
"imageSaved": {
"message": "新しいノートとして保存しました。"
},
"imageUnreadable": {
"message": "この画像のファイルを読み取れなかったため、保存していません。ページ上に描画された画像か、ログインしないとダウンロードできない画像の可能性があります。"
},
"imageTooLarge": {
"message": "この画像は 20 MB を超えているため、保存していません。"
},
"imageUnsupportedType": {
"message": "この画像形式はまだノートに保存できません。PNG、JPEG、GIF、WebP、AVIF に対応しています。"
},
"imagePermissionIntro": {
"message": "このページからは画像ファイルを読み取れません。画像のあるサイトへのアクセスを許可すると、クリップ拡張機能の既定ノートブックに新しいノートとして保存します。確認した画像だけを読み取ります。"
},
"imagePermissionHost": {
"message": "サイト: $HOST$",
"placeholders": {
"host": {
"content": "$1"
}
}
},
"imagePermissionToast": {
"message": "保存するには、この画像があるサイトの読み取りを許可してください。"
},
"allowAndSaveImage": {
"message": "許可して保存"
},
"allowAllSitesAndSave": {
"message": "すべてのサイトを許可し、以後は右クリックで直接保存"
},
"imagePermissionDenied": {
"message": "サイトへのアクセスが許可されなかったため、画像は保存されていません。"
},
"imageResourceScopeRequired": {
"message": "この API トークンには添付ファイルの書き込み権限がありません。EdgeEver の設定でフルアクセスのトークンを作成してください。"
},
"imageNoteFallbackTitle": {
"message": "画像"
},
"imageAltFallback": {
"message": "画像"
},
"imageSaveWindowTitle": {
"message": "画像を EdgeEver に保存"
},
"imageSaveExpired": {
"message": "この保存は終了しています。画像を右クリックしてやり直してください。"
}
}
@@ -3,7 +3,7 @@
"message": "EdgeEver 网页剪藏"
},
"extensionDescription": {
"message": "将当前网页或选中的内容保存到你自托管的 EdgeEver 实例。"
"message": "将当前网页、选中内容,或右键图片保存到你自托管的 EdgeEver 实例。"
},
"actionTitle": {
"message": "保存到 EdgeEver"
@@ -12,7 +12,7 @@
"message": "网页剪藏"
},
"popupDescription": {
"message": "剪藏当前网页或选中的内容到你的 EdgeEver。"
"message": "剪藏当前网页或选中内容。在图片上右键,可以把它存成一条笔记。"
},
"saveCurrentPage": {
"message": "剪藏当前网页"
@@ -195,5 +195,64 @@
},
"emptyPageContent": {
"message": "(网页没有可提取的正文内容)"
},
"saveImageToEdgeEver": {
"message": "保存图片到 EdgeEver"
},
"imageSaveHint": {
"message": "在网页图片上右键,选择“保存图片到 EdgeEver”。图片会存成一条新笔记,放进上面选择的默认笔记本。"
},
"savingImage": {
"message": "正在保存图片……"
},
"imageSaved": {
"message": "已保存为一条新笔记。"
},
"imageUnreadable": {
"message": "读不到这张图片的文件,没有保存。它可能是页面画出来的,或者需要登录后才能下载。"
},
"imageTooLarge": {
"message": "这张图片超过 20 MB,没有保存。"
},
"imageUnsupportedType": {
"message": "这种图片格式暂时不能存进笔记。支持 PNG、JPEG、GIF、WebP 和 AVIF。"
},
"imagePermissionIntro": {
"message": "当前网页读不到这张图片的文件。允许访问图片所在网站后,会把它存成一条新笔记,放进剪藏插件的默认笔记本。只会读取你这次要保存的这一张。"
},
"imagePermissionHost": {
"message": "网站:$HOST$",
"placeholders": {
"host": {
"content": "$1"
}
}
},
"imagePermissionToast": {
"message": "需要允许读取这个网站上的图片,才能保存。"
},
"allowAndSaveImage": {
"message": "允许并保存"
},
"allowAllSitesAndSave": {
"message": "允许所有网站,以后右键直接保存"
},
"imagePermissionDenied": {
"message": "没有获得网站访问权限,图片还没有保存。"
},
"imageResourceScopeRequired": {
"message": "这个 API Token 没有写入附件的权限。请在 EdgeEver 设置里创建一个完整访问的 Token。"
},
"imageNoteFallbackTitle": {
"message": "图片"
},
"imageAltFallback": {
"message": "图片"
},
"imageSaveWindowTitle": {
"message": "保存图片到 EdgeEver"
},
"imageSaveExpired": {
"message": "这次保存已经结束。请在图片上重新右键。"
}
}
+426 -2
View File
@@ -1,9 +1,27 @@
import {
edgeEverRequest,
getInstanceOrigin,
getSettings,
listNotebooks,
uploadMemoImage,
type ExtensionSettings,
} from "./extension";
import {
filenameForImage,
imageFromBase64,
imageFromBytes,
imageHostName,
imageOriginPattern,
isPageImageRead,
MAX_IMAGE_BYTES,
noteTitleForImage,
preferredImageUrls,
saveCapturedImageNote,
type ImageNoteClient,
type PageImageRead,
type StoredImage,
type StoredImageFailure,
} from "./image-clip";
import { t } from "./i18n";
type CapturedPage = {
@@ -12,6 +30,21 @@ type CapturedPage = {
markdown: string;
};
type PendingImageSave = {
urls: string[];
srcUrl: string;
pageUrl: string;
pageTitle: string;
tabId: number | null;
frameId: number | null;
originPattern: string;
host: string;
};
const IMAGE_MENU_ID = "save-image";
const PENDING_IMAGE_SAVE_KEY = "pendingImageSave";
const IMAGE_SAVE_WINDOW_KEY = "imageSaveWindowId";
const toMarkdown = (page: CapturedPage) => {
const capturedAt = new Date().toISOString();
return `# ${page.title.replace(/\n/g, " ")}\n\n${t("sourceLabel")}: [${page.url}](${page.url})\n\n${t("capturedAtLabel")}: ${capturedAt}\n\n---\n\n${page.markdown}`;
@@ -35,6 +68,9 @@ const describeCaptureError = (error: unknown) => {
const describeSaveError = (error: unknown) => {
const message = compactError(error);
if (/write:resources/i.test(message)) {
return t("imageResourceScopeRequired");
}
if (/failed to fetch|networkerror|load failed|network request/i.test(message)) {
return t("instanceNetworkFailed");
}
@@ -59,15 +95,403 @@ const createMemo = async (settings: ExtensionSettings, page: CapturedPage) => {
});
};
let pendingCapture: ((page: CapturedPage) => void) | null = null;
const imageNoteClient = (settings: ExtensionSettings): ImageNoteClient => ({
listNotebooks: () => listNotebooks(settings),
createMemo: (body) => edgeEverRequest(settings, "/api/v1/memos", {
method: "POST",
body: JSON.stringify(body),
}),
uploadImage: async (memoId, file) => {
const uploaded = await uploadMemoImage(settings, memoId, file);
return uploaded.resource;
},
createEditSession: (memoId) => edgeEverRequest(
settings,
`/api/v1/memos/${encodeURIComponent(memoId)}/edit-sessions`,
{ method: "POST", body: JSON.stringify({}) },
),
saveMemo: (memoId, body) => edgeEverRequest(
settings,
`/api/v1/memos/${encodeURIComponent(memoId)}/save`,
{ method: "POST", body: JSON.stringify(body) },
),
deleteMemo: (memoId) => edgeEverRequest(
settings,
`/api/v1/memos/${encodeURIComponent(memoId)}?permanent=1`,
{ method: "DELETE" },
),
});
chrome.runtime.onMessage.addListener((message: { type?: string; page?: CapturedPage }, _sender: unknown, sendResponse: (response: unknown) => void) => {
const scriptTarget = (tabId: number, frameId: number | null) =>
typeof frameId === "number" ? { tabId, frameIds: [frameId] } : { tabId };
const injectImageHelper = async (tabId: number, frameId: number | null, payload: unknown) => {
const target = scriptTarget(tabId, frameId);
await chrome.scripting.executeScript({
target,
func: (value: unknown) => {
(globalThis as { __edgeeverImageClipPayload?: unknown }).__edgeeverImageClipPayload = value;
},
args: [payload],
});
await chrome.scripting.executeScript({
target,
files: ["assets/capture-image.js"],
});
};
const showFeedback = async (
tabId: number | null,
frameId: number | null,
message: string,
kind: "success" | "error",
) => {
if (tabId) {
try {
await injectImageHelper(tabId, frameId, { action: "toast", message, kind });
return;
} catch {
// Restricted pages cannot host the toast. The toolbar badge still reports the result.
}
}
await chrome.action.setBadgeBackgroundColor({ color: kind === "error" ? "#b91c1c" : "#0f766e" });
await chrome.action.setBadgeText({ text: kind === "error" ? "!" : "✓" });
setTimeout(() => {
void chrome.action.setBadgeText({ text: "" });
}, 4000);
};
const imageFailureMessage = (reason: StoredImageFailure["error"]) => {
if (reason === "too-large") return t("imageTooLarge");
if (reason === "unsupported") return t("imageUnsupportedType");
return t("imageUnreadable");
};
const describeImageError = (error: unknown) => {
const message = error instanceof Error ? error.message : "";
if (
message === t("instancePermissionRequired")
|| message === t("completePluginConfiguration")
|| message === t("noAvailableNotebooks")
|| message === t("imageTooLarge")
|| message === t("imageUnsupportedType")
|| message === t("imageUnreadable")
) {
return message;
}
return describeSaveError(error);
};
const ensureClipperReady = async () => {
const settings = await getSettings();
if (!settings.instanceUrl || !settings.token) {
throw new Error(t("completePluginConfiguration"));
}
const granted = await chrome.permissions.contains({ origins: [`${getInstanceOrigin(settings.instanceUrl)}/*`] });
if (!granted) throw new Error(t("instancePermissionRequired"));
return settings;
};
const persistImage = async (
settings: ExtensionSettings,
image: StoredImage,
context: { srcUrl: string; pageUrl: string; pageTitle: string; alt: string },
) => {
try {
await saveCapturedImageNote(imageNoteClient(settings), {
notebookId: settings.notebookId,
title: noteTitleForImage(context.pageTitle, context.alt, t("imageNoteFallbackTitle")),
alt: context.alt,
filename: filenameForImage(context.srcUrl, image.mimeType),
mimeType: image.mimeType,
bytes: image.bytes,
pageUrl: context.pageUrl,
capturedAt: new Date().toISOString(),
sourceLabel: t("sourceLabel"),
capturedAtLabel: t("capturedAtLabel"),
altFallback: t("imageAltFallback"),
});
} catch (error) {
if (error instanceof Error && error.message === "no-notebook") {
throw new Error(t("noAvailableNotebooks"));
}
throw error;
}
};
const downloadImage = async (urls: string[]): Promise<StoredImage | StoredImageFailure> => {
const matchUrl = urls[urls.length - 1] ?? "";
let matchError: StoredImageFailure["error"] | "" = "";
for (const url of urls) {
if (!/^https?:/i.test(url)) continue;
for (const credentials of ["omit", "include"] as const) {
try {
const response = await fetch(url, { credentials });
if (!response.ok) continue;
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > MAX_IMAGE_BYTES) {
if (url === matchUrl) matchError = "too-large";
continue;
}
const image = imageFromBytes(new Uint8Array(await response.arrayBuffer()), response.headers.get("content-type") ?? "");
if (!("error" in image)) return image;
if (url === matchUrl) matchError = image.error;
} catch {
if (url === matchUrl) matchError = matchError || "unreadable";
}
}
}
if (matchError === "too-large" || matchError === "unsupported") return { error: matchError };
return { error: "unreadable" };
};
const hasImagePermission = async (urls: string[]) => {
const patterns = [...new Set(urls.map(imageOriginPattern).filter((pattern): pattern is string => Boolean(pattern)))];
for (const pattern of patterns) {
if (await chrome.permissions.contains({ origins: [pattern] })) return true;
}
return false;
};
const isPendingImageSave = (value: unknown): value is PendingImageSave => {
if (!value || typeof value !== "object") return false;
const record = value as Partial<PendingImageSave>;
return Array.isArray(record.urls)
&& record.urls.every((url) => typeof url === "string")
&& typeof record.srcUrl === "string"
&& typeof record.pageUrl === "string"
&& typeof record.pageTitle === "string"
&& typeof record.originPattern === "string"
&& typeof record.host === "string";
};
const readPendingImageSave = async () => {
const stored = await chrome.storage.session.get(PENDING_IMAGE_SAVE_KEY);
return isPendingImageSave(stored[PENDING_IMAGE_SAVE_KEY]) ? stored[PENDING_IMAGE_SAVE_KEY] : null;
};
const openImagePermissionWindow = async (pending: PendingImageSave) => {
await chrome.storage.session.set({ [PENDING_IMAGE_SAVE_KEY]: pending });
const stored = await chrome.storage.session.get(IMAGE_SAVE_WINDOW_KEY);
const existingId = stored[IMAGE_SAVE_WINDOW_KEY];
if (typeof existingId === "number") {
try {
const existing = await chrome.windows.get(existingId, { populate: true });
const tabId = existing.tabs?.[0]?.id;
if (tabId) {
await chrome.tabs.update(tabId, { url: chrome.runtime.getURL("image-save.html") });
await chrome.windows.update(existingId, { focused: true });
return;
}
} catch {
// The previous permission window has already closed.
}
}
const created = await chrome.windows.create({
url: chrome.runtime.getURL("image-save.html"),
type: "popup",
width: 440,
height: 640,
focused: true,
});
if (created.id) await chrome.storage.session.set({ [IMAGE_SAVE_WINDOW_KEY]: created.id });
};
let pendingCapture: ((page: CapturedPage) => void) | null = null;
const pendingImageReads = new Map<string, (result: unknown) => void>();
let imageSaveQueue = Promise.resolve();
let completingImageSave = false;
const enqueueImageSave = (job: () => Promise<void>) => {
const run = imageSaveQueue.then(job, job);
imageSaveQueue = run.then(() => undefined, () => undefined);
return run;
};
const readImageFromPage = async (tabId: number, frameId: number | null, urls: string[], matchUrl: string) => {
const requestId = crypto.randomUUID();
const result = await new Promise<unknown>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingImageReads.delete(requestId);
reject(new Error("timeout"));
}, 20_000);
pendingImageReads.set(requestId, (value) => {
clearTimeout(timeout);
resolve(value);
});
void injectImageHelper(tabId, frameId, {
action: "read",
requestId,
urls,
matchUrl,
maxBytes: MAX_IMAGE_BYTES,
}).catch((error: unknown) => {
clearTimeout(timeout);
pendingImageReads.delete(requestId);
reject(error);
});
});
return isPageImageRead(result) ? result : { ok: false as const, reason: "unreadable" as const };
};
const reportImageFailure = async (tabId: number | null, frameId: number | null, error: unknown) => {
const message = describeImageError(error);
if (message === t("completePluginConfiguration") || message === t("instancePermissionRequired")) {
await chrome.runtime.openOptionsPage();
}
await showFeedback(tabId, frameId, message, "error");
};
const saveDownloadedImage = async (
pending: Pick<PendingImageSave, "urls" | "srcUrl" | "pageUrl" | "pageTitle" | "tabId" | "frameId">,
alt: string,
) => {
const settings = await ensureClipperReady();
const downloaded = await downloadImage(pending.urls);
if ("error" in downloaded) throw new Error(imageFailureMessage(downloaded.error));
await persistImage(settings, downloaded, {
srcUrl: pending.srcUrl,
pageUrl: pending.pageUrl,
pageTitle: pending.pageTitle,
alt,
});
await chrome.storage.session.remove(PENDING_IMAGE_SAVE_KEY);
await showFeedback(pending.tabId, pending.frameId, t("imageSaved"), "success");
};
const saveImageFromMenu = async (
info: { srcUrl?: string; pageUrl?: string; frameId?: number },
tab?: { id?: number; url?: string; title?: string },
) => {
const tabId = typeof tab?.id === "number" ? tab.id : null;
const frameId = typeof info.frameId === "number" ? info.frameId : null;
const srcUrl = info.srcUrl ?? "";
const pageUrl = tab?.url || info.pageUrl || "";
const pageTitle = tab?.title || "";
try {
await ensureClipperReady();
if (!srcUrl) throw new Error(t("imageUnreadable"));
await showFeedback(tabId, frameId, t("savingImage"), "success");
const urls = preferredImageUrls(srcUrl);
let pageRead: PageImageRead | null = null;
if (tabId) {
try {
pageRead = await readImageFromPage(tabId, frameId, urls, srcUrl);
} catch {
pageRead = null;
}
}
if (pageRead?.ok) {
const image = imageFromBase64(pageRead.base64, pageRead.mimeType, pageRead.byteSize);
if ("error" in image) throw new Error(imageFailureMessage(image.error));
const settings = await ensureClipperReady();
await persistImage(settings, image, { srcUrl, pageUrl, pageTitle, alt: pageRead.alt });
await chrome.storage.session.remove(PENDING_IMAGE_SAVE_KEY);
await showFeedback(tabId, frameId, t("imageSaved"), "success");
return;
}
if (pageRead && !pageRead.ok && (pageRead.reason === "too-large" || pageRead.reason === "unsupported")) {
throw new Error(imageFailureMessage(pageRead.reason));
}
if (await hasImagePermission(urls)) {
await saveDownloadedImage({ urls, srcUrl, pageUrl, pageTitle, tabId, frameId }, "");
return;
}
const originPattern = imageOriginPattern(srcUrl);
const host = imageHostName(srcUrl);
if (!originPattern || !host) throw new Error(t("imageUnreadable"));
await openImagePermissionWindow({
urls,
srcUrl,
pageUrl,
pageTitle,
tabId,
frameId,
originPattern,
host,
});
await showFeedback(tabId, frameId, t("imagePermissionToast"), "success");
} catch (error) {
await reportImageFailure(tabId, frameId, error);
}
};
const registerImageMenu = () => {
chrome.contextMenus.create({
id: IMAGE_MENU_ID,
title: t("saveImageToEdgeEver"),
contexts: ["image"],
documentUrlPatterns: ["http://*/*", "https://*/*"],
}, () => {
void chrome.runtime.lastError;
});
};
chrome.runtime.onInstalled.addListener(registerImageMenu);
registerImageMenu();
chrome.contextMenus.onClicked.addListener((info: { menuItemId?: string | number; srcUrl?: string; pageUrl?: string; frameId?: number }, tab?: { id?: number; url?: string; title?: string }) => {
if (info.menuItemId !== IMAGE_MENU_ID) return;
void enqueueImageSave(() => saveImageFromMenu(info, tab));
});
chrome.windows.onRemoved.addListener((windowId: number) => {
void chrome.storage.session.get(IMAGE_SAVE_WINDOW_KEY).then((stored: Record<string, unknown>) => {
if (stored[IMAGE_SAVE_WINDOW_KEY] === windowId) {
void chrome.storage.session.remove(IMAGE_SAVE_WINDOW_KEY);
}
}).catch(() => undefined);
});
chrome.runtime.onMessage.addListener((message: { type?: string; page?: CapturedPage; requestId?: string; result?: unknown }, _sender: unknown, sendResponse: (response: unknown) => void) => {
if (message.type === "capturedPage" && message.page) {
pendingCapture?.(message.page);
pendingCapture = null;
return false;
}
if (message.type === "pageImageRead" && message.requestId) {
pendingImageReads.get(message.requestId)?.(message.result);
pendingImageReads.delete(message.requestId);
return false;
}
if (message.type === "getPendingImageSave") {
void readPendingImageSave().then((pending) => {
sendResponse(pending ? { originPattern: pending.originPattern, host: pending.host } : null);
}, () => sendResponse(null));
return true;
}
if (message.type === "completePendingImageSave") {
if (completingImageSave) {
sendResponse({ ok: false, message: t("savingImage") });
return false;
}
completingImageSave = true;
void (async () => {
const pending = await readPendingImageSave();
if (!pending) {
sendResponse({ ok: false, message: t("imageSaveExpired") });
return;
}
try {
await showFeedback(pending.tabId, pending.frameId, t("savingImage"), "success");
await saveDownloadedImage(pending, "");
sendResponse({ ok: true });
} catch (error) {
const message = describeImageError(error);
await showFeedback(pending.tabId, pending.frameId, message, "error");
sendResponse({ ok: false, message });
}
})().catch((error: unknown) => {
sendResponse({ ok: false, message: describeImageError(error) });
}).finally(() => {
completingImageSave = false;
});
return true;
}
if (message.type === "testConnection") {
void (async () => {
try {
+175
View File
@@ -0,0 +1,175 @@
// Injected with chrome.scripting.executeScript({ files }). This file must stay
// free of imports so the build remains a classic script. The background page
// writes the request onto globalThis immediately before injection.
(() => {
const root = globalThis as typeof globalThis & {
__edgeeverImageClipPayload?: {
action?: string;
requestId?: string;
urls?: unknown;
matchUrl?: string;
maxBytes?: number;
message?: string;
kind?: string;
};
};
const payload = root.__edgeeverImageClipPayload;
delete root.__edgeeverImageClipPayload;
if (!payload) return;
const supported = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
const sniff = (bytes: Uint8Array) => {
const starts = (signature: number[]) => signature.every((value, index) => bytes[index] === value);
if (starts([0x89, 0x50, 0x4e, 0x47])) return "image/png";
if (starts([0xff, 0xd8, 0xff])) return "image/jpeg";
if (starts([0x47, 0x49, 0x46])) return "image/gif";
if (bytes.length >= 12 && starts([0x52, 0x49, 0x46, 0x46]) && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
return "image/webp";
}
if (bytes.length >= 12 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
const brand = String.fromCharCode(bytes[8] ?? 0, bytes[9] ?? 0, bytes[10] ?? 0, bytes[11] ?? 0);
if (brand === "avif" || brand === "avis") return "image/avif";
}
return "";
};
const bytesToBase64 = (bytes: Uint8Array) => {
let binary = "";
const chunkSize = 0x2000;
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(index, index + chunkSize)));
}
return btoa(binary);
};
const showToast = (message: string, kind: string) => {
document.getElementById("edgeever-clipper-toast")?.remove();
const host = document.createElement("div");
host.id = "edgeever-clipper-toast";
host.style.position = "fixed";
host.style.zIndex = "2147483647";
host.style.right = "16px";
host.style.bottom = "16px";
host.style.pointerEvents = "none";
const shadow = host.attachShadow({ mode: "open" });
const toast = document.createElement("div");
toast.textContent = message;
toast.style.cssText = [
"max-width:280px",
"padding:12px 14px",
"border-radius:10px",
`background:${kind === "error" ? "#b91c1c" : "#0f766e"}`,
"color:#fff",
"font:600 13px/1.45 -apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif",
"box-shadow:0 8px 24px rgb(15 23 42 / 24%)",
].join(";");
shadow.append(toast);
(document.documentElement ?? document.body).append(host);
window.setTimeout(() => host.remove(), kind === "error" ? 8000 : 4000);
};
if (payload.action === "toast") {
showToast(typeof payload.message === "string" ? payload.message : "", payload.kind === "error" ? "error" : "success");
return;
}
if (payload.action !== "read" || typeof payload.requestId !== "string") return;
const requestId = payload.requestId;
const finish = (result: { ok: true; mimeType: string; base64: string; byteSize: number; alt: string } | { ok: false; reason: string }) => {
void chrome.runtime.sendMessage({ type: "pageImageRead", requestId, result });
};
const urlsMatch = (left: string, right: string) => {
if (!left || !right) return false;
if (left === right) return true;
try {
return new URL(left, document.baseURI).href === new URL(right, document.baseURI).href;
} catch {
return false;
}
};
const read = async () => {
const maxBytes = typeof payload.maxBytes === "number" ? payload.maxBytes : 20 * 1024 * 1024;
const matchUrl = typeof payload.matchUrl === "string" ? payload.matchUrl : "";
const requested = Array.isArray(payload.urls) ? payload.urls.filter((url): url is string => typeof url === "string" && url.length > 0) : [];
const matched = [...document.images].filter((img) => {
const candidates = [img.currentSrc, img.src, img.getAttribute("src") ?? ""];
return candidates.some((value) => urlsMatch(value, matchUrl) || requested.some((url) => urlsMatch(value, url)));
});
const alt = matched.find((img) => img.alt.trim())?.alt.trim()
|| matched.find((img) => img.getAttribute("title")?.trim())?.getAttribute("title")?.trim()
|| "";
const queue = [...requested];
for (const img of matched) {
if (img.currentSrc) queue.push(img.currentSrc);
if (img.src) queue.push(img.src);
}
const urls = [...new Set(queue)];
const classify = async (blob: Blob) => {
if (blob.size <= 0) return { ok: false as const, reason: "empty" as const };
if (blob.size > maxBytes) return { ok: false as const, reason: "too-large" as const };
const bytes = new Uint8Array(await blob.arrayBuffer());
if (bytes.byteLength > maxBytes) return { ok: false as const, reason: "too-large" as const };
const reported = blob.type.split(";")[0]?.trim().toLowerCase() ?? "";
const normalized = reported === "image/jpg" ? "image/jpeg" : reported;
const mimeType = supported.has(normalized) ? normalized : sniff(bytes);
if (!mimeType) return { ok: false as const, reason: "unsupported" as const };
return {
ok: true as const,
mimeType,
base64: bytesToBase64(bytes),
byteSize: bytes.byteLength,
alt,
};
};
let matchOutcome = "";
for (const url of urls) {
const attempts: Array<"omit" | "include"> = url.startsWith("http") ? ["omit", "include"] : ["omit"];
for (const credentials of attempts) {
try {
const response = await fetch(url, { credentials });
if (!response.ok) continue;
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > maxBytes) {
if (urlsMatch(url, matchUrl)) matchOutcome = "too-large";
continue;
}
const classified = await classify(await response.blob());
if (classified.ok) return classified;
if (urlsMatch(url, matchUrl)) matchOutcome = classified.reason;
} catch {
if (urlsMatch(url, matchUrl)) matchOutcome = matchOutcome || "unreadable";
}
}
}
for (const img of matched) {
if (!img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) continue;
try {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const context = canvas.getContext("2d");
if (!context) continue;
context.drawImage(img, 0, 0);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob((value) => resolve(value), "image/png"));
if (!blob) continue;
const classified = await classify(blob);
if (classified.ok) return classified;
} catch {
// A cross-origin image without CORS taints the canvas.
}
}
if (matchOutcome === "too-large") return { ok: false as const, reason: "too-large" };
if (matchOutcome === "unsupported") return { ok: false as const, reason: "unsupported" };
return { ok: false as const, reason: "unreadable" };
};
void read().then(finish, () => finish({ ok: false, reason: "unreadable" }));
})();
+36
View File
@@ -76,6 +76,42 @@ export const edgeEverRequest = async <T>(settings: ExtensionSettings, path: stri
return response.json() as Promise<T>;
};
export const edgeEverFormRequest = async <T>(settings: ExtensionSettings, path: string, form: FormData): Promise<T> => {
const instanceUrl = normalizeInstanceUrl(settings.instanceUrl);
if (!instanceUrl || !settings.token) {
throw new Error(t("missingSettingsDetails"));
}
const response = await fetch(`${instanceUrl}${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${settings.token}` },
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => null) as { error?: { message?: string } } | null;
throw new Error(body?.error?.message || t("requestFailed", String(response.status)));
}
return response.json() as Promise<T>;
};
export const uploadMemoImage = async (
settings: ExtensionSettings,
memoId: string,
file: { bytes: Uint8Array; mimeType: string; filename: string },
) => {
const buffer = new ArrayBuffer(file.bytes.byteLength);
new Uint8Array(buffer).set(file.bytes);
const form = new FormData();
form.append("file", new File([buffer], file.filename, { type: file.mimeType }));
return edgeEverFormRequest<{ resource: { id: string } }>(
settings,
`/api/v1/memos/${encodeURIComponent(memoId)}/resources`,
form,
);
};
export const listNotebooks = (settings: ExtensionSettings) =>
edgeEverRequest<{ notebooks: Notebook[] }>(settings, "/api/v1/notebooks");
+306
View File
@@ -0,0 +1,306 @@
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
const SUPPORTED_IMAGE_MIME_TYPES = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
]);
const EXTENSION_BY_MIME: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/gif": "gif",
"image/webp": "webp",
"image/avif": "avif",
};
export type PageImageRead =
| { ok: true; mimeType: string; base64: string; byteSize: number; alt: string }
| { ok: false; reason: "too-large" | "unsupported" | "unreadable" | "empty" };
export type StoredImage = {
mimeType: string;
bytes: Uint8Array;
};
export type StoredImageFailure = {
error: "empty" | "too-large" | "unsupported" | "unreadable";
};
const PAGE_IMAGE_FAILURES = new Set(["too-large", "unsupported", "unreadable", "empty"]);
const startsWith = (bytes: Uint8Array, signature: number[]) =>
signature.every((value, index) => bytes[index] === value);
export const sniffImageMimeType = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47])) return "image/png";
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
if (startsWith(bytes, [0x47, 0x49, 0x46])) return "image/gif";
if (
bytes.length >= 12
&& startsWith(bytes, [0x52, 0x49, 0x46, 0x46])
&& bytes[8] === 0x57
&& bytes[9] === 0x45
&& bytes[10] === 0x42
&& bytes[11] === 0x50
) {
return "image/webp";
}
if (bytes.length >= 12 && bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
if (brand === "avif" || brand === "avis") return "image/avif";
}
return null;
};
export const isPageImageRead = (value: unknown): value is PageImageRead => {
if (!value || typeof value !== "object") return false;
const record = value as {
ok?: unknown;
mimeType?: unknown;
base64?: unknown;
byteSize?: unknown;
alt?: unknown;
reason?: unknown;
};
if (record.ok === true) {
return typeof record.mimeType === "string"
&& typeof record.base64 === "string"
&& typeof record.byteSize === "number"
&& typeof record.alt === "string";
}
return record.ok === false && typeof record.reason === "string" && PAGE_IMAGE_FAILURES.has(record.reason);
};
export const imageFromBytes = (bytes: Uint8Array, reportedType: string): StoredImage | StoredImageFailure => {
if (bytes.byteLength === 0) return { error: "empty" };
if (bytes.byteLength > MAX_IMAGE_BYTES) return { error: "too-large" };
const reported = reportedType.split(";")[0]?.trim().toLowerCase() ?? "";
const normalized = reported === "image/jpg" ? "image/jpeg" : reported;
const mimeType = SUPPORTED_IMAGE_MIME_TYPES.has(normalized) ? normalized : sniffImageMimeType(bytes);
if (!mimeType) return { error: "unsupported" };
return { mimeType, bytes };
};
export const base64ToBytes = (value: string) => {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
};
export const imageFromBase64 = (
base64: string,
reportedType: string,
declaredSize?: number,
): StoredImage | StoredImageFailure => {
if (typeof declaredSize === "number" && declaredSize > MAX_IMAGE_BYTES) return { error: "too-large" };
try {
return imageFromBytes(base64ToBytes(base64), reportedType);
} catch {
return { error: "unreadable" };
}
};
export const preferredImageUrls = (srcUrl: string) => {
if (!srcUrl) return [];
const ordered: string[] = [];
const push = (value: string) => {
if (value && !ordered.includes(value)) ordered.push(value);
};
try {
const url = new URL(srcUrl);
const host = url.hostname.toLowerCase();
if (host === "pbs.twimg.com" || host.endsWith(".twimg.com")) {
const name = url.searchParams.get("name");
if (name && name !== "orig") {
const original = new URL(url);
original.searchParams.set("name", "orig");
push(original.toString());
}
if (name && name !== "orig" && name !== "large") {
const large = new URL(url);
large.searchParams.set("name", "large");
push(large.toString());
}
const sized = url.pathname.match(/:(small|medium|large|thumb|orig)$/i);
if (sized && sized[1]?.toLowerCase() !== "orig") {
const original = new URL(url);
original.pathname = url.pathname.replace(/:(small|medium|large|thumb|orig)$/i, ":orig");
push(original.toString());
}
}
} catch {
return [srcUrl];
}
push(srcUrl);
return ordered;
};
export const imageOriginPattern = (srcUrl: string) => {
try {
const url = new URL(srcUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
return `${url.origin}/*`;
} catch {
return null;
}
};
export const imageHostName = (srcUrl: string) => {
try {
const url = new URL(srcUrl);
if (url.protocol !== "https:" && url.protocol !== "http:") return "";
return url.hostname;
} catch {
return "";
}
};
export const filenameForImage = (srcUrl: string, mimeType: string) => {
const extension = EXTENSION_BY_MIME[mimeType] ?? "img";
let stem = "image";
try {
const url = new URL(srcUrl);
const base = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
const withoutSize = base.replace(/:(small|medium|large|thumb|orig)$/i, "");
const withoutExtension = withoutSize.replace(/\.[a-z0-9]{2,5}$/i, "");
const safe = withoutExtension.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
if (safe) stem = safe;
} catch {
// data: and blob: URLs keep the generic file name.
}
return `${stem}.${extension}`;
};
export const noteTitleForImage = (pageTitle: string, alt: string, fallback: string) => {
const candidate = pageTitle.replace(/\s+/g, " ").trim()
|| alt.replace(/\s+/g, " ").trim()
|| fallback;
return candidate.slice(0, 160);
};
export const imageAltText = (alt: string, fallback: string) => {
const cleaned = alt.replace(/[\r\n\]]/g, " ").replace(/\s+/g, " ").trim();
return cleaned || fallback;
};
const escapeMarkdownLabel = (value: string) =>
value.replace(/[\r\n]+/g, " ").replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
const markdownDestination = (value: string) =>
value.replace(/\\/g, "%5C").replace(/ /g, "%20").replace(/\(/g, "%28").replace(/\)/g, "%29");
export const imageSourceMarkdown = (input: {
pageUrl: string;
capturedAt: string;
sourceLabel: string;
capturedAtLabel: string;
}) => [
`${input.sourceLabel}: [${escapeMarkdownLabel(input.pageUrl)}](${markdownDestination(input.pageUrl)})`,
"",
`${input.capturedAtLabel}: ${input.capturedAt}`,
].join("\n");
export const imageMemoMarkdown = (input: {
resourceId: string;
alt: string;
altFallback: string;
pageUrl: string;
capturedAt: string;
sourceLabel: string;
capturedAtLabel: string;
}) => [
`![${imageAltText(input.alt, input.altFallback)}](/api/v1/resources/${encodeURIComponent(input.resourceId)}/blob)`,
"",
imageSourceMarkdown(input),
].join("\n");
export type ImageNoteClient = {
listNotebooks: () => Promise<{ notebooks: Array<{ id: string }> }>;
createMemo: (body: {
notebookId: string;
title: string;
contentMarkdown: string;
tags: string[];
}) => Promise<{ memo: { id: string } }>;
uploadImage: (
memoId: string,
file: { bytes: Uint8Array; mimeType: string; filename: string },
) => Promise<{ id: string }>;
createEditSession: (memoId: string) => Promise<{
editSession: { id: string; baseRevision: number; baseContentHash: string };
}>;
saveMemo: (
memoId: string,
body: {
editSessionId: string;
expectedRevision: number;
expectedContentHash: string;
contentMarkdown: string;
},
) => Promise<unknown>;
deleteMemo: (memoId: string) => Promise<unknown>;
};
export const saveCapturedImageNote = async (
client: ImageNoteClient,
input: {
notebookId: string;
title: string;
alt: string;
filename: string;
mimeType: string;
bytes: Uint8Array;
pageUrl: string;
capturedAt: string;
sourceLabel: string;
capturedAtLabel: string;
altFallback: string;
},
) => {
let notebookId = input.notebookId;
if (!notebookId) {
const listed = await client.listNotebooks();
notebookId = listed.notebooks[0]?.id ?? "";
}
if (!notebookId) throw new Error("no-notebook");
const sourceMarkdown = imageSourceMarkdown(input);
let memoId = "";
try {
const created = await client.createMemo({
notebookId,
title: input.title,
contentMarkdown: sourceMarkdown,
tags: ["web-clip"],
});
memoId = created.memo.id;
if (!memoId) throw new Error("create-missing-id");
const resource = await client.uploadImage(memoId, {
bytes: input.bytes,
mimeType: input.mimeType,
filename: input.filename,
});
if (!resource.id) throw new Error("upload-missing-id");
const session = await client.createEditSession(memoId);
await client.saveMemo(memoId, {
editSessionId: session.editSession.id,
expectedRevision: session.editSession.baseRevision,
expectedContentHash: session.editSession.baseContentHash,
contentMarkdown: imageMemoMarkdown({ ...input, resourceId: resource.id }),
});
return { memoId, resourceId: resource.id };
} catch (error) {
if (memoId) await client.deleteMemo(memoId).catch(() => undefined);
throw error;
}
};
+71
View File
@@ -0,0 +1,71 @@
import "./styles.css";
import { localizeDocument, t } from "./i18n";
localizeDocument();
document.title = t("imageSaveWindowTitle");
const hostLine = document.querySelector<HTMLParagraphElement>("#host");
const status = document.querySelector<HTMLParagraphElement>("#status");
const allowButton = document.querySelector<HTMLButtonElement>("#allow");
const allowAllButton = document.querySelector<HTMLButtonElement>("#allow-all");
const setStatus = (message: string, kind: "normal" | "error" | "success" = "normal") => {
if (!status) return;
status.textContent = message;
status.dataset.kind = kind;
};
const grant = async (origins: string[]) => {
if (!allowButton || !allowAllButton) return;
allowButton.disabled = true;
allowAllButton.disabled = true;
let granted = false;
try {
granted = await chrome.permissions.request({ origins });
} catch {
granted = false;
}
if (!granted) {
setStatus(t("imagePermissionDenied"), "error");
allowButton.disabled = false;
allowAllButton.disabled = false;
return;
}
setStatus(t("savingImage"));
try {
const response = await chrome.runtime.sendMessage({ type: "completePendingImageSave" }) as { ok?: boolean; message?: string } | undefined;
if (!response?.ok) {
setStatus(response?.message || t("saveFailed"), "error");
allowButton.disabled = false;
allowAllButton.disabled = false;
return;
}
} catch (error) {
setStatus(error instanceof Error ? error.message : t("saveFailed"), "error");
allowButton.disabled = false;
allowAllButton.disabled = false;
return;
}
setStatus(t("imageSaved"), "success");
window.setTimeout(() => window.close(), 700);
};
void chrome.runtime.sendMessage({ type: "getPendingImageSave" }).then((pending: { originPattern?: string; host?: string } | null) => {
if (!pending?.originPattern || !allowButton || !allowAllButton) {
setStatus(t("imageSaveExpired"), "error");
return;
}
if (hostLine && pending.host) hostLine.textContent = t("imagePermissionHost", pending.host);
allowButton.disabled = false;
allowAllButton.disabled = false;
allowButton.addEventListener("click", () => {
void grant([pending.originPattern as string]);
});
allowAllButton.addEventListener("click", () => {
void grant(["https://*/*", "http://*/*"]);
});
}).catch(() => {
setStatus(t("imageSaveExpired"), "error");
});
+25
View File
@@ -267,3 +267,28 @@ button:disabled {
.about-links span {
color: #94a3b8;
}
.image-save-shell {
width: min(100%, 420px);
padding: 22px;
font-size: 14px;
}
.image-save-shell h1 {
margin-bottom: 12px;
font-size: 18px;
}
.image-save-shell #host:empty {
display: none;
}
.image-save-actions {
display: grid;
gap: 8px;
margin-top: 18px;
}
.image-save-actions button {
width: 100%;
}
+2
View File
@@ -28,8 +28,10 @@ export default defineConfig({
input: {
popup: fileURLToPath(new URL("./popup.html", import.meta.url)),
options: fileURLToPath(new URL("./options.html", import.meta.url)),
"image-save": fileURLToPath(new URL("./image-save.html", import.meta.url)),
background: fileURLToPath(new URL("./src/background.ts", import.meta.url)),
capture: fileURLToPath(new URL("./src/capture.ts", import.meta.url)),
"capture-image": fileURLToPath(new URL("./src/capture-image.ts", import.meta.url)),
},
output: {
entryFileNames: "assets/[name].js",