@oh-my-pi/pi-coding-agent 3.25.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 +90 -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/complete.ts +2 -4
- 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/jtd-to-json-schema.ts +174 -196
- 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 +152 -76
- 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 +204 -67
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/name-generator.ts +1544 -214
- 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 +34 -11
- package/src/core/tools/task/worker.ts +152 -27
- package/src/core/tools/web-fetch.ts +220 -1657
- package/src/core/tools/web-scrapers/academic.test.ts +239 -0
- package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
- package/src/core/tools/web-scrapers/arxiv.ts +88 -0
- package/src/core/tools/web-scrapers/aur.ts +175 -0
- package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
- package/src/core/tools/web-scrapers/bluesky.ts +284 -0
- package/src/core/tools/web-scrapers/brew.ts +177 -0
- package/src/core/tools/web-scrapers/business.test.ts +82 -0
- package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
- package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
- 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-scrapers/coingecko.ts +184 -0
- package/src/core/tools/web-scrapers/crates-io.ts +128 -0
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-scrapers/devto.ts +177 -0
- package/src/core/tools/web-scrapers/discogs.ts +308 -0
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
- package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/finance-media.test.ts +144 -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-scrapers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-scrapers/github-gist.ts +68 -0
- package/src/core/tools/web-scrapers/github.ts +455 -0
- package/src/core/tools/web-scrapers/gitlab.ts +456 -0
- package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
- package/src/core/tools/web-scrapers/hackage.ts +94 -0
- package/src/core/tools/web-scrapers/hackernews.ts +208 -0
- package/src/core/tools/web-scrapers/hex.ts +121 -0
- package/src/core/tools/web-scrapers/huggingface.ts +385 -0
- package/src/core/tools/web-scrapers/iacr.ts +86 -0
- 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-scrapers/lobsters.ts +186 -0
- package/src/core/tools/web-scrapers/mastodon.ts +310 -0
- package/src/core/tools/web-scrapers/maven.ts +152 -0
- package/src/core/tools/web-scrapers/mdn.ts +174 -0
- package/src/core/tools/web-scrapers/media.test.ts +138 -0
- package/src/core/tools/web-scrapers/metacpan.ts +253 -0
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/web-scrapers/npm.ts +114 -0
- package/src/core/tools/web-scrapers/nuget.ts +205 -0
- package/src/core/tools/web-scrapers/nvd.ts +243 -0
- 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-scrapers/opencorporates.ts +275 -0
- package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/web-scrapers/osv.ts +189 -0
- package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
- package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
- package/src/core/tools/web-scrapers/packagist.ts +174 -0
- package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
- package/src/core/tools/web-scrapers/pubmed.ts +178 -0
- package/src/core/tools/web-scrapers/pypi.ts +129 -0
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
- package/src/core/tools/web-scrapers/reddit.ts +104 -0
- package/src/core/tools/web-scrapers/repology.ts +262 -0
- package/src/core/tools/web-scrapers/research.test.ts +107 -0
- package/src/core/tools/web-scrapers/rfc.ts +209 -0
- package/src/core/tools/web-scrapers/rubygems.ts +117 -0
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
- package/src/core/tools/web-scrapers/security.test.ts +103 -0
- package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
- package/src/core/tools/web-scrapers/social.test.ts +259 -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-scrapers/spotify.ts +218 -0
- package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
- package/src/core/tools/web-scrapers/standards.test.ts +122 -0
- package/src/core/tools/web-scrapers/terraform.ts +304 -0
- package/src/core/tools/web-scrapers/tldr.ts +51 -0
- package/src/core/tools/web-scrapers/twitter.ts +96 -0
- package/src/core/tools/web-scrapers/types.ts +234 -0
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/web-scrapers/vimeo.ts +152 -0
- 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-scrapers/wikidata.ts +357 -0
- package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
- package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
- package/src/core/tools/web-scrapers/youtube.ts +371 -0
- 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/src/utils/tools-manager.ts +110 -8
- package/examples/extensions/subagent/agents/reviewer.md +0 -35
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface MarketplaceProperty {
|
|
5
|
+
key?: string;
|
|
6
|
+
value?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface MarketplaceVersion {
|
|
10
|
+
version?: string;
|
|
11
|
+
properties?: MarketplaceProperty[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface MarketplaceStatistic {
|
|
15
|
+
statisticName?: string;
|
|
16
|
+
value?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface MarketplacePublisher {
|
|
20
|
+
publisherName?: string;
|
|
21
|
+
displayName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface MarketplaceExtension {
|
|
25
|
+
extensionName?: string;
|
|
26
|
+
displayName?: string;
|
|
27
|
+
shortDescription?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
publisher?: MarketplacePublisher;
|
|
30
|
+
versions?: MarketplaceVersion[];
|
|
31
|
+
statistics?: MarketplaceStatistic[];
|
|
32
|
+
categories?: string[];
|
|
33
|
+
tags?: string[];
|
|
34
|
+
properties?: MarketplaceProperty[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface MarketplaceResponse {
|
|
38
|
+
results?: Array<{ extensions?: MarketplaceExtension[] }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MARKETPLACE_HOSTS = new Set(["marketplace.visualstudio.com", "www.marketplace.visualstudio.com"]);
|
|
42
|
+
|
|
43
|
+
function getItemName(parsed: URL): string | null {
|
|
44
|
+
if (!parsed.pathname.startsWith("/items")) return null;
|
|
45
|
+
const itemName = parsed.searchParams.get("itemName");
|
|
46
|
+
if (!itemName) return null;
|
|
47
|
+
const decoded = decodeURIComponent(itemName);
|
|
48
|
+
if (!decoded.includes(".")) return null;
|
|
49
|
+
return decoded;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toStatMap(stats: MarketplaceStatistic[] | undefined): Map<string, number> {
|
|
53
|
+
const map = new Map<string, number>();
|
|
54
|
+
if (!stats) return map;
|
|
55
|
+
for (const stat of stats) {
|
|
56
|
+
if (!stat.statisticName || typeof stat.value !== "number") continue;
|
|
57
|
+
map.set(stat.statisticName.trim().toLowerCase(), stat.value);
|
|
58
|
+
}
|
|
59
|
+
return map;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatRating(averageRating?: number, ratingCount?: number): string | null {
|
|
63
|
+
if (averageRating === undefined && ratingCount === undefined) return null;
|
|
64
|
+
if (averageRating !== undefined) {
|
|
65
|
+
const formatted = averageRating.toFixed(2).replace(/\.0+$/, "").replace(/\.$/, "");
|
|
66
|
+
if (ratingCount !== undefined) {
|
|
67
|
+
return `${formatted} (${formatCount(ratingCount)} ratings)`;
|
|
68
|
+
}
|
|
69
|
+
return formatted;
|
|
70
|
+
}
|
|
71
|
+
if (ratingCount !== undefined) {
|
|
72
|
+
return `${formatCount(ratingCount)} ratings`;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractRepoLink(properties: MarketplaceProperty[] | undefined): string | null {
|
|
78
|
+
if (!properties) return null;
|
|
79
|
+
for (const prop of properties) {
|
|
80
|
+
const key = prop.key?.trim().toLowerCase();
|
|
81
|
+
const value = prop.value?.trim();
|
|
82
|
+
if (!key || !value) continue;
|
|
83
|
+
if (!value.startsWith("http")) continue;
|
|
84
|
+
if (key.includes("links.source") || key.includes("repository")) return value;
|
|
85
|
+
}
|
|
86
|
+
for (const prop of properties) {
|
|
87
|
+
const key = prop.key?.trim().toLowerCase();
|
|
88
|
+
const value = prop.value?.trim();
|
|
89
|
+
if (!key || !value) continue;
|
|
90
|
+
if (!value.startsWith("http")) continue;
|
|
91
|
+
if (key === "source" || key.endsWith(".source")) return value;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Handle VS Code Marketplace URLs via extension query API
|
|
98
|
+
*/
|
|
99
|
+
export const handleVscodeMarketplace: SpecialHandler = async (
|
|
100
|
+
url: string,
|
|
101
|
+
timeout: number,
|
|
102
|
+
signal?: AbortSignal,
|
|
103
|
+
): Promise<RenderResult | null> => {
|
|
104
|
+
try {
|
|
105
|
+
const parsed = new URL(url);
|
|
106
|
+
if (!MARKETPLACE_HOSTS.has(parsed.hostname)) return null;
|
|
107
|
+
|
|
108
|
+
const itemName = getItemName(parsed);
|
|
109
|
+
if (!itemName) return null;
|
|
110
|
+
|
|
111
|
+
const [publisherFromUrl, ...nameParts] = itemName.split(".");
|
|
112
|
+
const extensionFromUrl = nameParts.join(".");
|
|
113
|
+
|
|
114
|
+
const fetchedAt = new Date().toISOString();
|
|
115
|
+
const apiUrl = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery";
|
|
116
|
+
const payload = JSON.stringify({
|
|
117
|
+
filters: [
|
|
118
|
+
{
|
|
119
|
+
criteria: [{ filterType: 7, value: itemName }],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
flags: 950,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const result = await loadPage(apiUrl, {
|
|
126
|
+
timeout,
|
|
127
|
+
signal,
|
|
128
|
+
method: "POST",
|
|
129
|
+
body: payload,
|
|
130
|
+
headers: {
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
Accept: "application/json;api-version=7.2-preview.1",
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!result.ok) return null;
|
|
137
|
+
|
|
138
|
+
let data: MarketplaceResponse;
|
|
139
|
+
try {
|
|
140
|
+
data = JSON.parse(result.content) as MarketplaceResponse;
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const extension = data.results?.[0]?.extensions?.[0];
|
|
146
|
+
if (!extension) return null;
|
|
147
|
+
|
|
148
|
+
const extensionName = extension.extensionName ?? extensionFromUrl;
|
|
149
|
+
const displayName = extension.displayName ?? extensionName ?? itemName;
|
|
150
|
+
const description = extension.shortDescription ?? extension.description;
|
|
151
|
+
|
|
152
|
+
const publisherName = extension.publisher?.publisherName ?? publisherFromUrl;
|
|
153
|
+
const publisherDisplayName = extension.publisher?.displayName;
|
|
154
|
+
const publisherLabel =
|
|
155
|
+
publisherDisplayName && publisherName && publisherDisplayName !== publisherName
|
|
156
|
+
? `${publisherDisplayName} (${publisherName})`
|
|
157
|
+
: (publisherDisplayName ?? publisherName);
|
|
158
|
+
|
|
159
|
+
const version = extension.versions?.[0]?.version;
|
|
160
|
+
const statMap = toStatMap(extension.statistics);
|
|
161
|
+
const installs = statMap.get("install") ?? statMap.get("installs");
|
|
162
|
+
const averageRating = statMap.get("averagerating");
|
|
163
|
+
const ratingCount = statMap.get("ratingcount");
|
|
164
|
+
const ratingLabel = formatRating(averageRating, ratingCount);
|
|
165
|
+
|
|
166
|
+
const repoLink = extractRepoLink(extension.versions?.[0]?.properties) ?? extractRepoLink(extension.properties);
|
|
167
|
+
|
|
168
|
+
const identifier = publisherName && extensionName ? `${publisherName}.${extensionName}` : itemName;
|
|
169
|
+
|
|
170
|
+
let md = `# ${displayName}\n\n`;
|
|
171
|
+
if (description) md += `${description}\n\n`;
|
|
172
|
+
md += `**Identifier:** ${identifier}\n`;
|
|
173
|
+
if (publisherLabel) md += `**Publisher:** ${publisherLabel}\n`;
|
|
174
|
+
if (version) md += `**Version:** ${version}\n`;
|
|
175
|
+
if (installs !== undefined) md += `**Installs:** ${formatCount(installs)}\n`;
|
|
176
|
+
if (ratingLabel) md += `**Rating:** ${ratingLabel}\n`;
|
|
177
|
+
if (extension.categories?.length) md += `**Categories:** ${extension.categories.join(", ")}\n`;
|
|
178
|
+
if (extension.tags?.length) md += `**Tags:** ${extension.tags.join(", ")}\n`;
|
|
179
|
+
if (repoLink) md += `**Repository:** ${repoLink}\n`;
|
|
180
|
+
|
|
181
|
+
const output = finalizeOutput(md);
|
|
182
|
+
return {
|
|
183
|
+
url,
|
|
184
|
+
finalUrl: url,
|
|
185
|
+
contentType: "text/markdown",
|
|
186
|
+
method: "vscode-marketplace",
|
|
187
|
+
content: output.content,
|
|
188
|
+
fetchedAt,
|
|
189
|
+
truncated: output.truncated,
|
|
190
|
+
notes: ["Fetched via VS Code Marketplace API"],
|
|
191
|
+
};
|
|
192
|
+
} catch {}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
type JsonRecord = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
function asRecord(value: unknown): JsonRecord | null {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
8
|
+
return value as JsonRecord;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function getString(record: JsonRecord | null, key: string): string | undefined {
|
|
12
|
+
if (!record) return undefined;
|
|
13
|
+
const value = record[key];
|
|
14
|
+
return typeof value === "string" ? value : undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getRecord(record: JsonRecord | null, key: string): JsonRecord | null {
|
|
18
|
+
if (!record) return null;
|
|
19
|
+
return asRecord(record[key]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getArray(record: JsonRecord | null, key: string): unknown[] | undefined {
|
|
23
|
+
if (!record) return undefined;
|
|
24
|
+
const value = record[key];
|
|
25
|
+
return Array.isArray(value) ? value : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function extractShortname(pathname: string): string | null {
|
|
29
|
+
const trimmed = pathname.replace(/\/+$/g, "");
|
|
30
|
+
const segments = trimmed.split("/").filter(Boolean);
|
|
31
|
+
|
|
32
|
+
if (segments.length < 2 || segments[0] !== "TR") return null;
|
|
33
|
+
|
|
34
|
+
if (segments.length === 2) {
|
|
35
|
+
const shortname = segments[1];
|
|
36
|
+
if (/^\d{4}$/.test(shortname)) return null;
|
|
37
|
+
return decodeURIComponent(shortname);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (segments.length >= 3 && /^\d{4}$/.test(segments[1])) {
|
|
41
|
+
const version = segments[2];
|
|
42
|
+
const match = version.match(/^[A-Za-z]+-(.+)-\d{8}$/);
|
|
43
|
+
if (match?.[1]) return decodeURIComponent(match[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeStatus(status?: string): { code?: string; label?: string } {
|
|
50
|
+
if (!status) return {};
|
|
51
|
+
const lower = status.toLowerCase();
|
|
52
|
+
|
|
53
|
+
if (lower.includes("working draft")) return { code: "WD", label: status };
|
|
54
|
+
if (lower.includes("candidate recommendation")) return { code: "CR", label: status };
|
|
55
|
+
if (lower.includes("proposed recommendation")) return { code: "PR", label: status };
|
|
56
|
+
if (lower.includes("recommendation")) return { code: "REC", label: status };
|
|
57
|
+
|
|
58
|
+
return { label: status };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractEditors(editorsPayload: JsonRecord | null): string[] {
|
|
62
|
+
const links = getRecord(editorsPayload, "_links");
|
|
63
|
+
const editors = getArray(links, "editors") ?? [];
|
|
64
|
+
const names: string[] = [];
|
|
65
|
+
|
|
66
|
+
for (const entry of editors) {
|
|
67
|
+
const record = asRecord(entry);
|
|
68
|
+
const title = getString(record, "title");
|
|
69
|
+
if (title) names.push(title);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return names;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const handleW3c: 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 (parsed.hostname !== "www.w3.org" && parsed.hostname !== "w3.org") return null;
|
|
83
|
+
|
|
84
|
+
const shortname = extractShortname(parsed.pathname);
|
|
85
|
+
if (!shortname) return null;
|
|
86
|
+
|
|
87
|
+
const fetchedAt = new Date().toISOString();
|
|
88
|
+
|
|
89
|
+
const specUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}`;
|
|
90
|
+
const latestUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}/versions/latest`;
|
|
91
|
+
|
|
92
|
+
const [specResult, latestResult] = await Promise.all([
|
|
93
|
+
loadPage(specUrl, { timeout, signal, headers: { Accept: "application/json" } }),
|
|
94
|
+
loadPage(latestUrl, { timeout, signal, headers: { Accept: "application/json" } }),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
if (!specResult.ok || !latestResult.ok) return null;
|
|
98
|
+
|
|
99
|
+
const specPayload = asRecord(JSON.parse(specResult.content));
|
|
100
|
+
const latestPayload = asRecord(JSON.parse(latestResult.content));
|
|
101
|
+
if (!specPayload || !latestPayload) return null;
|
|
102
|
+
|
|
103
|
+
const title = getString(specPayload, "title");
|
|
104
|
+
const shortnameValue = getString(specPayload, "shortname") ?? shortname;
|
|
105
|
+
const description = getString(specPayload, "description") ?? getString(specPayload, "abstract");
|
|
106
|
+
const abstract = description ? htmlToBasicMarkdown(description) : undefined;
|
|
107
|
+
|
|
108
|
+
const latestVersionUrl =
|
|
109
|
+
getString(latestPayload, "uri") ??
|
|
110
|
+
getString(latestPayload, "shortlink") ??
|
|
111
|
+
getString(specPayload, "shortlink");
|
|
112
|
+
|
|
113
|
+
const latestStatus = getString(latestPayload, "status");
|
|
114
|
+
const normalizedStatus = normalizeStatus(latestStatus);
|
|
115
|
+
|
|
116
|
+
const specLinks = getRecord(specPayload, "_links");
|
|
117
|
+
const historyUrl = getString(getRecord(specLinks, "version-history"), "href");
|
|
118
|
+
|
|
119
|
+
const latestLinks = getRecord(latestPayload, "_links");
|
|
120
|
+
const editorsUrl = getString(getRecord(latestLinks, "editors"), "href");
|
|
121
|
+
|
|
122
|
+
let editors: string[] = [];
|
|
123
|
+
if (editorsUrl) {
|
|
124
|
+
const editorsResult = await loadPage(editorsUrl, { timeout: Math.min(timeout, 10), signal });
|
|
125
|
+
if (editorsResult.ok) {
|
|
126
|
+
try {
|
|
127
|
+
const editorsPayload = asRecord(JSON.parse(editorsResult.content));
|
|
128
|
+
editors = editorsPayload ? extractEditors(editorsPayload) : [];
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let md = `# ${title ?? shortnameValue}\n\n`;
|
|
134
|
+
if (abstract) md += `## Abstract\n\n${abstract}\n\n`;
|
|
135
|
+
|
|
136
|
+
md += "## Metadata\n\n";
|
|
137
|
+
md += `**Shortname:** ${shortnameValue}\n`;
|
|
138
|
+
if (normalizedStatus.code) {
|
|
139
|
+
md += `**Status:** ${normalizedStatus.code}`;
|
|
140
|
+
if (normalizedStatus.label) md += ` (${normalizedStatus.label})`;
|
|
141
|
+
md += "\n";
|
|
142
|
+
} else if (normalizedStatus.label) {
|
|
143
|
+
md += `**Status:** ${normalizedStatus.label}\n`;
|
|
144
|
+
}
|
|
145
|
+
if (editors.length) md += `**Editors:** ${editors.join(", ")}\n`;
|
|
146
|
+
if (latestVersionUrl) md += `**Latest Version:** ${latestVersionUrl}\n`;
|
|
147
|
+
if (historyUrl) md += `**History:** ${historyUrl}\n`;
|
|
148
|
+
|
|
149
|
+
const output = finalizeOutput(md);
|
|
150
|
+
return {
|
|
151
|
+
url,
|
|
152
|
+
finalUrl: latestVersionUrl ?? url,
|
|
153
|
+
contentType: "text/markdown",
|
|
154
|
+
method: "w3c-api",
|
|
155
|
+
content: output.content,
|
|
156
|
+
fetchedAt,
|
|
157
|
+
truncated: output.truncated,
|
|
158
|
+
notes: ["Fetched via W3C API"],
|
|
159
|
+
};
|
|
160
|
+
} catch {}
|
|
161
|
+
|
|
162
|
+
return null;
|
|
163
|
+
};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, formatCount, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Common Wikidata property IDs mapped to human-readable names
|
|
6
|
+
*/
|
|
7
|
+
const PROPERTY_LABELS: Record<string, string> = {
|
|
8
|
+
P31: "Instance of",
|
|
9
|
+
P279: "Subclass of",
|
|
10
|
+
P17: "Country",
|
|
11
|
+
P131: "Located in",
|
|
12
|
+
P625: "Coordinates",
|
|
13
|
+
P18: "Image",
|
|
14
|
+
P154: "Logo",
|
|
15
|
+
P571: "Founded",
|
|
16
|
+
P576: "Dissolved",
|
|
17
|
+
P169: "CEO",
|
|
18
|
+
P112: "Founded by",
|
|
19
|
+
P159: "Headquarters",
|
|
20
|
+
P452: "Industry",
|
|
21
|
+
P1128: "Employees",
|
|
22
|
+
P2139: "Revenue",
|
|
23
|
+
P856: "Website",
|
|
24
|
+
P21: "Sex/Gender",
|
|
25
|
+
P27: "Citizenship",
|
|
26
|
+
P569: "Born",
|
|
27
|
+
P570: "Died",
|
|
28
|
+
P19: "Birthplace",
|
|
29
|
+
P20: "Death place",
|
|
30
|
+
P106: "Occupation",
|
|
31
|
+
P108: "Employer",
|
|
32
|
+
P69: "Educated at",
|
|
33
|
+
P22: "Father",
|
|
34
|
+
P25: "Mother",
|
|
35
|
+
P26: "Spouse",
|
|
36
|
+
P40: "Child",
|
|
37
|
+
P166: "Award",
|
|
38
|
+
P136: "Genre",
|
|
39
|
+
P495: "Country of origin",
|
|
40
|
+
P577: "Publication date",
|
|
41
|
+
P50: "Author",
|
|
42
|
+
P123: "Publisher",
|
|
43
|
+
P364: "Original language",
|
|
44
|
+
P86: "Composer",
|
|
45
|
+
P57: "Director",
|
|
46
|
+
P161: "Cast member",
|
|
47
|
+
P170: "Creator",
|
|
48
|
+
P178: "Developer",
|
|
49
|
+
P275: "License",
|
|
50
|
+
P306: "Operating system",
|
|
51
|
+
P277: "Programming language",
|
|
52
|
+
P348: "Version",
|
|
53
|
+
P1566: "GeoNames ID",
|
|
54
|
+
P214: "VIAF ID",
|
|
55
|
+
P227: "GND ID",
|
|
56
|
+
P213: "ISNI",
|
|
57
|
+
P496: "ORCID",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
interface WikidataEntity {
|
|
61
|
+
type: string;
|
|
62
|
+
id: string;
|
|
63
|
+
labels?: Record<string, { language: string; value: string }>;
|
|
64
|
+
descriptions?: Record<string, { language: string; value: string }>;
|
|
65
|
+
aliases?: Record<string, Array<{ language: string; value: string }>>;
|
|
66
|
+
claims?: Record<string, WikidataClaim[]>;
|
|
67
|
+
sitelinks?: Record<string, { site: string; title: string }>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface WikidataClaim {
|
|
71
|
+
mainsnak: {
|
|
72
|
+
snaktype: string;
|
|
73
|
+
property: string;
|
|
74
|
+
datavalue?: {
|
|
75
|
+
type: string;
|
|
76
|
+
value: WikidataValue;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
rank: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type WikidataValue =
|
|
83
|
+
| string
|
|
84
|
+
| { "entity-type": string; id: string; "numeric-id": number }
|
|
85
|
+
| { time: string; precision: number; calendarmodel: string }
|
|
86
|
+
| { amount: string; unit: string }
|
|
87
|
+
| { text: string; language: string }
|
|
88
|
+
| { latitude: number; longitude: number; precision: number };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Handle Wikidata URLs via EntityData API
|
|
92
|
+
*/
|
|
93
|
+
export const handleWikidata: SpecialHandler = async (
|
|
94
|
+
url: string,
|
|
95
|
+
timeout: number,
|
|
96
|
+
signal?: AbortSignal,
|
|
97
|
+
): Promise<RenderResult | null> => {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = new URL(url);
|
|
100
|
+
if (!parsed.hostname.includes("wikidata.org")) return null;
|
|
101
|
+
|
|
102
|
+
// Extract Q-id from /wiki/Q123 or /entity/Q123
|
|
103
|
+
const qidMatch = parsed.pathname.match(/\/(?:wiki|entity)\/(Q\d+)/i);
|
|
104
|
+
if (!qidMatch) return null;
|
|
105
|
+
|
|
106
|
+
const qid = qidMatch[1].toUpperCase();
|
|
107
|
+
const fetchedAt = new Date().toISOString();
|
|
108
|
+
|
|
109
|
+
// Fetch entity data from API
|
|
110
|
+
const apiUrl = `https://www.wikidata.org/wiki/Special:EntityData/${qid}.json`;
|
|
111
|
+
const result = await loadPage(apiUrl, { timeout, signal });
|
|
112
|
+
|
|
113
|
+
if (!result.ok) return null;
|
|
114
|
+
|
|
115
|
+
let data: { entities: Record<string, WikidataEntity> };
|
|
116
|
+
try {
|
|
117
|
+
data = JSON.parse(result.content);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const entity = data.entities[qid];
|
|
123
|
+
if (!entity) return null;
|
|
124
|
+
|
|
125
|
+
// Get label and description (prefer English)
|
|
126
|
+
const label = getLocalizedValue(entity.labels, "en") || qid;
|
|
127
|
+
const description = getLocalizedValue(entity.descriptions, "en");
|
|
128
|
+
const aliases = getLocalizedAliases(entity.aliases, "en");
|
|
129
|
+
|
|
130
|
+
let md = `# ${label} (${qid})\n\n`;
|
|
131
|
+
if (description) md += `*${description}*\n\n`;
|
|
132
|
+
if (aliases.length > 0) md += `**Also known as:** ${aliases.join(", ")}\n\n`;
|
|
133
|
+
|
|
134
|
+
// Count sitelinks
|
|
135
|
+
const sitelinkCount = entity.sitelinks ? Object.keys(entity.sitelinks).length : 0;
|
|
136
|
+
if (sitelinkCount > 0) {
|
|
137
|
+
md += `**Wikipedia articles:** ${formatCount(sitelinkCount)} languages\n\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Process claims
|
|
141
|
+
if (entity.claims && Object.keys(entity.claims).length > 0) {
|
|
142
|
+
md += "## Properties\n\n";
|
|
143
|
+
|
|
144
|
+
// Collect entity IDs we need to resolve
|
|
145
|
+
const entityIdsToResolve = new Set<string>();
|
|
146
|
+
for (const claims of Object.values(entity.claims)) {
|
|
147
|
+
for (const claim of claims) {
|
|
148
|
+
if (claim.mainsnak.datavalue?.type === "wikibase-entityid") {
|
|
149
|
+
const val = claim.mainsnak.datavalue.value as { id: string };
|
|
150
|
+
entityIdsToResolve.add(val.id);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fetch labels for referenced entities (limit to 50)
|
|
156
|
+
const entityLabels = await resolveEntityLabels(Array.from(entityIdsToResolve).slice(0, 50), timeout, signal);
|
|
157
|
+
|
|
158
|
+
// Group claims by property
|
|
159
|
+
const processedProperties: string[] = [];
|
|
160
|
+
for (const [propId, claims] of Object.entries(entity.claims)) {
|
|
161
|
+
const propLabel = PROPERTY_LABELS[propId] || propId;
|
|
162
|
+
const values: string[] = [];
|
|
163
|
+
|
|
164
|
+
for (const claim of claims) {
|
|
165
|
+
if (claim.rank === "deprecated") continue;
|
|
166
|
+
const value = formatClaimValue(claim, entityLabels);
|
|
167
|
+
if (value && !values.includes(value)) {
|
|
168
|
+
values.push(value);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (values.length > 0) {
|
|
173
|
+
// Limit values shown per property
|
|
174
|
+
const displayValues = values.slice(0, 10);
|
|
175
|
+
const overflow = values.length > 10 ? ` (+${values.length - 10} more)` : "";
|
|
176
|
+
processedProperties.push(`- **${propLabel}:** ${displayValues.join(", ")}${overflow}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Sort: known properties first, then by property ID
|
|
181
|
+
processedProperties.sort((a, b) => {
|
|
182
|
+
const aKnown = Object.values(PROPERTY_LABELS).some((l) => a.includes(`**${l}:**`));
|
|
183
|
+
const bKnown = Object.values(PROPERTY_LABELS).some((l) => b.includes(`**${l}:**`));
|
|
184
|
+
if (aKnown && !bKnown) return -1;
|
|
185
|
+
if (!aKnown && bKnown) return 1;
|
|
186
|
+
return a.localeCompare(b);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Limit total properties shown
|
|
190
|
+
const maxProps = 50;
|
|
191
|
+
md += processedProperties.slice(0, maxProps).join("\n");
|
|
192
|
+
if (processedProperties.length > maxProps) {
|
|
193
|
+
md += `\n\n*...and ${processedProperties.length - maxProps} more properties*`;
|
|
194
|
+
}
|
|
195
|
+
md += "\n";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Add notable sitelinks
|
|
199
|
+
if (entity.sitelinks) {
|
|
200
|
+
const notableSites = ["enwiki", "dewiki", "frwiki", "eswiki", "jawiki", "zhwiki"];
|
|
201
|
+
const links: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (const site of notableSites) {
|
|
204
|
+
const sitelink = entity.sitelinks[site];
|
|
205
|
+
if (sitelink) {
|
|
206
|
+
const lang = site.replace("wiki", "");
|
|
207
|
+
const wikiUrl = `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(sitelink.title)}`;
|
|
208
|
+
links.push(`[${lang.toUpperCase()}](${wikiUrl})`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (links.length > 0) {
|
|
213
|
+
md += `\n## Wikipedia Links\n\n${links.join(" · ")}\n`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const output = finalizeOutput(md);
|
|
218
|
+
return {
|
|
219
|
+
url,
|
|
220
|
+
finalUrl: url,
|
|
221
|
+
contentType: "text/markdown",
|
|
222
|
+
method: "wikidata",
|
|
223
|
+
content: output.content,
|
|
224
|
+
fetchedAt,
|
|
225
|
+
truncated: output.truncated,
|
|
226
|
+
notes: ["Fetched via Wikidata EntityData API"],
|
|
227
|
+
};
|
|
228
|
+
} catch {}
|
|
229
|
+
|
|
230
|
+
return null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get localized value with fallback
|
|
235
|
+
*/
|
|
236
|
+
function getLocalizedValue(
|
|
237
|
+
values: Record<string, { language: string; value: string }> | undefined,
|
|
238
|
+
preferredLang: string,
|
|
239
|
+
): string | null {
|
|
240
|
+
if (!values) return null;
|
|
241
|
+
if (values[preferredLang]) return values[preferredLang].value;
|
|
242
|
+
// Fallback to any available
|
|
243
|
+
const first = Object.values(values)[0];
|
|
244
|
+
return first?.value || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Get aliases for a language
|
|
249
|
+
*/
|
|
250
|
+
function getLocalizedAliases(
|
|
251
|
+
aliases: Record<string, Array<{ language: string; value: string }>> | undefined,
|
|
252
|
+
preferredLang: string,
|
|
253
|
+
): string[] {
|
|
254
|
+
if (!aliases) return [];
|
|
255
|
+
const langAliases = aliases[preferredLang];
|
|
256
|
+
if (!langAliases) return [];
|
|
257
|
+
return langAliases.map((a) => a.value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve entity IDs to their labels via wbgetentities API
|
|
262
|
+
*/
|
|
263
|
+
async function resolveEntityLabels(
|
|
264
|
+
entityIds: string[],
|
|
265
|
+
timeout: number,
|
|
266
|
+
signal?: AbortSignal,
|
|
267
|
+
): Promise<Record<string, string>> {
|
|
268
|
+
if (entityIds.length === 0) return {};
|
|
269
|
+
|
|
270
|
+
const labels: Record<string, string> = {};
|
|
271
|
+
|
|
272
|
+
// Fetch in batches of 50
|
|
273
|
+
const batchSize = 50;
|
|
274
|
+
for (let i = 0; i < entityIds.length; i += batchSize) {
|
|
275
|
+
const batch = entityIds.slice(i, i + batchSize);
|
|
276
|
+
const apiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${batch.join("|")}&props=labels&languages=en&format=json`;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 10), signal });
|
|
280
|
+
if (result.ok) {
|
|
281
|
+
const data = JSON.parse(result.content) as {
|
|
282
|
+
entities: Record<string, { labels?: Record<string, { value: string }> }>;
|
|
283
|
+
};
|
|
284
|
+
for (const [id, entity] of Object.entries(data.entities)) {
|
|
285
|
+
const label = entity.labels?.en?.value;
|
|
286
|
+
if (label) labels[id] = label;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return labels;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Format a claim value to human-readable string
|
|
297
|
+
*/
|
|
298
|
+
function formatClaimValue(claim: WikidataClaim, entityLabels: Record<string, string>): string | null {
|
|
299
|
+
const snak = claim.mainsnak;
|
|
300
|
+
if (snak.snaktype !== "value" || !snak.datavalue) return null;
|
|
301
|
+
|
|
302
|
+
const { type, value } = snak.datavalue;
|
|
303
|
+
|
|
304
|
+
switch (type) {
|
|
305
|
+
case "wikibase-entityid": {
|
|
306
|
+
const entityVal = value as { id: string };
|
|
307
|
+
return entityLabels[entityVal.id] || entityVal.id;
|
|
308
|
+
}
|
|
309
|
+
case "string":
|
|
310
|
+
return value as string;
|
|
311
|
+
case "time": {
|
|
312
|
+
const timeVal = value as { time: string; precision: number };
|
|
313
|
+
return formatWikidataTime(timeVal.time, timeVal.precision);
|
|
314
|
+
}
|
|
315
|
+
case "quantity": {
|
|
316
|
+
const qtyVal = value as { amount: string; unit: string };
|
|
317
|
+
const amount = qtyVal.amount.replace(/^\+/, "");
|
|
318
|
+
// Extract unit Q-id if present
|
|
319
|
+
const unitMatch = qtyVal.unit.match(/Q\d+$/);
|
|
320
|
+
const unit = unitMatch ? entityLabels[unitMatch[0]] || "" : "";
|
|
321
|
+
return unit ? `${amount} ${unit}` : amount;
|
|
322
|
+
}
|
|
323
|
+
case "monolingualtext": {
|
|
324
|
+
const textVal = value as { text: string; language: string };
|
|
325
|
+
return textVal.text;
|
|
326
|
+
}
|
|
327
|
+
case "globecoordinate": {
|
|
328
|
+
const coordVal = value as { latitude: number; longitude: number };
|
|
329
|
+
return `${coordVal.latitude.toFixed(4)}, ${coordVal.longitude.toFixed(4)}`;
|
|
330
|
+
}
|
|
331
|
+
default:
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Format Wikidata time value to readable date
|
|
338
|
+
*/
|
|
339
|
+
function formatWikidataTime(time: string, precision: number): string {
|
|
340
|
+
// Time format: +YYYY-MM-DDT00:00:00Z
|
|
341
|
+
const match = time.match(/^([+-]?\d+)-(\d{2})-(\d{2})/);
|
|
342
|
+
if (!match) return time;
|
|
343
|
+
|
|
344
|
+
const [, year, month, day] = match;
|
|
345
|
+
const yearNum = Number.parseInt(year, 10);
|
|
346
|
+
const absYear = Math.abs(yearNum);
|
|
347
|
+
const era = yearNum < 0 ? " BCE" : "";
|
|
348
|
+
|
|
349
|
+
// Precision: 9=year, 10=month, 11=day
|
|
350
|
+
if (precision >= 11) {
|
|
351
|
+
return `${day}/${month}/${absYear}${era}`;
|
|
352
|
+
}
|
|
353
|
+
if (precision >= 10) {
|
|
354
|
+
return `${month}/${absYear}${era}`;
|
|
355
|
+
}
|
|
356
|
+
return `${absYear}${era}`;
|
|
357
|
+
}
|