141 lines
4.9 KiB
TypeScript
141 lines
4.9 KiB
TypeScript
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<string>();
|
|
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<void> {
|
|
const url = safeConversationExternalUrl(value);
|
|
if (!url) throw new Error('Unsupported external URL');
|
|
await invokeIpc('shell:openExternal', url);
|
|
}
|
|
|
|
export async function revealConversationPath(value: string): Promise<void> {
|
|
const path = revealableConversationPath(value);
|
|
if (!path) throw new Error('Unsupported local path');
|
|
|
|
let resolvedPath = path;
|
|
if (path === '~' || /^~[\\/]/.test(path)) {
|
|
const home = await invokeIpc<string>('app:getPath', 'home');
|
|
resolvedPath = resolveHomeRelativePath(path, home);
|
|
}
|
|
await invokeIpc('shell:showItemInFolder', resolvedPath);
|
|
}
|
|
|
|
export async function openConversationLocalWebFile(value: string): Promise<void> {
|
|
const path = openableConversationLocalWebPath(value);
|
|
if (!path) throw new Error('Unsupported local web file');
|
|
|
|
let resolvedPath = path;
|
|
if (path === '~' || /^~[\\/]/.test(path)) {
|
|
const home = await invokeIpc<string>('app:getPath', 'home');
|
|
resolvedPath = resolveHomeRelativePath(path, home);
|
|
}
|
|
const error = await invokeIpc<string>('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<void> {
|
|
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 });
|
|
}
|