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}