@oh-my-pi/pi-coding-agent 3.30.0 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/package.json +5 -5
- package/src/cli/args.ts +4 -0
- package/src/core/agent-session.ts +29 -2
- package/src/core/bash-executor.ts +2 -1
- package/src/core/custom-commands/bundled/review/index.ts +369 -14
- package/src/core/custom-commands/bundled/wt/index.ts +1 -1
- package/src/core/session-manager.ts +158 -246
- package/src/core/session-storage.ts +379 -0
- package/src/core/settings-manager.ts +155 -4
- package/src/core/system-prompt.ts +62 -64
- package/src/core/tools/ask.ts +5 -4
- package/src/core/tools/bash-interceptor.ts +26 -61
- package/src/core/tools/bash.ts +13 -8
- package/src/core/tools/edit-diff.ts +11 -4
- package/src/core/tools/edit.ts +7 -13
- package/src/core/tools/find.ts +111 -50
- package/src/core/tools/gemini-image.ts +128 -147
- package/src/core/tools/grep.ts +397 -415
- package/src/core/tools/index.test.ts +5 -1
- package/src/core/tools/index.ts +6 -8
- package/src/core/tools/ls.ts +12 -10
- package/src/core/tools/lsp/client.ts +58 -9
- package/src/core/tools/lsp/config.ts +205 -656
- package/src/core/tools/lsp/defaults.json +465 -0
- package/src/core/tools/lsp/index.ts +55 -32
- package/src/core/tools/lsp/rust-analyzer.ts +49 -10
- package/src/core/tools/lsp/types.ts +1 -0
- package/src/core/tools/lsp/utils.ts +1 -1
- package/src/core/tools/read.ts +150 -74
- package/src/core/tools/render-utils.ts +70 -10
- package/src/core/tools/review.ts +38 -126
- package/src/core/tools/task/artifacts.ts +5 -4
- package/src/core/tools/task/executor.ts +94 -83
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/parallel.ts +30 -3
- package/src/core/tools/task/render.ts +85 -39
- package/src/core/tools/task/types.ts +15 -6
- package/src/core/tools/task/worker.ts +124 -89
- package/src/core/tools/web-fetch.ts +112 -377
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
- package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
- package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
- package/src/core/tools/web-scrapers/clojars.ts +180 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
- package/src/core/tools/web-scrapers/flathub.ts +239 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
- package/src/core/tools/web-scrapers/index.ts +250 -0
- package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
- package/src/core/tools/web-scrapers/lemmy.ts +220 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
- package/src/core/tools/web-scrapers/ollama.ts +267 -0
- package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
- package/src/core/tools/web-scrapers/spdx.ts +121 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
- package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
- package/src/core/tools/web-scrapers/w3c.ts +163 -0
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
- package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
- package/src/core/tools/write.ts +21 -18
- package/src/core/voice.ts +3 -2
- package/src/lib/worktree/collapse.ts +2 -1
- package/src/lib/worktree/git.ts +2 -18
- package/src/main.ts +59 -3
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
- package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
- package/src/modes/interactive/components/hook-editor.ts +2 -1
- package/src/modes/interactive/components/model-selector.ts +19 -4
- package/src/modes/interactive/interactive-mode.ts +41 -38
- package/src/modes/interactive/theme/theme.ts +58 -58
- package/src/modes/rpc/rpc-mode.ts +10 -9
- package/src/prompts/review-request.md +27 -0
- package/src/prompts/reviewer.md +64 -68
- package/src/prompts/tools/output.md +22 -3
- package/src/prompts/tools/task.md +32 -33
- package/src/utils/clipboard.ts +2 -1
- package/examples/extensions/subagent/agents/reviewer.md +0 -35
- package/src/core/tools/web-fetch-handlers/index.ts +0 -69
- package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
- /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface CrossrefAuthor {
|
|
5
|
+
given?: string;
|
|
6
|
+
family?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface CrossrefDate {
|
|
11
|
+
"date-parts"?: number[][];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CrossrefMessage {
|
|
15
|
+
title?: string[];
|
|
16
|
+
author?: CrossrefAuthor[];
|
|
17
|
+
"container-title"?: string[];
|
|
18
|
+
"short-container-title"?: string[];
|
|
19
|
+
publisher?: string;
|
|
20
|
+
published?: CrossrefDate;
|
|
21
|
+
"published-print"?: CrossrefDate;
|
|
22
|
+
"published-online"?: CrossrefDate;
|
|
23
|
+
issued?: CrossrefDate;
|
|
24
|
+
created?: CrossrefDate;
|
|
25
|
+
DOI?: string;
|
|
26
|
+
abstract?: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CrossrefResponse {
|
|
31
|
+
message?: CrossrefMessage;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DOI_HOSTS = new Set(["doi.org", "dx.doi.org", "www.doi.org"]);
|
|
35
|
+
|
|
36
|
+
function extractDoi(pathname: string): string | null {
|
|
37
|
+
const raw = pathname.replace(/^\/+/, "");
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
return decodeURIComponent(raw);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatAuthors(authors?: CrossrefAuthor[]): string | null {
|
|
43
|
+
if (!authors || authors.length === 0) return null;
|
|
44
|
+
const names = authors
|
|
45
|
+
.map((author) => {
|
|
46
|
+
if (author.name) return author.name;
|
|
47
|
+
const parts = [author.given, author.family].filter(Boolean);
|
|
48
|
+
return parts.length > 0 ? parts.join(" ") : null;
|
|
49
|
+
})
|
|
50
|
+
.filter((name): name is string => Boolean(name));
|
|
51
|
+
if (names.length === 0) return null;
|
|
52
|
+
return names.join(", ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDate(date?: CrossrefDate): string | null {
|
|
56
|
+
const parts = date?.["date-parts"]?.[0];
|
|
57
|
+
if (!parts || parts.length === 0) return null;
|
|
58
|
+
const [year, month, day] = parts;
|
|
59
|
+
if (!year) return null;
|
|
60
|
+
const formatted = [
|
|
61
|
+
String(year),
|
|
62
|
+
month ? String(month).padStart(2, "0") : "",
|
|
63
|
+
day ? String(day).padStart(2, "0") : "",
|
|
64
|
+
].filter(Boolean);
|
|
65
|
+
return formatted.join("-");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatAbstract(abstract?: string): string | null {
|
|
69
|
+
if (!abstract) return null;
|
|
70
|
+
const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, (match) => (match.startsWith("</") ? "</p>" : "<p>"));
|
|
71
|
+
const markdown = htmlToBasicMarkdown(normalized);
|
|
72
|
+
return markdown.trim().length > 0 ? markdown : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const handleCrossref: SpecialHandler = async (
|
|
76
|
+
url: string,
|
|
77
|
+
timeout: number,
|
|
78
|
+
signal?: AbortSignal,
|
|
79
|
+
): Promise<RenderResult | null> => {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(url);
|
|
82
|
+
if (!DOI_HOSTS.has(parsed.hostname.toLowerCase())) return null;
|
|
83
|
+
|
|
84
|
+
const doi = extractDoi(parsed.pathname);
|
|
85
|
+
if (!doi) return null;
|
|
86
|
+
|
|
87
|
+
const fetchedAt = new Date().toISOString();
|
|
88
|
+
const apiUrl = `https://api.crossref.org/works/${encodeURIComponent(doi)}`;
|
|
89
|
+
const result = await loadPage(apiUrl, {
|
|
90
|
+
timeout,
|
|
91
|
+
signal,
|
|
92
|
+
headers: {
|
|
93
|
+
Accept: "application/json",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!result.ok) return null;
|
|
98
|
+
|
|
99
|
+
let data: CrossrefResponse;
|
|
100
|
+
try {
|
|
101
|
+
data = JSON.parse(result.content);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const message = data.message;
|
|
107
|
+
if (!message) return null;
|
|
108
|
+
|
|
109
|
+
const title = message.title?.[0]?.trim() || "CrossRef Record";
|
|
110
|
+
const authors = formatAuthors(message.author);
|
|
111
|
+
const journal = message["container-title"]?.[0] || message["short-container-title"]?.[0];
|
|
112
|
+
const publisher = message.publisher;
|
|
113
|
+
const published =
|
|
114
|
+
formatDate(message.published) ||
|
|
115
|
+
formatDate(message["published-print"]) ||
|
|
116
|
+
formatDate(message["published-online"]) ||
|
|
117
|
+
formatDate(message.issued) ||
|
|
118
|
+
formatDate(message.created);
|
|
119
|
+
const doiValue = message.DOI || doi;
|
|
120
|
+
const abstract = formatAbstract(message.abstract);
|
|
121
|
+
const type = message.type?.replace(/-/g, " ");
|
|
122
|
+
|
|
123
|
+
let md = `# ${title}\n\n`;
|
|
124
|
+
if (authors) md += `**Authors:** ${authors}\n`;
|
|
125
|
+
if (journal) md += `**Journal:** ${journal}\n`;
|
|
126
|
+
if (publisher) md += `**Publisher:** ${publisher}\n`;
|
|
127
|
+
if (published) md += `**Published:** ${published}\n`;
|
|
128
|
+
md += `**DOI:** ${doiValue}\n`;
|
|
129
|
+
if (type) md += `**Type:** ${type}\n`;
|
|
130
|
+
md += "\n---\n\n";
|
|
131
|
+
md += "## Abstract\n\n";
|
|
132
|
+
md += abstract || "No abstract available.";
|
|
133
|
+
md += "\n";
|
|
134
|
+
|
|
135
|
+
const output = finalizeOutput(md);
|
|
136
|
+
return {
|
|
137
|
+
url,
|
|
138
|
+
finalUrl: url,
|
|
139
|
+
contentType: "text/markdown",
|
|
140
|
+
method: "crossref",
|
|
141
|
+
content: output.content,
|
|
142
|
+
fetchedAt,
|
|
143
|
+
truncated: output.truncated,
|
|
144
|
+
notes: ["Fetched via CrossRef API"],
|
|
145
|
+
};
|
|
146
|
+
} catch {}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
};
|
|
@@ -23,7 +23,11 @@ interface DevToArticle {
|
|
|
23
23
|
/**
|
|
24
24
|
* Handle dev.to URLs via API
|
|
25
25
|
*/
|
|
26
|
-
export const handleDevTo: SpecialHandler = async (
|
|
26
|
+
export const handleDevTo: SpecialHandler = async (
|
|
27
|
+
url: string,
|
|
28
|
+
timeout: number,
|
|
29
|
+
signal?: AbortSignal,
|
|
30
|
+
): Promise<RenderResult | null> => {
|
|
27
31
|
try {
|
|
28
32
|
const parsed = new URL(url);
|
|
29
33
|
if (parsed.hostname !== "dev.to") return null;
|
|
@@ -39,7 +43,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number):
|
|
|
39
43
|
const tag = pathParts[1];
|
|
40
44
|
const apiUrl = `https://dev.to/api/articles?tag=${encodeURIComponent(tag)}&per_page=20`;
|
|
41
45
|
|
|
42
|
-
const result = await loadPage(apiUrl, { timeout });
|
|
46
|
+
const result = await loadPage(apiUrl, { timeout, signal });
|
|
43
47
|
if (!result.ok) return null;
|
|
44
48
|
|
|
45
49
|
const articles = JSON.parse(result.content) as DevToArticle[];
|
|
@@ -82,7 +86,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number):
|
|
|
82
86
|
const username = pathParts[0];
|
|
83
87
|
const apiUrl = `https://dev.to/api/articles?username=${encodeURIComponent(username)}&per_page=20`;
|
|
84
88
|
|
|
85
|
-
const result = await loadPage(apiUrl, { timeout });
|
|
89
|
+
const result = await loadPage(apiUrl, { timeout, signal });
|
|
86
90
|
if (!result.ok) return null;
|
|
87
91
|
|
|
88
92
|
const articles = JSON.parse(result.content) as DevToArticle[];
|
|
@@ -125,7 +129,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number):
|
|
|
125
129
|
const slug = pathParts[1];
|
|
126
130
|
const apiUrl = `https://dev.to/api/articles/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`;
|
|
127
131
|
|
|
128
|
-
const result = await loadPage(apiUrl, { timeout });
|
|
132
|
+
const result = await loadPage(apiUrl, { timeout, signal });
|
|
129
133
|
if (!result.ok) return null;
|
|
130
134
|
|
|
131
135
|
const article = JSON.parse(result.content) as DevToArticle;
|
|
@@ -248,7 +248,11 @@ function buildMasterMarkdown(master: DiscogsMaster): string {
|
|
|
248
248
|
return sections.join("\n");
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
export const handleDiscogs: SpecialHandler = async (
|
|
251
|
+
export const handleDiscogs: SpecialHandler = async (
|
|
252
|
+
url: string,
|
|
253
|
+
timeout: number,
|
|
254
|
+
signal?: AbortSignal,
|
|
255
|
+
): Promise<RenderResult | null> => {
|
|
252
256
|
try {
|
|
253
257
|
const parsed = new URL(url);
|
|
254
258
|
if (!parsed.hostname.includes("discogs.com")) return null;
|
|
@@ -269,6 +273,7 @@ export const handleDiscogs: SpecialHandler = async (url: string, timeout: number
|
|
|
269
273
|
|
|
270
274
|
const result = await loadPage(apiUrl, {
|
|
271
275
|
timeout,
|
|
276
|
+
signal,
|
|
272
277
|
headers: {
|
|
273
278
|
Accept: "application/json",
|
|
274
279
|
"User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface DiscourseUser {
|
|
5
|
+
username?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DiscoursePost {
|
|
10
|
+
id: number;
|
|
11
|
+
username?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
created_at?: string;
|
|
14
|
+
cooked?: string;
|
|
15
|
+
raw?: string;
|
|
16
|
+
like_count?: number;
|
|
17
|
+
post_number?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DiscoursePostResponse extends DiscoursePost {
|
|
21
|
+
topic_id?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DiscourseTopic {
|
|
25
|
+
id?: number;
|
|
26
|
+
title?: string;
|
|
27
|
+
fancy_title?: string;
|
|
28
|
+
posts_count?: number;
|
|
29
|
+
created_at?: string;
|
|
30
|
+
views?: number;
|
|
31
|
+
like_count?: number;
|
|
32
|
+
tags?: string[];
|
|
33
|
+
category_id?: number;
|
|
34
|
+
category_slug?: string;
|
|
35
|
+
category?: { id?: number; name?: string; slug?: string };
|
|
36
|
+
excerpt?: string;
|
|
37
|
+
details?: { created_by?: DiscourseUser };
|
|
38
|
+
post_stream?: { posts?: DiscoursePost[] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_POSTS = 20;
|
|
42
|
+
|
|
43
|
+
function normalizeBasePath(basePath: string): string {
|
|
44
|
+
if (!basePath || basePath === "/") return "";
|
|
45
|
+
return basePath.replace(/\/$/, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseTopicPath(pathname: string): { basePath: string; topicId: string } | null {
|
|
49
|
+
const match = pathname.match(/^(.*?)(?:\/t\/)(?:[^/]+\/)?(\d+)(?:\.json)?(?:\/|$)/);
|
|
50
|
+
if (!match) return null;
|
|
51
|
+
return { basePath: match[1] ?? "", topicId: match[2] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parsePostPath(pathname: string): { basePath: string; postId: string } | null {
|
|
55
|
+
const match = pathname.match(/^(.*?)(?:\/posts\/)(\d+)(?:\.json)?(?:\/|$)/);
|
|
56
|
+
if (!match) return null;
|
|
57
|
+
return { basePath: match[1] ?? "", postId: match[2] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatAuthor(user?: DiscourseUser | null): string {
|
|
61
|
+
if (!user) return "unknown";
|
|
62
|
+
const name = user.name?.trim();
|
|
63
|
+
const username = user.username?.trim();
|
|
64
|
+
if (name && username && name !== username) return `${name} (@${username})`;
|
|
65
|
+
if (username) return `@${username}`;
|
|
66
|
+
if (name) return name;
|
|
67
|
+
return "unknown";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatIsoDate(value?: string): string {
|
|
71
|
+
if (!value) return "unknown";
|
|
72
|
+
const date = new Date(value);
|
|
73
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
74
|
+
return date.toISOString().split("T")[0];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatCategory(topic: DiscourseTopic): string | null {
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
const name = topic.category?.name ?? topic.category_slug;
|
|
80
|
+
if (name) parts.push(name);
|
|
81
|
+
const id = topic.category?.id ?? topic.category_id;
|
|
82
|
+
if (id != null) parts.push(`#${id}`);
|
|
83
|
+
return parts.length ? parts.join(" ") : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatPostBody(post: DiscoursePost): string {
|
|
87
|
+
const raw = post.raw?.trim();
|
|
88
|
+
if (raw) return raw;
|
|
89
|
+
const cooked = post.cooked?.trim();
|
|
90
|
+
if (!cooked) return "";
|
|
91
|
+
return htmlToBasicMarkdown(cooked);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildTopicUrl(baseUrl: string, topicId: string): string {
|
|
95
|
+
const topicUrl = new URL(`${baseUrl}/t/${topicId}.json`);
|
|
96
|
+
topicUrl.searchParams.set("include_raw", "1");
|
|
97
|
+
return topicUrl.toString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function buildPostUrl(baseUrl: string, postId: string): string {
|
|
101
|
+
const postUrl = new URL(`${baseUrl}/posts/${postId}.json`);
|
|
102
|
+
postUrl.searchParams.set("include_raw", "1");
|
|
103
|
+
return postUrl.toString();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle Discourse forum URLs via API
|
|
108
|
+
*/
|
|
109
|
+
export const handleDiscourse: SpecialHandler = async (
|
|
110
|
+
url: string,
|
|
111
|
+
timeout: number,
|
|
112
|
+
signal?: AbortSignal,
|
|
113
|
+
): Promise<RenderResult | null> => {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
const topicMatch = parseTopicPath(parsed.pathname);
|
|
117
|
+
const postMatch = topicMatch ? null : parsePostPath(parsed.pathname);
|
|
118
|
+
if (!topicMatch && !postMatch) return null;
|
|
119
|
+
|
|
120
|
+
const basePath = normalizeBasePath(topicMatch?.basePath ?? postMatch?.basePath ?? "");
|
|
121
|
+
const baseUrl = `${parsed.origin}${basePath}`;
|
|
122
|
+
|
|
123
|
+
let requestedPost: DiscoursePost | null = null;
|
|
124
|
+
let topicId = topicMatch?.topicId ?? null;
|
|
125
|
+
|
|
126
|
+
if (!topicId && postMatch) {
|
|
127
|
+
const postResult = await loadPage(buildPostUrl(baseUrl, postMatch.postId), { timeout, signal });
|
|
128
|
+
if (!postResult.ok) return null;
|
|
129
|
+
|
|
130
|
+
let postData: DiscoursePostResponse;
|
|
131
|
+
try {
|
|
132
|
+
postData = JSON.parse(postResult.content) as DiscoursePostResponse;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!postData.topic_id) return null;
|
|
138
|
+
topicId = String(postData.topic_id);
|
|
139
|
+
requestedPost = postData;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!topicId) return null;
|
|
143
|
+
|
|
144
|
+
const topicResult = await loadPage(buildTopicUrl(baseUrl, topicId), { timeout, signal });
|
|
145
|
+
if (!topicResult.ok) return null;
|
|
146
|
+
|
|
147
|
+
let topic: DiscourseTopic;
|
|
148
|
+
try {
|
|
149
|
+
topic = JSON.parse(topicResult.content) as DiscourseTopic;
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const title = topic.title || topic.fancy_title;
|
|
155
|
+
if (!title) return null;
|
|
156
|
+
|
|
157
|
+
const fetchedAt = new Date().toISOString();
|
|
158
|
+
|
|
159
|
+
const posts: DiscoursePost[] = [...(topic.post_stream?.posts ?? [])];
|
|
160
|
+
if (requestedPost && !posts.some((post) => post.id === requestedPost?.id)) {
|
|
161
|
+
posts.unshift(requestedPost);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let md = `# ${title}\n\n`;
|
|
165
|
+
|
|
166
|
+
const metaParts: string[] = [];
|
|
167
|
+
if (topic.id != null) metaParts.push(`**Topic ID:** ${topic.id}`);
|
|
168
|
+
if (topic.posts_count != null) metaParts.push(`**Posts:** ${topic.posts_count}`);
|
|
169
|
+
if (topic.views != null) metaParts.push(`**Views:** ${topic.views}`);
|
|
170
|
+
if (topic.like_count != null) metaParts.push(`**Likes:** ${topic.like_count}`);
|
|
171
|
+
if (metaParts.length) md += `${metaParts.join(" | ")}\n`;
|
|
172
|
+
|
|
173
|
+
const categoryLabel = formatCategory(topic);
|
|
174
|
+
if (categoryLabel) md += `**Category:** ${categoryLabel}\n`;
|
|
175
|
+
if (topic.tags?.length) md += `**Tags:** ${topic.tags.join(", ")}\n`;
|
|
176
|
+
|
|
177
|
+
const createdBy = formatAuthor(topic.details?.created_by ?? null);
|
|
178
|
+
if (createdBy !== "unknown" || topic.created_at) {
|
|
179
|
+
md += `**Created by:** ${createdBy} - ${formatIsoDate(topic.created_at)}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
md += "\n";
|
|
183
|
+
|
|
184
|
+
const description = topic.excerpt
|
|
185
|
+
? htmlToBasicMarkdown(topic.excerpt)
|
|
186
|
+
: posts.length
|
|
187
|
+
? formatPostBody(posts[0])
|
|
188
|
+
: "";
|
|
189
|
+
if (description) {
|
|
190
|
+
md += `## Description\n\n${description}\n\n`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (posts.length) {
|
|
194
|
+
md += "## Posts\n\n";
|
|
195
|
+
for (const post of posts.slice(0, MAX_POSTS)) {
|
|
196
|
+
const author = formatAuthor({ name: post.name, username: post.username });
|
|
197
|
+
const date = formatIsoDate(post.created_at);
|
|
198
|
+
const likes = post.like_count ?? 0;
|
|
199
|
+
const content = formatPostBody(post);
|
|
200
|
+
const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
|
|
201
|
+
|
|
202
|
+
md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
|
|
203
|
+
md += content ? `${content}\n\n---\n\n` : "_No content available._\n\n---\n\n";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const output = finalizeOutput(md);
|
|
208
|
+
return {
|
|
209
|
+
url,
|
|
210
|
+
finalUrl: url,
|
|
211
|
+
contentType: "text/markdown",
|
|
212
|
+
method: "discourse-api",
|
|
213
|
+
content: output.content,
|
|
214
|
+
fetchedAt,
|
|
215
|
+
truncated: output.truncated,
|
|
216
|
+
notes: ["Fetched via Discourse API"],
|
|
217
|
+
};
|
|
218
|
+
} catch {}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
};
|
|
@@ -39,7 +39,11 @@ function formatSize(bytes: number): string {
|
|
|
39
39
|
/**
|
|
40
40
|
* Handle Docker Hub URLs via API
|
|
41
41
|
*/
|
|
42
|
-
export const handleDockerHub: SpecialHandler = async (
|
|
42
|
+
export const handleDockerHub: SpecialHandler = async (
|
|
43
|
+
url: string,
|
|
44
|
+
timeout: number,
|
|
45
|
+
signal?: AbortSignal,
|
|
46
|
+
): Promise<RenderResult | null> => {
|
|
43
47
|
try {
|
|
44
48
|
const parsed = new URL(url);
|
|
45
49
|
if (!parsed.hostname.includes("hub.docker.com")) return null;
|
|
@@ -67,8 +71,8 @@ export const handleDockerHub: SpecialHandler = async (url: string, timeout: numb
|
|
|
67
71
|
const tagsUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/?page_size=10`;
|
|
68
72
|
|
|
69
73
|
const [repoResult, tagsResult] = await Promise.all([
|
|
70
|
-
loadPage(repoUrl, { timeout, headers: { Accept: "application/json" } }),
|
|
71
|
-
loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" } }),
|
|
74
|
+
loadPage(repoUrl, { timeout, headers: { Accept: "application/json" }, signal }),
|
|
75
|
+
loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" }, signal }),
|
|
72
76
|
]);
|
|
73
77
|
|
|
74
78
|
if (!repoResult.ok) return null;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
type LocalizedText = string | Record<string, string>;
|
|
5
|
+
|
|
6
|
+
type FdroidPackage = {
|
|
7
|
+
packageName?: string;
|
|
8
|
+
name?: LocalizedText;
|
|
9
|
+
summary?: LocalizedText;
|
|
10
|
+
description?: LocalizedText;
|
|
11
|
+
author?: string | { name?: string; email?: string };
|
|
12
|
+
authorName?: string;
|
|
13
|
+
authorEmail?: string;
|
|
14
|
+
license?: string;
|
|
15
|
+
categories?: string[];
|
|
16
|
+
antiFeatures?: string[];
|
|
17
|
+
sourceCode?: string;
|
|
18
|
+
packages?: Array<{
|
|
19
|
+
versionName?: string;
|
|
20
|
+
versionCode?: number;
|
|
21
|
+
added?: number;
|
|
22
|
+
antiFeatures?: string[];
|
|
23
|
+
}>;
|
|
24
|
+
suggestedVersionCode?: number;
|
|
25
|
+
suggestedVersionName?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function pickLocalizedText(value?: LocalizedText): string | undefined {
|
|
29
|
+
if (!value) return undefined;
|
|
30
|
+
if (typeof value === "string") return value;
|
|
31
|
+
const preferred = value["en-US"] ?? value.en_US ?? value.en;
|
|
32
|
+
if (preferred) return preferred;
|
|
33
|
+
const first = Object.values(value).find((entry) => typeof entry === "string");
|
|
34
|
+
return first;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeAuthor(data: FdroidPackage): string | undefined {
|
|
38
|
+
if (data.authorName) return data.authorName;
|
|
39
|
+
if (typeof data.author === "string") return data.author;
|
|
40
|
+
if (data.author && typeof data.author !== "string" && typeof data.author.name === "string") return data.author.name;
|
|
41
|
+
if (data.authorEmail) return data.authorEmail;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeAuthorEmail(data: FdroidPackage): string | undefined {
|
|
46
|
+
if (data.authorEmail) return data.authorEmail;
|
|
47
|
+
if (data.author && typeof data.author !== "string" && typeof data.author.email === "string")
|
|
48
|
+
return data.author.email;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectAntiFeatures(data: FdroidPackage): string[] {
|
|
53
|
+
const values = new Set<string>();
|
|
54
|
+
for (const feature of data.antiFeatures ?? []) values.add(feature);
|
|
55
|
+
for (const pkg of data.packages ?? []) {
|
|
56
|
+
for (const feature of pkg.antiFeatures ?? []) values.add(feature);
|
|
57
|
+
}
|
|
58
|
+
return Array.from(values);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveSuggestedVersion(data: FdroidPackage): string | undefined {
|
|
62
|
+
if (data.suggestedVersionName) return data.suggestedVersionName;
|
|
63
|
+
if (data.suggestedVersionCode) {
|
|
64
|
+
const match = data.packages?.find((pkg) => pkg.versionCode === data.suggestedVersionCode);
|
|
65
|
+
if (match?.versionName) return match.versionName;
|
|
66
|
+
}
|
|
67
|
+
return data.packages?.[0]?.versionName;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Handle F-Droid URLs via API
|
|
72
|
+
*/
|
|
73
|
+
export const handleFdroid: SpecialHandler = async (
|
|
74
|
+
url: string,
|
|
75
|
+
timeout: number,
|
|
76
|
+
signal?: AbortSignal,
|
|
77
|
+
): Promise<RenderResult | null> => {
|
|
78
|
+
try {
|
|
79
|
+
const parsed = new URL(url);
|
|
80
|
+
if (parsed.hostname !== "f-droid.org" && parsed.hostname !== "www.f-droid.org") return null;
|
|
81
|
+
|
|
82
|
+
// Extract package name from /packages/{packageName} or /en/packages/{packageName}
|
|
83
|
+
const match = parsed.pathname.match(/^\/(?:en\/)?packages\/([^/]+)/);
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
|
|
86
|
+
const packageName = decodeURIComponent(match[1]);
|
|
87
|
+
const fetchedAt = new Date().toISOString();
|
|
88
|
+
const apiUrl = `https://f-droid.org/api/v1/packages/${encodeURIComponent(packageName)}`;
|
|
89
|
+
|
|
90
|
+
const result = await loadPage(apiUrl, {
|
|
91
|
+
timeout,
|
|
92
|
+
headers: { Accept: "application/json" },
|
|
93
|
+
signal,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!result.ok) return null;
|
|
97
|
+
|
|
98
|
+
let data: FdroidPackage;
|
|
99
|
+
try {
|
|
100
|
+
data = JSON.parse(result.content) as FdroidPackage;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const displayName = pickLocalizedText(data.name) ?? packageName;
|
|
106
|
+
const summary = pickLocalizedText(data.summary);
|
|
107
|
+
const description = pickLocalizedText(data.description);
|
|
108
|
+
const author = normalizeAuthor(data);
|
|
109
|
+
const authorEmail = normalizeAuthorEmail(data);
|
|
110
|
+
const antiFeatures = collectAntiFeatures(data);
|
|
111
|
+
const latestVersion = resolveSuggestedVersion(data);
|
|
112
|
+
|
|
113
|
+
let md = `# ${displayName}\n\n`;
|
|
114
|
+
if (summary) md += `${summary}\n\n`;
|
|
115
|
+
|
|
116
|
+
md += `**Package:** ${packageName}`;
|
|
117
|
+
if (latestVersion) md += ` · **Latest:** ${latestVersion}`;
|
|
118
|
+
if (data.license) md += ` · **License:** ${data.license}`;
|
|
119
|
+
md += "\n";
|
|
120
|
+
|
|
121
|
+
if (author) {
|
|
122
|
+
md += `**Author:** ${author}`;
|
|
123
|
+
if (authorEmail && authorEmail !== author) md += ` <${authorEmail}>`;
|
|
124
|
+
md += "\n";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (data.sourceCode) md += `**Source Code:** ${data.sourceCode}\n`;
|
|
128
|
+
if (data.categories?.length) md += `**Categories:** ${data.categories.join(", ")}\n`;
|
|
129
|
+
if (antiFeatures.length) md += `**Anti-Features:** ${antiFeatures.join(", ")}\n`;
|
|
130
|
+
|
|
131
|
+
if (description) {
|
|
132
|
+
md += `\n## Description\n\n${description}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (data.packages?.length) {
|
|
136
|
+
md += "\n## Version History\n\n";
|
|
137
|
+
for (const version of data.packages.slice(0, 10)) {
|
|
138
|
+
const label = version.versionName ?? "unknown";
|
|
139
|
+
const code = version.versionCode ? ` (${version.versionCode})` : "";
|
|
140
|
+
md += `- ${label}${code}\n`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const output = finalizeOutput(md);
|
|
145
|
+
return {
|
|
146
|
+
url,
|
|
147
|
+
finalUrl: url,
|
|
148
|
+
contentType: "text/markdown",
|
|
149
|
+
method: "fdroid",
|
|
150
|
+
content: output.content,
|
|
151
|
+
fetchedAt,
|
|
152
|
+
truncated: output.truncated,
|
|
153
|
+
notes: ["Fetched via F-Droid API"],
|
|
154
|
+
};
|
|
155
|
+
} catch {}
|
|
156
|
+
|
|
157
|
+
return null;
|
|
158
|
+
};
|