paseo-plugin-genui 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/OVERVIEW.md +21 -0
- package/README.md +77 -2
- package/assets/fonts/Excalifont-OFL.txt +109 -0
- package/assets/fonts/Excalifont-Regular-latin.woff2 +0 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/assets/screenshot-chat.png +0 -0
- package/assets/screenshot-gallery.png +0 -0
- package/assets/screenshot-playground.png +0 -0
- package/client/components/content.tsx +468 -0
- package/client/components/data.tsx +967 -0
- package/client/components/dev.tsx +998 -0
- package/client/components/diagram.tsx +513 -0
- package/client/components/inputs.tsx +994 -0
- package/client/components/layout.tsx +239 -0
- package/client/components/media.tsx +428 -0
- package/client/components/project.tsx +641 -0
- package/client/components/widgets.tsx +836 -0
- package/client/gallery.tsx +449 -0
- package/client/live-ui.tsx +525 -0
- package/client/loaders.ts +342 -0
- package/client/markdown.tsx +172 -0
- package/client/node-renderer.tsx +149 -0
- package/client/render.tsx +186 -0
- package/client/runtime.ts +103 -0
- package/client/settings.tsx +57 -0
- package/client/web.ts +186 -0
- package/gallery/Acceptance-baseline.ui +1 -0
- package/gallery/Acceptance-checklist.ui +14 -0
- package/gallery/Acceptance-evidence.ui +9 -0
- package/gallery/Acceptance.ui +1 -0
- package/gallery/Badge.ui +3 -0
- package/gallery/Benchmark-context.ui +8 -0
- package/gallery/Benchmark-hyperfine.ui +1 -0
- package/gallery/Benchmark-runs-list.ui +9 -0
- package/gallery/Benchmark-runs.ui +1 -0
- package/gallery/Benchmark-vitest.ui +1 -0
- package/gallery/Benchmark.ui +9 -0
- package/gallery/Board-bb-tasks.ui +1 -0
- package/gallery/Board-gh-issues.ui +1 -0
- package/gallery/Board-gh-prs.ui +1 -0
- package/gallery/Board.ui +12 -0
- package/gallery/Button-destructive.ui +4 -0
- package/gallery/Button.ui +5 -0
- package/gallery/Calendar.ui +15 -0
- package/gallery/CalendarHeatmap-short.ui +11 -0
- package/gallery/CalendarHeatmap.ui +4 -0
- package/gallery/Callout.ui +4 -0
- package/gallery/Card.ui +4 -0
- package/gallery/Carousel.ui +8 -0
- package/gallery/Changes-paths.ui +1 -0
- package/gallery/Changes-uncommitted.ui +1 -0
- package/gallery/Changes.ui +1 -0
- package/gallery/Chart-area.ui +3 -0
- package/gallery/Chart-funnel.ui +3 -0
- package/gallery/Chart-heatmap.ui +11 -0
- package/gallery/Chart-pie.ui +3 -0
- package/gallery/Chart-radar.ui +3 -0
- package/gallery/Chart-scatter.ui +4 -0
- package/gallery/Chart-stacked.ui +5 -0
- package/gallery/Chart.ui +3 -0
- package/gallery/Checklist.ui +6 -0
- package/gallery/Choice.ui +8 -0
- package/gallery/Code.ui +3 -0
- package/gallery/Commits-limit.ui +1 -0
- package/gallery/Commits.ui +1 -0
- package/gallery/Compare-conditions.ui +4 -0
- package/gallery/Compare-three.ui +7 -0
- package/gallery/Compare.ui +4 -0
- package/gallery/Coverage-go.ui +1 -0
- package/gallery/Coverage-lcov.ui +1 -0
- package/gallery/Coverage-python.ui +1 -0
- package/gallery/Coverage.ui +1 -0
- package/gallery/DateInput.ui +5 -0
- package/gallery/Decision-percent.ui +10 -0
- package/gallery/Decision.ui +12 -0
- package/gallery/Dependencies-audit.ui +1 -0
- package/gallery/Dependencies-both.ui +1 -0
- package/gallery/Dependencies-list.ui +7 -0
- package/gallery/Dependencies-pip.ui +1 -0
- package/gallery/Dependencies-pnpm.ui +1 -0
- package/gallery/Dependencies.ui +1 -0
- package/gallery/Details.ui +4 -0
- package/gallery/Diagram-flow.ui +16 -0
- package/gallery/Diagram-sequence.ui +15 -0
- package/gallery/Diagram.ui +16 -0
- package/gallery/Diff.ui +3 -0
- package/gallery/Divider.ui +5 -0
- package/gallery/EditableTable.ui +9 -0
- package/gallery/Entity-list.ui +6 -0
- package/gallery/Entity.ui +8 -0
- package/gallery/Environments.ui +1 -0
- package/gallery/Estimate-points.ui +6 -0
- package/gallery/Estimate.ui +8 -0
- package/gallery/File.ui +4 -0
- package/gallery/Findings-biome.ui +1 -0
- package/gallery/Findings-eslint.ui +1 -0
- package/gallery/Findings-oxlint.ui +1 -0
- package/gallery/Findings-review.ui +1 -0
- package/gallery/Findings-ruff.ui +1 -0
- package/gallery/Findings-sarif.ui +1 -0
- package/gallery/Findings-ship.ui +11 -0
- package/gallery/Findings-tsc.ui +1 -0
- package/gallery/Findings.ui +7 -0
- package/gallery/Flashcards.ui +3 -0
- package/gallery/FollowUps.ui +4 -0
- package/gallery/Form.ui +8 -0
- package/gallery/Gallery-links.ui +8 -0
- package/gallery/Gallery-strip.ui +14 -0
- package/gallery/Gallery.ui +10 -0
- package/gallery/Gauge.ui +7 -0
- package/gallery/Grid.ui +8 -0
- package/gallery/Hotspots-photo.ui +8 -0
- package/gallery/Hotspots.ui +11 -0
- package/gallery/Icon.ui +5 -0
- package/gallery/Image.ui +4 -0
- package/gallery/ImageSearch-aspects.ui +6 -0
- package/gallery/ImageSearch.ui +4 -0
- package/gallery/Input.ui +5 -0
- package/gallery/Json-api.ui +1 -0
- package/gallery/Json.ui +8 -0
- package/gallery/KeyValue.ui +3 -0
- package/gallery/List-dots.ui +8 -0
- package/gallery/List.ui +8 -0
- package/gallery/ListEditor.ui +5 -0
- package/gallery/Logs-jsonl.ui +1 -0
- package/gallery/Logs-lines.ui +11 -0
- package/gallery/Logs-python.ui +1 -0
- package/gallery/Logs.ui +1 -0
- package/gallery/Map-city.ui +8 -0
- package/gallery/Map.ui +8 -0
- package/gallery/Metric.ui +3 -0
- package/gallery/OptionCards-multi.ui +10 -0
- package/gallery/OptionCards.ui +10 -0
- package/gallery/Pipeline-checks.ui +1 -0
- package/gallery/Pipeline-passed.ui +1 -0
- package/gallery/Pipeline-running.ui +1 -0
- package/gallery/Pipeline-stages.ui +13 -0
- package/gallery/Pipeline.ui +1 -0
- package/gallery/Plan.ui +9 -0
- package/gallery/Progress.ui +4 -0
- package/gallery/Provenance-deploy.ui +6 -0
- package/gallery/Provenance-no-receipt.ui +1 -0
- package/gallery/Provenance.ui +1 -0
- package/gallery/PullRequest.ui +1 -0
- package/gallery/Quiz.ui +6 -0
- package/gallery/Radio.ui +5 -0
- package/gallery/Rating.ui +5 -0
- package/gallery/Roadmap-sprint.ui +8 -0
- package/gallery/Roadmap-year.ui +9 -0
- package/gallery/Roadmap.ui +12 -0
- package/gallery/Row.ui +3 -0
- package/gallery/SegmentedBar-status.ui +8 -0
- package/gallery/SegmentedBar.ui +8 -0
- package/gallery/Select.ui +5 -0
- package/gallery/Slider.ui +7 -0
- package/gallery/Snippet.ui +3 -0
- package/gallery/Sources-chips.ui +4 -0
- package/gallery/Sources.ui +8 -0
- package/gallery/Sparkline.ui +6 -0
- package/gallery/Stack.ui +7 -0
- package/gallery/StackTrace-aggregate.ui +1 -0
- package/gallery/StackTrace-browser.ui +1 -0
- package/gallery/StackTrace-go.ui +1 -0
- package/gallery/StackTrace-java.ui +1 -0
- package/gallery/StackTrace-python.ui +1 -0
- package/gallery/StackTrace-rust.ui +1 -0
- package/gallery/StackTrace.ui +1 -0
- package/gallery/Status.ui +9 -0
- package/gallery/Stepper.ui +5 -0
- package/gallery/Steps.ui +8 -0
- package/gallery/Swatches-long-names.ui +4 -0
- package/gallery/Swatches-picker.ui +5 -0
- package/gallery/Swatches.ui +10 -0
- package/gallery/Table.ui +7 -0
- package/gallery/Tabs.ui +8 -0
- package/gallery/Tests-junit.ui +1 -0
- package/gallery/Tests-vitest-junit.ui +1 -0
- package/gallery/Tests-vitest.ui +1 -0
- package/gallery/Tests.ui +9 -0
- package/gallery/Text.ui +4 -0
- package/gallery/TextArea.ui +5 -0
- package/gallery/Threads-ids.ui +1 -0
- package/gallery/Threads.ui +1 -0
- package/gallery/Timeline.ui +8 -0
- package/gallery/Timer.ui +4 -0
- package/gallery/Toggle.ui +5 -0
- package/gallery/Transcript-text.ui +1 -0
- package/gallery/Transcript-untimed.ui +1 -0
- package/gallery/Transcript-whisper.ui +1 -0
- package/gallery/Transcript.ui +1 -0
- package/gallery/Tree.ui +3 -0
- package/gallery/Treemap-du.ui +1 -0
- package/gallery/Treemap-esbuild.ui +1 -0
- package/gallery/Treemap-tree.ui +7 -0
- package/gallery/Treemap.ui +18 -0
- package/gallery/Walkthrough.ui +9 -0
- package/gallery/Waterfall-har.ui +1 -0
- package/gallery/Waterfall-trace.ui +1 -0
- package/gallery/Waterfall.ui +11 -0
- package/gallery/data/acceptance/coil/viewport-lifecycle.json +25 -0
- package/gallery/data/acceptance/coil-gate.json +153 -0
- package/gallery/data/acceptance/orbit-baseline.json +163 -0
- package/gallery/data/acceptance/shots/gate-cell-opened.png +0 -0
- package/gallery/data/acceptance/shots/gate-light.png +0 -0
- package/gallery/data/acceptance/shots/gate-phone-dark.png +0 -0
- package/gallery/data/activity.json +1 -0
- package/gallery/data/bb-tasks-orb.json +420 -0
- package/gallery/data/biome.json +229 -0
- package/gallery/data/compare/status-after.png +0 -0
- package/gallery/data/compare/status-before.png +0 -0
- package/gallery/data/compare/status-dark-2x.png +0 -0
- package/gallery/data/compare/status-rerender.png +0 -0
- package/gallery/data/coverage-py.json +1 -0
- package/gallery/data/coverage-summary.json +1 -0
- package/gallery/data/coverage.out +23 -0
- package/gallery/data/deploys.json +1 -0
- package/gallery/data/device-runs.json +285 -0
- package/gallery/data/du-node-modules.txt +359 -0
- package/gallery/data/esbuild-meta.json +292 -0
- package/gallery/data/eslint.json +266 -0
- package/gallery/data/eslint.sarif +343 -0
- package/gallery/data/findings-review.json +76 -0
- package/gallery/data/gh-issues-nextjs.json +293 -0
- package/gallery/data/gh-pr-checks.json +272 -0
- package/gallery/data/gh-prs-nextjs.json +419 -0
- package/gallery/data/gh-run-failed.json +1 -0
- package/gallery/data/gh-run-passed.json +1 -0
- package/gallery/data/gh-run-running.json +1 -0
- package/gallery/data/github-release.json +57 -0
- package/gallery/data/hyperfine-startup.json +187 -0
- package/gallery/data/lcov.info +234 -0
- package/gallery/data/logs/media-sync.log +127 -0
- package/gallery/data/logs/thumbnailer.jsonl +17 -0
- package/gallery/data/logs/vitest-run.log +89 -0
- package/gallery/data/npm-audit.json +672 -0
- package/gallery/data/npm-outdated.json +179 -0
- package/gallery/data/oxlint.json +156 -0
- package/gallery/data/pip-outdated.json +68 -0
- package/gallery/data/pnpm-outdated.json +58 -0
- package/gallery/data/provenance/devicectl-install.json +35 -0
- package/gallery/data/provenance/install-timeout.log +4 -0
- package/gallery/data/provenance/iphone-install.json +72 -0
- package/gallery/data/provenance/iphone-no-receipt.json +57 -0
- package/gallery/data/provenance/live-home.jpg +0 -0
- package/gallery/data/provenance/live-probe.txt +10 -0
- package/gallery/data/provenance/xcodebuild-2811.log +19 -0
- package/gallery/data/provenance/xcodebuild-4712.log +19 -0
- package/gallery/data/pytest-junit.xml +64 -0
- package/gallery/data/python-org-trace.json +35 -0
- package/gallery/data/python-org.har +31 -0
- package/gallery/data/retry.ts +13 -0
- package/gallery/data/ruff.json +261 -0
- package/gallery/data/src/durations.py +63 -0
- package/gallery/data/src/json-logic.ts +156 -0
- package/gallery/data/src/mapMath.ts +135 -0
- package/gallery/data/src/semver/bump.go +73 -0
- package/gallery/data/traces/browser-error.json +5 -0
- package/gallery/data/traces/go/main.go +27 -0
- package/gallery/data/traces/go-panic.txt +16 -0
- package/gallery/data/traces/java-caused-by.txt +15 -0
- package/gallery/data/traces/node-aggregate.txt +30 -0
- package/gallery/data/traces/node-cause.txt +21 -0
- package/gallery/data/traces/py/deploy.json +5 -0
- package/gallery/data/traces/py/deploy.py +17 -0
- package/gallery/data/traces/py/settings.py +19 -0
- package/gallery/data/traces/python-chain.txt +30 -0
- package/gallery/data/traces/rust/main.rs +11 -0
- package/gallery/data/traces/rust-panic.txt +16 -0
- package/gallery/data/traces/shop/cart.mjs +19 -0
- package/gallery/data/traces/shop/pricing.mjs +12 -0
- package/gallery/data/traces/shop/status.mjs +9 -0
- package/gallery/data/traces/web/app.js +11 -0
- package/gallery/data/traces/web/index.html +1 -0
- package/gallery/data/traces/web/search.js +10 -0
- package/gallery/data/transcript/call-noisy.wav +0 -0
- package/gallery/data/transcript/call.json +362 -0
- package/gallery/data/transcript/call.wav +0 -0
- package/gallery/data/transcript/mlx-whisper-tiny.en-noisy.json +1 -0
- package/gallery/data/transcript/whisper-cpp-base.en-noisy.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.json +658 -0
- package/gallery/data/transcript/whisper-cpp-base.en.srt +16 -0
- package/gallery/data/transcript/whisper-cpp-tiny.en-noisy.json +619 -0
- package/gallery/data/tsc.txt +4 -0
- package/gallery/data/vitest-bench.json +119 -0
- package/gallery/data/vitest-junit.xml +133 -0
- package/gallery/data/vitest-report.json +612 -0
- package/gallery/images/bell.svg +10 -0
- package/gallery/images/bike.svg +47 -0
- package/gallery/images/footer-planet.webp +0 -0
- package/gallery/images/front-light.svg +13 -0
- package/gallery/images/golden-hour.webp +0 -0
- package/gallery/images/option-flowers.webp +0 -0
- package/gallery/images/option-hilltop.webp +0 -0
- package/gallery/images/option-meadow.webp +0 -0
- package/gallery/images/option-sky.webp +0 -0
- package/gallery/images/u-lock.svg +10 -0
- package/index.client.tsx +164 -0
- package/index.server.ts +216 -0
- package/package.json +50 -4
- package/paseo-plugin.json +14 -0
- package/server/data-source.ts +407 -0
- package/server/project.ts +981 -0
- package/server/web/address.ts +57 -0
- package/server/web/cache.ts +85 -0
- package/server/web/commons.ts +126 -0
- package/server/web/geocode.ts +40 -0
- package/server/web/image.ts +35 -0
- package/server/web/lookups.ts +248 -0
- package/server/web/net.ts +210 -0
- package/shared/core/acceptance.ts +744 -0
- package/shared/core/ast.ts +76 -0
- package/shared/core/benchmark.ts +743 -0
- package/shared/core/catalog.ts +733 -0
- package/shared/core/compare.ts +225 -0
- package/shared/core/coverage.ts +456 -0
- package/shared/core/data.ts +334 -0
- package/shared/core/decisions.ts +527 -0
- package/shared/core/dependencies.ts +604 -0
- package/shared/core/diagnostics.ts +24 -0
- package/shared/core/environments.ts +212 -0
- package/shared/core/evaluate.ts +737 -0
- package/shared/core/fields.ts +66 -0
- package/shared/core/findings.ts +1172 -0
- package/shared/core/form.ts +69 -0
- package/shared/core/index.ts +44 -0
- package/shared/core/junit.ts +252 -0
- package/shared/core/lexer.ts +275 -0
- package/shared/core/logs.ts +496 -0
- package/shared/core/markdown.ts +2374 -0
- package/shared/core/parser.ts +355 -0
- package/shared/core/perf-units.ts +228 -0
- package/shared/core/pipeline.ts +679 -0
- package/shared/core/project.ts +472 -0
- package/shared/core/provenance.ts +725 -0
- package/shared/core/stack-trace.ts +718 -0
- package/shared/core/streaming.ts +136 -0
- package/shared/core/test-results.ts +562 -0
- package/shared/core/thread-activity.ts +203 -0
- package/shared/core/tracking.ts +887 -0
- package/shared/core/transcript.ts +1364 -0
- package/shared/core/treemap.ts +446 -0
- package/shared/core/types.ts +70 -0
- package/shared/core/validate.ts +574 -0
- package/shared/core/waterfall.ts +536 -0
- package/shared/diagram/flowchart.ts +205 -0
- package/shared/diagram/layout.ts +440 -0
- package/shared/diagram/markdown.ts +135 -0
- package/shared/diagram/sequence.ts +122 -0
- package/shared/diagram/zoom.ts +114 -0
- package/shared/fallback.ts +120 -0
- package/shared/genui.test.ts +122 -0
- package/shared/instructions.ts +144 -0
- package/shared/logic/calendarMath.ts +130 -0
- package/shared/logic/chartMath.ts +289 -0
- package/shared/logic/choose-logic.ts +196 -0
- package/shared/logic/compare-logic.ts +270 -0
- package/shared/logic/content-model.ts +227 -0
- package/shared/logic/environments-logic.ts +53 -0
- package/shared/logic/explain-logic.ts +315 -0
- package/shared/logic/fieldLogic.ts +138 -0
- package/shared/logic/gallery-search.ts +71 -0
- package/shared/logic/icons.ts +322 -0
- package/shared/logic/json-logic.ts +153 -0
- package/shared/logic/mapMath.ts +133 -0
- package/shared/logic/project-logic.ts +39 -0
- package/shared/logic/references-model.ts +201 -0
- package/shared/logic/sketch.ts +165 -0
- package/shared/logic/supersession.ts +77 -0
- package/shared/logic/values.ts +133 -0
- package/shared/logic/widget-logic.ts +163 -0
- package/shared/rpc.ts +252 -0
- package/shared/segment.ts +81 -0
- package/shared/settings.ts +12 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Which IP addresses the plugin may connect to. A URL in a spec is model
|
|
2
|
+
// output, so fetching it must never reach this machine, its network or any
|
|
3
|
+
// address that isn't on the public internet.
|
|
4
|
+
import { BlockList, isIP } from "node:net";
|
|
5
|
+
|
|
6
|
+
/** IPv4 ranges that are not public unicast (RFC 6890 and friends). */
|
|
7
|
+
const PRIVATE_V4: readonly (readonly [string, number])[] = [
|
|
8
|
+
["0.0.0.0", 8], // "this network"
|
|
9
|
+
["10.0.0.0", 8], // private
|
|
10
|
+
["100.64.0.0", 10], // carrier-grade NAT
|
|
11
|
+
["127.0.0.0", 8], // loopback
|
|
12
|
+
["169.254.0.0", 16], // link-local (cloud metadata lives here)
|
|
13
|
+
["172.16.0.0", 12], // private
|
|
14
|
+
["192.0.0.0", 24], // IETF protocol assignments
|
|
15
|
+
["192.0.2.0", 24], // documentation
|
|
16
|
+
["192.88.99.0", 24], // 6to4 relay anycast
|
|
17
|
+
["192.168.0.0", 16], // private
|
|
18
|
+
["198.18.0.0", 15], // benchmarking
|
|
19
|
+
["198.51.100.0", 24], // documentation
|
|
20
|
+
["203.0.113.0", 24], // documentation
|
|
21
|
+
["224.0.0.0", 4], // multicast
|
|
22
|
+
["240.0.0.0", 4], // reserved, broadcast
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* IPv6 is allowed only inside global unicast (2000::/3), which already
|
|
27
|
+
* excludes loopback, unspecified, IPv4-mapped, NAT64, unique-local,
|
|
28
|
+
* link-local and multicast; these special blocks inside it are refused too.
|
|
29
|
+
*/
|
|
30
|
+
const SPECIAL_V6: readonly (readonly [string, number])[] = [
|
|
31
|
+
["2001::", 23], // IETF protocol assignments (Teredo, benchmarking, ORCHID)
|
|
32
|
+
["2001:db8::", 32], // documentation
|
|
33
|
+
["2002::", 16], // 6to4: tunnels to an embedded IPv4 address
|
|
34
|
+
["3fff::", 20], // documentation
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const privateV4 = new BlockList();
|
|
38
|
+
for (const [network, prefix] of PRIVATE_V4) privateV4.addSubnet(network, prefix, "ipv4");
|
|
39
|
+
const globalV6 = new BlockList();
|
|
40
|
+
globalV6.addSubnet("2000::", 3, "ipv6");
|
|
41
|
+
const specialV6 = new BlockList();
|
|
42
|
+
for (const [network, prefix] of SPECIAL_V6) specialV6.addSubnet(network, prefix, "ipv6");
|
|
43
|
+
|
|
44
|
+
/** True only for a literal IP address on the public internet. */
|
|
45
|
+
export function isPublicAddress(address: string): boolean {
|
|
46
|
+
const bare = address.startsWith("[") && address.endsWith("]") ? address.slice(1, -1) : address;
|
|
47
|
+
// A zone index (fe80::1%en0) only exists on link-local addresses.
|
|
48
|
+
if (bare.includes("%")) return false;
|
|
49
|
+
switch (isIP(bare)) {
|
|
50
|
+
case 4:
|
|
51
|
+
return !privateV4.check(bare, "ipv4");
|
|
52
|
+
case 6:
|
|
53
|
+
return globalV6.check(bare, "ipv6") && !specialV6.check(bare, "ipv6");
|
|
54
|
+
default:
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const ROW_OVERHEAD = 256;
|
|
2
|
+
|
|
3
|
+
export interface CacheEntry {
|
|
4
|
+
readonly body: Buffer;
|
|
5
|
+
readonly meta: string;
|
|
6
|
+
readonly expired: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface StoredRow {
|
|
10
|
+
body: Buffer;
|
|
11
|
+
meta: string;
|
|
12
|
+
bytes: number;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
usedAt: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WebCache {
|
|
18
|
+
private readonly rows = new Map<string, StoredRow>();
|
|
19
|
+
private totalBytes = 0;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly maxBytes: number,
|
|
23
|
+
private readonly now: () => number = Date.now,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
get(key: string): CacheEntry | null {
|
|
27
|
+
const row = this.rows.get(key);
|
|
28
|
+
if (row === undefined) return null;
|
|
29
|
+
const now = this.now();
|
|
30
|
+
row.usedAt = now;
|
|
31
|
+
this.rows.delete(key);
|
|
32
|
+
this.rows.set(key, row);
|
|
33
|
+
return { body: row.body, meta: row.meta, expired: row.expiresAt <= now };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
put(key: string, body: Buffer, meta: string, ttlMs: number): void {
|
|
37
|
+
const existing = this.rows.get(key);
|
|
38
|
+
if (existing !== undefined) {
|
|
39
|
+
this.totalBytes -= existing.bytes;
|
|
40
|
+
this.rows.delete(key);
|
|
41
|
+
}
|
|
42
|
+
const now = this.now();
|
|
43
|
+
const bytes = body.length + meta.length + ROW_OVERHEAD;
|
|
44
|
+
this.rows.set(key, {
|
|
45
|
+
body,
|
|
46
|
+
meta,
|
|
47
|
+
bytes,
|
|
48
|
+
expiresAt: now + ttlMs,
|
|
49
|
+
usedAt: now,
|
|
50
|
+
});
|
|
51
|
+
this.totalBytes += bytes;
|
|
52
|
+
this.evict();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
renew(key: string, meta: string, ttlMs: number): void {
|
|
56
|
+
const existing = this.rows.get(key);
|
|
57
|
+
if (existing === undefined) return;
|
|
58
|
+
const now = this.now();
|
|
59
|
+
this.totalBytes -= existing.bytes;
|
|
60
|
+
const bytes = existing.body.length + meta.length + ROW_OVERHEAD;
|
|
61
|
+
this.rows.delete(key);
|
|
62
|
+
this.rows.set(key, {
|
|
63
|
+
body: existing.body,
|
|
64
|
+
meta,
|
|
65
|
+
bytes,
|
|
66
|
+
expiresAt: now + ttlMs,
|
|
67
|
+
usedAt: now,
|
|
68
|
+
});
|
|
69
|
+
this.totalBytes += bytes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
size(): number {
|
|
73
|
+
return this.totalBytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private evict(): void {
|
|
77
|
+
if (this.totalBytes <= this.maxBytes) return;
|
|
78
|
+
const target = this.maxBytes * 0.8;
|
|
79
|
+
for (const [key, row] of this.rows.entries()) {
|
|
80
|
+
if (this.totalBytes <= target) break;
|
|
81
|
+
this.rows.delete(key);
|
|
82
|
+
this.totalBytes -= row.bytes;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Image search on Wikimedia Commons. Chosen over Openverse after comparing
|
|
2
|
+
// both on everyday queries ("slow roasted lamb shoulder", "Lisbon tram 28"):
|
|
3
|
+
// Commons ranked on-topic photos first where Openverse mixed in salads and an
|
|
4
|
+
// art edit, holds only free licences (Openverse's top hits were mostly
|
|
5
|
+
// NonCommercial/NoDerivs Flickr photos), has no 200-requests-a-day anonymous
|
|
6
|
+
// cap, and scales thumbnails server-side to the width asked for.
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/** A photo found for a description, with what its licence asks to be credited. */
|
|
10
|
+
export const foundImageSchema = z.object({
|
|
11
|
+
/** https URL of a scaled rendition (≤ THUMB_WIDTH wide). */
|
|
12
|
+
url: z.string(),
|
|
13
|
+
width: z.number(),
|
|
14
|
+
height: z.number(),
|
|
15
|
+
title: z.string(),
|
|
16
|
+
creator: z.string(),
|
|
17
|
+
/** Short licence name ("CC BY-SA 4.0", "Public domain"). */
|
|
18
|
+
license: z.string(),
|
|
19
|
+
licenseUrl: z.string().nullable(),
|
|
20
|
+
/** The file's page on Commons, which holds the full attribution. */
|
|
21
|
+
sourceUrl: z.string(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type FoundImage = z.infer<typeof foundImageSchema>;
|
|
25
|
+
|
|
26
|
+
const API = "https://commons.wikimedia.org/w/api.php";
|
|
27
|
+
/** A standard Wikimedia thumbnail step, sharp at chat width on a 2× screen. */
|
|
28
|
+
export const THUMB_WIDTH = 1280;
|
|
29
|
+
const CANDIDATES = 12;
|
|
30
|
+
const MIN_WIDTH = 480;
|
|
31
|
+
|
|
32
|
+
/** The API request for `query`: bitmap files only, with the metadata a credit needs. */
|
|
33
|
+
export function searchUrl(query: string): string {
|
|
34
|
+
const params = new URLSearchParams({
|
|
35
|
+
action: "query",
|
|
36
|
+
format: "json",
|
|
37
|
+
formatversion: "2",
|
|
38
|
+
generator: "search",
|
|
39
|
+
gsrsearch: `${query} filetype:bitmap`,
|
|
40
|
+
gsrnamespace: "6",
|
|
41
|
+
gsrlimit: String(CANDIDATES),
|
|
42
|
+
prop: "imageinfo",
|
|
43
|
+
iiprop: "url|size|mime|extmetadata",
|
|
44
|
+
iiurlwidth: String(THUMB_WIDTH),
|
|
45
|
+
iiextmetadatafilter: "Artist|LicenseShortName|LicenseUrl|ObjectName",
|
|
46
|
+
maxlag: "5",
|
|
47
|
+
});
|
|
48
|
+
return `${API}?${params}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plain text from an extmetadata HTML fragment (artist links, spans). */
|
|
52
|
+
function plain(html: unknown): string {
|
|
53
|
+
if (typeof html !== "string") return "";
|
|
54
|
+
const entities: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", nbsp: " " };
|
|
55
|
+
return html
|
|
56
|
+
.replace(/<[^>]*>/g, " ")
|
|
57
|
+
.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (_, name: string) => entities[name])
|
|
58
|
+
.replace(/\s+/g, " ")
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function record(value: unknown): Readonly<Record<string, unknown>> {
|
|
63
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function metadata(fields: Readonly<Record<string, unknown>>, name: string): string {
|
|
67
|
+
return plain(record(fields[name]).value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function https(value: unknown): string | null {
|
|
71
|
+
if (typeof value !== "string") return null;
|
|
72
|
+
try {
|
|
73
|
+
const url = new URL(value, "https://commons.wikimedia.org");
|
|
74
|
+
// Commons now appends utm_ tracking parameters to thumbnail URLs.
|
|
75
|
+
for (const key of [...url.searchParams.keys()]) if (key.startsWith("utm_")) url.searchParams.delete(key);
|
|
76
|
+
return url.protocol === "https:" ? url.href : null;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The best photo in a search response: in search order, the first JPEG wide
|
|
84
|
+
* enough to fill a chat column (PNGs on Commons are mostly diagrams and
|
|
85
|
+
* screenshots, so one is taken only when no JPEG qualifies). Null when none fits.
|
|
86
|
+
*/
|
|
87
|
+
export function pickImage(response: unknown): FoundImage | null {
|
|
88
|
+
const pages = record(record(response).query).pages;
|
|
89
|
+
if (!Array.isArray(pages)) return null;
|
|
90
|
+
const candidates = pages
|
|
91
|
+
.map(record)
|
|
92
|
+
.sort((a, b) => Number(a.index ?? 0) - Number(b.index ?? 0))
|
|
93
|
+
.flatMap((page): (FoundImage & { mime: string })[] => {
|
|
94
|
+
const info = record(Array.isArray(page.imageinfo) ? page.imageinfo[0] : undefined);
|
|
95
|
+
const mime = typeof info.mime === "string" ? info.mime : "";
|
|
96
|
+
const width = Number(info.width);
|
|
97
|
+
const height = Number(info.height);
|
|
98
|
+
const url = https(info.thumburl) ?? https(info.url);
|
|
99
|
+
const sourceUrl = https(info.descriptionurl);
|
|
100
|
+
if (url === null || sourceUrl === null || !Number.isFinite(width) || !Number.isFinite(height)) return [];
|
|
101
|
+
if (width < MIN_WIDTH || height < MIN_WIDTH * 0.5) return [];
|
|
102
|
+
if (mime !== "image/jpeg" && mime !== "image/png" && mime !== "image/webp") return [];
|
|
103
|
+
const fields = record(info.extmetadata);
|
|
104
|
+
const fileTitle = typeof page.title === "string" ? page.title.replace(/^File:/, "").replace(/\.[a-z0-9]+$/i, "") : "";
|
|
105
|
+
const thumbWidth = Number(info.thumbwidth);
|
|
106
|
+
const thumbHeight = Number(info.thumbheight);
|
|
107
|
+
const scaled = Number.isFinite(thumbWidth) && Number.isFinite(thumbHeight) && thumbWidth > 0;
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
mime,
|
|
111
|
+
url,
|
|
112
|
+
width: scaled ? thumbWidth : width,
|
|
113
|
+
height: scaled ? thumbHeight : height,
|
|
114
|
+
title: (metadata(fields, "ObjectName") || fileTitle).slice(0, 200),
|
|
115
|
+
creator: (metadata(fields, "Artist") || "Unknown author").slice(0, 120),
|
|
116
|
+
license: metadata(fields, "LicenseShortName") || "See source page",
|
|
117
|
+
licenseUrl: https(record(fields.LicenseUrl).value),
|
|
118
|
+
sourceUrl,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
});
|
|
122
|
+
const chosen = candidates.find((candidate) => candidate.mime === "image/jpeg") ?? candidates[0];
|
|
123
|
+
if (chosen === undefined) return null;
|
|
124
|
+
const { mime: _mime, ...image } = chosen;
|
|
125
|
+
return image;
|
|
126
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Place names to coordinates with Photon (photon.komoot.io), OpenStreetMap
|
|
2
|
+
// data under the ODbL. Not Nominatim: its usage policy forbids the public API
|
|
3
|
+
// being offered through or generated by AI coding tools, which is exactly how
|
|
4
|
+
// Map's place names arrive. Photon asks for fair use; we keep to one request
|
|
5
|
+
// a second (see lookups.ts), identify ourselves and cache every answer.
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export const locatedSchema = z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180) });
|
|
9
|
+
|
|
10
|
+
export type Located = z.infer<typeof locatedSchema>;
|
|
11
|
+
|
|
12
|
+
const API = "https://photon.komoot.io/api/";
|
|
13
|
+
|
|
14
|
+
/** The request for `name`, nudged toward `near` (the places already on the map) when given. */
|
|
15
|
+
export function geocodeUrl(name: string, near: Located | null): string {
|
|
16
|
+
const params = new URLSearchParams({ q: name, limit: "1" });
|
|
17
|
+
if (near !== null) {
|
|
18
|
+
params.set("lat", near.lat.toFixed(3));
|
|
19
|
+
params.set("lon", near.lng.toFixed(3));
|
|
20
|
+
// A regional nudge: it breaks ties between same-named places without
|
|
21
|
+
// overriding a clearly named one far away.
|
|
22
|
+
params.set("zoom", "8");
|
|
23
|
+
params.set("location_bias_scale", "0.3");
|
|
24
|
+
}
|
|
25
|
+
return `${API}?${params}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const photonResponse = z.object({
|
|
29
|
+
features: z
|
|
30
|
+
.array(z.object({ geometry: z.object({ coordinates: z.tuple([z.number(), z.number()]).rest(z.number()) }) }))
|
|
31
|
+
.min(1),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** The first feature's position in a Photon GeoJSON response, or null. */
|
|
35
|
+
export function pickLocation(response: unknown): Located | null {
|
|
36
|
+
const parsed = photonResponse.safeParse(response);
|
|
37
|
+
if (!parsed.success) return null;
|
|
38
|
+
const [lng, lat] = parsed.data.features[0].geometry.coordinates;
|
|
39
|
+
return Math.abs(lat) > 90 || Math.abs(lng) > 180 ? null : { lat, lng };
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What a fetched body really is. A server's Content-Type is a claim; the
|
|
2
|
+
// bytes decide which image type the page is told to decode.
|
|
3
|
+
|
|
4
|
+
/** Image types the proxy passes on, by MIME type. */
|
|
5
|
+
export const IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "image/svg+xml"] as const;
|
|
6
|
+
|
|
7
|
+
export type ImageMime = (typeof IMAGE_MIME_TYPES)[number];
|
|
8
|
+
|
|
9
|
+
function startsWith(body: Uint8Array, bytes: readonly number[], at = 0): boolean {
|
|
10
|
+
return body.length >= at + bytes.length && bytes.every((byte, index) => body[at + index] === byte);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ascii(text: string): number[] {
|
|
14
|
+
return [...text].map((char) => char.charCodeAt(0));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const SVG_START = /^(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<!DOCTYPE[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg[\s>/]/i;
|
|
18
|
+
|
|
19
|
+
/** The image type `body` holds, from its leading bytes; null when it is not one of IMAGE_MIME_TYPES. */
|
|
20
|
+
export function sniffImage(body: Uint8Array): ImageMime | null {
|
|
21
|
+
if (startsWith(body, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
|
|
22
|
+
if (startsWith(body, [0xff, 0xd8, 0xff])) return "image/jpeg";
|
|
23
|
+
if (startsWith(body, ascii("GIF87a")) || startsWith(body, ascii("GIF89a"))) return "image/gif";
|
|
24
|
+
if (startsWith(body, ascii("RIFF")) && startsWith(body, ascii("WEBP"), 8)) return "image/webp";
|
|
25
|
+
if (startsWith(body, ascii("ftyp"), 4) && (startsWith(body, ascii("avif"), 8) || startsWith(body, ascii("avis"), 8))) {
|
|
26
|
+
return "image/avif";
|
|
27
|
+
}
|
|
28
|
+
const head = new TextDecoder("utf-8", { fatal: false }).decode(body.subarray(0, 4096)).replace(/^\uFEFF/, "").trimStart();
|
|
29
|
+
return SVG_START.test(head) ? "image/svg+xml" : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** True when a Content-Type header names an image type (parameters ignored). */
|
|
33
|
+
export function declaresImage(contentType: string | undefined): boolean {
|
|
34
|
+
return contentType !== undefined && /^image\/[a-z0-9.+-]+\s*(;|$)/i.test(contentType.trim());
|
|
35
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { WebCache, type CacheEntry } from "./cache";
|
|
3
|
+
import { foundImageSchema, pickImage, searchUrl, type FoundImage } from "./commons";
|
|
4
|
+
import { geocodeUrl, locatedSchema, pickLocation, type Located } from "./geocode";
|
|
5
|
+
import { declaresImage, sniffImage } from "./image";
|
|
6
|
+
import { safeGet, type Fetched, type GetOptions } from "./net";
|
|
7
|
+
|
|
8
|
+
export const MAX_REMOTE_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
9
|
+
const CACHE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const DAY_MS = 86_400_000;
|
|
11
|
+
const TILE_TTL_MS = 7 * DAY_MS;
|
|
12
|
+
const IMAGE_TTL_MS = 7 * DAY_MS;
|
|
13
|
+
const FOUND_TTL_MS = 30 * DAY_MS;
|
|
14
|
+
const MISS_TTL_MS = DAY_MS;
|
|
15
|
+
const GEOCODE_SPACING_MS = 1_100;
|
|
16
|
+
export const MAX_TILE_ZOOM = 19;
|
|
17
|
+
|
|
18
|
+
export type WebFailure = { readonly ok: false; readonly error: string; readonly off?: boolean };
|
|
19
|
+
export type WebImage = { readonly ok: true; readonly dataUrl: string } | WebFailure;
|
|
20
|
+
export type WebSearch = { readonly ok: true; readonly image: FoundImage } | WebFailure;
|
|
21
|
+
export type WebPlace = ({ readonly ok: true } & Located) | WebFailure;
|
|
22
|
+
|
|
23
|
+
const OFF: WebFailure = {
|
|
24
|
+
ok: false,
|
|
25
|
+
off: true,
|
|
26
|
+
error: "Web lookups are off in the Generative UI plugin settings",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function limiter(max: number): <T>(task: () => Promise<T>) => Promise<T> {
|
|
30
|
+
let active = 0;
|
|
31
|
+
const waiting: (() => void)[] = [];
|
|
32
|
+
return async (task) => {
|
|
33
|
+
if (active >= max) await new Promise<void>((resolve) => waiting.push(resolve));
|
|
34
|
+
active += 1;
|
|
35
|
+
try {
|
|
36
|
+
return await task();
|
|
37
|
+
} finally {
|
|
38
|
+
active -= 1;
|
|
39
|
+
waiting.shift()?.();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dataUrl(mime: string, body: Buffer): string {
|
|
45
|
+
return `data:${mime};base64,${body.toString("base64")}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseJson(body: Buffer): unknown {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(body.toString("utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function metaOf(entry: CacheEntry): Record<string, unknown> {
|
|
57
|
+
const meta = parseJson(Buffer.from(entry.meta, "utf8"));
|
|
58
|
+
return typeof meta === "object" && meta !== null && !Array.isArray(meta)
|
|
59
|
+
? Object.fromEntries(Object.entries(meta))
|
|
60
|
+
: {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalize(text: string): string {
|
|
64
|
+
return text.trim().replace(/\s+/g, " ").toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface WebLookups {
|
|
68
|
+
image(url: string): Promise<WebImage>;
|
|
69
|
+
search(query: string): Promise<WebSearch>;
|
|
70
|
+
geocode(name: string, near: Located | null): Promise<WebPlace>;
|
|
71
|
+
tile(z: number, x: number, y: number): Promise<WebImage>;
|
|
72
|
+
isOn(): boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createWebLookups(isEnabled: () => Promise<boolean>, isOnSync: () => boolean): WebLookups {
|
|
76
|
+
const cache = new WebCache(CACHE_BYTES);
|
|
77
|
+
const inFlight = new Map<string, Promise<unknown>>();
|
|
78
|
+
|
|
79
|
+
function once<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
80
|
+
const running = inFlight.get(key);
|
|
81
|
+
if (running !== undefined) return running as Promise<T>;
|
|
82
|
+
const started = task().finally(() => inFlight.delete(key));
|
|
83
|
+
inFlight.set(key, started);
|
|
84
|
+
return started;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cached(key: string): CacheEntry | null {
|
|
88
|
+
try {
|
|
89
|
+
return cache.get(key);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function store(write: () => void): void {
|
|
96
|
+
try {
|
|
97
|
+
write();
|
|
98
|
+
} catch {
|
|
99
|
+
// Ignore cache write errors
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const images = limiter(4);
|
|
104
|
+
const tiles = limiter(2);
|
|
105
|
+
const api = limiter(2);
|
|
106
|
+
let nextGeocodeAt = 0;
|
|
107
|
+
|
|
108
|
+
const imageRequest: GetOptions = {
|
|
109
|
+
accept: "image/avif,image/webp,image/png,image/jpeg,image/gif,image/svg+xml;q=0.8",
|
|
110
|
+
maxBytes: MAX_REMOTE_IMAGE_BYTES,
|
|
111
|
+
timeoutMs: 20_000,
|
|
112
|
+
};
|
|
113
|
+
const jsonRequest: GetOptions = {
|
|
114
|
+
accept: "application/json",
|
|
115
|
+
maxBytes: 2 * 1024 * 1024,
|
|
116
|
+
timeoutMs: 15_000,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
async function image(input: string): Promise<WebImage> {
|
|
120
|
+
if (!(await isEnabled())) return OFF;
|
|
121
|
+
let url: string;
|
|
122
|
+
try {
|
|
123
|
+
url = new URL(input).href;
|
|
124
|
+
} catch {
|
|
125
|
+
return { ok: false, error: "not a valid URL" };
|
|
126
|
+
}
|
|
127
|
+
const key = `img:${url}`;
|
|
128
|
+
return once(key, async (): Promise<WebImage> => {
|
|
129
|
+
const hit = cached(key);
|
|
130
|
+
const hitMime = hit === null ? null : metaOf(hit).mime;
|
|
131
|
+
if (hit !== null && !hit.expired && typeof hitMime === "string") {
|
|
132
|
+
return { ok: true, dataUrl: dataUrl(hitMime, hit.body) };
|
|
133
|
+
}
|
|
134
|
+
const fetched = await images(() => safeGet(url, imageRequest));
|
|
135
|
+
if (!fetched.ok) {
|
|
136
|
+
return hit !== null && typeof hitMime === "string"
|
|
137
|
+
? { ok: true, dataUrl: dataUrl(hitMime, hit.body) }
|
|
138
|
+
: fetched;
|
|
139
|
+
}
|
|
140
|
+
const mime = sniffImage(fetched.body);
|
|
141
|
+
if (mime === null || !declaresImage(fetched.headers["content-type"])) {
|
|
142
|
+
return { ok: false, error: "the link is not a png, jpeg, gif, webp, avif or svg image" };
|
|
143
|
+
}
|
|
144
|
+
store(() => cache.put(key, fetched.body, JSON.stringify({ mime }), IMAGE_TTL_MS));
|
|
145
|
+
return { ok: true, dataUrl: dataUrl(mime, fetched.body) };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function search(query: string): Promise<WebSearch> {
|
|
150
|
+
if (!(await isEnabled())) return OFF;
|
|
151
|
+
const normalized = normalize(query);
|
|
152
|
+
if (normalized === "") return { ok: false, error: "the search is empty" };
|
|
153
|
+
const key = `search:${normalized}`;
|
|
154
|
+
const notFound: WebFailure = { ok: false, error: `no photo found for “${query.trim()}”` };
|
|
155
|
+
return once(key, async (): Promise<WebSearch> => {
|
|
156
|
+
const hit = cached(key);
|
|
157
|
+
const parsed = hit === null ? null : foundImageSchema.nullable().safeParse(metaOf(hit).image);
|
|
158
|
+
const remembered = parsed?.success ? parsed.data : null;
|
|
159
|
+
if (hit !== null && !hit.expired && parsed?.success) {
|
|
160
|
+
return remembered === null ? notFound : { ok: true, image: remembered };
|
|
161
|
+
}
|
|
162
|
+
const fetched: Fetched = await api(() => safeGet(searchUrl(normalized), jsonRequest));
|
|
163
|
+
if (!fetched.ok) return remembered !== null ? { ok: true, image: remembered } : fetched;
|
|
164
|
+
const found: FoundImage | null = pickImage(parseJson(fetched.body));
|
|
165
|
+
store(() =>
|
|
166
|
+
cache.put(
|
|
167
|
+
key,
|
|
168
|
+
Buffer.alloc(0),
|
|
169
|
+
JSON.stringify({ image: found }),
|
|
170
|
+
found === null ? MISS_TTL_MS : FOUND_TTL_MS,
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
return found === null ? notFound : { ok: true, image: found };
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function geocode(name: string, near: Located | null): Promise<WebPlace> {
|
|
178
|
+
if (!(await isEnabled())) return OFF;
|
|
179
|
+
const normalized = normalize(name);
|
|
180
|
+
if (normalized === "") return { ok: false, error: "the place has no name" };
|
|
181
|
+
const bias = near === null ? "" : `${near.lat.toFixed(1)},${near.lng.toFixed(1)}`;
|
|
182
|
+
const key = `geo:${normalized}|${bias}`;
|
|
183
|
+
const notFound: WebFailure = { ok: false, error: `couldn't find “${name.trim()}” on the map` };
|
|
184
|
+
return once(key, async (): Promise<WebPlace> => {
|
|
185
|
+
const hit = cached(key);
|
|
186
|
+
const parsed = hit === null ? null : locatedSchema.nullable().safeParse(metaOf(hit).place);
|
|
187
|
+
const remembered = parsed?.success ? parsed.data : null;
|
|
188
|
+
if (hit !== null && !hit.expired && parsed?.success) {
|
|
189
|
+
return remembered === null ? notFound : { ok: true, ...remembered };
|
|
190
|
+
}
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const slot = Math.max(now, nextGeocodeAt);
|
|
193
|
+
nextGeocodeAt = slot + GEOCODE_SPACING_MS;
|
|
194
|
+
if (slot > now) await sleep(slot - now);
|
|
195
|
+
const fetched = await safeGet(geocodeUrl(normalized, near), jsonRequest);
|
|
196
|
+
if (!fetched.ok) return remembered !== null ? { ok: true, ...remembered } : fetched;
|
|
197
|
+
const found = pickLocation(parseJson(fetched.body));
|
|
198
|
+
store(() =>
|
|
199
|
+
cache.put(
|
|
200
|
+
key,
|
|
201
|
+
Buffer.alloc(0),
|
|
202
|
+
JSON.stringify({ place: found }),
|
|
203
|
+
found === null ? MISS_TTL_MS : FOUND_TTL_MS,
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
return found === null ? notFound : { ok: true, ...found };
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function tile(z: number, x: number, y: number): Promise<WebImage> {
|
|
211
|
+
if (!(await isEnabled())) return OFF;
|
|
212
|
+
const span = 2 ** z;
|
|
213
|
+
if (![z, x, y].every(Number.isInteger) || z < 0 || z > MAX_TILE_ZOOM || x < 0 || y < 0 || x >= span || y >= span) {
|
|
214
|
+
return { ok: false, error: "no such tile" };
|
|
215
|
+
}
|
|
216
|
+
const key = `tile:${z}/${x}/${y}`;
|
|
217
|
+
return once(key, async (): Promise<WebImage> => {
|
|
218
|
+
const hit = cached(key);
|
|
219
|
+
const meta = hit === null ? {} : metaOf(hit);
|
|
220
|
+
if (hit !== null && !hit.expired) return { ok: true, dataUrl: dataUrl("image/png", hit.body) };
|
|
221
|
+
const validators: Record<string, string> = {};
|
|
222
|
+
if (hit !== null && typeof meta.etag === "string") validators["if-none-match"] = meta.etag;
|
|
223
|
+
if (hit !== null && typeof meta.lastModified === "string") validators["if-modified-since"] = meta.lastModified;
|
|
224
|
+
const fetched = await tiles(() =>
|
|
225
|
+
safeGet(`https://tile.openstreetmap.org/${z}/${x}/${y}.png`, {
|
|
226
|
+
accept: "image/png",
|
|
227
|
+
maxBytes: 1024 * 1024,
|
|
228
|
+
timeoutMs: 15_000,
|
|
229
|
+
headers: validators,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
if (!fetched.ok) return hit !== null ? { ok: true, dataUrl: dataUrl("image/png", hit.body) } : fetched;
|
|
233
|
+
if (fetched.status === 304 && hit !== null) {
|
|
234
|
+
store(() => cache.renew(key, hit.meta, TILE_TTL_MS));
|
|
235
|
+
return { ok: true, dataUrl: dataUrl("image/png", hit.body) };
|
|
236
|
+
}
|
|
237
|
+
if (sniffImage(fetched.body) !== "image/png") {
|
|
238
|
+
return { ok: false, error: "the tile server sent something other than a png" };
|
|
239
|
+
}
|
|
240
|
+
const etag = fetched.headers.etag;
|
|
241
|
+
const lastModified = fetched.headers["last-modified"];
|
|
242
|
+
store(() => cache.put(key, fetched.body, JSON.stringify({ etag, lastModified }), TILE_TTL_MS));
|
|
243
|
+
return { ok: true, dataUrl: dataUrl("image/png", fetched.body) };
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { image, search, geocode, tile, isOn: isOnSync };
|
|
248
|
+
}
|