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