@wootsup/mcp 0.1.0 → 0.4.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 +157 -83
- package/README.md +31 -27
- package/SECURITY.md +15 -6
- package/dist/auth/keychain.d.ts +27 -1
- package/dist/auth/keychain.js +48 -2
- package/dist/auth/keychain.js.map +1 -1
- package/dist/catalog/build-catalog.d.ts +31 -0
- package/dist/catalog/build-catalog.js +68 -0
- package/dist/catalog/build-catalog.js.map +1 -0
- package/dist/cli-hint.d.ts +22 -0
- package/dist/cli-hint.js +55 -0
- package/dist/cli-hint.js.map +1 -0
- package/dist/index.js +129 -22
- package/dist/index.js.map +1 -1
- package/dist/install-skill.js +1 -1
- package/dist/modules/apimapper/auto-layout.d.ts +21 -0
- package/dist/modules/apimapper/auto-layout.js +54 -0
- package/dist/modules/apimapper/auto-layout.js.map +1 -0
- package/dist/modules/apimapper/cache.js +25 -17
- package/dist/modules/apimapper/cache.js.map +1 -1
- package/dist/modules/apimapper/client.d.ts +115 -4
- package/dist/modules/apimapper/client.js +699 -304
- package/dist/modules/apimapper/client.js.map +1 -1
- package/dist/modules/apimapper/connections-format.d.ts +31 -1
- package/dist/modules/apimapper/connections-format.js +97 -5
- package/dist/modules/apimapper/connections-format.js.map +1 -1
- package/dist/modules/apimapper/connections.d.ts +9 -7
- package/dist/modules/apimapper/connections.js +449 -127
- package/dist/modules/apimapper/connections.js.map +1 -1
- package/dist/modules/apimapper/credential-sanitizer.d.ts +5 -0
- package/dist/modules/apimapper/credential-sanitizer.js +60 -1
- package/dist/modules/apimapper/credential-sanitizer.js.map +1 -1
- package/dist/modules/apimapper/credentials.js +105 -61
- package/dist/modules/apimapper/credentials.js.map +1 -1
- package/dist/modules/apimapper/diagnose.js +21 -2
- package/dist/modules/apimapper/diagnose.js.map +1 -1
- package/dist/modules/apimapper/elicitation.d.ts +29 -0
- package/dist/modules/apimapper/elicitation.js +62 -0
- package/dist/modules/apimapper/elicitation.js.map +1 -1
- package/dist/modules/apimapper/example-extract.d.ts +13 -0
- package/dist/modules/apimapper/example-extract.js +111 -0
- package/dist/modules/apimapper/example-extract.js.map +1 -0
- package/dist/modules/apimapper/filter-operators.d.ts +24 -0
- package/dist/modules/apimapper/filter-operators.js +103 -0
- package/dist/modules/apimapper/filter-operators.js.map +1 -0
- package/dist/modules/apimapper/flows-format.js +92 -22
- package/dist/modules/apimapper/flows-format.js.map +1 -1
- package/dist/modules/apimapper/flows.d.ts +8 -7
- package/dist/modules/apimapper/flows.js +275 -120
- package/dist/modules/apimapper/flows.js.map +1 -1
- package/dist/modules/apimapper/gateway/advanced-read-tool.d.ts +9 -0
- package/dist/modules/apimapper/gateway/advanced-read-tool.js +172 -0
- package/dist/modules/apimapper/gateway/advanced-read-tool.js.map +1 -0
- package/dist/modules/apimapper/gateway/advanced-tool.js +66 -106
- package/dist/modules/apimapper/gateway/advanced-tool.js.map +1 -1
- package/dist/modules/apimapper/gateway/collect-module-tools.d.ts +17 -0
- package/dist/modules/apimapper/gateway/collect-module-tools.js +44 -0
- package/dist/modules/apimapper/gateway/collect-module-tools.js.map +1 -0
- package/dist/modules/apimapper/gateway/essentials.d.ts +1 -1
- package/dist/modules/apimapper/gateway/essentials.js +21 -2
- package/dist/modules/apimapper/gateway/essentials.js.map +1 -1
- package/dist/modules/apimapper/gateway/gateway-shared.d.ts +21 -0
- package/dist/modules/apimapper/gateway/gateway-shared.js +124 -0
- package/dist/modules/apimapper/gateway/gateway-shared.js.map +1 -0
- package/dist/modules/apimapper/gateway/test-support.d.ts +1 -17
- package/dist/modules/apimapper/gateway/test-support.js +4 -33
- package/dist/modules/apimapper/gateway/test-support.js.map +1 -1
- package/dist/modules/apimapper/get-skill-cores.d.ts +4 -0
- package/dist/modules/apimapper/get-skill-cores.js +220 -0
- package/dist/modules/apimapper/get-skill-cores.js.map +1 -0
- package/dist/modules/apimapper/get-skill.d.ts +1 -1
- package/dist/modules/apimapper/get-skill.js +74 -9
- package/dist/modules/apimapper/get-skill.js.map +1 -1
- package/dist/modules/apimapper/graph-builder.d.ts +85 -2
- package/dist/modules/apimapper/graph-builder.js +151 -15
- package/dist/modules/apimapper/graph-builder.js.map +1 -1
- package/dist/modules/apimapper/graph.js +152 -48
- package/dist/modules/apimapper/graph.js.map +1 -1
- package/dist/modules/apimapper/index.js +27 -13
- package/dist/modules/apimapper/index.js.map +1 -1
- package/dist/modules/apimapper/jmespath-test.d.ts +4 -0
- package/dist/modules/apimapper/jmespath-test.js +152 -0
- package/dist/modules/apimapper/jmespath-test.js.map +1 -0
- package/dist/modules/apimapper/library.js +553 -88
- package/dist/modules/apimapper/library.js.map +1 -1
- package/dist/modules/apimapper/license.js +12 -36
- package/dist/modules/apimapper/license.js.map +1 -1
- package/dist/modules/apimapper/list-footer.d.ts +27 -0
- package/dist/modules/apimapper/list-footer.js +57 -0
- package/dist/modules/apimapper/list-footer.js.map +1 -0
- package/dist/modules/apimapper/local-sources.js +100 -57
- package/dist/modules/apimapper/local-sources.js.map +1 -1
- package/dist/modules/apimapper/mcp-client-identity.d.ts +32 -0
- package/dist/modules/apimapper/mcp-client-identity.js +70 -0
- package/dist/modules/apimapper/mcp-client-identity.js.map +1 -0
- package/dist/modules/apimapper/merge-constants.d.ts +6 -0
- package/dist/modules/apimapper/merge-constants.js +26 -0
- package/dist/modules/apimapper/merge-constants.js.map +1 -0
- package/dist/modules/apimapper/misc.js +13 -27
- package/dist/modules/apimapper/misc.js.map +1 -1
- package/dist/modules/apimapper/node-schema.d.ts +52 -2
- package/dist/modules/apimapper/node-schema.js +95 -4
- package/dist/modules/apimapper/node-schema.js.map +1 -1
- package/dist/modules/apimapper/onboarding.d.ts +59 -1
- package/dist/modules/apimapper/onboarding.js +231 -28
- package/dist/modules/apimapper/onboarding.js.map +1 -1
- package/dist/modules/apimapper/read-cache.d.ts +16 -3
- package/dist/modules/apimapper/read-cache.js +59 -4
- package/dist/modules/apimapper/read-cache.js.map +1 -1
- package/dist/modules/apimapper/render/index.js +26 -5
- package/dist/modules/apimapper/render/index.js.map +1 -1
- package/dist/modules/apimapper/resource-id.d.ts +13 -0
- package/dist/modules/apimapper/resource-id.js +69 -0
- package/dist/modules/apimapper/resource-id.js.map +1 -0
- package/dist/modules/apimapper/schema.js +9 -18
- package/dist/modules/apimapper/schema.js.map +1 -1
- package/dist/modules/apimapper/settings.js +49 -52
- package/dist/modules/apimapper/settings.js.map +1 -1
- package/dist/modules/apimapper/sites-tools.d.ts +29 -0
- package/dist/modules/apimapper/sites-tools.js +165 -0
- package/dist/modules/apimapper/sites-tools.js.map +1 -0
- package/dist/modules/apimapper/tool-result.d.ts +66 -0
- package/dist/modules/apimapper/tool-result.js +125 -0
- package/dist/modules/apimapper/tool-result.js.map +1 -0
- package/dist/modules/apimapper/toolslist-size.d.ts +12 -11
- package/dist/modules/apimapper/toolslist-size.js +34 -21
- package/dist/modules/apimapper/toolslist-size.js.map +1 -1
- package/dist/modules/apimapper/types.d.ts +34 -0
- package/dist/modules/apimapper/types.js +1 -1
- package/dist/modules/apimapper/types.js.map +1 -1
- package/dist/modules/apimapper/whitelist-drift.d.ts +85 -0
- package/dist/modules/apimapper/whitelist-drift.js +375 -0
- package/dist/modules/apimapper/whitelist-drift.js.map +1 -0
- package/dist/modules/apimapper/workflows.js +302 -58
- package/dist/modules/apimapper/workflows.js.map +1 -1
- package/dist/modules/apimapper/yootheme-binding.d.ts +35 -0
- package/dist/modules/apimapper/yootheme-binding.js +267 -0
- package/dist/modules/apimapper/yootheme-binding.js.map +1 -0
- package/dist/platform/index.d.ts +56 -0
- package/dist/platform/index.js +158 -2
- package/dist/platform/index.js.map +1 -1
- package/dist/proxy/bridge.d.ts +35 -0
- package/dist/proxy/bridge.js +129 -0
- package/dist/proxy/bridge.js.map +1 -0
- package/dist/proxy/mode.d.ts +9 -0
- package/dist/proxy/mode.js +20 -0
- package/dist/proxy/mode.js.map +1 -0
- package/dist/setup/detect-clients.d.ts +40 -1
- package/dist/setup/detect-clients.js +148 -1
- package/dist/setup/detect-clients.js.map +1 -1
- package/dist/setup/probe-auth.d.ts +51 -0
- package/dist/setup/probe-auth.js +141 -0
- package/dist/setup/probe-auth.js.map +1 -0
- package/dist/setup/probe-handshake.js +40 -7
- package/dist/setup/probe-handshake.js.map +1 -1
- package/dist/setup/remove-config.d.ts +8 -0
- package/dist/setup/remove-config.js +145 -0
- package/dist/setup/remove-config.js.map +1 -0
- package/dist/setup/uninstall.d.ts +34 -0
- package/dist/setup/uninstall.js +147 -0
- package/dist/setup/uninstall.js.map +1 -0
- package/dist/setup-cli.d.ts +16 -0
- package/dist/setup-cli.js +63 -1
- package/dist/setup-cli.js.map +1 -1
- package/dist/sites/loader.d.ts +48 -0
- package/dist/sites/loader.js +134 -0
- package/dist/sites/loader.js.map +1 -0
- package/dist/sites/schema.d.ts +69 -0
- package/dist/sites/schema.js +71 -0
- package/dist/sites/schema.js.map +1 -0
- package/dist/sites/secret-resolver.d.ts +47 -0
- package/dist/sites/secret-resolver.js +150 -0
- package/dist/sites/secret-resolver.js.map +1 -0
- package/dist/skill-instructions.d.ts +14 -1
- package/dist/skill-instructions.js +35 -6
- package/dist/skill-instructions.js.map +1 -1
- package/dist/transports/stdio.js +4 -4
- package/dist/transports/stdio.js.map +1 -1
- package/dist/uninstall-skill.d.ts +27 -0
- package/dist/uninstall-skill.js +89 -0
- package/dist/uninstall-skill.js.map +1 -0
- package/docs/architecture.md +21 -21
- package/docs/customgraph-internal-migration.md +4 -4
- package/docs/security.md +2 -21
- package/docs/tools.md +40 -12
- package/manifest.json +77 -79
- package/package.json +69 -65
- package/skills/apimapper/SKILL.md +128 -7
- package/skills/apimapper/reference/conditional-style-multi-items.md +114 -0
- package/skills/apimapper/reference/dynamize-existing-layout.md +158 -0
- package/skills/apimapper/reference/jmespath-cookbook.md +241 -0
- package/skills/apimapper/reference/jmespath-pitfalls.md +189 -0
- package/skills/apimapper/reference/joomla.md +1 -1
- package/skills/apimapper/reference/library-template-discovery.md +65 -0
- package/skills/apimapper/reference/merge-two-sources-on-key.md +204 -0
- package/skills/apimapper/reference/oauth.md +143 -52
- package/skills/apimapper/reference/troubleshooting.md +22 -2
- package/skills/apimapper/reference/yootheme-source-to-builder-handoff.md +348 -0
- package/skills/apimapper/reference/yootheme.md +75 -44
- package/dist/auth/oauth-provider.d.ts +0 -68
- package/dist/auth/oauth-provider.js +0 -232
- package/dist/auth/oauth-provider.js.map +0 -1
- package/dist/server-http.d.ts +0 -22
- package/dist/server-http.js +0 -159
- package/dist/server-http.js.map +0 -1
- package/dist/transports/http.d.ts +0 -29
- package/dist/transports/http.js +0 -267
- package/dist/transports/http.js.map +0 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# Merge two sources on a shared key
|
|
2
|
+
|
|
3
|
+
Join items from two sources on a shared key, optionally filter the join, surface the result as one Source for YOOtheme.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
- You have two APIs whose rows belong together but live behind separate endpoints (orders + customers, Calendly slots + Sheet schedule, GitHub issues + sprint planning).
|
|
8
|
+
- The keys you want to join on either (a) are already string-equal, or (b) need a small Transform to be made comparable (e.g. ISO datetime → weekday name).
|
|
9
|
+
|
|
10
|
+
## The canonical recipe (REST / MCP — no UI dragging)
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Source-A ─┐
|
|
14
|
+
├─► Merge (strategy: join, joinKey: <field>) ─► Filter ─► Output
|
|
15
|
+
Source-B ─┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Build the whole thing in ONE call — `apimapper_flow_setup_with_sources` lays out the source → merge → (filter) → output graph for you:
|
|
19
|
+
|
|
20
|
+
```jsonc
|
|
21
|
+
apimapper_flow_setup_with_sources({
|
|
22
|
+
name: "Properties + Ratings",
|
|
23
|
+
sources: [
|
|
24
|
+
{ connection: "con_properties" }, // Source-A
|
|
25
|
+
{ connection: "con_ratings" } // Source-B
|
|
26
|
+
],
|
|
27
|
+
merge: {
|
|
28
|
+
strategy: "join", // emit one row per matched A item, B fields flat-merged on (NOT nested under `b`)
|
|
29
|
+
joinKey: "property_id", // field on Source-A
|
|
30
|
+
joinKeyRight: "propertyId", // field on Source-B — OMIT when both sides name it the same
|
|
31
|
+
joinType: "left" // "left" keeps every A row; "inner" drops A rows with no B match
|
|
32
|
+
},
|
|
33
|
+
// optional: filter runs AFTER merge, references both A- and B-side fields by their bare names
|
|
34
|
+
filter: { field: "rating", operator: "gte", value: 4 },
|
|
35
|
+
output: { type: "yootheme", name: "RatedProperties" }
|
|
36
|
+
})
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Key points:
|
|
40
|
+
- `strategy: "join"` (NOT `mode`), `joinKey` (NOT `key`). The old UI words `mode`/`key` are not REST parameters.
|
|
41
|
+
- `joinKey` is the Source-A field; `joinKeyRight` is the Source-B field. When both sides use the **same** field name, set only `joinKey` (`joinKeyRight` defaults to it).
|
|
42
|
+
- Discover the right `joinKey`/`joinKeyRight` BEFORE you build — see the next section.
|
|
43
|
+
- Already have a saved flow? Edit the merge node with `apimapper_flow_update` (same `strategy`/`joinKey`/`joinKeyRight`/`joinType` node-data keys).
|
|
44
|
+
|
|
45
|
+
## Discovering the join key
|
|
46
|
+
|
|
47
|
+
You almost never know the join key up front, and **guessing wrong yields 0 matched rows with no error**. Discover it by sampling both sources first.
|
|
48
|
+
|
|
49
|
+
1. **Sample Source-A:**
|
|
50
|
+
```
|
|
51
|
+
apimapper_connection_data({ id: "con_properties", limit: 3 })
|
|
52
|
+
```
|
|
53
|
+
Note the candidate key fields and their values, e.g. `property_id: "P-100"` (a **string**).
|
|
54
|
+
2. **Sample Source-B:**
|
|
55
|
+
```
|
|
56
|
+
apimapper_connection_data({ id: "con_ratings", limit: 3 })
|
|
57
|
+
```
|
|
58
|
+
e.g. `propertyId: 42` (a **number**).
|
|
59
|
+
3. **Pick a key that is present and comparable on BOTH sides.** The join compares values with `===`-style equality, so the two keys must match in **type AND format**:
|
|
60
|
+
- string `"P-100"` never equals number `42` — even `"42"` never equals `42`.
|
|
61
|
+
- `" P-100"` (trailing/leading space) never equals `"P-100"`.
|
|
62
|
+
- `"p-100"` never equals `"P-100"` (case matters).
|
|
63
|
+
4. **If the keys aren't comparable as-is, bridge them with a Transform** before the merge (see "When the keys aren't string-equal" below) — e.g. `to_string(propertyId)` on the number side, or a `date_weekday(...)` projection for datetime↔weekday joins.
|
|
64
|
+
|
|
65
|
+
### When the join still returns 0 rows — the `merge_no_match` signal
|
|
66
|
+
|
|
67
|
+
If `apimapper_graph_preview` returns `item_count: 0` for a join, check the response for `merge_diagnostics[<mergeNodeId>]`:
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
"merge_diagnostics": {
|
|
71
|
+
"merge-1": {
|
|
72
|
+
"reason": "merge_no_match",
|
|
73
|
+
"message": "Merge 'merge-1' (join inner) matched 0 rows: left joinKey 'property_id' (2 items) never equalled right joinKeyRight 'propertyId' (2 items). …",
|
|
74
|
+
"joinKey": "property_id",
|
|
75
|
+
"joinKeyRight": "propertyId",
|
|
76
|
+
"left_key_sample": ["P-100", "P-200"],
|
|
77
|
+
"right_key_sample": [42, 77]
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `left_key_sample` / `right_key_sample` show the actual key values from each side. Compare them: above, the left side is strings (`"P-100"`) and the right side is numbers (`42`) — they can never match. Fix it by either picking a different key or adding a Transform to make them comparable, then re-preview.
|
|
83
|
+
|
|
84
|
+
## The flat-merge contract (READ THIS)
|
|
85
|
+
|
|
86
|
+
The Merge join does **NOT** nest the B-side under a `b` object. There is no `b.` prefix and no option to enable one. Each matched B row is merged **flat onto the A row**, field by field, with this collision rule:
|
|
87
|
+
|
|
88
|
+
| Situation | Result |
|
|
89
|
+
|-----------|--------|
|
|
90
|
+
| B has a field A does **not** have | B's value is copied verbatim at the **top level** (e.g. `open`, `close`, `rating`, `email`). |
|
|
91
|
+
| Both sides carry the field with the **same** value | Kept once from A — no duplicate, no prefix. |
|
|
92
|
+
| Both sides carry the field with **different** values | A's value stays under the original key; **B's value is renamed `branch_1_<key>`** (the `branch_1` prefix denotes the secondary input handle `input-1`). |
|
|
93
|
+
|
|
94
|
+
So if Source-A is the orders feed and Source-B (a products sheet) carries `rating`, the merged row exposes `rating` at the top level — bind to `rating`, **not** `b.rating` (which is always null). Only when a field name collides does the B value move, and it moves to `branch_1_<field>`, never `b.<field>`.
|
|
95
|
+
|
|
96
|
+
### Worked example
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
Source-A (orders) row: { "id": 7, "product": "Widget", "status": "shipped" }
|
|
100
|
+
Source-B (products) row: { "id": 7, "rating": 4.5, "status": "active" }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Join key `id`. The merged row is:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"id": 7,
|
|
108
|
+
"product": "Widget",
|
|
109
|
+
"status": "shipped",
|
|
110
|
+
"rating": 4.5,
|
|
111
|
+
"branch_1_status": "active"
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- `rating` — B-only field, flat at top level.
|
|
116
|
+
- `status` — present on BOTH with different values → A's `"shipped"` keeps the key, B's `"active"` becomes `branch_1_status`.
|
|
117
|
+
- `id` — same value on both sides → kept once, no prefix.
|
|
118
|
+
|
|
119
|
+
Bind YOOtheme element fields to `rating` and `branch_1_status`; binding to `b.rating` returns nothing.
|
|
120
|
+
|
|
121
|
+
> **`join` is left-join by default** (every A row survives; A rows with no B match pass through unchanged). Set `joinType: inner` to drop A rows that have no match. A 1:N or N:M match emits one merged row per B match (the cartesian product of the matching rows).
|
|
122
|
+
|
|
123
|
+
## When the keys aren't string-equal — date-primitive bridging
|
|
124
|
+
|
|
125
|
+
This is the case Cold-AI #5 walk-through hit: Calendly emits `start_time` as ISO-UTC (`"2026-06-04T09:00:00+00:00"`), but the customer's Sheet schedule is keyed on weekday names (`"Wednesday"`). The keys aren't comparable as-is.
|
|
126
|
+
|
|
127
|
+
**Fix:** insert a Transform node BEFORE Merge on the Calendly side that derives a Sheet-compatible key.
|
|
128
|
+
|
|
129
|
+
### Maria's flow (Calendly + Google Sheet schedule)
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
Calendly available_times ─► Transform (project day+local_time) ─┐
|
|
133
|
+
├─► Merge (strategy: join, joinKey: day) ─► Filter ─► Output
|
|
134
|
+
Google Sheet schedule ──────────────────────────────────────┘
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Step 1 — Transform on the Calendly side
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
[].{
|
|
141
|
+
day: date_weekday(start_time, 'Europe/Berlin'),
|
|
142
|
+
local_time: date_iso_to_time(start_time, 'Europe/Berlin'),
|
|
143
|
+
start_time: start_time
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Each Calendly row now carries:
|
|
148
|
+
|
|
149
|
+
| field | value |
|
|
150
|
+
|-------|-------|
|
|
151
|
+
| `day` | `"Wednesday"` — Sheet-comparable |
|
|
152
|
+
| `local_time` | `"09:00"` — comparable to Sheet's `open`/`close` |
|
|
153
|
+
| `start_time` | original ISO datetime (preserved for downstream rendering) |
|
|
154
|
+
|
|
155
|
+
#### Step 2 — Merge on `day`
|
|
156
|
+
|
|
157
|
+
Merge `strategy: "join"`, `joinKey: "day"` (both sides name it `day`/`weekday` — set `joinKeyRight: "weekday"` if the Sheet column is `weekday` rather than `day`). The matching Sheet row is **flat-merged** onto each Calendly row — its `weekday`, `open`, and `close` fields land at the top level (the Calendly side carries `day`/`local_time`/`start_time`, so there is no field-name collision and nothing gets a `branch_1_` prefix):
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"day": "Wednesday",
|
|
162
|
+
"local_time": "09:00",
|
|
163
|
+
"start_time": "2026-06-03T07:00:00+00:00",
|
|
164
|
+
"weekday": "Wednesday",
|
|
165
|
+
"open": "09:00",
|
|
166
|
+
"close": "17:00"
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### Step 3 — Filter to slots inside business hours
|
|
171
|
+
|
|
172
|
+
Reference the Sheet's `open`/`close` by their **bare top-level names** — there is no `b.` prefix:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
[?time_in_window(local_time, open, close)]
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`time_in_window` is left-closed, right-open ([from, to)) — a 17:00 close-time does NOT include a 17:00 slot. Cross-midnight windows (e.g. bar `22:00..02:00`) are also handled.
|
|
179
|
+
|
|
180
|
+
#### Step 4 — Output
|
|
181
|
+
|
|
182
|
+
Wire to a YOOtheme Output. Slots that survive Steps 1-3 become the rows of a YOOtheme Source named e.g. `BookableSlots`.
|
|
183
|
+
|
|
184
|
+
## Other date-bridge patterns
|
|
185
|
+
|
|
186
|
+
| Customer scenario | Source-A key | Source-B key | Bridge |
|
|
187
|
+
|-------------------|--------------|--------------|--------|
|
|
188
|
+
| HubSpot meetings ↔ office-hours | ISO datetime | weekday name | `date_weekday(@.start, tz)` |
|
|
189
|
+
| Stripe invoices ↔ month-rollup | ISO datetime | `"YYYY-MM"` | `substring(date_iso_to_date(@.created, tz), 0, 7)` |
|
|
190
|
+
| GitHub issues ↔ daily-standup notes | ISO datetime | `"YYYY-MM-DD"` | `date_iso_to_date(@.updated_at, tz)` |
|
|
191
|
+
|
|
192
|
+
## Pitfalls
|
|
193
|
+
|
|
194
|
+
- **No `b.` namespace.** B-side fields are flat-merged onto the row (see "The flat-merge contract"). Bind to the bare field name (`rating`, `open`), never `b.rating` / `b.open` — those resolve to null. The only time a B value moves is a name collision, and then it moves to `branch_1_<key>`, not `b.<key>`.
|
|
195
|
+
- **Collisions are silent unless you name them.** If both sources carry a field of the same name with different values (e.g. both have `status`), A wins the original key and B is preserved as `branch_1_status`. If you actually wanted B's value, either rename it in a Transform on the B side before the Merge, or read `branch_1_status` downstream.
|
|
196
|
+
- **Timezone matters.** A slot at `2026-06-04T23:30:00Z` is Wednesday in UTC but Thursday in Berlin. Always supply the second `tz` arg matching the customer's business timezone.
|
|
197
|
+
- **`null` rows propagate.** A Calendly row with a malformed `start_time` projects to `{day: null, local_time: null, start_time: <bad>}`. The Merge will silently emit it under a `null` key — pre-filter with `[?day]` if you need to drop them.
|
|
198
|
+
- **Depth limit.** Don't try to do the whole projection + merge + filter inside ONE expression. JMESPath caps depth at 10. Two transform nodes piped is cleaner and stays under the limit.
|
|
199
|
+
- **0 rows? Read `merge_diagnostics`.** A join that matches nothing comes back as `item_count: 0`, not an error. `apimapper_graph_preview` attaches `merge_diagnostics[<mergeNodeId>]` (`reason: merge_no_match`) with the keys and a `left_key_sample`/`right_key_sample` so you can spot the type/format mismatch. Don't guess — sample both sides with `apimapper_connection_data` (see "Discovering the join key").
|
|
200
|
+
|
|
201
|
+
## See also
|
|
202
|
+
|
|
203
|
+
- `jmespath-pitfalls` — full pitfall catalogue + date-primitive function reference.
|
|
204
|
+
- `yootheme` — how the published Source appears in YOOtheme Builder.
|
|
@@ -2,93 +2,184 @@
|
|
|
2
2
|
|
|
3
3
|
Wiring OAuth-protected sources (Pexels API key, Google Sheets/Drive, Instagram Graph, Facebook Pages, Notion, Airtable, …) through API Mapper.
|
|
4
4
|
|
|
5
|
+
> **Schema note (read first).** Every snippet below uses the EXACT tool parameters the server accepts. The wire contract is snake_case: credentials carry `auth_type` / `auth_data` / `oauth_provider`; connections carry `endpoint` / `auth_type` / `credential_id`. The credential's auth shape is `auth_type` (never a bare `type`), its secrets live under `auth_data` (no top-level `scopes`), and a connection's request is described by `endpoint` + `method` (there is no driver-slug parameter and no nested driver-config object). Older drafts that showed those phantom params were wrong and the tools reject them.
|
|
6
|
+
|
|
7
|
+
## Tool-surface map (which tools are top-level vs gateway)
|
|
8
|
+
|
|
9
|
+
| Tool | Surface | How to call |
|
|
10
|
+
|------|---------|-------------|
|
|
11
|
+
| `apimapper_library_featured`, `apimapper_library_list`, `apimapper_library_activate` | top-level | call directly |
|
|
12
|
+
| `apimapper_connection_create`, `apimapper_connection_data`, `apimapper_connection_list` | top-level | call directly |
|
|
13
|
+
| `apimapper_credential_list`, `apimapper_oauth_authorize_begin` | top-level | call directly |
|
|
14
|
+
| `apimapper_credential_create`, `apimapper_credential_get`, `apimapper_connection_test` | **advanced** | via `apimapper_advanced({ tool, arguments })` |
|
|
15
|
+
|
|
16
|
+
A bare `apimapper_credential_create({…})` returns "No such tool" — it is reachable **only** through the `apimapper_advanced` gateway. The same is true of `apimapper_credential_get` and `apimapper_connection_test`.
|
|
17
|
+
|
|
5
18
|
## When to use OAuth vs static key
|
|
6
19
|
|
|
7
|
-
| Auth shape | Examples |
|
|
8
|
-
|
|
9
|
-
| Static API key
|
|
10
|
-
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
20
|
+
| Auth shape | Examples | `auth_type` (credential) |
|
|
21
|
+
|------------|----------|--------------------------|
|
|
22
|
+
| Static API key | Pexels, NewsAPI, OpenWeather | `api_key` |
|
|
23
|
+
| Bearer / personal access token | GitHub, GitLab, Airtable, Notion | `bearer` |
|
|
24
|
+
| Basic auth (user + pass) | legacy/internal APIs | `basic_auth` |
|
|
25
|
+
| OAuth2 authorization-code (user consent + refresh token) | Google Sheets/Drive, Instagram, Facebook | `oauth2_code` |
|
|
26
|
+
| OAuth2 client-credentials (machine-to-machine) | Spotify catalog, some B2B APIs | `oauth2_cc` |
|
|
13
27
|
|
|
14
|
-
If the API
|
|
28
|
+
If the API needs a user-consent screen and returns a `refresh_token`, use `oauth2_code`. Everything else is `bearer` / `api_key` / `basic_auth`.
|
|
15
29
|
|
|
16
|
-
##
|
|
30
|
+
## The canonical path for a covered API: activate a library template
|
|
17
31
|
|
|
18
|
-
|
|
32
|
+
For Google Sheets, Drive, Docs, Slides, Tasks, Calendly, Notion, Airtable, GitHub, Pexels, Unsplash, OpenWeatherMap, REST Countries and more, **do not hand-build a credential + connection.** Activate the curated template — it pre-wires the auth shape, scopes, and field detection, and the server's library-first guard will block a custom create on a covered host anyway.
|
|
19
33
|
|
|
20
|
-
|
|
34
|
+
```jsonc
|
|
35
|
+
// 1. Find the template.
|
|
36
|
+
apimapper_library_list({ search: "google sheets" }) // min 3 chars
|
|
21
37
|
|
|
38
|
+
// 2. (OAuth templates) reuse an existing credential if you have one,
|
|
39
|
+
// otherwise create one — see "Creating an OAuth credential" below.
|
|
40
|
+
apimapper_credential_list({}) // top-level; find a reusable cred
|
|
41
|
+
|
|
42
|
+
// 3. Activate. credential_id is auto-resolved when exactly ONE OAuth
|
|
43
|
+
// credential for the provider exists; pass it explicitly otherwise.
|
|
44
|
+
apimapper_library_activate({
|
|
45
|
+
id: "google-sheets",
|
|
46
|
+
credential_id: "cred_google", // optional when unique
|
|
47
|
+
extra_fields: { spreadsheet_id: "1AbC2dEf...REPLACE_WITH_ID" }
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`extra_fields` carries the template placeholder values (for Sheets that is the **bare** spreadsheet id — see the note below). `apimapper_library_activate` produces a ready connection; you never touch `connection_create` for a covered API.
|
|
52
|
+
|
|
53
|
+
> **Spreadsheet id, not URL.** `extra_fields.spreadsheet_id` wants the bare id (`1AbC2dEf…`), not the full `https://docs.google.com/spreadsheets/d/…/edit` Drive URL. Paste only the path segment between `/d/` and the next `/`.
|
|
54
|
+
|
|
55
|
+
## Creating an OAuth credential (when none is reusable)
|
|
56
|
+
|
|
57
|
+
`apimapper_credential_create` is an **advanced** tool — call it through the gateway. It does **not** return an authorize URL; you trigger consent separately with `apimapper_oauth_authorize_begin`.
|
|
58
|
+
|
|
59
|
+
```jsonc
|
|
60
|
+
// Step 1 — create the oauth2_code credential (via the gateway).
|
|
61
|
+
apimapper_advanced({
|
|
62
|
+
tool: "apimapper_credential_create",
|
|
63
|
+
arguments: {
|
|
64
|
+
name: "My Google Account",
|
|
65
|
+
auth_type: "oauth2_code",
|
|
66
|
+
oauth_provider: "google",
|
|
67
|
+
auth_data: {
|
|
68
|
+
client_id: "…",
|
|
69
|
+
client_secret: "…",
|
|
70
|
+
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
71
|
+
token_url: "https://oauth2.googleapis.com/token",
|
|
72
|
+
scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
// → { created: true, id: "cred_…", name, auth_type } (NO authorize_url here)
|
|
22
77
|
```
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
)
|
|
78
|
+
|
|
79
|
+
```jsonc
|
|
80
|
+
// Step 2 — begin authorization → get the consent URL.
|
|
81
|
+
apimapper_oauth_authorize_begin({ credential_id: "cred_…" })
|
|
82
|
+
// → { authorize_url: "https://accounts.google.com/o/oauth2/v2/auth?…" }
|
|
29
83
|
```
|
|
30
84
|
|
|
31
|
-
The
|
|
85
|
+
The caller (Claude / ChatGPT / Cursor) **must surface `authorize_url` to the user** — the user clicks it, completes consent, and the OAuth callback writes the `access_token` + `refresh_token` into the credential. If you skip surfacing the URL the credential stays `pending` and every call returns 401.
|
|
32
86
|
|
|
33
|
-
|
|
87
|
+
`apimapper_oauth_authorize_begin` also resolves by `provider` when you omit `credential_id`: `apimapper_oauth_authorize_begin({ provider: "google" })` picks the unique `oauth2_code` credential for that provider (or asks you to choose when several match).
|
|
34
88
|
|
|
35
|
-
###
|
|
89
|
+
### Re-authorizing an expired credential
|
|
36
90
|
|
|
37
|
-
If the credential is `expired` or
|
|
91
|
+
If the credential is `expired` or its refresh token was revoked, re-trigger consent — **never delete-and-recreate** (that wipes the refresh token):
|
|
38
92
|
|
|
93
|
+
```jsonc
|
|
94
|
+
apimapper_oauth_authorize_begin({ credential_id: "cred_…" })
|
|
39
95
|
```
|
|
40
|
-
|
|
96
|
+
|
|
97
|
+
Custom-provider (non-catalog) credentials re-authorize the same way — the server now recovers the stored `authorization_url` / `token_url` from the credential's `auth_data`, so a bare `apimapper_oauth_authorize_begin({ credential_id })` works without re-passing `oauth_config`.
|
|
98
|
+
|
|
99
|
+
### A CUSTOM OAuth provider not in the catalog (authorization-code)
|
|
100
|
+
|
|
101
|
+
For an authorization-code provider that has **no** library template (e.g. Teamleader, a self-hosted Keycloak, a niche SaaS), drive the whole consent flow in one call by passing `oauth_config` to `apimapper_oauth_authorize_begin`. You do **not** pre-create the credential — the callback creates it for you.
|
|
102
|
+
|
|
103
|
+
```jsonc
|
|
104
|
+
apimapper_oauth_authorize_begin({
|
|
105
|
+
client_id: "…",
|
|
106
|
+
client_secret: "…",
|
|
107
|
+
credential_name: "Teamleader Prod", // display name for the new credential
|
|
108
|
+
oauth_config: {
|
|
109
|
+
authorization_url: "https://app.teamleader.eu/oauth2/authorize",
|
|
110
|
+
token_url: "https://app.teamleader.eu/oauth2/access_token",
|
|
111
|
+
scopes: [], // OPTIONAL — omit/empty for scope-less providers
|
|
112
|
+
use_pkce: false // OPTIONAL — default true; turn off if the provider rejects PKCE
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
// → { authorize_url: "https://app.teamleader.eu/oauth2/authorize?…",
|
|
116
|
+
// redirect_uri: "https://your-site/wp-json/api-mapper/v1/oauth/callback",
|
|
117
|
+
// state: "…" }
|
|
41
118
|
```
|
|
42
119
|
|
|
43
|
-
|
|
120
|
+
**Before opening `authorize_url`, add the returned `redirect_uri` to the provider's allowed-redirect list.** A mismatched redirect URI is the single most common custom-provider failure — the provider rejects the callback with `redirect_uri_mismatch` and no credential is created. The `redirect_uri` is platform-specific (WordPress `…/wp-json/api-mapper/v1/oauth/callback`; Joomla a path-based `…/index.php/apimapper/oauth/callback`), so always whitelist the exact value the response gives you.
|
|
44
121
|
|
|
45
|
-
|
|
122
|
+
Notes:
|
|
123
|
+
- **Scopes are optional.** Some providers (Teamleader class) reject a `scope` parameter entirely — pass `scopes: []` or omit it. For comma-separated providers, the server normalizes the delimiter.
|
|
124
|
+
- **PKCE defaults on.** Leave `use_pkce` unset for modern providers; set it to `false` only if the provider does not support PKCE.
|
|
125
|
+
- The `oauth_config` you pass is **never** echoed back in the response (secret hygiene) — only `authorize_url`, `redirect_uri`, and `state` are returned.
|
|
46
126
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
127
|
+
## Creating a CUSTOM connection (only when no template fits)
|
|
128
|
+
|
|
129
|
+
When the API is genuinely **not** covered by any library template, build a custom connection. `apimapper_connection_create` is top-level; its real parameters are `endpoint`, `method`, `auth_type` (`none` / `api_key` / `bearer` / `basic` / `oauth2`), `credential_id`, `items_path`, `cache_ttl`.
|
|
130
|
+
|
|
131
|
+
```jsonc
|
|
132
|
+
// Static-key example (Pexels-style, if it had no template).
|
|
133
|
+
apimapper_connection_create({
|
|
134
|
+
name: "My Niche API",
|
|
135
|
+
endpoint: "https://api.example.com/v1/items",
|
|
136
|
+
method: "GET",
|
|
137
|
+
auth_type: "bearer",
|
|
138
|
+
credential_id: "cred_…", // the bearer credential created via the gateway
|
|
139
|
+
items_path: "data" // JSON path to the array of rows
|
|
140
|
+
})
|
|
54
141
|
```
|
|
55
142
|
|
|
56
|
-
`
|
|
143
|
+
The request shape comes entirely from `endpoint` + `method`, and auth from `credential_id` — there is no driver-slug parameter and no nested driver-config object to set. If the host is already covered by a template the server returns a **409** naming the template to activate; only set `acknowledge_no_library: true` **after** you receive that 409 and are sure the endpoint is genuinely uncovered.
|
|
57
144
|
|
|
58
|
-
|
|
145
|
+
## Verify
|
|
59
146
|
|
|
60
|
-
```
|
|
61
|
-
|
|
147
|
+
```jsonc
|
|
148
|
+
// connection_test is advanced — call via the gateway.
|
|
149
|
+
apimapper_advanced({ tool: "apimapper_connection_test", arguments: { connection_id: "conn_…" } })
|
|
150
|
+
|
|
151
|
+
// Or sample real rows (top-level):
|
|
152
|
+
apimapper_connection_data({ id: "conn_…", limit: 3 })
|
|
62
153
|
```
|
|
63
154
|
|
|
64
|
-
|
|
65
|
-
- `{ ok: true, sample: { ... } }` — green, you can build a flow on this
|
|
66
|
-
- `{ ok: false, error: "401", hint: "credential expired → call apimapper_oauth_authorize_begin" }`
|
|
67
|
-
- `{ ok: false, error: "scope_missing", hint: "credential lacks scope X → recreate with scopes=[...]" }`
|
|
155
|
+
A green test (or non-empty `connection_data`) means you can build a flow on the connection.
|
|
68
156
|
|
|
69
157
|
## Common pitfalls
|
|
70
158
|
|
|
71
159
|
1. **Recreating credentials after expiry** — wipes the `refresh_token`. Always `apimapper_oauth_authorize_begin` first; only delete-and-recreate if the user revoked access on the provider side.
|
|
72
|
-
2. **Hardcoding tokens
|
|
73
|
-
3. **Forgetting to surface
|
|
74
|
-
4. **
|
|
75
|
-
5. **
|
|
160
|
+
2. **Hardcoding tokens** — bypasses the credential system, breaks rotation. Always reference a `credential_id`.
|
|
161
|
+
3. **Forgetting to surface `authorize_url`** — the user can't grant consent telepathically. Treat the URL as a required UI step.
|
|
162
|
+
4. **Calling `apimapper_credential_create` directly** — it is advanced; route it through `apimapper_advanced`. Same for `apimapper_credential_get` and `apimapper_connection_test`.
|
|
163
|
+
5. **Custom-creating a covered API** — you'll hit a 409. Activate the template instead (`apimapper_library_activate`).
|
|
164
|
+
6. **Pasting a Drive URL where a bare id is expected** — `extra_fields.spreadsheet_id` is the bare id, not the URL.
|
|
165
|
+
7. **Sharing a credential across users** — credentials are per-user (tied to the WordPress/Joomla user that created them). Cross-user share is not supported in 2.0.x.
|
|
76
166
|
|
|
77
167
|
## Provider-specific notes
|
|
78
168
|
|
|
79
|
-
- **Google** (Sheets/Drive/Calendar/Tasks) — needs `access_type=offline`
|
|
80
|
-
- **Instagram Graph** — token expires every 60 days
|
|
81
|
-
- **Notion / Airtable** — issue personal access tokens, not full OAuth. Use `bearer`
|
|
82
|
-
- **Pexels** — static API key;
|
|
169
|
+
- **Google** (Sheets/Drive/Calendar/Tasks) — needs `access_type=offline` + `prompt=consent` for a refresh token; the server adds these automatically. Activate the `google-sheets` / `google-drive` templates rather than hand-building.
|
|
170
|
+
- **Instagram Graph** — token expires every 60 days; the refresh flow is non-standard (long-lived → exchange). The server's refresh handler covers it.
|
|
171
|
+
- **Notion / Airtable** — issue personal access tokens, not full OAuth. Use `auth_type: 'bearer'` and paste the token in `auth_data: { token: "…" }`; no `authorize_url` step.
|
|
172
|
+
- **Pexels** — static API key; `auth_type: 'api_key'`, `auth_data: { api_key: "…" }`. No consent screen.
|
|
83
173
|
|
|
84
174
|
## Diagnose flow
|
|
85
175
|
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
|
|
176
|
+
```jsonc
|
|
177
|
+
apimapper_diagnose({ connection_id: "conn_…" })
|
|
178
|
+
// consolidates credential state + last test + recent logs
|
|
89
179
|
|
|
90
|
-
|
|
91
|
-
|
|
180
|
+
apimapper_advanced({ tool: "apimapper_credential_get", arguments: { id: "cred_…" } })
|
|
181
|
+
// → sanitised metadata: { status: "expired", scopes: [...] } (secrets redacted)
|
|
92
182
|
```
|
|
93
183
|
|
|
94
|
-
For schema/transform issues (not auth issues), see `
|
|
184
|
+
For schema/transform issues (not auth issues), see `apimapper_get_skill({ topic: "troubleshooting" })`.
|
|
185
|
+
For the post-publish handoff into the YOOtheme Builder, see `apimapper_get_skill({ topic: "yootheme-source-to-builder-handoff" })`.
|
|
@@ -5,10 +5,10 @@ Diagnostic checklist when API Mapper tools return errors or flows misbehave. Wor
|
|
|
5
5
|
## Quick triage
|
|
6
6
|
|
|
7
7
|
```
|
|
8
|
-
apimapper_diagnose(
|
|
8
|
+
apimapper_diagnose({ token: "amk_live_…", siteUrl: "https://example.com/wp" }) # validate a token+siteUrl pair
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
`apimapper_diagnose` (
|
|
11
|
+
`apimapper_diagnose` takes a **`token` + `siteUrl`** pair (NOT a `connection_id`). It runs token decode, an identity probe (WordPress first, Joomla fallback), an `iss`/site-URL match (anti-phishing), and capability + scope checks, then returns `ready: true/false` plus structured `blockers[]` and a recommendation. Use it FIRST when a token won't authenticate or before creating a profile — it pinpoints whether the problem is the token, the site URL, the platform, or a missing scope.
|
|
12
12
|
|
|
13
13
|
If `apimapper_diagnose` is unavailable in your version, run the manual checks below.
|
|
14
14
|
|
|
@@ -79,6 +79,26 @@ If `apimapper_diagnose` is unavailable in your version, run the manual checks be
|
|
|
79
79
|
### "Source not appearing in YOOtheme Builder"
|
|
80
80
|
- Flow is saved but not **published**. See `skill://apimapper/yootheme` step 4.
|
|
81
81
|
|
|
82
|
+
### "YOOtheme Builder source list missing newly-published flow"
|
|
83
|
+
|
|
84
|
+
Symptom: A flow was just published with `apimapper_flow_full_recompile_publish`, but
|
|
85
|
+
YOOtheme Builder's source dropdown (or `yootheme_builder_sources_list` from the
|
|
86
|
+
sibling MCP) doesn't show it. The schema cache went stale.
|
|
87
|
+
|
|
88
|
+
Fix (one of):
|
|
89
|
+
|
|
90
|
+
- `apimapper_cache_invalidate({ scope: "yootheme.schema" })` — explicit flush of the
|
|
91
|
+
YOOtheme GraphQL schema cache files (`schema-*.{php,gql,error.gql}`). Direct-file
|
|
92
|
+
surface, bypasses the InvalidationBus.
|
|
93
|
+
- `apimapper_flow_full_recompile_publish({ id: ..., autofix: true })` — re-publish
|
|
94
|
+
with autofix. Since 2.0.8 (Wave-12 F8) this clears the schema-`.php`/`.gql`/`.error.gql`
|
|
95
|
+
triplets correctly.
|
|
96
|
+
|
|
97
|
+
Root cause was a glob-pattern gap in `YOOthemeSchemaCacheBuster` pre-2.0.8 — the
|
|
98
|
+
`.gql` file was the source-of-truth for YT's `LoadSourceSchema::handle()`, but only
|
|
99
|
+
`.php` files were unlinked. The `yootheme.schema` scope is the manual escape hatch
|
|
100
|
+
for customers still on cached state from before the fix landed.
|
|
101
|
+
|
|
82
102
|
### "Source shows but fields are empty"
|
|
83
103
|
- Field-name case mismatch. YOOtheme expects lowercase keys.
|
|
84
104
|
- Fix: Transform projection should lowercase: `[*].{title: name, image: cover_url}`.
|