import { invokeIpc } from './api-client'; const MAX_LINK_LENGTH = 4_096; const LOCAL_WEB_FILE_PATTERN = /\.(?:html?|xhtml)$/i; function isBoundedSingleLine(value: string): boolean { return value.length > 0 && value.length <= MAX_LINK_LENGTH && !/[\0\r\n]/.test(value); } export function safeConversationExternalUrl(value: string | undefined): string | null { const candidate = value?.trim() ?? ''; if (!isBoundedSingleLine(candidate)) return null; try { const url = new URL(candidate); if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.username || url.password) { return null; } return url.toString(); } catch { return null; } } export function revealableConversationPath(value: string): string | null { const candidate = value.trim(); if (!isBoundedSingleLine(candidate)) return null; const homeRelative = candidate === '~' || /^~[\\/]/.test(candidate); const posixAbsolute = candidate.startsWith('/'); const windowsAbsolute = /^[A-Za-z]:[\\/]/.test(candidate); const windowsNetwork = /^\\\\[^\\]+\\[^\\]+/.test(candidate); return homeRelative || posixAbsolute || windowsAbsolute || windowsNetwork ? candidate : null; } export function openableConversationLocalWebPath(value: string): string | null { const path = revealableConversationPath(value); return path && LOCAL_WEB_FILE_PATTERN.test(path) ? path : null; } export function previewableConversationRelativePath(value: string): string | null { const candidate = value.trim(); if (!isBoundedSingleLine(candidate) || revealableConversationPath(candidate) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) { return null; } const normalized = candidate.replaceAll('\\', '/').replace(/^\.\//, ''); const segments = normalized.split('/'); if (!normalized || normalized.startsWith('/') || segments.some((segment) => !segment || segment === '.' || segment === '..') || !LOCAL_WEB_FILE_PATTERN.test(normalized)) { return null; } return normalized; } function comparableLocalPath(value: string): string { return value.replaceAll('\\', '/').replace(/\/$/, ''); } export function resolveConversationMarkdownWebPath( value: string, markdown: string, ): string | null { const relativePath = previewableConversationRelativePath(value); if (!relativePath) return null; const matches = new Set(); for (const match of markdown.matchAll(/`([^`\r\n]+)`/g)) { const absolutePath = revealableConversationPath(match[1] ?? ''); if (!absolutePath || !LOCAL_WEB_FILE_PATTERN.test(absolutePath)) continue; const comparable = comparableLocalPath(absolutePath); if (comparable.endsWith(`/${relativePath}`)) matches.add(absolutePath); } return matches.size === 1 ? [...matches][0] ?? null : null; } function resolveHomeRelativePath(value: string, home: string): string { if (value === '~') return home; const separator = home.includes('\\') ? '\\' : '/'; const suffix = value.slice(2).replace(/[\\/]/g, separator); return `${home.replace(/[\\/]$/, '')}${separator}${suffix}`; } export async function openConversationExternalUrl(value: string): Promise { const url = safeConversationExternalUrl(value); if (!url) throw new Error('Unsupported external URL'); await invokeIpc('shell:openExternal', url); } export async function revealConversationPath(value: string): Promise { const path = revealableConversationPath(value); if (!path) throw new Error('Unsupported local path'); let resolvedPath = path; if (path === '~' || /^~[\\/]/.test(path)) { const home = await invokeIpc('app:getPath', 'home'); resolvedPath = resolveHomeRelativePath(path, home); } await invokeIpc('shell:showItemInFolder', resolvedPath); } export async function openConversationLocalWebFile(value: string): Promise { const path = openableConversationLocalWebPath(value); if (!path) throw new Error('Unsupported local web file'); let resolvedPath = path; if (path === '~' || /^~[\\/]/.test(path)) { const home = await invokeIpc('app:getPath', 'home'); resolvedPath = resolveHomeRelativePath(path, home); } const error = await invokeIpc('shell:openPath', resolvedPath); if (error) throw new Error('Local web file could not be opened'); } export async function showConversationLinkContextMenu( request: | { kind: 'external'; target: string } | { kind: 'local-web'; target: string }, ): Promise { if (request.kind === 'external') { const target = safeConversationExternalUrl(request.target); if (!target) throw new Error('Unsupported external URL'); await invokeIpc('shell:showLinkContextMenu', { kind: request.kind, target }); return; } const target = openableConversationLocalWebPath(request.target); if (!target) throw new Error('Unsupported local web file'); await invokeIpc('shell:showLinkContextMenu', { kind: request.kind, target }); }