1import { toast } from 'svelte-sonner';
2import { AttachmentType } from '$lib/enums';
3import type {
4 DatabaseMessageExtra,
5 DatabaseMessageExtraTextFile,
6 DatabaseMessageExtraLegacyContext
7} from '$lib/types/database';
8
9/**
10 * Copy text to clipboard with toast notification
11 * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
12 * @param text - Text to copy to clipboard
13 * @param successMessage - Custom success message (optional)
14 * @param errorMessage - Custom error message (optional)
15 * @returns Promise<boolean> - True if successful, false otherwise
16 */
17export async function copyToClipboard(
18 text: string,
19 successMessage = 'Copied to clipboard',
20 errorMessage = 'Failed to copy to clipboard'
21): Promise<boolean> {
22 try {
23 // Try modern clipboard API first (secure contexts only)
24 if (navigator.clipboard && navigator.clipboard.writeText) {
25 await navigator.clipboard.writeText(text);
26 toast.success(successMessage);
27 return true;
28 }
29
30 // Fallback for non-secure contexts
31 const textArea = document.createElement('textarea');
32 textArea.value = text;
33 textArea.style.position = 'fixed';
34 textArea.style.left = '-999999px';
35 textArea.style.top = '-999999px';
36 document.body.appendChild(textArea);
37 textArea.focus();
38 textArea.select();
39
40 const successful = document.execCommand('copy');
41 document.body.removeChild(textArea);
42
43 if (successful) {
44 toast.success(successMessage);
45 return true;
46 } else {
47 throw new Error('execCommand failed');
48 }
49 } catch (error) {
50 console.error('Failed to copy to clipboard:', error);
51 toast.error(errorMessage);
52 return false;
53 }
54}
55
56/**
57 * Copy code with HTML entity decoding and toast notification
58 * @param rawCode - Raw code string that may contain HTML entities
59 * @param successMessage - Custom success message (optional)
60 * @param errorMessage - Custom error message (optional)
61 * @returns Promise<boolean> - True if successful, false otherwise
62 */
63export async function copyCodeToClipboard(
64 rawCode: string,
65 successMessage = 'Code copied to clipboard',
66 errorMessage = 'Failed to copy code'
67): Promise<boolean> {
68 return copyToClipboard(rawCode, successMessage, errorMessage);
69}
70
71/**
72 * Format for text attachments when copied to clipboard
73 */
74export interface ClipboardTextAttachment {
75 type: typeof AttachmentType.TEXT;
76 name: string;
77 content: string;
78}
79
80/**
81 * Parsed result from clipboard content
82 */
83export interface ParsedClipboardContent {
84 message: string;
85 textAttachments: ClipboardTextAttachment[];
86}
87
88/**
89 * Formats a message with text attachments for clipboard copying.
90 *
91 * Default format (asPlainText = false):
92 * ```
93 * "Text message content"
94 * [
95 * {"type":"TEXT","name":"filename.txt","content":"..."},
96 * {"type":"TEXT","name":"another.txt","content":"..."}
97 * ]
98 * ```
99 *
100 * Plain text format (asPlainText = true):
101 * ```
102 * Text message content
103 *
104 * file content here
105 *
106 * another file content
107 * ```
108 *
109 * @param content - The message text content
110 * @param extras - Optional array of message attachments
111 * @param asPlainText - If true, format as plain text without JSON structure
112 * @returns Formatted string for clipboard
113 */
114export function formatMessageForClipboard(
115 content: string,
116 extras?: DatabaseMessageExtra[],
117 asPlainText: boolean = false
118): string {
119 // Filter only text attachments (TEXT type and legacy CONTEXT type)
120 const textAttachments =
121 extras?.filter(
122 (extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
123 extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
124 ) ?? [];
125
126 if (textAttachments.length === 0) {
127 return content;
128 }
129
130 if (asPlainText) {
131 const parts = [content];
132 for (const att of textAttachments) {
133 parts.push(att.content);
134 }
135 return parts.join('\n\n');
136 }
137
138 const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
139 type: AttachmentType.TEXT,
140 name: att.name,
141 content: att.content
142 }));
143
144 return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
145}
146
147/**
148 * Parses clipboard content to extract message and text attachments.
149 * Supports both plain text and the special format with attachments.
150 *
151 * @param clipboardText - Raw text from clipboard
152 * @returns Parsed content with message and attachments
153 */
154export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
155 const defaultResult: ParsedClipboardContent = {
156 message: clipboardText,
157 textAttachments: []
158 };
159
160 if (!clipboardText.startsWith('"')) {
161 return defaultResult;
162 }
163
164 try {
165 let stringEndIndex = -1;
166 let escaped = false;
167
168 for (let i = 1; i < clipboardText.length; i++) {
169 const char = clipboardText[i];
170
171 if (escaped) {
172 escaped = false;
173 continue;
174 }
175
176 if (char === '\\') {
177 escaped = true;
178 continue;
179 }
180
181 if (char === '"') {
182 stringEndIndex = i;
183 break;
184 }
185 }
186
187 if (stringEndIndex === -1) {
188 return defaultResult;
189 }
190
191 const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
192 const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
193
194 const message = JSON.parse(jsonStringPart) as string;
195
196 if (!remainingPart || !remainingPart.startsWith('[')) {
197 return {
198 message,
199 textAttachments: []
200 };
201 }
202
203 const attachments = JSON.parse(remainingPart) as unknown[];
204
205 const validAttachments: ClipboardTextAttachment[] = [];
206
207 for (const att of attachments) {
208 if (isValidTextAttachment(att)) {
209 validAttachments.push({
210 type: AttachmentType.TEXT,
211 name: att.name,
212 content: att.content
213 });
214 }
215 }
216
217 return {
218 message,
219 textAttachments: validAttachments
220 };
221 } catch {
222 return defaultResult;
223 }
224}
225
226/**
227 * Type guard to validate a text attachment object
228 * @param obj The object to validate
229 * @returns true if the object is a valid text attachment
230 */
231function isValidTextAttachment(
232 obj: unknown
233): obj is { type: string; name: string; content: string } {
234 if (typeof obj !== 'object' || obj === null) {
235 return false;
236 }
237
238 const record = obj as Record<string, unknown>;
239
240 return (
241 (record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
242 typeof record.name === 'string' &&
243 typeof record.content === 'string'
244 );
245}
246
247/**
248 * Checks if clipboard content contains our special format with attachments
249 * @param clipboardText - Raw text from clipboard
250 * @returns true if the clipboard content contains our special format with attachments
251 */
252export function hasClipboardAttachments(clipboardText: string): boolean {
253 if (!clipboardText.startsWith('"')) {
254 return false;
255 }
256
257 const parsed = parseClipboardContent(clipboardText);
258 return parsed.textAttachments.length > 0;
259}