@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.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 +152 -18
- package/dist/react.js +516 -61
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +8 -1
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +7 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +3 -1
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -11
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +123 -0
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +37 -0
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +26 -13
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `handleSupportRequest`: the server side of the "Need help?" pill for a host with no tracker of its own.
|
|
3
|
+
* The twin of `handleSessionRequest`: one same-origin route in the host, with the host's secret kept there.
|
|
4
|
+
*
|
|
5
|
+
* The host calls it **after its own sign-in check** and says who the person is. Nothing here checks the
|
|
6
|
+
* host's sign-in, and nothing the browser sends is believed about who is asking: the reporter is the one in
|
|
7
|
+
* `config`, on every call.
|
|
8
|
+
*
|
|
9
|
+
* - `POST` (what `supportRequestsAt(url).submit` sends) files the request in Comment Tool with the
|
|
10
|
+
* integration API's `fileReport`. The report's id is this helper's own: the reporter the host named and
|
|
11
|
+
* the browser's id for the request, together (`reportIdOf`). So the same person sending a request again
|
|
12
|
+
* files nothing twice, and nobody reaches another person's report by sending its id.
|
|
13
|
+
* - `GET` (what `supportRequestsAt(url).list` reads) answers the reporter's own requests from `reportsBy`:
|
|
14
|
+
* their words and where each stands. Nothing written in the console is in that answer.
|
|
15
|
+
*
|
|
16
|
+
* What the person wrote is never logged here. A refusal is logged by its reason only.
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
HELP_BODY_BYTES,
|
|
20
|
+
HELP_ID,
|
|
21
|
+
HELP_IMAGE_TYPES,
|
|
22
|
+
HELP_LIMITS,
|
|
23
|
+
type HelpFailureWire,
|
|
24
|
+
type HelpImageType,
|
|
25
|
+
type HelpReceiptWire,
|
|
26
|
+
} from "../help/wire.js";
|
|
27
|
+
import type { SubmittedRequest, SubmittedRequestStatus } from "../help/types.js";
|
|
28
|
+
|
|
29
|
+
export type SupportRequestConfig = {
|
|
30
|
+
/** Comment Tool: `https://comment-tool.example.com`. */
|
|
31
|
+
serviceUrl: string;
|
|
32
|
+
/** The host's connection token. Server only: it never reaches the browser. */
|
|
33
|
+
connectionToken: string;
|
|
34
|
+
/** The connection's own id for the project its support requests are filed in. */
|
|
35
|
+
project: string;
|
|
36
|
+
/** Who is asking, from the host's own sign-in. `userId` is the host's id for them. */
|
|
37
|
+
reporter: { userId: string; name: string };
|
|
38
|
+
/** The reporter's account in the host (a customer, a workspace), shown beside them in the console. */
|
|
39
|
+
account?: { id: string; name: string };
|
|
40
|
+
fetch?: typeof fetch;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// The integration API's shapes this helper writes and reads (`@comment-tool/contract`, `ops.ts`:
|
|
44
|
+
// `FiledReport`, `fileReport`, `reportsBy`, `OwnReport`). Restated here because this package is published
|
|
45
|
+
// on its own and takes nothing from the service's workspace.
|
|
46
|
+
type FiledReport = {
|
|
47
|
+
reportId: string;
|
|
48
|
+
reporter: { userId: string; name: string };
|
|
49
|
+
account?: { id: string; name: string };
|
|
50
|
+
pageUrl?: string;
|
|
51
|
+
fields?: { label: string; value: string }[];
|
|
52
|
+
messages: {
|
|
53
|
+
id: string;
|
|
54
|
+
author: string;
|
|
55
|
+
role: "reporter";
|
|
56
|
+
body: string;
|
|
57
|
+
attachments?: { id: string; name: string; contentType: string; bytes: { $bytes: string } }[];
|
|
58
|
+
}[];
|
|
59
|
+
};
|
|
60
|
+
type FileReportResult = { refused?: readonly { id: string; reason: string }[] };
|
|
61
|
+
type OwnReport = {
|
|
62
|
+
reportId: string;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
status: "open" | "resolved";
|
|
65
|
+
title: string | null;
|
|
66
|
+
firstWords: string;
|
|
67
|
+
ticket: { displaySeq: number; status: string; statusLabel: string | null } | null;
|
|
68
|
+
/** Set while the team has the report merged into another of the same issue: where that one stands. */
|
|
69
|
+
consolidated?: {
|
|
70
|
+
status: "open" | "resolved";
|
|
71
|
+
ticket: { status: string; statusLabel: string | null } | null;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
type ReportsByResult = { reports?: readonly Partial<OwnReport>[] };
|
|
75
|
+
|
|
76
|
+
const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
|
|
77
|
+
|
|
78
|
+
const UNAVAILABLE = "Support requests are not available right now. Try again in a moment.";
|
|
79
|
+
const TOO_MANY = "Too many requests right now. Try again in a few minutes.";
|
|
80
|
+
const NOT_ACCEPTED = "The request could not be accepted. Check it and try again.";
|
|
81
|
+
const UNREADABLE = "The request could not be read.";
|
|
82
|
+
const TOO_LARGE = "That is too large to send. Remove a picture and try again.";
|
|
83
|
+
const NOT_A_PICTURE = "Only pictures can be attached: PNG, JPEG or WebP.";
|
|
84
|
+
const PICTURE_TOO_LARGE = "A picture is too large. Pictures can be up to 5 MB.";
|
|
85
|
+
|
|
86
|
+
/** The service's limits for what this helper forwards (`apps/api/src/project/reports.ts`). */
|
|
87
|
+
const SERVICE = { name: 80, accountName: 120, id: 200, fieldValue: 200 } as const;
|
|
88
|
+
|
|
89
|
+
class Refusal extends Error {
|
|
90
|
+
constructor(
|
|
91
|
+
readonly status: number,
|
|
92
|
+
message: string,
|
|
93
|
+
) {
|
|
94
|
+
super(message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function answer(body: unknown, status = 200, extra: Record<string, string> = {}): Response {
|
|
99
|
+
return Response.json(body, { status, headers: { ...HEADERS, ...extra } });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function failure(status: number, error: string, extra?: Record<string, string>): Response {
|
|
103
|
+
const body: HelpFailureWire = { error };
|
|
104
|
+
return answer(body, status, extra);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Text without the one character Comment Tool's database cannot hold. The service answers a text with a
|
|
109
|
+
* NUL in it as a failure of its own (HTTP 500), which a person could only meet again by sending again.
|
|
110
|
+
*/
|
|
111
|
+
function storable(value: string): string {
|
|
112
|
+
return value.replaceAll("\u0000", "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function oneLine(value: unknown, limit: number): string {
|
|
116
|
+
return typeof value === "string"
|
|
117
|
+
? storable(value).replace(/\s+/g, " ").trim().slice(0, limit)
|
|
118
|
+
: "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** An id the host gave, as the service takes one: 1 to 200 characters, none of them space or NUL. */
|
|
122
|
+
function isId(value: unknown): value is string {
|
|
123
|
+
return (
|
|
124
|
+
typeof value === "string" &&
|
|
125
|
+
value.length > 0 &&
|
|
126
|
+
value.length <= SERVICE.id &&
|
|
127
|
+
!/\s/.test(value) &&
|
|
128
|
+
!value.includes("\u0000")
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** For the host's own logs: which call was refused and why, and never what the person wrote. */
|
|
133
|
+
function note(op: string, reason: string): void {
|
|
134
|
+
// Server-side, in the host app: its logs are the only place a wrong token or project shows.
|
|
135
|
+
// oxlint-disable-next-line no-console
|
|
136
|
+
console.warn(`[comment-service] ${op} was refused: ${reason}.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The body, read no further than `limit` bytes. Null when it is longer. */
|
|
140
|
+
async function bodyWithin(request: Request, limit: number): Promise<Uint8Array | null> {
|
|
141
|
+
if (Number(request.headers.get("content-length") ?? "0") > limit) return null;
|
|
142
|
+
if (!request.body) return new Uint8Array();
|
|
143
|
+
const reader = request.body.getReader();
|
|
144
|
+
const chunks: Uint8Array[] = [];
|
|
145
|
+
let size = 0;
|
|
146
|
+
for (;;) {
|
|
147
|
+
const { done, value } = await reader.read();
|
|
148
|
+
if (done) break;
|
|
149
|
+
size += value.byteLength;
|
|
150
|
+
if (size > limit) {
|
|
151
|
+
await reader.cancel().catch(() => {});
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
chunks.push(value);
|
|
155
|
+
}
|
|
156
|
+
const bytes = new Uint8Array(size);
|
|
157
|
+
let offset = 0;
|
|
158
|
+
for (const chunk of chunks) {
|
|
159
|
+
bytes.set(chunk, offset);
|
|
160
|
+
offset += chunk.byteLength;
|
|
161
|
+
}
|
|
162
|
+
return bytes;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The type a file's first bytes say it is, whatever name or type it was sent with. */
|
|
166
|
+
function sniff(head: Uint8Array): HelpImageType | null {
|
|
167
|
+
const starts = (...bytes: number[]) => bytes.every((byte, index) => head[index] === byte);
|
|
168
|
+
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
|
|
169
|
+
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
|
|
170
|
+
if (
|
|
171
|
+
starts(0x52, 0x49, 0x46, 0x46) &&
|
|
172
|
+
head[8] === 0x57 &&
|
|
173
|
+
head[9] === 0x45 &&
|
|
174
|
+
head[10] === 0x42 &&
|
|
175
|
+
head[11] === 0x50
|
|
176
|
+
) {
|
|
177
|
+
return "image/webp";
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
183
|
+
|
|
184
|
+
function picture(given: unknown, index: number, taken: Set<string>) {
|
|
185
|
+
const image = (typeof given === "object" && given !== null ? given : {}) as Record<
|
|
186
|
+
string,
|
|
187
|
+
unknown
|
|
188
|
+
>;
|
|
189
|
+
const data = image["data"];
|
|
190
|
+
if (typeof data !== "string" || data.length === 0 || data.length % 4 !== 0) {
|
|
191
|
+
throw new Refusal(400, NOT_A_PICTURE);
|
|
192
|
+
}
|
|
193
|
+
// Judged by length before anything is decoded: four characters carry three bytes.
|
|
194
|
+
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
|
|
195
|
+
if ((data.length / 4) * 3 - padding > HELP_LIMITS.imageBytes) {
|
|
196
|
+
throw new Refusal(413, PICTURE_TOO_LARGE);
|
|
197
|
+
}
|
|
198
|
+
if (!BASE64.test(data)) throw new Refusal(400, NOT_A_PICTURE);
|
|
199
|
+
const head = Uint8Array.from(atob(data.slice(0, 16)), (character) => character.charCodeAt(0));
|
|
200
|
+
const contentType = sniff(head);
|
|
201
|
+
if (!contentType || !(HELP_IMAGE_TYPES as readonly string[]).includes(contentType)) {
|
|
202
|
+
throw new Refusal(415, NOT_A_PICTURE);
|
|
203
|
+
}
|
|
204
|
+
const extension = contentType.slice("image/".length).replace("jpeg", "jpg");
|
|
205
|
+
const name = oneLine(image["name"], HELP_LIMITS.name) || `picture-${index + 1}.${extension}`;
|
|
206
|
+
// The file's id within the report. A picture keeps its id when another is removed before a retry, so
|
|
207
|
+
// what was stored the first time is not stored again under a new one.
|
|
208
|
+
const stem = name.replace(/[^A-Za-z0-9._-]+/g, "_").slice(0, 150) || `picture-${index + 1}`;
|
|
209
|
+
let id = stem;
|
|
210
|
+
for (let copy = 2; taken.has(id); copy += 1) id = `${stem}-${copy}`;
|
|
211
|
+
taken.add(id);
|
|
212
|
+
return { id, name, contentType, bytes: { $bytes: data } };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The most a reporter's id may be to go into a report's id as it is: with `:` and a request's id, 200. */
|
|
216
|
+
const OWNER_AS_IS = SERVICE.id - HELP_LIMITS.id - 1;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The reporter's half of a report's id. Their id as the host gave it when that is safe: no `:`, so the
|
|
220
|
+
* first `:` of a report's id always ends this half; not starting `~`; and short enough that the whole id
|
|
221
|
+
* keeps to the service's 200 characters. Any other id goes in as `~` and its SHA-256 in base64url, 44
|
|
222
|
+
* characters, which no id taken as it is can equal. Two people never share a half.
|
|
223
|
+
*/
|
|
224
|
+
async function ownerOf(userId: string): Promise<string> {
|
|
225
|
+
if (userId.length <= OWNER_AS_IS && !userId.includes(":") && !userId.startsWith("~")) {
|
|
226
|
+
return userId;
|
|
227
|
+
}
|
|
228
|
+
const digest = new Uint8Array(
|
|
229
|
+
await crypto.subtle.digest("SHA-256", new TextEncoder().encode(userId)),
|
|
230
|
+
);
|
|
231
|
+
let binary = "";
|
|
232
|
+
for (const byte of digest) binary += String.fromCharCode(byte);
|
|
233
|
+
return `~${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "")}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The report's id in Comment Tool for a request: `<the reporter's half>:<the browser's id>`. The browser
|
|
238
|
+
* chooses only the second half, so what it sends can name a report of the person the host says is asking
|
|
239
|
+
* and nobody else's.
|
|
240
|
+
*/
|
|
241
|
+
function reportIdOf(owner: string, id: string): string {
|
|
242
|
+
return `${owner}:${id}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The report to file for what the browser sent. Throws a `Refusal` for anything out of bounds. */
|
|
246
|
+
function reportOf(given: unknown, config: SupportRequestConfig, owner: string): FiledReport {
|
|
247
|
+
if (typeof given !== "object" || given === null || Array.isArray(given)) {
|
|
248
|
+
throw new Refusal(400, UNREADABLE);
|
|
249
|
+
}
|
|
250
|
+
const sent = given as Record<string, unknown>;
|
|
251
|
+
const id = sent["id"];
|
|
252
|
+
if (
|
|
253
|
+
typeof id !== "string" ||
|
|
254
|
+
id.length === 0 ||
|
|
255
|
+
id.length > HELP_LIMITS.id ||
|
|
256
|
+
!HELP_ID.test(id)
|
|
257
|
+
) {
|
|
258
|
+
throw new Refusal(400, NOT_ACCEPTED);
|
|
259
|
+
}
|
|
260
|
+
const message = typeof sent["message"] === "string" ? storable(sent["message"]).trim() : "";
|
|
261
|
+
if (message.length === 0) throw new Refusal(400, "Write what you need help with.");
|
|
262
|
+
if (message.length > HELP_LIMITS.message) {
|
|
263
|
+
throw new Refusal(400, "That is too long. Shorten it to 4,000 characters.");
|
|
264
|
+
}
|
|
265
|
+
const images: unknown = sent["images"] ?? [];
|
|
266
|
+
if (!Array.isArray(images)) throw new Refusal(400, NOT_ACCEPTED);
|
|
267
|
+
if (images.length > HELP_LIMITS.images) {
|
|
268
|
+
throw new Refusal(400, `At most ${HELP_LIMITS.images} pictures can be sent.`);
|
|
269
|
+
}
|
|
270
|
+
const taken = new Set<string>();
|
|
271
|
+
const attachments = images.map((image, index) => picture(image, index, taken));
|
|
272
|
+
|
|
273
|
+
const fields: { label: string; value: string }[] = [];
|
|
274
|
+
const field = (label: string, value: string) => {
|
|
275
|
+
if (value) fields.push({ label, value: value.slice(0, SERVICE.fieldValue) });
|
|
276
|
+
};
|
|
277
|
+
const pointed = sent["pointed"];
|
|
278
|
+
if (typeof pointed === "object" && pointed !== null) {
|
|
279
|
+
field("Pointed at", oneLine((pointed as { label?: unknown }).label, HELP_LIMITS.label));
|
|
280
|
+
}
|
|
281
|
+
const client = (
|
|
282
|
+
typeof sent["client"] === "object" && sent["client"] !== null ? sent["client"] : {}
|
|
283
|
+
) as { userAgent?: unknown; language?: unknown; viewport?: unknown };
|
|
284
|
+
const viewport = (
|
|
285
|
+
typeof client.viewport === "object" && client.viewport !== null ? client.viewport : {}
|
|
286
|
+
) as { width?: unknown; height?: unknown };
|
|
287
|
+
field("Browser", oneLine(client.userAgent, SERVICE.fieldValue));
|
|
288
|
+
if (Number.isFinite(viewport.width) && Number.isFinite(viewport.height)) {
|
|
289
|
+
field(
|
|
290
|
+
"Window",
|
|
291
|
+
`${Math.round(Number(viewport.width))} × ${Math.round(Number(viewport.height))}`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
field("Language", oneLine(client.language, 40));
|
|
295
|
+
|
|
296
|
+
let pageUrl: string | undefined;
|
|
297
|
+
const page = sent["pageUrl"];
|
|
298
|
+
if (typeof page === "string" && page.length <= HELP_LIMITS.url) {
|
|
299
|
+
try {
|
|
300
|
+
const url = new URL(page);
|
|
301
|
+
if (url.protocol === "http:" || url.protocol === "https:") pageUrl = url.href;
|
|
302
|
+
} catch {
|
|
303
|
+
// Not an address: the report is filed without one.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (pageUrl && pageUrl.length > HELP_LIMITS.url) pageUrl = undefined;
|
|
307
|
+
|
|
308
|
+
const name = oneLine(config.reporter.name, SERVICE.name) || "Someone";
|
|
309
|
+
const account = config.account
|
|
310
|
+
? { id: config.account.id, name: oneLine(config.account.name, SERVICE.accountName) }
|
|
311
|
+
: undefined;
|
|
312
|
+
return {
|
|
313
|
+
reportId: reportIdOf(owner, id),
|
|
314
|
+
reporter: { userId: config.reporter.userId, name },
|
|
315
|
+
...(account?.name ? { account } : {}),
|
|
316
|
+
...(pageUrl ? { pageUrl } : {}),
|
|
317
|
+
...(fields.length > 0 ? { fields } : {}),
|
|
318
|
+
messages: [
|
|
319
|
+
{
|
|
320
|
+
id,
|
|
321
|
+
author: name,
|
|
322
|
+
role: "reporter",
|
|
323
|
+
body: message,
|
|
324
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
325
|
+
},
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* One call of the integration API. Resolves with the op's value. Throws a `Refusal` carrying what the
|
|
332
|
+
* person is told: 502 when the service is down, wrongly configured or will not have this connection, 4xx
|
|
333
|
+
* when it refused what was sent.
|
|
334
|
+
*/
|
|
335
|
+
async function callOp<T>(config: SupportRequestConfig, op: string, input: unknown): Promise<T> {
|
|
336
|
+
const send = config.fetch ?? fetch;
|
|
337
|
+
let response: Response;
|
|
338
|
+
try {
|
|
339
|
+
response = await send(`${config.serviceUrl.replace(/\/+$/, "")}/api/v1/ops/${op}`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: {
|
|
342
|
+
authorization: `Bearer ${config.connectionToken}`,
|
|
343
|
+
"content-type": "application/json",
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify({ project: config.project, input }),
|
|
346
|
+
});
|
|
347
|
+
} catch {
|
|
348
|
+
note(op, "the service could not be reached");
|
|
349
|
+
throw new Refusal(502, UNAVAILABLE);
|
|
350
|
+
}
|
|
351
|
+
const payload = (await response.json().catch(() => null)) as {
|
|
352
|
+
ok?: unknown;
|
|
353
|
+
value?: unknown;
|
|
354
|
+
reason?: unknown;
|
|
355
|
+
error?: { code?: unknown };
|
|
356
|
+
} | null;
|
|
357
|
+
if (!response.ok) {
|
|
358
|
+
note(
|
|
359
|
+
op,
|
|
360
|
+
`HTTP ${response.status}${typeof payload?.error?.code === "string" ? ` ${payload.error.code}` : ""}`,
|
|
361
|
+
);
|
|
362
|
+
if (response.status === 413) throw new Refusal(413, TOO_LARGE);
|
|
363
|
+
if (response.status === 429) throw new Refusal(429, TOO_MANY);
|
|
364
|
+
throw new Refusal(502, UNAVAILABLE);
|
|
365
|
+
}
|
|
366
|
+
if (payload?.ok === true) return payload.value as T;
|
|
367
|
+
const reason =
|
|
368
|
+
typeof payload?.reason === "string" ? payload.reason : "an answer that could not be read";
|
|
369
|
+
note(op, reason);
|
|
370
|
+
if (reason === "invalid_input") throw new Refusal(400, NOT_ACCEPTED);
|
|
371
|
+
if (reason === "rate_limited") throw new Refusal(429, TOO_MANY);
|
|
372
|
+
throw new Refusal(502, UNAVAILABLE);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function file(request: Request, config: SupportRequestConfig): Promise<Response> {
|
|
376
|
+
if (!/^application\/json\b/i.test(request.headers.get("content-type") ?? "")) {
|
|
377
|
+
return failure(415, UNREADABLE);
|
|
378
|
+
}
|
|
379
|
+
const body = await bodyWithin(request, HELP_BODY_BYTES);
|
|
380
|
+
if (body === null) return failure(413, TOO_LARGE);
|
|
381
|
+
let sent: unknown;
|
|
382
|
+
try {
|
|
383
|
+
sent = JSON.parse(new TextDecoder().decode(body));
|
|
384
|
+
} catch {
|
|
385
|
+
return failure(400, UNREADABLE);
|
|
386
|
+
}
|
|
387
|
+
const report = reportOf(sent, config, await ownerOf(config.reporter.userId));
|
|
388
|
+
const filed = await callOp<FileReportResult>(config, "fileReport", report);
|
|
389
|
+
const refused = Array.isArray(filed?.refused) ? filed.refused.length : 0;
|
|
390
|
+
const receipt: HelpReceiptWire =
|
|
391
|
+
refused === 0
|
|
392
|
+
? {}
|
|
393
|
+
: {
|
|
394
|
+
note:
|
|
395
|
+
refused === 1
|
|
396
|
+
? "1 picture could not be kept."
|
|
397
|
+
: `${refused} pictures could not be kept.`,
|
|
398
|
+
};
|
|
399
|
+
return answer(receipt);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** What says where a report stands: the report itself, or the one it was merged into. */
|
|
403
|
+
type Stands = {
|
|
404
|
+
status?: unknown;
|
|
405
|
+
ticket?: { status?: unknown; statusLabel?: unknown } | null;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The report whose standing the person is told. A report the team merged into another of the same issue
|
|
410
|
+
* stands where that other one does: its own thread was closed by the merge, and the issue may be open.
|
|
411
|
+
*/
|
|
412
|
+
function standingOf(report: Partial<OwnReport>): { stands: Stands; consolidated: boolean } {
|
|
413
|
+
const into: unknown = report.consolidated;
|
|
414
|
+
return typeof into === "object" && into !== null
|
|
415
|
+
? { stands: into as Stands, consolidated: true }
|
|
416
|
+
: { stands: report, consolidated: false };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Where a report stands, in the pill's three words. A resolved report is `resolved`. An open one is
|
|
421
|
+
* `in_progress` once its ticket has left `new`, and `received` otherwise. A report with no ticket, which is
|
|
422
|
+
* every report this helper files, stays `received` until it is resolved: `reportsBy` says nothing of what a
|
|
423
|
+
* triager did in the console, on purpose, so there is nothing to tell "in progress" by.
|
|
424
|
+
*/
|
|
425
|
+
function statusOf(report: Stands): SubmittedRequestStatus {
|
|
426
|
+
if (report.status === "resolved") return "resolved";
|
|
427
|
+
const ticket = report.ticket?.status;
|
|
428
|
+
return typeof ticket === "string" && ticket !== "" && ticket !== "new"
|
|
429
|
+
? "in_progress"
|
|
430
|
+
: "received";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function list(config: SupportRequestConfig): Promise<Response> {
|
|
434
|
+
// A request is listed under the id its browser sent it with: the reporter's half of the report's id is
|
|
435
|
+
// this helper's business and is not sent back. A report the host filed for the person some other way
|
|
436
|
+
// keeps the id the host gave it.
|
|
437
|
+
const mine = reportIdOf(await ownerOf(config.reporter.userId), "");
|
|
438
|
+
const read = await callOp<ReportsByResult>(config, "reportsBy", {
|
|
439
|
+
reporterUserId: config.reporter.userId,
|
|
440
|
+
limit: HELP_LIMITS.listed,
|
|
441
|
+
});
|
|
442
|
+
const requests: SubmittedRequest[] = (Array.isArray(read?.reports) ? read.reports : [])
|
|
443
|
+
.filter(
|
|
444
|
+
(report) => typeof report?.reportId === "string" && typeof report.createdAt === "string",
|
|
445
|
+
)
|
|
446
|
+
.map((report) => {
|
|
447
|
+
const reportId = report.reportId as string;
|
|
448
|
+
const { stands, consolidated } = standingOf(report);
|
|
449
|
+
const label = oneLine(stands.ticket?.statusLabel, 80);
|
|
450
|
+
return {
|
|
451
|
+
id:
|
|
452
|
+
reportId.startsWith(mine) && reportId.length > mine.length
|
|
453
|
+
? reportId.slice(mine.length)
|
|
454
|
+
: reportId,
|
|
455
|
+
...(Number.isInteger(report.ticket?.displaySeq)
|
|
456
|
+
? { reference: String(report.ticket?.displaySeq) }
|
|
457
|
+
: {}),
|
|
458
|
+
summary: oneLine(report.firstWords, 200) || oneLine(report.title, 200),
|
|
459
|
+
createdAt: report.createdAt as string,
|
|
460
|
+
status: statusOf(stands),
|
|
461
|
+
...(label ? { statusLabel: label } : {}),
|
|
462
|
+
...(consolidated ? { consolidated: true } : {}),
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
return answer({ requests });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export async function handleSupportRequest(
|
|
469
|
+
request: Request,
|
|
470
|
+
config: SupportRequestConfig,
|
|
471
|
+
): Promise<Response> {
|
|
472
|
+
if (
|
|
473
|
+
!isId(config?.reporter?.userId) ||
|
|
474
|
+
!config.serviceUrl ||
|
|
475
|
+
!config.connectionToken ||
|
|
476
|
+
!config.project ||
|
|
477
|
+
(config.account !== undefined && !isId(config.account?.id))
|
|
478
|
+
) {
|
|
479
|
+
// A host that calls this without saying who is asking has not done its sign-in check.
|
|
480
|
+
// oxlint-disable-next-line no-console
|
|
481
|
+
console.error(
|
|
482
|
+
"[comment-service] handleSupportRequest needs serviceUrl, connectionToken, project, reporter.userId and, when an account is given, its id.",
|
|
483
|
+
);
|
|
484
|
+
return failure(500, UNAVAILABLE);
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
switch (request.method) {
|
|
488
|
+
case "POST":
|
|
489
|
+
return await file(request, config);
|
|
490
|
+
case "GET":
|
|
491
|
+
return await list(config);
|
|
492
|
+
default:
|
|
493
|
+
return failure(405, "Use GET or POST.", { allow: "GET, POST" });
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
if (error instanceof Refusal) return failure(error.status, error.message);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { handleSessionRequest, readCommentServiceConfig } from "./server/index.js";
|
|
2
|
+
export type { CommentServiceConfig } from "./server/index.js";
|
|
3
|
+
export { handleSupportRequest } from "./server/support.js";
|
|
4
|
+
export type { SupportRequestConfig } from "./server/support.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { CONFIG_KEY, annotateSource, buildConfigured, stampingEnabled } from "../compiler/index.js";
|
|
4
|
+
import type { HostBuild } from "../runtime/debug-context-model.js";
|
|
5
|
+
|
|
6
|
+
/** The constant `<CommentToolbar>` reads for the debug context's `build`, when nothing else gave one. */
|
|
7
|
+
export const HOST_BUILD_CONSTANT = "__COMMENT_TOOL_HOST_BUILD__";
|
|
8
|
+
|
|
9
|
+
/** Where CI systems put the commit being built, in the order they are read. */
|
|
10
|
+
const COMMIT_VARIABLES = [
|
|
11
|
+
"COMMENT_SERVICE_BUILD_COMMIT",
|
|
12
|
+
"GITHUB_SHA",
|
|
13
|
+
"CF_PAGES_COMMIT_SHA",
|
|
14
|
+
"WORKERS_CI_COMMIT_SHA",
|
|
15
|
+
"VERCEL_GIT_COMMIT_SHA",
|
|
16
|
+
"CI_COMMIT_SHA",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The build this bundle is: the commit from the CI's variable or from `git`, the version from
|
|
21
|
+
* `COMMENT_SERVICE_BUILD_VERSION` or the package being built, and the mode. Only short, public facts.
|
|
22
|
+
*/
|
|
23
|
+
export function hostBuildOf(
|
|
24
|
+
mode: string,
|
|
25
|
+
root: string,
|
|
26
|
+
env: Record<string, string | undefined> = process.env,
|
|
27
|
+
git: () => string = () =>
|
|
28
|
+
execFileSync("git", ["rev-parse", "--short=12", "HEAD"], {
|
|
29
|
+
cwd: root,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
32
|
+
}),
|
|
33
|
+
): HostBuild {
|
|
34
|
+
let commit = COMMIT_VARIABLES.map((name) => env[name]?.trim()).find(Boolean);
|
|
35
|
+
if (!commit) {
|
|
36
|
+
try {
|
|
37
|
+
commit = git().trim();
|
|
38
|
+
} catch {
|
|
39
|
+
commit = undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const short = commit && /^[0-9a-f]{7,40}$/i.test(commit) ? commit.slice(0, 12) : undefined;
|
|
43
|
+
const version = (env.COMMENT_SERVICE_BUILD_VERSION ?? env.npm_package_version)
|
|
44
|
+
?.trim()
|
|
45
|
+
.slice(0, 64);
|
|
46
|
+
return {
|
|
47
|
+
...(short ? { commit: short } : {}),
|
|
48
|
+
...(version ? { version } : {}),
|
|
49
|
+
env: mode.slice(0, 40),
|
|
50
|
+
builtAt: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type ResolvedConfig = {
|
|
55
|
+
command: "build" | "serve";
|
|
56
|
+
mode: string;
|
|
57
|
+
logger: { info(message: string): void };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function commentService(options?: { enabled?: boolean; root?: string }) {
|
|
61
|
+
const root = (options?.root ?? process.cwd()).replace(/\/$/, "");
|
|
62
|
+
let enabled = options?.enabled ?? process.env.COMMENT_SERVICE_ENABLED !== "0";
|
|
63
|
+
let told = false;
|
|
64
|
+
return {
|
|
65
|
+
name: "devaccel-comment-service",
|
|
66
|
+
enforce: "pre" as const,
|
|
67
|
+
// The build's commit and version, for the debug context of each comment (`<CommentToolbar debugContext>`).
|
|
68
|
+
config(_config: unknown, environment: { mode: string }) {
|
|
69
|
+
return {
|
|
70
|
+
define: { [HOST_BUILD_CONSTANT]: JSON.stringify(hostBuildOf(environment.mode, root)) },
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
configResolved(config: ResolvedConfig) {
|
|
74
|
+
enabled = stampingEnabled({
|
|
75
|
+
option: options?.enabled,
|
|
76
|
+
flag: process.env.COMMENT_SERVICE_ENABLED,
|
|
77
|
+
command: config.command,
|
|
78
|
+
configured: () => buildConfigured(root, config.mode),
|
|
79
|
+
});
|
|
80
|
+
if (config.command === "build" && !enabled && !told) {
|
|
81
|
+
told = true;
|
|
82
|
+
config.logger.info(
|
|
83
|
+
`[comment-service] This build sees no ${CONFIG_KEY}, so components are not stamped. ` +
|
|
84
|
+
"If it serves the comment toolbar, set COMMENT_SERVICE_ENABLED=1 or commentService({ enabled: true }).",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
transform(code: string, id: string) {
|
|
89
|
+
if (!enabled) return null;
|
|
90
|
+
const file = id.split("?")[0];
|
|
91
|
+
if (!/\.[jt]sx$/.test(file) || file.includes("/node_modules/")) return null;
|
|
92
|
+
if (!file.startsWith(`${root}/`)) return null;
|
|
93
|
+
try {
|
|
94
|
+
const next = annotateSource(code, file, root);
|
|
95
|
+
return next === code ? null : { code: next, map: null };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.warn("[comment-service] annotation skipped", file, error);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { commentService } from "./vite/plugin.js";
|