| 1 | import { escapeRefPath, refTokenRe, unescapeRefPath } from "./refToken"; |
| 2 | |
| 3 | const attachmentRefRe = /@(\.reasonix\/attachments\/[^\s]+)/g; |
| 4 | const namedAttachmentRefRe = /(^|\s)@\[([^\]\r\n]+)\]\(([^)\s]+)\)/g; |
| 5 | const trailingPunctuationRe = /[.,;!?)\]},。;!?)】]+$/; |
| 6 | |
| 7 | export interface DisplayAttachment { |
| 8 | path: string; |
| 9 | name: string; |
| 10 | kind: "image" | "file" | "folder"; |
| 11 | source: "attachment" | "workspace"; |
| 12 | ext: string; |
| 13 | mime?: string; |
| 14 | } |
| 15 | |
| 16 | function splitTrailingPunctuation(token: string): { core: string; suffix: string } { |
| 17 | const m = token.match(trailingPunctuationRe); |
| 18 | if (!m || m.index === undefined) return { core: token, suffix: "" }; |
| 19 | return { core: token.slice(0, m.index), suffix: m[0] }; |
| 20 | } |
| 21 | |
| 22 | export function baseName(path: string): string { |
| 23 | const clean = path.replace(/[\\/]+$/, ""); |
| 24 | const slash = clean.lastIndexOf("/"); |
| 25 | const backslash = clean.lastIndexOf("\\"); |
| 26 | const idx = Math.max(slash, backslash); |
| 27 | return idx >= 0 ? clean.slice(idx + 1) : clean; |
| 28 | } |
| 29 | |
| 30 | function isImageAttachmentRef(path: string): boolean { |
| 31 | if (path.startsWith("draft:") || path.startsWith("attachment:")) return true; |
| 32 | const ext = attachmentExt(path); |
| 33 | switch (ext) { |
| 34 | case ".png": |
| 35 | case ".jpg": |
| 36 | case ".jpeg": |
| 37 | case ".gif": |
| 38 | case ".webp": |
| 39 | case ".bmp": |
| 40 | case ".svg": |
| 41 | case ".tif": |
| 42 | case ".tiff": |
| 43 | return true; |
| 44 | default: |
| 45 | return false; |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | function attachmentExt(path: string): string { |
| 50 | const name = baseName(path).toLowerCase(); |
| 51 | const dot = name.lastIndexOf("."); |
| 52 | return dot >= 0 ? name.slice(dot) : ""; |
| 53 | } |
| 54 | |
| 55 | function cleanDisplayName(name: string): string { |
| 56 | return name.replace(/\\([\[\]])/g, "$1").replace(/\s+/g, " ").trim(); |
| 57 | } |
| 58 | |
| 59 | export function replaceAttachmentRefsForDisplay(text: string): string { |
| 60 | return text |
| 61 | .replace(namedAttachmentRefRe, (_full, lead: string, label: string, token: string) => { |
| 62 | const { core, suffix } = splitTrailingPunctuation(token); |
| 63 | if (!core || !isDisplayReference(core)) return _full; |
| 64 | const name = cleanDisplayName(label) || baseName(core) || "attachment"; |
| 65 | return `${lead}${isImageAttachmentRef(core) ? "[image]" : `[file:${name}]`}${suffix}`; |
| 66 | }) |
| 67 | .replace(attachmentRefRe, (_full, token: string) => { |
| 68 | const { core, suffix } = splitTrailingPunctuation(token); |
| 69 | if (!core) return _full; |
| 70 | if (isImageAttachmentRef(core)) return `[image]${suffix}`; |
| 71 | const name = baseName(core) || "attachment"; |
| 72 | return `[file:${name}]${suffix}`; |
| 73 | }); |
| 74 | } |
| 75 | |
| 76 | export function restoreAttachmentRefsForSubmit(text: string): string { |
| 77 | return text.replace(namedAttachmentRefRe, (_full, lead: string, _label: string, token: string) => { |
| 78 | const { core, suffix } = splitTrailingPunctuation(token); |
| 79 | if (!core || !isDisplayReference(core)) return _full; |
| 80 | return `${lead}@${core}${suffix}`; |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | function displayRefName(name: string): string { |
| 85 | return name.replace(/[\[\]\(\)\r\n]+/g, " ").replace(/\s+/g, " ").trim() || "attachment"; |
| 86 | } |
| 87 | |
| 88 | export function formatAttachmentRefForDisplay(attachment: Pick<DisplayAttachment, "path" | "name" | "source">): string { |
| 89 | if (attachment.source === "attachment") { |
| 90 | return `@[${displayRefName(attachment.name || baseName(attachment.path) || "attachment")}](${attachment.path})`; |
| 91 | } |
| 92 | return `@${escapeRefPath(attachment.path)}`; |
| 93 | } |
| 94 | |
| 95 | // Whitespace in the path is escaped so the ref survives @-token parsing on |
| 96 | // submit; attachment-store paths are generated without spaces, so this is a |
| 97 | // no-op for them. |
| 98 | export function formatAttachmentRefForSubmit(attachment: Pick<DisplayAttachment, "path">): string { |
| 99 | return `@${escapeRefPath(attachment.path)}`; |
| 100 | } |
| 101 | |
| 102 | export function parseAttachmentRefsForDisplay(text: string): { text: string; attachments: DisplayAttachment[] } { |
| 103 | const attachments: DisplayAttachment[] = []; |
| 104 | const cleaned = text |
| 105 | .replace(namedAttachmentRefRe, (_full, lead: string, label: string, token: string) => { |
| 106 | const { core, suffix } = splitTrailingPunctuation(token); |
| 107 | if (!core || !isDisplayReference(core)) return _full; |
| 108 | const name = cleanDisplayName(label) || baseName(core) || "attachment"; |
| 109 | attachments.push(displayAttachment(core, name)); |
| 110 | return lead + suffix; |
| 111 | }) |
| 112 | .replace(refTokenRe(), (_full, lead: string, token: string) => { |
| 113 | const { core, suffix } = splitTrailingPunctuation(token); |
| 114 | const path = unescapeRefPath(core); |
| 115 | if (!path || !isDisplayReference(path)) return _full; |
| 116 | const name = baseName(path) || "attachment"; |
| 117 | const attachment = displayAttachment(path, name); |
| 118 | attachments.push(attachment); |
| 119 | return lead + suffix; |
| 120 | }) |
| 121 | .replace(/[ \t]+([.,;!?)\]},。;!?)】])/g, "$1") |
| 122 | .replace(/[ \t]{2,}/g, " ") |
| 123 | .trim(); |
| 124 | return { text: cleaned, attachments }; |
| 125 | } |
| 126 | |
| 127 | export function sortDisplayAttachments<T extends { kind: "image" | "file" | "folder" }>(attachments: T[]): T[] { |
| 128 | return [...attachments].sort((a, b) => { |
| 129 | if (a.kind === b.kind) return 0; |
| 130 | if (a.kind === "image") return -1; |
| 131 | if (b.kind === "image") return 1; |
| 132 | return 0; |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | function isDisplayReference(path: string): boolean { |
| 137 | if (path.startsWith("draft:") || path.startsWith("attachment:")) return true; |
| 138 | if (path.startsWith(".reasonix/attachments/")) return true; |
| 139 | if (path.endsWith("/")) return true; |
| 140 | if (path.includes("/")) return true; |
| 141 | return attachmentExt(path) !== ""; |
| 142 | } |
| 143 | |
| 144 | function displayAttachment(path: string, name: string): DisplayAttachment { |
| 145 | if (path.startsWith("draft:") || path.startsWith("attachment:") || path.startsWith(".reasonix/attachments/")) { |
| 146 | const kind = isImageAttachmentRef(path) ? "image" : "file"; |
| 147 | return { |
| 148 | path, |
| 149 | name, |
| 150 | kind, |
| 151 | source: "attachment", |
| 152 | ext: attachmentExt(path.startsWith("attachment:") ? name : path).replace(/^\./, "").toUpperCase(), |
| 153 | }; |
| 154 | } |
| 155 | const isDir = path.endsWith("/"); |
| 156 | return { |
| 157 | path, |
| 158 | name, |
| 159 | kind: isDir ? "folder" : "file", |
| 160 | source: "workspace", |
| 161 | ext: isDir ? "" : attachmentExt(path).replace(/^\./, "").toUpperCase(), |
| 162 | }; |
| 163 | } |
| 164 |