trackrev 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/README.md +120 -0
- package/package.json +25 -0
- package/src/index.js +321 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# trackrev
|
|
2
|
+
|
|
3
|
+
Your [TrackRev](https://trackrev.io) link analytics, in the terminal — performance per channel,
|
|
4
|
+
per link, the raw click stream, and any single visitor's full journey.
|
|
5
|
+
|
|
6
|
+
Zero dependencies. Node 20 or newer.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx trackrev channels
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or keep it around:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g trackrev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Authenticate
|
|
21
|
+
|
|
22
|
+
Create a **secret** key under Settings → Developers, then:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export TRACKREV_KEY=lk_live_...
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Set `TRACKREV_API_URL` to point at a different API base (self-hosted, staging).
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
### `channels` — performance per traffic source
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
trackrev channels # last 30 days
|
|
36
|
+
trackrev channels --days 7
|
|
37
|
+
trackrev channels --ltv # add all-time lifetime value per channel
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
channel clicks visitors conversions revenue
|
|
42
|
+
youtube 1,204 980 12.50 2,410.50
|
|
43
|
+
newsletter 310 287 9.00 1,890.00
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`conversions` is a decimal because attribution splits credit — one sale touched by two channels
|
|
47
|
+
counts 0.5 on each.
|
|
48
|
+
|
|
49
|
+
### `links` — performance per short link
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
trackrev links --limit 20
|
|
53
|
+
trackrev links --from 2026-01-01 --to 2026-02-01
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `clicks` — the raw click stream
|
|
57
|
+
|
|
58
|
+
Newest first, bots excluded, cursor-paginated.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
trackrev clicks # most recent 100
|
|
62
|
+
trackrev clicks --limit 500 # one page, max size
|
|
63
|
+
trackrev clicks --all # follow the cursor to the end
|
|
64
|
+
trackrev clicks --link <link-id> # one link only
|
|
65
|
+
trackrev clicks --bots # include bot traffic
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`--all` stops after 200 pages and says so on stderr, so a runaway cursor can't loop forever.
|
|
69
|
+
|
|
70
|
+
### `journey` — one visitor's timeline
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
trackrev journey 3f1b8c22-9d4e-4a71-b8c0-2e6f5a91d7e4
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Every click, identify event and order in order. The visitor id is the `visitor_id` column from
|
|
77
|
+
`trackrev clicks`.
|
|
78
|
+
|
|
79
|
+
## Window
|
|
80
|
+
|
|
81
|
+
`channels` and `links` accept:
|
|
82
|
+
|
|
83
|
+
| flag | meaning |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `--days N` | last N days (default 30, max 365) |
|
|
86
|
+
| `--from ISO` | explicit start, e.g. `2026-01-01` |
|
|
87
|
+
| `--to ISO` | explicit end (defaults to now) |
|
|
88
|
+
|
|
89
|
+
`--from`/`--to` win over `--days`.
|
|
90
|
+
|
|
91
|
+
## Output
|
|
92
|
+
|
|
93
|
+
The same data comes out three ways, so the command works both as something you read and as
|
|
94
|
+
something you pipe:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
trackrev channels # aligned table (a terminal)
|
|
98
|
+
trackrev channels > channels.tsv # tab-separated, raw values (a pipe or file)
|
|
99
|
+
trackrev channels --json | jq . # the API's own JSON body
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Piped output is deliberately unformatted — `2410.5`, not `2,410.50` — so `cut` and `awk` see real
|
|
103
|
+
numbers:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
trackrev channels | cut -f1,5
|
|
107
|
+
trackrev links --limit 10 | column -t
|
|
108
|
+
trackrev clicks --all --json | jq '.clicks[] | select(.country == "BD")'
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Warnings, errors and the `journey` caption always go to **stderr**, never into your pipe.
|
|
112
|
+
|
|
113
|
+
## Exit codes
|
|
114
|
+
|
|
115
|
+
| code | meaning |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `0` | success |
|
|
118
|
+
| `1` | no API key, auth failure, plan gate, network error, API error |
|
|
119
|
+
| `2` | usage error — unknown flag or command, bad `--limit`, missing visitor id |
|
|
120
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trackrev",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Your TrackRev link analytics, in the terminal — channels, links and the raw click stream.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"trackrev": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"trackrev",
|
|
15
|
+
"analytics",
|
|
16
|
+
"attribution",
|
|
17
|
+
"link-tracking",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://trackrev.io",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// trackrev — your link analytics, in the terminal.
|
|
3
|
+
//
|
|
4
|
+
// trackrev channels --days 7
|
|
5
|
+
// trackrev clicks --all --json > clicks.json
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
|
|
9
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
10
|
+
const BASE = process.env.TRACKREV_API_URL ?? "https://app.trackrev.io/api/v1";
|
|
11
|
+
const KEY = process.env.TRACKREV_KEY;
|
|
12
|
+
|
|
13
|
+
const EXIT_FAIL = 1;
|
|
14
|
+
const EXIT_USAGE = 2;
|
|
15
|
+
const MAX_PAGES = 200;
|
|
16
|
+
|
|
17
|
+
const HELP = `trackrev — your link analytics, in the terminal
|
|
18
|
+
|
|
19
|
+
Usage
|
|
20
|
+
trackrev channels [--ltv] performance per channel
|
|
21
|
+
trackrev links [--limit N] performance per link
|
|
22
|
+
trackrev clicks [--all] [--link ID] the raw click stream
|
|
23
|
+
trackrev journey <visitor-id> one visitor's full timeline
|
|
24
|
+
|
|
25
|
+
Window (channels, links)
|
|
26
|
+
--days N last N days (default 30)
|
|
27
|
+
--from ISO explicit start, e.g. 2026-01-01
|
|
28
|
+
--to ISO explicit end (defaults to now)
|
|
29
|
+
|
|
30
|
+
Options
|
|
31
|
+
--ltv channels: add all-time lifetime value per channel
|
|
32
|
+
--limit N links: rows to return; clicks: page size (max 500)
|
|
33
|
+
--all clicks: follow the cursor to the end of the stream
|
|
34
|
+
--link ID clicks: restrict to one link
|
|
35
|
+
--bots clicks: include bot traffic (excluded by default)
|
|
36
|
+
--json print raw JSON instead of a table
|
|
37
|
+
--version print the version
|
|
38
|
+
--help show this message
|
|
39
|
+
|
|
40
|
+
Output
|
|
41
|
+
A table when you are watching, tab-separated when piped or redirected,
|
|
42
|
+
JSON with --json. Warnings and errors always go to stderr.
|
|
43
|
+
|
|
44
|
+
Environment
|
|
45
|
+
TRACKREV_KEY secret API key, from Settings → Developers
|
|
46
|
+
TRACKREV_API_URL override the API base URL
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
/* ── plumbing ───────────────────────────────────────────────────────────── */
|
|
50
|
+
|
|
51
|
+
/** Anything that is not data goes to stderr, so it never lands in a pipe. */
|
|
52
|
+
function warn(message) {
|
|
53
|
+
console.error(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Print to stderr and quit with a failure code. */
|
|
57
|
+
function die(message, code = EXIT_FAIL) {
|
|
58
|
+
console.error(message);
|
|
59
|
+
process.exit(code);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** GET a path under the API base, returning the parsed JSON body. */
|
|
63
|
+
async function api(path) {
|
|
64
|
+
if (!KEY) die("No API key. Set TRACKREV_KEY to a secret key from Settings → Developers.");
|
|
65
|
+
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}` } });
|
|
69
|
+
} catch {
|
|
70
|
+
return die(`Could not reach ${BASE} — is the server running?`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const body = await res.json().catch(() => null);
|
|
75
|
+
const message = body?.error?.message ?? res.statusText;
|
|
76
|
+
if (res.status === 401) die(`${message}. Check TRACKREV_KEY.`);
|
|
77
|
+
if (res.status === 402) die(`${message}\nUpgrade at https://www.trackrev.io/pricing`);
|
|
78
|
+
if (res.status === 404) die(message);
|
|
79
|
+
die(`API error ${res.status}: ${message}`);
|
|
80
|
+
}
|
|
81
|
+
return res.json();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ── arguments ──────────────────────────────────────────────────────────── */
|
|
85
|
+
|
|
86
|
+
// parseArgs throws on an unknown flag. Left uncaught that is a stack trace in
|
|
87
|
+
// the user's face; caught, it is a one-line usage error.
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = parseArgs({
|
|
91
|
+
allowPositionals: true,
|
|
92
|
+
options: {
|
|
93
|
+
days: { type: "string", default: "30" },
|
|
94
|
+
from: { type: "string" },
|
|
95
|
+
to: { type: "string" },
|
|
96
|
+
limit: { type: "string" },
|
|
97
|
+
link: { type: "string" },
|
|
98
|
+
ltv: { type: "boolean", default: false },
|
|
99
|
+
all: { type: "boolean", default: false },
|
|
100
|
+
bots: { type: "boolean", default: false },
|
|
101
|
+
json: { type: "boolean", default: false },
|
|
102
|
+
version: { type: "boolean", default: false },
|
|
103
|
+
help: { type: "boolean", default: false },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`${err.message}\n\nRun: trackrev --help`);
|
|
108
|
+
process.exit(EXIT_USAGE);
|
|
109
|
+
}
|
|
110
|
+
const { positionals, values } = parsed;
|
|
111
|
+
const command = positionals[0];
|
|
112
|
+
|
|
113
|
+
if (values.version) {
|
|
114
|
+
console.log(version);
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
if (values.help || !command) {
|
|
118
|
+
console.log(HELP);
|
|
119
|
+
process.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The reporting window as query params. `--from`/`--to` win over `--days`:
|
|
124
|
+
* the server ignores `days` once either bound is present, so sending all three
|
|
125
|
+
* would only be noise in the request log.
|
|
126
|
+
*/
|
|
127
|
+
function windowParams() {
|
|
128
|
+
const params = new URLSearchParams();
|
|
129
|
+
if (values.from) params.set("from", values.from);
|
|
130
|
+
if (values.to) params.set("to", values.to);
|
|
131
|
+
if (!values.from && !values.to) params.set("days", values.days);
|
|
132
|
+
return params;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse a numeric flag, failing with a usage error rather than sending NaN. */
|
|
136
|
+
function intFlag(name, rawValue, { min = 1, max }) {
|
|
137
|
+
const n = Number(rawValue);
|
|
138
|
+
if (!Number.isInteger(n) || n < min || n > max) {
|
|
139
|
+
die(`--${name} must be a whole number between ${min} and ${max}.`, EXIT_USAGE);
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* ── output ─────────────────────────────────────────────────────────────── */
|
|
145
|
+
|
|
146
|
+
const TTY = process.stdout.isTTY;
|
|
147
|
+
|
|
148
|
+
/** Raw value for a pipe: no padding, no separators, empty cell for null. */
|
|
149
|
+
function rawCell(value) {
|
|
150
|
+
return value === null || value === undefined ? "" : String(value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Human value for a table. `fixed` is the column's decimal places, so a money
|
|
155
|
+
* column stays uniform (9 → 9.00) instead of following each individual value.
|
|
156
|
+
*/
|
|
157
|
+
function pretty(value, fixed = 0) {
|
|
158
|
+
if (value === null || value === undefined || value === "") return "—";
|
|
159
|
+
if (typeof value === "number") {
|
|
160
|
+
if (!Number.isFinite(value)) return "—";
|
|
161
|
+
return value.toLocaleString("en-US", {
|
|
162
|
+
minimumFractionDigits: fixed,
|
|
163
|
+
maximumFractionDigits: fixed,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return String(value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Render rows three ways:
|
|
171
|
+
* --json the API's own body, untouched, for jq
|
|
172
|
+
* a terminal an aligned table
|
|
173
|
+
* a pipe tab-separated raw values, ready for cut/awk
|
|
174
|
+
*/
|
|
175
|
+
function emit(columns, rows, body) {
|
|
176
|
+
if (values.json) {
|
|
177
|
+
console.log(JSON.stringify(body, null, 2));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (rows.length === 0) {
|
|
181
|
+
warn("No rows for this window.");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const header = columns.map((c) => c.header);
|
|
186
|
+
const cells = rows.map((row) => columns.map((c) => c.value(row)));
|
|
187
|
+
|
|
188
|
+
if (!TTY) {
|
|
189
|
+
console.log(header.join("\t"));
|
|
190
|
+
for (const line of cells) console.log(line.map(rawCell).join("\t"));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const table = [header, ...cells.map((line) => line.map((v, i) => pretty(v, columns[i].fixed)))];
|
|
195
|
+
const widths = header.map((_, i) => Math.max(...table.map((line) => line[i].length)));
|
|
196
|
+
for (const line of table) {
|
|
197
|
+
console.log(
|
|
198
|
+
line
|
|
199
|
+
.map((v, i) => (columns[i].align === "right" ? v.padStart(widths[i]) : v.padEnd(widths[i])))
|
|
200
|
+
.join(" ")
|
|
201
|
+
.trimEnd(),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ── commands ───────────────────────────────────────────────────────────── */
|
|
207
|
+
|
|
208
|
+
async function channels() {
|
|
209
|
+
const params = windowParams();
|
|
210
|
+
if (values.ltv) params.set("ltv", "1");
|
|
211
|
+
const body = await api(`/channels?${params}`);
|
|
212
|
+
|
|
213
|
+
const columns = [
|
|
214
|
+
{ header: "channel", value: (r) => r.channel },
|
|
215
|
+
{ header: "clicks", value: (r) => Number(r.clicks), align: "right" },
|
|
216
|
+
{ header: "visitors", value: (r) => Number(r.visitors), align: "right" },
|
|
217
|
+
{ header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
|
|
218
|
+
{ header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
|
|
219
|
+
];
|
|
220
|
+
if (values.ltv) {
|
|
221
|
+
columns.push({ header: "ltv", value: (r) => Number(r.ltv), align: "right", fixed: 2 });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
emit(columns, body.channels, body);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function links() {
|
|
228
|
+
const params = windowParams();
|
|
229
|
+
if (values.limit !== undefined) {
|
|
230
|
+
params.set("limit", String(intFlag("limit", values.limit, { max: 500 })));
|
|
231
|
+
}
|
|
232
|
+
const body = await api(`/links?${params}`);
|
|
233
|
+
|
|
234
|
+
emit(
|
|
235
|
+
[
|
|
236
|
+
{ header: "slug", value: (r) => r.slug },
|
|
237
|
+
{ header: "channel", value: (r) => r.channel },
|
|
238
|
+
{ header: "clicks", value: (r) => Number(r.clicks), align: "right" },
|
|
239
|
+
{ header: "visitors", value: (r) => Number(r.visitors), align: "right" },
|
|
240
|
+
{ header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
|
|
241
|
+
{ header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
|
|
242
|
+
{ header: "destination", value: (r) => r.destination },
|
|
243
|
+
],
|
|
244
|
+
body.links,
|
|
245
|
+
body,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function clicks() {
|
|
250
|
+
const pageSize = values.limit === undefined ? 100 : intFlag("limit", values.limit, { max: 500 });
|
|
251
|
+
const stream = [];
|
|
252
|
+
let before = null;
|
|
253
|
+
let pages = 0;
|
|
254
|
+
|
|
255
|
+
do {
|
|
256
|
+
const params = new URLSearchParams({ limit: String(pageSize) });
|
|
257
|
+
if (before) params.set("before", before);
|
|
258
|
+
if (values.link) params.set("link_id", values.link);
|
|
259
|
+
if (values.bots) params.set("bots", "include");
|
|
260
|
+
|
|
261
|
+
const page = await api(`/clicks?${params}`);
|
|
262
|
+
stream.push(...page.clicks);
|
|
263
|
+
before = page.next_cursor;
|
|
264
|
+
|
|
265
|
+
if (++pages >= MAX_PAGES && before) {
|
|
266
|
+
warn(`Stopped after ${MAX_PAGES} pages (${stream.length} clicks). Narrow it with --link.`);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
} while (values.all && before);
|
|
270
|
+
|
|
271
|
+
const columns = [
|
|
272
|
+
{ header: "ts", value: (r) => r.ts },
|
|
273
|
+
{ header: "channel", value: (r) => r.channel },
|
|
274
|
+
{ header: "country", value: (r) => r.country },
|
|
275
|
+
{ header: "device", value: (r) => r.device },
|
|
276
|
+
{ header: "browser", value: (r) => r.browser },
|
|
277
|
+
{ header: "visitor", value: (r) => r.visitor_id },
|
|
278
|
+
];
|
|
279
|
+
if (values.bots) columns.push({ header: "bot", value: (r) => (r.is_bot ? "yes" : "") });
|
|
280
|
+
|
|
281
|
+
emit(columns, stream, { clicks: stream });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function journey() {
|
|
285
|
+
const id = positionals[1];
|
|
286
|
+
if (!id) die("Usage: trackrev journey <visitor-id>", EXIT_USAGE);
|
|
287
|
+
|
|
288
|
+
const body = await api(`/visitors/${encodeURIComponent(id)}/journey`);
|
|
289
|
+
const who = body.visitor;
|
|
290
|
+
// Identity goes to stderr: it is a caption, not a row, so a pipe stays clean.
|
|
291
|
+
if (!values.json) warn(`visitor ${who.id}${who.email ? ` · ${who.email}` : ""}`);
|
|
292
|
+
|
|
293
|
+
emit(
|
|
294
|
+
[
|
|
295
|
+
{ header: "ts", value: (r) => r.ts },
|
|
296
|
+
{ header: "kind", value: (r) => r.kind },
|
|
297
|
+
{ header: "label", value: (r) => r.label },
|
|
298
|
+
{ header: "channel", value: (r) => r.channel },
|
|
299
|
+
{ header: "link", value: (r) => r.link_slug },
|
|
300
|
+
{ header: "amount", value: (r) => (r.amount == null ? null : Number(r.amount)), align: "right", fixed: 2 },
|
|
301
|
+
{ header: "detail", value: (r) => r.detail },
|
|
302
|
+
],
|
|
303
|
+
body.journey,
|
|
304
|
+
body,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ── dispatch ───────────────────────────────────────────────────────────── */
|
|
309
|
+
|
|
310
|
+
const COMMANDS = { channels, links, clicks, journey };
|
|
311
|
+
const run = COMMANDS[command];
|
|
312
|
+
if (!run) {
|
|
313
|
+
console.error(`Unknown command "${command}".\n\nRun: trackrev --help`);
|
|
314
|
+
process.exit(EXIT_USAGE);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
await run();
|
|
319
|
+
} catch (err) {
|
|
320
|
+
die(err?.message ?? String(err));
|
|
321
|
+
}
|