Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

188 linesCodeBlameRaw
1/**
2 * Images pasted, dropped or chosen in a comment box, as GitHub does it: an
3 * `![Uploading name…]()` placeholder goes in at the caret while the image
4 * uploads, and is swapped for `![name](url)` when it is kept. The work
5 * service keeps it (`upload_attachment`, crates/contracts/src/attachments.rs)
6 * and the usercontent origin serves it. This is the part with no DOM, so it
7 * can be tested under Node: which pastes upload, the placeholder's text and
8 * where it goes, and what a refusal says.
9 */
10
11/** As `MAX_ATTACHMENT_BYTES` in the contracts: 10 MB. */
12export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
13
14/** What an attachment may be, as the work service reads it from the bytes. */
15export const ATTACHMENT_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const;
16
17/** What the Attach an image control offers to pick. */
18export const ATTACHMENT_ACCEPT = ATTACHMENT_TYPES.join(",");
19
20/** The form field a box that uploads later (a new work item with no repository) sends its images in. */
21export const DEFERRED_FILE_FIELD = "attachment";
22/** With each of those, the reference its Markdown holds: `![name](attachment:<ref>)`. */
23export const DEFERRED_REF_FIELD = "attachmentRef";
24
25/** What an attachment is about to be: as much of a `File` as these need. */
26export type ImageFile = { name: string; type: string; size: number };
27
28/** What the clipboard holds, as much of a `DataTransfer` as the choice needs. */
29export type Clipboard<T extends ImageFile = ImageFile> = {
30 /** Its files, from its items of kind `file`. */
31 files: readonly T[];
32 /** `getData("text/plain")`. */
33 text: string;
34};
35
36/**
37 * The images a paste uploads, or none for the browser to paste as it
38 * always does. Only when the clipboard holds image files and no plain
39 * text: a screenshot, or an image copied from a page (which comes with
40 * its HTML but no text). A spreadsheet's cells or a document's paragraphs
41 * come with a picture of themselves too, and paste as their text.
42 */
43export function pastedImages<T extends ImageFile>(clipboard: Clipboard<T>): T[] {
44 if (clipboard.text.trim() !== "") return [];
45 return clipboard.files.filter((file) => file.type.startsWith("image/"));
46}
47
48/** Whether something dragged over the box carries files. */
49export function dragsFiles(types: readonly string[]): boolean {
50 return types.includes("Files");
51}
52
53/** Why an image will not be attached, before it is sent; null when it may go. */
54export function refusal(file: ImageFile): string | null {
55 if (!(ATTACHMENT_TYPES as readonly string[]).includes(file.type.toLowerCase())) {
56 return `${file.name || "That file"} isn't a PNG, JPEG, GIF or WebP image, so it wasn't attached.`;
57 }
58 if (file.size > MAX_ATTACHMENT_BYTES) return `${file.name || "That image"} is over 10 MB, so it wasn't attached.`;
59 if (file.size === 0) return `${file.name || "That image"} is empty, so it wasn't attached.`;
60 return null;
61}
62
63const EXTENSIONS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
64
65/** A file's name as its alt text: its own, or `image.png` for a pasted one that has none. */
66export function imageName(file: ImageFile): string {
67 const own =
68 file.name
69 .split(/[/\\]/)
70 .pop()
71 ?.replace(/[\u0000-\u001f\u007f]/g, "")
72 .trim() ?? "";
73 return own || `image.${EXTENSIONS[file.type.toLowerCase()] ?? "png"}`;
74}
75
76/** A name as Markdown alt text: its brackets and backslashes escaped, as the contracts' `attachment_markdown`. */
77export function altText(name: string): string {
78 return name.replace(/[[\]\\]/g, (c) => `\\${c}`);
79}
80
81/** The Markdown that shows an attachment. */
82export function imageMarkdown(name: string, url: string): string {
83 return `![${altText(name)}](${url})`;
84}
85
86/**
87 * The placeholder for an image while it uploads, one no other in `text`
88 * is: a second `image.png` is `image.png (2)`, so each is replaced by its
89 * own upload alone.
90 */
91export function placeholderFor(name: string, text: string): string {
92 for (let n = 1; ; n++) {
93 const shown = n === 1 ? name : `${name} (${n})`;
94 const candidate = `![Uploading ${altText(shown)}…]()`;
95 if (!text.includes(candidate)) return candidate;
96 }
97}
98
99/** Text put in at `start`..`end`, and where the caret goes after it. */
100export type Insertion = { text: string; start: number; end: number; caret: number };
101
102/**
103 * How `markdown` goes in at the selection `start`..`end` of `value`: on a
104 * line of its own, as an image is shown, with a line break added before
105 * or after it only where the text beside it needs one.
106 */
107export function insertion(value: string, start: number, end: number, markdown: string): Insertion {
108 const before = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
109 const after = end < value.length && value[end] !== "\n" ? "\n" : "";
110 const text = `${before}${markdown}${after}`;
111 return { text, start, end, caret: start + before.length + markdown.length };
112}
113
114/**
115 * What of `value` to replace to swap `placeholder` for `replacement`,
116 * found where it is now (the person may have written around it); an
117 * empty replacement takes the placeholder's line with it when it was
118 * alone there. Null when it is gone (they deleted it), so nothing they
119 * wrote is touched.
120 */
121export function placeholderRange(value: string, placeholder: string, replacement: string): { start: number; end: number } | null {
122 let start = value.indexOf(placeholder);
123 if (start < 0) return null;
124 let end = start + placeholder.length;
125 if (replacement === "" && (start === 0 || value[start - 1] === "\n") && value[end] === "\n") end += 1;
126 else if (replacement === "" && value[start - 1] === "\n" && end === value.length) start -= 1;
127 return { start, end };
128}
129
130/**
131 * Where a caret at `at` belongs once `start`..`end` became `length`
132 * characters: where it was before them, as far along after them, and at
133 * the end of them from inside (or right at the end of a placeholder, where
134 * a paste leaves it), so nothing is left selected to type over.
135 */
136export function caretAfter(at: number, start: number, end: number, length: number): number {
137 if (at <= start) return at;
138 if (at > end) return at + length - (end - start);
139 return start + length;
140}
141
142/** `value` with `placeholder` replaced by `replacement`, as {@link placeholderRange} finds it. */
143export function replacePlaceholder(value: string, placeholder: string, replacement: string): string {
144 const at = placeholderRange(value, placeholder, replacement);
145 return at ? value.slice(0, at.start) + replacement + value.slice(at.end) : value;
146}
147
148/** A refusal from the upload route (the work service's code and message), as the box says it. */
149export function uploadFailure(name: string, failure: { code?: string; message?: string } | null): string {
150 switch (failure?.code) {
151 case "too_large":
152 return `${name} is over 10 MB, so it wasn't attached.`;
153 case "unsupported_type":
154 return `${name} isn't a PNG, JPEG, GIF or WebP image, so it wasn't attached.`;
155 case "forbidden":
156 case "not_found":
157 case "unauthenticated":
158 return `You can't attach images here, so ${name} wasn't attached.`;
159 default:
160 return failure?.message ? `${name} wasn't attached: ${failure.message}` : `${name} didn't upload. Try again in a moment.`;
161 }
162}
163
164/**
165 * The reference a deferred image's Markdown holds until it is uploaded:
166 * `attachment:<ref>`, a short random word so two never meet.
167 */
168export function deferredUrl(ref: string): string {
169 return `attachment:${ref}`;
170}
171
172/**
173 * `body` with each deferred image's `attachment:<ref>` swapped for the
174 * address it was given; one with no address (its upload failed) is taken
175 * out, image and all.
176 */
177export function resolveDeferred(body: string, urls: ReadonlyMap<string, string | null>): string {
178 return body.replace(/!\[((?:\\.|[^\]\\])*)\]\(attachment:([A-Za-z0-9_-]{1,64})\)\n?/g, (whole, alt: string, ref: string) => {
179 if (!urls.has(ref)) return whole;
180 const url = urls.get(ref);
181 return url ? `![${alt}](${url})${whole.endsWith("\n") ? "\n" : ""}` : "";
182 });
183}
184
185/** The refs a body still holds, so an image whose Markdown was deleted is not uploaded. */
186export function deferredRefs(body: string): Set<string> {
187 return new Set([...body.matchAll(/\]\(attachment:([A-Za-z0-9_-]{1,64})\)/g)].map((match) => match[1]!));
188}