mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
* fix(images): preserve Windows preview paths Provider image markdown used escaped backslash paths on Windows, which markdown parsing could turn into encoded drive paths that the app treated as workspace-relative. Emit drive-letter paths as file URIs and normalize existing encoded local paths before file preview. * test(server): accept file URI image sources Windows provider image output now renders drive-letter paths as file URIs. Decode those markdown sources before filesystem assertions so the image rendering tests keep checking the materialized file. * fix(images): keep plain local paths literal Limit legacy URI decoding to markdown-encoded Windows drive paths so ordinary local paths with percent sequences remain unchanged. Document why provider image markdown matching still accepts old doubled-backslash history.
189 lines
5.4 KiB
TypeScript
189 lines
5.4 KiB
TypeScript
import { generateMessageId } from "@/types/stream";
|
|
import { isAbsolutePath } from "@/utils/path";
|
|
import { isRasterImageMimeType } from "./file-types";
|
|
|
|
export function generateAttachmentId(): string {
|
|
return `att_${generateMessageId()}`;
|
|
}
|
|
|
|
export function normalizeMimeType(input: string | undefined | null): string {
|
|
if (!input) {
|
|
return "image/jpeg";
|
|
}
|
|
const trimmed = input.trim();
|
|
return trimmed.length > 0 ? trimmed : "image/jpeg";
|
|
}
|
|
|
|
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
|
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
|
|
if (!match) {
|
|
throw new Error("Malformed data URL for attachment.");
|
|
}
|
|
const metadata = match[1] ?? "";
|
|
const base64 = match[2]?.replace(/\s/g, "");
|
|
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
|
|
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
|
|
if (!isBase64) {
|
|
throw new Error("Attachment data URL is not base64 encoded.");
|
|
}
|
|
if (!base64) {
|
|
throw new Error("Attachment data URL is missing base64 payload.");
|
|
}
|
|
return {
|
|
mimeType: normalizeMimeType(mimeTypeRaw),
|
|
base64,
|
|
};
|
|
}
|
|
|
|
function hashString(value: string): string {
|
|
let hash = 2166136261;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash ^= value.charCodeAt(index);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
export function parseImageDataUrl(
|
|
uri: string,
|
|
): { mimeType: string; base64: string; cacheKey: string } | null {
|
|
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = parseDataUrl(uri);
|
|
if (!isRasterImageMimeType(parsed.mimeType)) {
|
|
return null;
|
|
}
|
|
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;
|
|
return {
|
|
...parsed,
|
|
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function createImageSourceCacheKey(source: string): string {
|
|
return parseImageDataUrl(source)?.cacheKey ?? source;
|
|
}
|
|
|
|
export function getFileNameFromPath(path: string | null | undefined): string | null {
|
|
const trimmed = path?.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
const fileName = normalized.split("/").pop()?.trim();
|
|
return fileName || null;
|
|
}
|
|
|
|
export function createPreviewAttachmentId(input: {
|
|
mimeType: string;
|
|
path?: string | null;
|
|
size?: number | null;
|
|
modifiedAt?: string | null;
|
|
contentLength?: number | null;
|
|
}): string {
|
|
const path = input.path?.trim() ?? "";
|
|
const size = Number.isFinite(input.size) ? String(input.size) : "";
|
|
const modifiedAt = input.modifiedAt?.trim() ?? "";
|
|
const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : "";
|
|
const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`);
|
|
return `preview_${size || contentLength || "unknown"}_${hash}`;
|
|
}
|
|
|
|
export async function blobToBase64(blob: Blob): Promise<string> {
|
|
return await new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", () => {
|
|
if (typeof reader.result !== "string") {
|
|
reject(new Error("Unexpected FileReader result while encoding attachment."));
|
|
return;
|
|
}
|
|
const payload = reader.result.split(",", 2)[1];
|
|
if (!payload) {
|
|
reject(new Error("Attachment FileReader result did not contain base64 payload."));
|
|
return;
|
|
}
|
|
resolve(payload);
|
|
});
|
|
reader.addEventListener("error", () => {
|
|
reject(reader.error ?? new Error("Failed to read attachment blob."));
|
|
});
|
|
reader.readAsDataURL(blob);
|
|
});
|
|
}
|
|
|
|
export function pathToFileUri(path: string): string {
|
|
if (path.startsWith("file://")) {
|
|
return path;
|
|
}
|
|
|
|
if (!isAbsolutePath(path)) {
|
|
return path;
|
|
}
|
|
|
|
if (path.startsWith("/")) {
|
|
return `file://${path}`;
|
|
}
|
|
|
|
// UNC paths: \\server\share -> file://server/share
|
|
if (path.startsWith("\\\\")) {
|
|
return `file:${path.replace(/\\/g, "/")}`;
|
|
}
|
|
|
|
return `file:///${path.replace(/\\/g, "/")}`;
|
|
}
|
|
|
|
function decodeFilePathSource(source: string): string {
|
|
try {
|
|
return decodeURIComponent(source);
|
|
} catch {
|
|
return source;
|
|
}
|
|
}
|
|
|
|
function normalizeWindowsDrivePath(path: string): string {
|
|
if (!/^[A-Za-z]:[\\/]/.test(path)) {
|
|
return path;
|
|
}
|
|
return path.replace(/\\/g, "/");
|
|
}
|
|
|
|
function isMarkdownEncodedWindowsDrivePath(source: string): boolean {
|
|
return /^[A-Za-z]:(?:%5[Cc]|%2[Ff])/.test(source);
|
|
}
|
|
|
|
export function fileUriToPath(uri: string): string {
|
|
if (!uri.startsWith("file://")) {
|
|
return uri;
|
|
}
|
|
const decodedPath = decodeFilePathSource(uri.replace(/^file:\/\//, ""));
|
|
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
|
|
}
|
|
|
|
export function localFileSourceToPath(source: string): string {
|
|
let path = source;
|
|
if (source.startsWith("file://")) {
|
|
path = fileUriToPath(source);
|
|
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
|
|
path = decodeFilePathSource(source);
|
|
}
|
|
return normalizeWindowsDrivePath(path);
|
|
}
|
|
|
|
export function getFileExtensionFromName(fileName: string | null | undefined): string {
|
|
if (!fileName) {
|
|
return "";
|
|
}
|
|
const idx = fileName.lastIndexOf(".");
|
|
if (idx <= 0 || idx === fileName.length - 1) {
|
|
return "";
|
|
}
|
|
return fileName.slice(idx);
|
|
}
|