feat(extension): save a GitHub repository as a note
Right-click a repository home or code tree to save the address, the About text, and the homepage, language, license, and topics when the page shows them. Issue, file, and discussion pages are left unsaved.
This commit is contained in:
1 parent
18c68235f4
commit
e8e982e1bb
14 files changed
+837
-20
No files matched your search
@@ -3,7 +3,7 @@
|
||||
## Listing
|
||||
|
||||
- Name: EdgeEver Web Clipper
|
||||
- Summary: Save a webpage, selected text, an image, or an X post to your EdgeEver instance.
|
||||
- Summary: Save a webpage, selected text, an image, an X post, or a GitHub repository to your EdgeEver instance.
|
||||
- Category: Bookmarks
|
||||
- Homepage: https://edgeever.org
|
||||
- Support: https://github.com/tianma-if/edgeever/issues
|
||||
@@ -11,18 +11,19 @@
|
||||
|
||||
## Description
|
||||
|
||||
EdgeEver Web Clipper saves the current webpage, selected text, a right-clicked image, or an X post directly to the self-hosted EdgeEver instance you configure.
|
||||
EdgeEver Web Clipper saves the current webpage, selected text, a right-clicked image, an X post, or a GitHub repository's address and description directly to the self-hosted EdgeEver instance you configure.
|
||||
|
||||
- Extract readable article content, or right-click selected text and save that passage only.
|
||||
- Right-click an image and save that image file as a new note. From Google Search, the note records Google Search and the keyword.
|
||||
- On X, right-click the post text and save the full post, including text behind Show more, plus photos already shown.
|
||||
- On a GitHub repository page or code tree, right-click the page background, the description, or the README text and save the repository address and description.
|
||||
- These commands stay on the top-level right-click menu.
|
||||
- Convert captured HTML to Markdown locally.
|
||||
- Choose a default EdgeEver notebook.
|
||||
- Send content directly to your instance without an EdgeEver-operated relay.
|
||||
- No advertising, analytics, tracking, or telemetry.
|
||||
|
||||
Before using the extension, enter your EdgeEver instance URL and API token in the extension settings. The extension reads and sends page content only after you click **Clip current page**, **Save selection to EdgeEver**, **Save image to EdgeEver**, or **Save this post to EdgeEver**.
|
||||
Before using the extension, enter your EdgeEver instance URL and API token in the extension settings. The extension reads and sends page content only after you click **Clip current page**, **Save selection to EdgeEver**, **Save image to EdgeEver**, **Save this post to EdgeEver**, or **Save this repository to EdgeEver**.
|
||||
|
||||
## Data collection and transmission
|
||||
|
||||
@@ -42,7 +43,8 @@ The project maintainers do not receive or retain this data. Instance settings ar
|
||||
6. On a normal webpage, select a passage, right-click it, choose **Save selection to EdgeEver**, and verify the note contains that passage and the page link.
|
||||
7. On a normal webpage, right-click an image, choose **Save image to EdgeEver**, and verify the new note contains the image.
|
||||
8. Open one X post page, right-click the post text (not a photo), choose **Save this post to EdgeEver**, and verify the note contains the post text and the post link.
|
||||
9. Verify the created notes in the review EdgeEver instance.
|
||||
9. Open one GitHub repository page, right-click the description or README text, choose **Save this repository to EdgeEver**, and verify the note contains the repository address and description.
|
||||
10. 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.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Chrome, Edge, and Firefox Manifest V3 extension for saving the current webpage,
|
||||
- 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. An image saved from Google Search records “Google Search” and the search keyword instead of the results URL.
|
||||
- Select text on a page, right-click, and choose **Save selection to EdgeEver**. That passage is saved as a new note in the default notebook, with the source page linked underneath. The rest of the page is not included.
|
||||
- On X, right-click the post text and choose **Save this post to EdgeEver**. The post text, author, time, and photos already shown in that post are saved into one note. Text folded behind Show more is expanded first, so a long post is saved in full. Right-clicking a photo saves that image instead, so the command stays on the top-level menu. A single-post page can be saved immediately. On a timeline, the extension asks once for access to X so it can remember the post under the pointer.
|
||||
- On a GitHub repository page or code tree, right-click the page background, the description, or the README text and choose **Save this repository to EdgeEver**. The note keeps the repository address, the About text, and the homepage, language, license, and topics when the page shows them. An empty About falls back to the README's first paragraph. Issue, discussion, and single-file pages are left unsaved. The toolbar action uses the same card on those repository pages. Right-clicking a link or an image keeps the existing selection and image commands, so this one stays on the top-level menu.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ Select the matching language in the Chrome Web Store developer dashboard and ent
|
||||
|
||||
#### Summary
|
||||
|
||||
将当前网页、选中文字、右键图片或 X 推文保存到你自托管的 EdgeEver 实例。
|
||||
将当前网页、选中文字、右键图片、X 推文或 GitHub 仓库保存到你自托管的 EdgeEver 实例。
|
||||
|
||||
#### Detailed description
|
||||
|
||||
EdgeEver Web Clipper 可以把当前网页、选中的文字、右键选中的图片,或 X 上的一条推文保存到自托管的 EdgeEver 实例。
|
||||
EdgeEver Web Clipper 可以把当前网页、选中的文字、右键选中的图片、X 上的一条推文,或 GitHub 仓库的地址和简介保存到自托管的 EdgeEver 实例。
|
||||
|
||||
主要功能:
|
||||
|
||||
@@ -39,12 +39,13 @@ EdgeEver Web Clipper 可以把当前网页、选中的文字、右键选中的
|
||||
- 选中一段文字后右键,选择“保存选中文字到 EdgeEver”,只保存这段文字。
|
||||
- 在图片上右键,选择“保存图片到 EdgeEver”,把图片文件存成一条新笔记。
|
||||
- 在 X 上右键推文正文,选择“保存这条推文到 EdgeEver”。长文会先展开“显示更多”,再保存全文和已经显示的图片。
|
||||
- 在 GitHub 仓库主页或代码目录上,右键页面空白、简介或 README 正文,选择“保存这个仓库到 EdgeEver”。笔记保留仓库地址和简介;页面上有主页、语言、许可证和话题时一并写入。
|
||||
- 这几项命令直接出现在右键菜单的第一级。
|
||||
- 在笔记中保留来源和剪藏时间。从 Google 搜索保存的图片记录“Google 搜索”和关键词。
|
||||
- 可选择默认笔记本,并自动添加 `web-clip` 标签。
|
||||
- 网页内容直接发送到你配置的 EdgeEver 实例,不经过开发者的中转服务器。
|
||||
|
||||
使用前,请在插件设置中填写 EdgeEver 实例地址和 API Token。插件只会在你点击“剪藏当前网页”,或选择“保存选中文字到 EdgeEver”“保存图片到 EdgeEver”“保存这条推文到 EdgeEver”后读取当前标签页。图片若无法由页面直接交出,才会再向你请求该图片所在网站的访问权限。
|
||||
使用前,请在插件设置中填写 EdgeEver 实例地址和 API Token。插件只会在你点击“剪藏当前网页”,或选择“保存选中文字到 EdgeEver”“保存图片到 EdgeEver”“保存这条推文到 EdgeEver”“保存这个仓库到 EdgeEver”后读取当前标签页。图片若无法由页面直接交出,才会再向你请求该图片所在网站的访问权限。
|
||||
|
||||
EdgeEver 是开源、自托管的现代笔记工作区。项目主页与源代码:https://github.com/tianma-if/edgeever
|
||||
|
||||
@@ -52,11 +53,11 @@ EdgeEver 是开源、自托管的现代笔记工作区。项目主页与源代
|
||||
|
||||
#### Summary
|
||||
|
||||
Save a webpage, selected text, a right-clicked image, or an X post to your self-hosted EdgeEver.
|
||||
Save a webpage, selected text, a right-clicked image, an X post, or a GitHub repository to your self-hosted EdgeEver.
|
||||
|
||||
#### Detailed description
|
||||
|
||||
EdgeEver Web Clipper saves the current webpage, selected text, a right-clicked image, or an X post to your self-hosted EdgeEver instance.
|
||||
EdgeEver Web Clipper saves the current webpage, selected text, a right-clicked image, an X post, or a GitHub repository's address and description to your self-hosted EdgeEver instance.
|
||||
|
||||
Key features:
|
||||
|
||||
@@ -64,12 +65,13 @@ Key features:
|
||||
- Select text, right-click, and choose “Save selection to EdgeEver” to store that passage only.
|
||||
- Right-click an image and choose “Save image to EdgeEver” to store the image file as a new note.
|
||||
- On X, right-click the post text and choose “Save this post to EdgeEver”. Long posts are expanded before saving, so the full text and already shown photos go into one note.
|
||||
- On a GitHub repository page or code tree, right-click the page background, the description, or the README text and choose “Save this repository to EdgeEver”. The note keeps the address and description, plus the homepage, language, license, and topics when the page shows them.
|
||||
- These commands stay on the top-level right-click menu.
|
||||
- Preserve the source and clipping time in the note. An image saved from Google Search records “Google Search” and the keyword.
|
||||
- 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” or choose “Save selection to EdgeEver”, “Save image to EdgeEver”, or “Save this post to EdgeEver”. It asks for access to an image's site only when that page cannot provide the image file.
|
||||
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 selection to EdgeEver”, “Save image to EdgeEver”, “Save this post to EdgeEver”, or “Save this repository 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
|
||||
|
||||
@@ -77,12 +79,12 @@ EdgeEver is an open-source, self-hosted modern notes workspace. Project homepage
|
||||
|
||||
### Single purpose
|
||||
|
||||
Save the current webpage, user-selected text, a user-chosen image, or one X post to the self-hosted EdgeEver instance explicitly configured by the user.
|
||||
Save the current webpage, user-selected text, a user-chosen image, one X post, or one GitHub repository card 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 or chooses Save selection to EdgeEver, Save image to EdgeEver, or Save this post to EdgeEver.
|
||||
- `contextMenus`: Add one top-level item for the thing the user right-clicked: selected text, an image, or an X post. It runs only after the user selects that item.
|
||||
- `activeTab`: Read the active page only after the user clicks the extension's save action or chooses Save selection to EdgeEver, Save image to EdgeEver, Save this post to EdgeEver, or Save this repository to EdgeEver.
|
||||
- `contextMenus`: Add one top-level item for the thing the user right-clicked: selected text, an image, an X post, or a GitHub repository page. 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 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. Saving a post from an X timeline asks for access to X so the extension can remember which post was under the pointer. The script on X only records that target and runs after the user allows it.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
cleanRepoDescription,
|
||||
githubRepoFactsFromSource,
|
||||
githubRepoNoteMarkdown,
|
||||
githubRepoNoteTitle,
|
||||
githubRepoTarget,
|
||||
readmeLead,
|
||||
} from "./src/github-repo.ts";
|
||||
|
||||
const labels = {
|
||||
capturedAt: "2026-10-01T00:00:00.000Z",
|
||||
sourceLabel: "来源",
|
||||
capturedAtLabel: "抓取时间",
|
||||
homepageLabel: "主页",
|
||||
languageLabel: "语言",
|
||||
licenseLabel: "许可证",
|
||||
topicsLabel: "话题",
|
||||
};
|
||||
|
||||
const source = (overrides = {}) => ({
|
||||
pageUrl: "https://github.com/tianma-if/edgeever",
|
||||
openGraphDescription: "",
|
||||
embeddedJsonChunks: [],
|
||||
aboutText: "",
|
||||
homepage: "",
|
||||
topics: [],
|
||||
license: "",
|
||||
language: "",
|
||||
readmeParagraphs: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const sidebarPayload = (overrides = {}) => JSON.stringify({
|
||||
payload: {
|
||||
sidebarAbout: {
|
||||
description: "Open-source, AI-native knowledge base.",
|
||||
website: "https://edgeever.org",
|
||||
topics: [{ name: "notes" }, { name: "mcp" }, { name: "notes" }],
|
||||
ownerLogin: "tianma-if",
|
||||
repoName: "edgeever",
|
||||
stargazerCount: 1915,
|
||||
forksCount: 995,
|
||||
repo: { license: { spdxId: "AGPL-3.0", name: "GNU Affero General Public License v3.0" } },
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("github repository addresses", () => {
|
||||
test("accepts a repository home and a code tree, and ignores other GitHub pages", () => {
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever")).toEqual({
|
||||
owner: "tianma-if",
|
||||
name: "edgeever",
|
||||
canonicalUrl: "https://github.com/tianma-if/edgeever",
|
||||
});
|
||||
expect(githubRepoTarget("https://www.github.com/tianma-if/edgeever/?tab=readme-ov-file#readme")?.canonicalUrl)
|
||||
.toBe("https://github.com/tianma-if/edgeever");
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever/tree/main")?.canonicalUrl)
|
||||
.toBe("https://github.com/tianma-if/edgeever");
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever/tree/release/1.0/apps")?.canonicalUrl)
|
||||
.toBe("https://github.com/tianma-if/edgeever");
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever/issues/12")).toBeNull();
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever/pull/4")).toBeNull();
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever/blob/main/README.md")).toBeNull();
|
||||
expect(githubRepoTarget("https://github.com/tianma-if/edgeever/tree")).toBeNull();
|
||||
expect(githubRepoTarget("https://github.com/tianma-if")).toBeNull();
|
||||
expect(githubRepoTarget("https://gist.github.com/tianma-if/edgeever")).toBeNull();
|
||||
expect(githubRepoTarget("https://example.com/tianma-if/edgeever")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("github repository card", () => {
|
||||
test("drops GitHub's generated description and the repository suffix", () => {
|
||||
expect(cleanRepoDescription(
|
||||
"Open-source notes. - tianma-if/edgeever",
|
||||
"tianma-if",
|
||||
"edgeever",
|
||||
)).toBe("Open-source notes.");
|
||||
expect(cleanRepoDescription(
|
||||
"Open-source notes & sync. - edgeever/apps at main · tianma-if/edgeever",
|
||||
"tianma-if",
|
||||
"edgeever",
|
||||
)).toBe("Open-source notes & sync.");
|
||||
expect(cleanRepoDescription(
|
||||
"Contribute to tianma-if/edgeever development by creating an account on GitHub.",
|
||||
"tianma-if",
|
||||
"edgeever",
|
||||
)).toBe("");
|
||||
});
|
||||
|
||||
test("uses the first real README paragraph when the about text is empty", () => {
|
||||
expect(readmeLead(["", "ok", "This is the project introduction."])).toBe("This is the project introduction.");
|
||||
expect(readmeLead(["字".repeat(900)]).endsWith("…")).toBe(true);
|
||||
expect(readmeLead(["字".repeat(900)]).length).toBe(801);
|
||||
});
|
||||
|
||||
test("builds a card from the repository payload and leaves out live counters", () => {
|
||||
const facts = githubRepoFactsFromSource(source({
|
||||
pageUrl: "https://github.com/Tianma-IF/EdgeEver/tree/main/apps",
|
||||
openGraphDescription: "Open-source, AI-native knowledge base. - tianma-if/edgeever",
|
||||
embeddedJsonChunks: [sidebarPayload()],
|
||||
language: "TypeScript 62.1%",
|
||||
topics: ["notes", "sqlite"],
|
||||
}));
|
||||
expect(facts).toMatchObject({
|
||||
owner: "tianma-if",
|
||||
name: "edgeever",
|
||||
canonicalUrl: "https://github.com/tianma-if/edgeever",
|
||||
intro: "Open-source, AI-native knowledge base.",
|
||||
homepage: "https://edgeever.org/",
|
||||
language: "TypeScript",
|
||||
license: "AGPL-3.0",
|
||||
topics: ["notes", "mcp", "sqlite"],
|
||||
});
|
||||
const markdown = githubRepoNoteMarkdown({ ...facts, ...labels });
|
||||
expect(githubRepoNoteTitle(facts.owner, facts.name)).toBe("tianma-if/edgeever");
|
||||
expect(markdown.startsWith("Open-source, AI-native knowledge base.")).toBe(true);
|
||||
expect(markdown).toContain("主页: [edgeever.org](https://edgeever.org/)");
|
||||
expect(markdown).toContain("语言: TypeScript");
|
||||
expect(markdown).toContain("许可证: AGPL-3.0");
|
||||
expect(markdown).toContain("话题: notes, mcp, sqlite");
|
||||
expect(markdown).toContain("[https://github.com/tianma-if/edgeever](https://github.com/tianma-if/edgeever)");
|
||||
expect(markdown).toContain("抓取时间: 2026-10-01T00:00:00.000Z");
|
||||
expect(markdown).not.toContain("1915");
|
||||
expect(markdown).not.toContain("995");
|
||||
});
|
||||
|
||||
test("reads a code tree that only identifies the repository, then uses the README lead", () => {
|
||||
const facts = githubRepoFactsFromSource(source({
|
||||
pageUrl: "https://github.com/tianma-if/edgeever/tree/main",
|
||||
openGraphDescription: "Contribute to tianma-if/edgeever development by creating an account on GitHub.",
|
||||
embeddedJsonChunks: [JSON.stringify({
|
||||
payload: {
|
||||
codeViewLayoutRoute: {
|
||||
repo: { ownerLogin: "tianma-if", name: "edgeever", defaultBranch: "main" },
|
||||
},
|
||||
},
|
||||
})],
|
||||
readmeParagraphs: ["Go", "A portable notes workspace for long-term writing."],
|
||||
}));
|
||||
expect(facts?.intro).toBe("A portable notes workspace for long-term writing.");
|
||||
expect(facts?.homepage).toBe("");
|
||||
expect(facts?.topics).toEqual([]);
|
||||
const markdown = githubRepoNoteMarkdown({ ...facts, ...labels });
|
||||
expect(markdown).not.toContain("主页:");
|
||||
expect(markdown).not.toContain("话题:");
|
||||
expect(markdown).toContain("来源: [https://github.com/tianma-if/edgeever](https://github.com/tianma-if/edgeever)");
|
||||
});
|
||||
|
||||
test("does not treat an unrelated GitHub page as the repository in the address", () => {
|
||||
expect(githubRepoFactsFromSource(source({
|
||||
pageUrl: "https://github.com/features/copilot",
|
||||
openGraphDescription: "AI pair programmer.",
|
||||
embeddedJsonChunks: [sidebarPayload()],
|
||||
}))).toBeNull();
|
||||
expect(githubRepoFactsFromSource(source({
|
||||
pageUrl: "https://github.com/tianma-if/edgeever/issues/8",
|
||||
embeddedJsonChunks: [sidebarPayload()],
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
<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>
|
||||
<p class="hint" data-i18n="selectionSaveHint">Select text on a page, right-click, and choose “Save selection to EdgeEver”. It is saved as a new note in the default notebook selected below.</p>
|
||||
<p class="hint" data-i18n="tweetSaveHint">On X, right-click a post and choose “Save this post to EdgeEver”. The visible text and photos go into one new note in the same notebook.</p>
|
||||
<p class="hint" data-i18n="githubRepoSaveHint">On a GitHub repository page or code tree, right-click the page background, the description, or the README text and choose “Save this repository to EdgeEver”. The address and description are saved as a new note in the default notebook selected above.</p>
|
||||
<form id="settings-form">
|
||||
<label>
|
||||
<span data-i18n="instanceAddress">EdgeEver instance URL</span>
|
||||
|
||||
@@ -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 image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs"
|
||||
"test": "bun test manifest.test.mjs image-clip.test.mjs tweet-clip.test.mjs selection-clip.test.mjs github-repo.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<p class="hint" data-i18n="webClipper">Web Clipper</p>
|
||||
</div>
|
||||
</header>
|
||||
<p class="popup-description" data-i18n="popupDescription">Clip the current page or selection. Right-click selected text to save that passage, right-click an image to save it, or right-click a post on X to save that post.</p>
|
||||
<p class="popup-description" data-i18n="popupDescription">Clip the current page or selection. Right-click selected text to save that passage, right-click an image to save it, right-click a post on X to save that post, or right-click a GitHub repository page to save its address and description.</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" />
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"message": "Web Clipper"
|
||||
},
|
||||
"popupDescription": {
|
||||
"message": "Clip the current page or selection. Right-click selected text to save that passage, right-click an image to save it, or right-click a post on X to save that post."
|
||||
"message": "Clip the current page or selection. Right-click selected text to save that passage, right-click an image to save it, right-click a post on X to save that post, or right-click a GitHub repository page to save its address and description."
|
||||
},
|
||||
"saveCurrentPage": {
|
||||
"message": "Clip current page"
|
||||
@@ -320,5 +320,35 @@
|
||||
},
|
||||
"tweetTimeLabel": {
|
||||
"message": "Time"
|
||||
},
|
||||
"saveGithubRepoToEdgeEver": {
|
||||
"message": "Save this repository to EdgeEver"
|
||||
},
|
||||
"githubRepoSaveHint": {
|
||||
"message": "On a GitHub repository page or code tree, right-click the page background, the description, or the README text and choose “Save this repository to EdgeEver”. The address and description are saved as a new note in the default notebook selected above. Issue, discussion, and single-file pages are left unsaved."
|
||||
},
|
||||
"savingGithubRepo": {
|
||||
"message": "Saving this repository…"
|
||||
},
|
||||
"githubRepoSaved": {
|
||||
"message": "Saved this repository as a note."
|
||||
},
|
||||
"githubRepoNotPage": {
|
||||
"message": "This is not a GitHub repository page or code tree, so nothing was saved."
|
||||
},
|
||||
"githubRepoUnreadable": {
|
||||
"message": "The repository details could not be read, so nothing was saved."
|
||||
},
|
||||
"githubRepoHomepageLabel": {
|
||||
"message": "Homepage"
|
||||
},
|
||||
"githubRepoLanguageLabel": {
|
||||
"message": "Language"
|
||||
},
|
||||
"githubRepoLicenseLabel": {
|
||||
"message": "License"
|
||||
},
|
||||
"githubRepoTopicsLabel": {
|
||||
"message": "Topics"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"message": "Web Clipper"
|
||||
},
|
||||
"popupDescription": {
|
||||
"message": "表示中のページや選択した内容を取り込みます。選択した文字の右クリックでその文章を、画像の右クリックで画像を、X 上の投稿の右クリックでその投稿を保存できます。"
|
||||
"message": "表示中のページや選択した内容を取り込みます。選択した文字の右クリックでその文章を、画像の右クリックで画像を、X 上の投稿の右クリックでその投稿を、GitHub のリポジトリページの右クリックでアドレスと紹介文を保存できます。"
|
||||
},
|
||||
"saveCurrentPage": {
|
||||
"message": "このページを取り込む"
|
||||
@@ -320,5 +320,35 @@
|
||||
},
|
||||
"tweetTimeLabel": {
|
||||
"message": "日時"
|
||||
},
|
||||
"saveGithubRepoToEdgeEver": {
|
||||
"message": "このリポジトリを EdgeEver に保存"
|
||||
},
|
||||
"githubRepoSaveHint": {
|
||||
"message": "GitHub のリポジトリページまたはコード一覧で、ページの余白、紹介文、または README 本文を右クリックし、「このリポジトリを EdgeEver に保存」を選びます。アドレスと紹介文は、上で選んだ既定のノートブックに 1 件のノートとして保存されます。Issue、ディスカッション、単一ファイルのページは保存しません。"
|
||||
},
|
||||
"savingGithubRepo": {
|
||||
"message": "このリポジトリを保存しています…"
|
||||
},
|
||||
"githubRepoSaved": {
|
||||
"message": "このリポジトリをノートとして保存しました。"
|
||||
},
|
||||
"githubRepoNotPage": {
|
||||
"message": "GitHub のリポジトリページまたはコード一覧ではないため、保存しませんでした。"
|
||||
},
|
||||
"githubRepoUnreadable": {
|
||||
"message": "リポジトリの情報を読み取れなかったため、保存しませんでした。"
|
||||
},
|
||||
"githubRepoHomepageLabel": {
|
||||
"message": "ホームページ"
|
||||
},
|
||||
"githubRepoLanguageLabel": {
|
||||
"message": "言語"
|
||||
},
|
||||
"githubRepoLicenseLabel": {
|
||||
"message": "ライセンス"
|
||||
},
|
||||
"githubRepoTopicsLabel": {
|
||||
"message": "トピック"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"message": "网页剪藏"
|
||||
},
|
||||
"popupDescription": {
|
||||
"message": "剪藏当前网页或选中内容。选中文字后右键可以保存这段文字;在图片上右键可以保存图片;在 X 上右键可以保存这条推文。"
|
||||
"message": "剪藏当前网页或选中内容。选中文字后右键可以保存这段文字;在图片上右键可以保存图片;在 X 上右键可以保存这条推文;在 GitHub 仓库页上右键可以保存仓库地址和简介。"
|
||||
},
|
||||
"saveCurrentPage": {
|
||||
"message": "剪藏当前网页"
|
||||
@@ -320,5 +320,35 @@
|
||||
},
|
||||
"tweetTimeLabel": {
|
||||
"message": "时间"
|
||||
},
|
||||
"saveGithubRepoToEdgeEver": {
|
||||
"message": "保存这个仓库到 EdgeEver"
|
||||
},
|
||||
"githubRepoSaveHint": {
|
||||
"message": "在 GitHub 仓库主页或代码目录上,右键页面空白、简介或 README 正文,选择“保存这个仓库到 EdgeEver”。仓库地址和简介会存成一条新笔记,放进上面选择的默认笔记本。Issue、讨论和单个文件页不会保存。"
|
||||
},
|
||||
"savingGithubRepo": {
|
||||
"message": "正在保存这个仓库……"
|
||||
},
|
||||
"githubRepoSaved": {
|
||||
"message": "已把这个仓库存成一条笔记。"
|
||||
},
|
||||
"githubRepoNotPage": {
|
||||
"message": "当前不是 GitHub 仓库主页或代码目录,没有保存。"
|
||||
},
|
||||
"githubRepoUnreadable": {
|
||||
"message": "没有读到这个仓库的信息,没有保存。"
|
||||
},
|
||||
"githubRepoHomepageLabel": {
|
||||
"message": "主页"
|
||||
},
|
||||
"githubRepoLanguageLabel": {
|
||||
"message": "语言"
|
||||
},
|
||||
"githubRepoLicenseLabel": {
|
||||
"message": "许可证"
|
||||
},
|
||||
"githubRepoTopicsLabel": {
|
||||
"message": "话题"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,14 @@ import {
|
||||
type StoredImage,
|
||||
type StoredImageFailure,
|
||||
} from "./image-clip";
|
||||
import {
|
||||
githubRepoFactsFromSource,
|
||||
githubRepoNoteMarkdown,
|
||||
githubRepoNoteTitle,
|
||||
githubRepoTarget,
|
||||
type GithubRepoFacts,
|
||||
type GithubRepoPageSource,
|
||||
} from "./github-repo";
|
||||
import {
|
||||
selectionNoteMarkdown,
|
||||
selectionNoteTitle,
|
||||
@@ -59,6 +67,7 @@ type PendingImageSave = {
|
||||
const IMAGE_MENU_ID = "save-image";
|
||||
const SELECTION_MENU_ID = "save-selection";
|
||||
const TWEET_MENU_ID = "save-tweet";
|
||||
const GITHUB_REPO_MENU_ID = "save-github-repo";
|
||||
const PENDING_IMAGE_SAVE_KEY = "pendingImageSave";
|
||||
const IMAGE_SAVE_WINDOW_KEY = "imageSaveWindowId";
|
||||
const PENDING_TWEET_KEY = "pendingTweetPermission";
|
||||
@@ -70,6 +79,10 @@ const TWEET_DOCUMENT_PATTERNS = [
|
||||
"https://www.twitter.com/*",
|
||||
"https://mobile.twitter.com/*",
|
||||
];
|
||||
const GITHUB_DOCUMENT_PATTERNS = [
|
||||
"https://github.com/*",
|
||||
"https://www.github.com/*",
|
||||
];
|
||||
|
||||
const toMarkdown = (page: CapturedPage) => {
|
||||
const capturedAt = new Date().toISOString();
|
||||
@@ -122,6 +135,32 @@ const createMemo = async (settings: ExtensionSettings, page: CapturedPage) => {
|
||||
});
|
||||
};
|
||||
|
||||
const createGithubRepoMemo = async (settings: ExtensionSettings, facts: GithubRepoFacts) => {
|
||||
await edgeEverRequest(settings, "/api/v1/memos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
notebookId: await notebookForClip(settings),
|
||||
title: githubRepoNoteTitle(facts.owner, facts.name),
|
||||
contentMarkdown: githubRepoNoteMarkdown({
|
||||
canonicalUrl: facts.canonicalUrl,
|
||||
intro: facts.intro,
|
||||
homepage: facts.homepage,
|
||||
language: facts.language,
|
||||
license: facts.license,
|
||||
topics: facts.topics,
|
||||
capturedAt: new Date().toISOString(),
|
||||
sourceLabel: t("sourceLabel"),
|
||||
capturedAtLabel: t("capturedAtLabel"),
|
||||
homepageLabel: t("githubRepoHomepageLabel"),
|
||||
languageLabel: t("githubRepoLanguageLabel"),
|
||||
licenseLabel: t("githubRepoLicenseLabel"),
|
||||
topicsLabel: t("githubRepoTopicsLabel"),
|
||||
}),
|
||||
tags: ["web-clip"],
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const imageNoteClient = (settings: ExtensionSettings): ImageNoteClient => ({
|
||||
listNotebooks: () => listNotebooks(settings),
|
||||
createMemo: (body) => edgeEverRequest(settings, "/api/v1/memos", {
|
||||
@@ -356,6 +395,7 @@ const openTweetPermissionWindow = async (tabId: number | null) => {
|
||||
let pendingCapture: ((page: CapturedPage) => void) | null = null;
|
||||
const pendingImageReads = new Map<string, (result: unknown) => void>();
|
||||
const pendingTweetReads = new Map<string, (result: unknown) => void>();
|
||||
const pendingGithubReads = new Map<string, (result: unknown) => void>();
|
||||
let clipQueue = Promise.resolve();
|
||||
let completingImageSave = false;
|
||||
|
||||
@@ -617,6 +657,108 @@ const saveTweetFromMenu = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isGithubRepoSource = (value: unknown): value is GithubRepoPageSource => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Partial<GithubRepoPageSource>;
|
||||
const strings = (items: unknown): items is string[] =>
|
||||
Array.isArray(items) && items.every((item) => typeof item === "string");
|
||||
return typeof record.pageUrl === "string"
|
||||
&& typeof record.openGraphDescription === "string"
|
||||
&& strings(record.embeddedJsonChunks)
|
||||
&& typeof record.aboutText === "string"
|
||||
&& typeof record.homepage === "string"
|
||||
&& strings(record.topics)
|
||||
&& typeof record.license === "string"
|
||||
&& typeof record.language === "string"
|
||||
&& strings(record.readmeParagraphs);
|
||||
};
|
||||
|
||||
const readGithubRepoFromPage = async (tabId: number, frameId: number | null) => {
|
||||
const requestId = crypto.randomUUID();
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingGithubReads.delete(requestId);
|
||||
reject(new Error(t("captureTimeout")));
|
||||
}, 10_000);
|
||||
pendingGithubReads.set(requestId, (value) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(value);
|
||||
});
|
||||
const target = scriptTarget(tabId, frameId);
|
||||
const inject = async () => {
|
||||
await chrome.scripting.executeScript({
|
||||
target,
|
||||
func: (value: unknown) => {
|
||||
(globalThis as { __edgeeverGithubClipPayload?: unknown }).__edgeeverGithubClipPayload = value;
|
||||
},
|
||||
args: [{ requestId }],
|
||||
});
|
||||
await chrome.scripting.executeScript({
|
||||
target,
|
||||
files: ["assets/capture-github.js"],
|
||||
});
|
||||
};
|
||||
void inject().catch((error: unknown) => {
|
||||
clearTimeout(timeout);
|
||||
pendingGithubReads.delete(requestId);
|
||||
reject(new Error(describeCaptureError(error)));
|
||||
});
|
||||
});
|
||||
if (!result || typeof result !== "object") return null;
|
||||
const source = (result as { source?: unknown }).source;
|
||||
if (!isGithubRepoSource(source)) return null;
|
||||
return githubRepoFactsFromSource(source);
|
||||
};
|
||||
|
||||
const describeGithubError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (
|
||||
message === t("completePluginConfiguration")
|
||||
|| message === t("instancePermissionRequired")
|
||||
|| message === t("noAvailableNotebooks")
|
||||
|| message === t("githubRepoNotPage")
|
||||
|| message === t("githubRepoUnreadable")
|
||||
|| message === t("captureTimeout")
|
||||
|| message === t("pageAccessDenied")
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
const captureScriptPrefix = t("captureScriptFailed", "\u0000").split("\u0000")[0] ?? "";
|
||||
if (captureScriptPrefix && message.startsWith(captureScriptPrefix)) return message;
|
||||
return describeSaveError(error);
|
||||
};
|
||||
|
||||
const saveGithubRepoFromMenu = async (
|
||||
info: { pageUrl?: string; frameId?: number },
|
||||
tab?: { id?: number; url?: string },
|
||||
) => {
|
||||
const tabId = typeof tab?.id === "number" ? tab.id : null;
|
||||
const frameId = typeof info.frameId === "number" ? info.frameId : null;
|
||||
const pageUrl = tab?.url || info.pageUrl || "";
|
||||
try {
|
||||
const settings = await ensureClipperReady();
|
||||
if (!githubRepoTarget(pageUrl)) {
|
||||
await showFeedback(tabId, frameId, t("githubRepoNotPage"), "error");
|
||||
return;
|
||||
}
|
||||
if (!tabId) throw new Error(t("githubRepoUnreadable"));
|
||||
const facts = await readGithubRepoFromPage(tabId, frameId);
|
||||
if (!facts) {
|
||||
await showFeedback(tabId, frameId, t("githubRepoUnreadable"), "error");
|
||||
return;
|
||||
}
|
||||
await showFeedback(tabId, frameId, t("savingGithubRepo"), "success");
|
||||
await createGithubRepoMemo(settings, facts);
|
||||
await showFeedback(tabId, frameId, t("githubRepoSaved"), "success");
|
||||
} catch (error) {
|
||||
const message = describeGithubError(error);
|
||||
if (message === t("completePluginConfiguration") || message === t("instancePermissionRequired")) {
|
||||
await chrome.runtime.openOptionsPage();
|
||||
}
|
||||
await showFeedback(tabId, frameId, message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const isCapturedPage = (value: unknown): value is CapturedPage => {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const record = value as Partial<CapturedPage>;
|
||||
@@ -728,8 +870,11 @@ const registerClipMenus = () => {
|
||||
// Chrome and Firefox fold an extension into a submenu when more than one of
|
||||
// its items is visible. These contexts stay disjoint so each command remains
|
||||
// on the top-level menu: a photo saves the image, selected words save the
|
||||
// passage, and the rest of an X post saves the post. Recreate from scratch
|
||||
// so a previous registration cannot keep an overlapping item.
|
||||
// passage, the rest of an X post saves the post, and a GitHub repository
|
||||
// page saves the repository.
|
||||
// Link context is omitted because a linked image would otherwise show both
|
||||
// commands. Recreate from scratch so a previous registration cannot keep an
|
||||
// overlapping item.
|
||||
chrome.contextMenus.removeAll(() => {
|
||||
void chrome.runtime.lastError;
|
||||
createClipMenus();
|
||||
@@ -761,6 +906,14 @@ const createClipMenus = () => {
|
||||
}, () => {
|
||||
void chrome.runtime.lastError;
|
||||
});
|
||||
chrome.contextMenus.create({
|
||||
id: GITHUB_REPO_MENU_ID,
|
||||
title: t("saveGithubRepoToEdgeEver"),
|
||||
contexts: ["page"],
|
||||
documentUrlPatterns: GITHUB_DOCUMENT_PATTERNS,
|
||||
}, () => {
|
||||
void chrome.runtime.lastError;
|
||||
});
|
||||
};
|
||||
|
||||
chrome.runtime.onInstalled.addListener(registerClipMenus);
|
||||
@@ -777,6 +930,10 @@ chrome.contextMenus.onClicked.addListener((info: { menuItemId?: string | number;
|
||||
}
|
||||
if (info.menuItemId === TWEET_MENU_ID) {
|
||||
void enqueueClip(() => saveTweetFromMenu(info, tab));
|
||||
return;
|
||||
}
|
||||
if (info.menuItemId === GITHUB_REPO_MENU_ID) {
|
||||
void enqueueClip(() => saveGithubRepoFromMenu(info, tab));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -810,6 +967,12 @@ chrome.runtime.onMessage.addListener((message: { type?: string; page?: CapturedP
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "pageGithubRead" && message.requestId) {
|
||||
pendingGithubReads.get(message.requestId)?.(message.result);
|
||||
pendingGithubReads.delete(message.requestId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.type === "activateTweetTarget") {
|
||||
void (async () => {
|
||||
const stored = await chrome.storage.session.get(PENDING_TWEET_KEY);
|
||||
@@ -885,6 +1048,21 @@ chrome.runtime.onMessage.addListener((message: { type?: string; page?: CapturedP
|
||||
throw new Error(t("currentPageNotFound"));
|
||||
}
|
||||
|
||||
const pageUrl = tab.url || "";
|
||||
if (githubRepoTarget(pageUrl)) {
|
||||
let facts: GithubRepoFacts | null = null;
|
||||
try {
|
||||
facts = await readGithubRepoFromPage(tab.id, null);
|
||||
} catch {
|
||||
// The repository card is unavailable. Save the page as an article.
|
||||
}
|
||||
if (facts) {
|
||||
await createGithubRepoMemo(settings, facts);
|
||||
sendResponse({ ok: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const page = await readCapturedPage(tab.id, null, "page");
|
||||
await createMemo(settings, page);
|
||||
sendResponse({ ok: true });
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Injected on demand into a GitHub tab. Must stay import-free: Chrome loads
|
||||
// this file with executeScript, which does not resolve extension modules.
|
||||
// The background page turns this snapshot into the note.
|
||||
(() => {
|
||||
const root = globalThis as typeof globalThis & {
|
||||
__edgeeverGithubClipPayload?: { requestId?: string };
|
||||
};
|
||||
const payload = root.__edgeeverGithubClipPayload;
|
||||
delete root.__edgeeverGithubClipPayload;
|
||||
if (!payload || typeof payload.requestId !== "string") return;
|
||||
const requestId = payload.requestId;
|
||||
|
||||
const finish = (result: Record<string, unknown>) => {
|
||||
void chrome.runtime.sendMessage({ type: "pageGithubRead", requestId, result });
|
||||
};
|
||||
|
||||
const text = (node: Node | null) => (node?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
|
||||
const metaContent = (selector: string) => document.querySelector(selector)?.getAttribute("content")?.trim() || "";
|
||||
|
||||
try {
|
||||
const chunks: string[] = [];
|
||||
for (const script of document.querySelectorAll('script[type="application/json"]')) {
|
||||
const body = script.textContent || "";
|
||||
if (!body.includes("ownerLogin") && !body.includes("sidebarAbout")) continue;
|
||||
if (body.length > 500_000) continue;
|
||||
chunks.push(body);
|
||||
if (chunks.length >= 4) break;
|
||||
}
|
||||
|
||||
const about = document.querySelector('[class*="SidebarAbout-module__description"]');
|
||||
const website = document.querySelector('[class*="website"] a[href^="http"], a[class*="website"][href^="http"]');
|
||||
const topics: string[] = [];
|
||||
for (const link of document.querySelectorAll('a[class*="TopicTag"], a[href^="/topics/"]')) {
|
||||
const href = link.getAttribute("href") || "";
|
||||
if (!/^\/topics\/[^/]+$/.test(href)) continue;
|
||||
const name = text(link);
|
||||
if (name) topics.push(name);
|
||||
}
|
||||
|
||||
let license = "";
|
||||
for (const icon of document.querySelectorAll(".octicon-law")) {
|
||||
const label = text(icon.closest("a") || icon.parentElement);
|
||||
const cleaned = label.replace(/\s+license$/i, "").trim();
|
||||
if (cleaned && cleaned.length <= 80 && !/readme/i.test(cleaned)) {
|
||||
license = cleaned;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const languages: { name: string; percent: number }[] = [];
|
||||
for (const section of document.querySelectorAll('[class*="sidebarSection"], [class*="SidebarSection"]')) {
|
||||
const stillLoading = Boolean(section.querySelector('[class*="Skeleton"], [data-component="SkeletonText"]'));
|
||||
if (stillLoading && !section.textContent?.includes("%")) continue;
|
||||
for (const row of section.querySelectorAll("li, a")) {
|
||||
const percentNode = [...row.querySelectorAll("span")].find((span) => /^\d+(?:\.\d+)?%$/.test(text(span)));
|
||||
const nameNode = [...row.querySelectorAll("span")].find((span) => {
|
||||
const label = text(span);
|
||||
return Boolean(label) && !label.endsWith("%") && !/^[\d.]+$/.test(label);
|
||||
});
|
||||
const name = nameNode ? text(nameNode) : "";
|
||||
const percent = percentNode ? Number.parseFloat(text(percentNode)) : Number.NaN;
|
||||
if (name && Number.isFinite(percent)) languages.push({ name, percent });
|
||||
}
|
||||
}
|
||||
languages.sort((left, right) => right.percent - left.percent);
|
||||
|
||||
const readmeRoot = document.querySelector("#readme") || document.querySelector(".markdown-body");
|
||||
const readme = readmeRoot?.querySelector(".markdown-body") || readmeRoot;
|
||||
const readmeParagraphs: string[] = [];
|
||||
if (readme) {
|
||||
for (const paragraph of readme.querySelectorAll("p")) {
|
||||
if (paragraph.closest("pre, table")) continue;
|
||||
const copy = paragraph.cloneNode(true) as HTMLElement;
|
||||
copy.querySelectorAll("img, svg, picture").forEach((node) => node.remove());
|
||||
const paragraphText = text(copy);
|
||||
if (paragraphText) readmeParagraphs.push(paragraphText);
|
||||
if (readmeParagraphs.length >= 8) break;
|
||||
}
|
||||
}
|
||||
|
||||
finish({
|
||||
ok: true,
|
||||
source: {
|
||||
pageUrl: location.href,
|
||||
openGraphDescription: metaContent('meta[property="og:description"]'),
|
||||
embeddedJsonChunks: chunks,
|
||||
aboutText: text(about),
|
||||
homepage: website?.getAttribute("href")?.trim() || "",
|
||||
topics,
|
||||
license,
|
||||
language: languages[0]?.name || "",
|
||||
readmeParagraphs,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
finish({ ok: false });
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,281 @@
|
||||
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
|
||||
const NAME = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export type GithubRepoTarget = {
|
||||
owner: string;
|
||||
name: string;
|
||||
canonicalUrl: string;
|
||||
};
|
||||
|
||||
export type GithubRepoPageSource = {
|
||||
pageUrl: string;
|
||||
openGraphDescription: string;
|
||||
embeddedJsonChunks: string[];
|
||||
aboutText: string;
|
||||
homepage: string;
|
||||
topics: string[];
|
||||
license: string;
|
||||
language: string;
|
||||
readmeParagraphs: string[];
|
||||
};
|
||||
|
||||
export type GithubRepoFacts = {
|
||||
owner: string;
|
||||
name: string;
|
||||
canonicalUrl: string;
|
||||
intro: string;
|
||||
homepage: string;
|
||||
language: string;
|
||||
license: string;
|
||||
topics: string[];
|
||||
};
|
||||
|
||||
const decodePathSegment = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const sameName = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "accent" }) === 0;
|
||||
|
||||
const readString = (value: unknown) => typeof value === "string" ? value.trim() : "";
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
const decodeEntities = (value: string) => value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const collapse = (value: string) => decodeEntities(value).replace(/\s+/g, " ").trim();
|
||||
|
||||
export const githubRepoTarget = (pageUrl: string): GithubRepoTarget | null => {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(pageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
if (!GITHUB_HOSTS.has(url.hostname.toLowerCase())) return null;
|
||||
const parts = url.pathname.split("/").filter(Boolean).map(decodePathSegment);
|
||||
if (parts.length < 2) return null;
|
||||
const [owner, name, section, ...rest] = parts;
|
||||
if (!owner || !name || !NAME.test(owner) || !NAME.test(name)) return null;
|
||||
if (section && section !== "tree") return null;
|
||||
if (section === "tree" && rest.length === 0) return null;
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
canonicalUrl: `https://github.com/${owner}/${name}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const cleanRepoDescription = (value: string, owner: string, name: string) => {
|
||||
let text = collapse(value);
|
||||
if (!text || !owner || !name) return text;
|
||||
const ownerName = `${escapeRegExp(owner)}/${escapeRegExp(name)}`;
|
||||
text = text
|
||||
.replace(new RegExp(`\\s+-\\s+.+\\s+at\\s+.+\\s+·\\s+${ownerName}\\s*$`, "i"), "")
|
||||
.replace(new RegExp(`\\s+-\\s+${ownerName}\\s*$`, "i"), "")
|
||||
.trim();
|
||||
if (new RegExp(`^contribute to (?:${ownerName}\\s+)?development by creating an account on github\\.?$`, "i").test(text)) {
|
||||
return "";
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
export const readmeLead = (paragraphs: readonly string[]) => {
|
||||
for (const paragraph of paragraphs) {
|
||||
const text = collapse(paragraph);
|
||||
if (text.length < 8 || !/[\p{L}\p{N}]/u.test(text)) continue;
|
||||
return text.length > 800 ? `${text.slice(0, 800).trimEnd()}…` : text;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const licenseText = (value: unknown) => {
|
||||
const record = asRecord(value);
|
||||
if (!record) return "";
|
||||
return readString(record.spdxId) || readString(record.name);
|
||||
};
|
||||
|
||||
const topicNames = (value: unknown) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const names: string[] = [];
|
||||
for (const item of value) {
|
||||
const name = typeof item === "string" ? item.trim() : readString(asRecord(item)?.name);
|
||||
if (name) names.push(name);
|
||||
}
|
||||
return names;
|
||||
};
|
||||
|
||||
type EmbeddedRepo = {
|
||||
owner: string;
|
||||
name: string;
|
||||
description: string;
|
||||
homepage: string;
|
||||
license: string;
|
||||
topics: string[];
|
||||
};
|
||||
|
||||
const embeddedRepoRecords = (value: unknown) => {
|
||||
const records: EmbeddedRepo[] = [];
|
||||
const visit = (node: unknown, depth: number) => {
|
||||
if (depth > 10 || node == null) return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node.slice(0, 40)) visit(item, depth + 1);
|
||||
return;
|
||||
}
|
||||
const record = asRecord(node);
|
||||
if (!record) return;
|
||||
const owner = readString(record.ownerLogin);
|
||||
const name = readString(record.repoName) || readString(record.name);
|
||||
const nestedRepo = asRecord(record.repo);
|
||||
const license = licenseText(record.license) || licenseText(nestedRepo?.license);
|
||||
const description = readString(record.description);
|
||||
const homepage = readString(record.website);
|
||||
const topics = topicNames(record.topics);
|
||||
const repoShaped = Boolean(owner && name && (
|
||||
description
|
||||
|| homepage
|
||||
|| topics.length
|
||||
|| license
|
||||
|| readString(record.defaultBranch)
|
||||
));
|
||||
if (repoShaped) {
|
||||
records.push({ owner, name, description, homepage, license, topics });
|
||||
}
|
||||
for (const child of Object.values(record)) {
|
||||
if (child && typeof child === "object") visit(child, depth + 1);
|
||||
}
|
||||
};
|
||||
visit(value, 0);
|
||||
return records;
|
||||
};
|
||||
|
||||
const normalizeHomepage = (value: string) => {
|
||||
const text = value.trim();
|
||||
if (!text) return "";
|
||||
const withScheme = /^https?:\/\//i.test(text)
|
||||
? text
|
||||
: /^[a-z0-9.-]+\.[a-z]{2,}(?:[/:?#].*)?$/i.test(text)
|
||||
? `https://${text}`
|
||||
: "";
|
||||
if (!withScheme) return "";
|
||||
try {
|
||||
const url = new URL(withScheme);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
return url.href;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const cleanLicense = (value: string) => collapse(value).replace(/\s+license$/i, "").trim().slice(0, 80);
|
||||
|
||||
const cleanLanguage = (value: string) => {
|
||||
const text = collapse(value).replace(/\s+\d+(?:\.\d+)?%$/, "").trim();
|
||||
if (!text || text.length > 40 || /%$/.test(text) || /^languages?$/i.test(text)) return "";
|
||||
return text;
|
||||
};
|
||||
|
||||
const dedupeTopics = (values: readonly string[]) => {
|
||||
const topics: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const value of values) {
|
||||
const topic = collapse(value).slice(0, 50);
|
||||
const key = topic.toLowerCase();
|
||||
if (!topic || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
topics.push(topic);
|
||||
if (topics.length >= 30) break;
|
||||
}
|
||||
return topics;
|
||||
};
|
||||
|
||||
const firstText = (values: readonly string[]) => values.map((value) => value.trim()).find(Boolean) ?? "";
|
||||
|
||||
export const githubRepoFactsFromSource = (source: GithubRepoPageSource): GithubRepoFacts | null => {
|
||||
const target = githubRepoTarget(source.pageUrl);
|
||||
if (!target) return null;
|
||||
const records: EmbeddedRepo[] = [];
|
||||
for (const chunk of source.embeddedJsonChunks) {
|
||||
try {
|
||||
records.push(...embeddedRepoRecords(JSON.parse(chunk)));
|
||||
} catch {
|
||||
// A GitHub page can carry unrelated JSON beside the repository payload.
|
||||
}
|
||||
}
|
||||
const matched = records.filter((record) => sameName(record.owner, target.owner) && sameName(record.name, target.name));
|
||||
if (matched.length === 0) return null;
|
||||
const richest = matched.find((record) => record.description || record.topics.length || record.homepage) ?? matched[0];
|
||||
if (!richest) return null;
|
||||
const owner = richest.owner;
|
||||
const name = richest.name;
|
||||
const description = [
|
||||
...matched.map((record) => record.description),
|
||||
source.aboutText,
|
||||
source.openGraphDescription,
|
||||
].map((value) => cleanRepoDescription(value, owner, name)).find(Boolean) ?? "";
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
canonicalUrl: `https://github.com/${owner}/${name}`,
|
||||
intro: description || readmeLead(source.readmeParagraphs),
|
||||
homepage: normalizeHomepage(firstText([...matched.map((record) => record.homepage), source.homepage])),
|
||||
language: cleanLanguage(source.language),
|
||||
license: cleanLicense(firstText([...matched.map((record) => record.license), source.license])),
|
||||
topics: dedupeTopics([...matched.flatMap((record) => record.topics), ...source.topics]),
|
||||
};
|
||||
};
|
||||
|
||||
const escapeMarkdownText = (value: string) =>
|
||||
value.replace(/[\r\n]+/g, " ").replace(/\\/g, "\\\\").replace(/([*_`[\]])/g, "\\$1");
|
||||
|
||||
const markdownDestination = (value: string) =>
|
||||
value.replace(/\\/g, "%5C").replace(/ /g, "%20").replace(/\(/g, "%28").replace(/\)/g, "%29");
|
||||
|
||||
const homepageLabelText = (value: string) => value.replace(/^https?:\/\//i, "").replace(/\/$/, "");
|
||||
|
||||
export const githubRepoNoteTitle = (owner: string, name: string) => `${owner}/${name}`.slice(0, 120);
|
||||
|
||||
export const githubRepoNoteMarkdown = (input: {
|
||||
canonicalUrl: string;
|
||||
intro: string;
|
||||
homepage: string;
|
||||
language: string;
|
||||
license: string;
|
||||
topics: readonly string[];
|
||||
capturedAt: string;
|
||||
sourceLabel: string;
|
||||
capturedAtLabel: string;
|
||||
homepageLabel: string;
|
||||
languageLabel: string;
|
||||
licenseLabel: string;
|
||||
topicsLabel: string;
|
||||
}) => {
|
||||
const lines: string[] = [];
|
||||
const intro = escapeMarkdownText(input.intro);
|
||||
if (intro) lines.push(intro);
|
||||
if (input.homepage) {
|
||||
lines.push(`${escapeMarkdownText(input.homepageLabel)}: [${escapeMarkdownText(homepageLabelText(input.homepage))}](${markdownDestination(input.homepage)})`);
|
||||
}
|
||||
if (input.language) lines.push(`${escapeMarkdownText(input.languageLabel)}: ${escapeMarkdownText(input.language)}`);
|
||||
if (input.license) lines.push(`${escapeMarkdownText(input.licenseLabel)}: ${escapeMarkdownText(input.license)}`);
|
||||
if (input.topics.length) {
|
||||
lines.push(`${escapeMarkdownText(input.topicsLabel)}: ${input.topics.map((topic) => escapeMarkdownText(topic)).join(", ")}`);
|
||||
}
|
||||
lines.push(`${escapeMarkdownText(input.sourceLabel)}: [${escapeMarkdownText(input.canonicalUrl)}](${markdownDestination(input.canonicalUrl)})`);
|
||||
lines.push(`${escapeMarkdownText(input.capturedAtLabel)}: ${input.capturedAt}`);
|
||||
return lines.join("\n\n");
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export default defineConfig({
|
||||
capture: fileURLToPath(new URL("./src/capture.ts", import.meta.url)),
|
||||
"capture-image": fileURLToPath(new URL("./src/capture-image.ts", import.meta.url)),
|
||||
"capture-tweet": fileURLToPath(new URL("./src/capture-tweet.ts", import.meta.url)),
|
||||
"capture-github": fileURLToPath(new URL("./src/capture-github.ts", import.meta.url)),
|
||||
"tweet-target": fileURLToPath(new URL("./src/tweet-target.ts", import.meta.url)),
|
||||
"tweet-save": fileURLToPath(new URL("./tweet-save.html", import.meta.url)),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user