Save image to EdgeEver
+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.
+ +diff --git a/apps/extension/FIREFOX_STORE_LISTING.md b/apps/extension/FIREFOX_STORE_LISTING.md index 2384e621..2923eab5 100644 --- a/apps/extension/FIREFOX_STORE_LISTING.md +++ b/apps/extension/FIREFOX_STORE_LISTING.md @@ -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 中转服务。 diff --git a/apps/extension/README.md b/apps/extension/README.md index 9223e840..afd7f10d 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -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. diff --git a/apps/extension/STORE_LISTING.md b/apps/extension/STORE_LISTING.md index 481b14c5..cd973dd9 100644 --- a/apps/extension/STORE_LISTING.md +++ b/apps/extension/STORE_LISTING.md @@ -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 diff --git a/apps/extension/image-clip.test.mjs b/apps/extension/image-clip.test.mjs new file mode 100644 index 00000000..df6916f1 --- /dev/null +++ b/apps/extension/image-clip.test.mjs @@ -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("")).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(""); + 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(""); + 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([]); + }); +}); diff --git a/apps/extension/image-save.html b/apps/extension/image-save.html new file mode 100644 index 00000000..68da7042 --- /dev/null +++ b/apps/extension/image-save.html @@ -0,0 +1,21 @@ + + +
+ + +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.
+ +Right-click an image on a webpage and choose “Save image to EdgeEver”. It is saved into the default notebook selected below.