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.

183 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 = file.name.split(/[/\\]/).pop()?.replace(/[\u0000-\u001f\u007f]/g, "").trim() ?? "";
68 return own || `image.${EXTENSIONS[file.type.toLowerCase()] ?? "png"}`;
69}
70
71/** A name as Markdown alt text: its brackets and backslashes escaped, as the contracts' `attachment_markdown`. */
72export function altText(name: string): string {
73 return name.replace(/[[\]\\]/g, (c) => `\\${c}`);
74}
75
76/** The Markdown that shows an attachment. */
77export function imageMarkdown(name: string, url: string): string {
78 return `![${altText(name)}](${url})`;
79}
80
81/**
82 * The placeholder for an image while it uploads, one no other in `text`
83 * is: a second `image.png` is `image.png (2)`, so each is replaced by its
84 * own upload alone.
85 */
86export function placeholderFor(name: string, text: string): string {
87 for (let n = 1; ; n++) {
88 const shown = n === 1 ? name : `${name} (${n})`;
89 const candidate = `![Uploading ${altText(shown)}…]()`;
90 if (!text.includes(candidate)) return candidate;
91 }
92}
93
94/** Text put in at `start`..`end`, and where the caret goes after it. */
95export type Insertion = { text: string; start: number; end: number; caret: number };
96
97/**
98 * How `markdown` goes in at the selection `start`..`end` of `value`: on a
99 * line of its own, as an image is shown, with a line break added before
100 * or after it only where the text beside it needs one.
101 */
102export function insertion(value: string, start: number, end: number, markdown: string): Insertion {
103 const before = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
104 const after = end < value.length && value[end] !== "\n" ? "\n" : "";
105 const text = `${before}${markdown}${after}`;
106 return { text, start, end, caret: start + before.length + markdown.length };
107}
108
109/**
110 * What of `value` to replace to swap `placeholder` for `replacement`,
111 * found where it is now (the person may have written around it); an
112 * empty replacement takes the placeholder's line with it when it was
113 * alone there. Null when it is gone (they deleted it), so nothing they
114 * wrote is touched.
115 */
116export function placeholderRange(value: string, placeholder: string, replacement: string): { start: number; end: number } | null {
117 let start = value.indexOf(placeholder);
118 if (start < 0) return null;
119 let end = start + placeholder.length;
120 if (replacement === "" && (start === 0 || value[start - 1] === "\n") && value[end] === "\n") end += 1;
121 else if (replacement === "" && value[start - 1] === "\n" && end === value.length) start -= 1;
122 return { start, end };
123}
124
125/**
126 * Where a caret at `at` belongs once `start`..`end` became `length`
127 * characters: where it was before them, as far along after them, and at
128 * the end of them from inside (or right at the end of a placeholder, where
129 * a paste leaves it), so nothing is left selected to type over.
130 */
131export function caretAfter(at: number, start: number, end: number, length: number): number {
132 if (at <= start) return at;
133 if (at > end) return at + length - (end - start);
134 return start + length;
135}
136
137/** `value` with `placeholder` replaced by `replacement`, as {@link placeholderRange} finds it. */
138export function replacePlaceholder(value: string, placeholder: string, replacement: string): string {
139 const at = placeholderRange(value, placeholder, replacement);
140 return at ? value.slice(0, at.start) + replacement + value.slice(at.end) : value;
141}
142
143/** A refusal from the upload route (the work service's code and message), as the box says it. */
144export function uploadFailure(name: string, failure: { code?: string; message?: string } | null): string {
145 switch (failure?.code) {
146 case "too_large":
147 return `${name} is over 10 MB, so it wasn't attached.`;
148 case "unsupported_type":
149 return `${name} isn't a PNG, JPEG, GIF or WebP image, so it wasn't attached.`;
150 case "forbidden":
151 case "not_found":
152 case "unauthenticated":
153 return `You can't attach images here, so ${name} wasn't attached.`;
154 default:
155 return failure?.message ? `${name} wasn't attached: ${failure.message}` : `${name} didn't upload. Try again in a moment.`;
156 }
157}
158
159/**
160 * The reference a deferred image's Markdown holds until it is uploaded:
161 * `attachment:<ref>`, a short random word so two never meet.
162 */
163export function deferredUrl(ref: string): string {
164 return `attachment:${ref}`;
165}
166
167/**
168 * `body` with each deferred image's `attachment:<ref>` swapped for the
169 * address it was given; one with no address (its upload failed) is taken
170 * out, image and all.
171 */
172export function resolveDeferred(body: string, urls: ReadonlyMap<string, string | null>): string {
173 return body.replace(/!\[((?:\\.|[^\]\\])*)\]\(attachment:([A-Za-z0-9_-]{1,64})\)\n?/g, (whole, alt: string, ref: string) => {
174 if (!urls.has(ref)) return whole;
175 const url = urls.get(ref);
176 return url ? `![${alt}](${url})${whole.endsWith("\n") ? "\n" : ""}` : "";
177 });
178}
179
180/** The refs a body still holds, so an image whose Markdown was deleted is not uploaded. */
181export function deferredRefs(body: string): Set<string> {
182 return new Set([...body.matchAll(/\]\(attachment:([A-Za-z0-9_-]{1,64})\)/g)].map((match) => match[1]!));
183}