Files
makelore/src/lib/conversation-links.ts

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 });
}