| 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 `` 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. */ |
| 12 | export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; |
| 13 | |
| 14 | /** What an attachment may be, as the work service reads it from the bytes. */ |
| 15 | export const ATTACHMENT_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const; |
| 16 | |
| 17 | /** What the Attach an image control offers to pick. */ |
| 18 | export 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. */ |
| 21 | export const DEFERRED_FILE_FIELD = "attachment"; |
| 22 | /** With each of those, the reference its Markdown holds: ``. */ |
| 23 | export const DEFERRED_REF_FIELD = "attachmentRef"; |
| 24 | |
| 25 | /** What an attachment is about to be: as much of a `File` as these need. */ |
| 26 | export type ImageFile = { name: string; type: string; size: number }; |
| 27 | |
| 28 | /** What the clipboard holds, as much of a `DataTransfer` as the choice needs. */ |
| 29 | export 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 | */ |
| 43 | export 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. */ |
| 49 | export 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. */ |
| 54 | export 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 | |
| 63 | const 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. */ |
| 66 | export 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`. */ |
| 77 | export function altText(name: string): string { |
| 78 | return name.replace(/[[\]\\]/g, (c) => `\\${c}`); |
| 79 | } |
| 80 | |
| 81 | /** The Markdown that shows an attachment. */ |
| 82 | export function imageMarkdown(name: string, url: string): string { |
| 83 | return ``; |
| 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 | */ |
| 91 | export 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. */ |
| 100 | export 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 | */ |
| 107 | export 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 | */ |
| 121 | export 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 | */ |
| 136 | export 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. */ |
| 143 | export 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. */ |
| 149 | export 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 | */ |
| 168 | export 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 | */ |
| 177 | export 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 ? `${whole.endsWith("\n") ? "\n" : ""}` : ""; |
| 182 | }); |
| 183 | } |
| 184 | |
| 185 | /** The refs a body still holds, so an image whose Markdown was deleted is not uploaded. */ |
| 186 | export 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 | } |