diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index de60ca357..95a088f22 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -27,6 +27,27 @@ describe("splitMediaFromOutput", () => { expect(result.text).toBe(""); }); + it("normalizes Windows file URLs", () => { + const result = splitMediaFromOutput("MEDIA:file:///C:/Users/pete/My%20File.png"); + const expected = + process.platform === "win32" ? "C:\\Users\\pete\\My File.png" : "C:/Users/pete/My File.png"; + expect(result.mediaUrls).toEqual([expected]); + expect(result.text).toBe(""); + }); + + it("accepts Windows drive letter paths", () => { + const result = splitMediaFromOutput("MEDIA:C:/Users/pete/My File.png"); + expect(result.mediaUrls).toEqual(["C:/Users/pete/My File.png"]); + expect(result.text).toBe(""); + }); + + it("accepts Windows drive letter paths with backslashes", () => { + const filePath = "C:\\Users\\pete\\My File.png"; + const result = splitMediaFromOutput(`MEDIA:${filePath}`); + expect(result.mediaUrls).toEqual([filePath]); + expect(result.text).toBe(""); + }); + it("keeps audio_as_voice detection stable across calls", () => { const input = "Hello [[audio_as_voice]]"; const first = splitMediaFromOutput(input); diff --git a/src/media/parse.ts b/src/media/parse.ts index de0c6a5bc..b421f67fa 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -1,5 +1,7 @@ // Shared helpers for parsing MEDIA tokens from command/stdout text. +import { fileURLToPath } from "node:url"; + import { parseFenceSpans } from "../markdown/fences.js"; import { parseAudioTag } from "./audio-tags.js"; @@ -7,7 +9,15 @@ import { parseAudioTag } from "./audio-tags.js"; export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi; export function normalizeMediaSource(src: string) { - return src.startsWith("file://") ? src.replace("file://", "") : src; + if (!src.startsWith("file://")) return src; + try { + const filePath = fileURLToPath(src); + // Normalize drive-letter paths that appear as `/C:/...` on non-Windows platforms. + return filePath.replace(/^\/([a-zA-Z]:[\\/])/, "$1"); + } catch { + // Best-effort fallback for malformed file URLs. + return src.replace("file://", ""); + } } function cleanCandidate(raw: string) { @@ -19,6 +29,8 @@ function isValidMedia(candidate: string, opts?: { allowSpaces?: boolean }) { if (candidate.length > 4096) return false; if (!opts?.allowSpaces && /\s/.test(candidate)) return false; if (/^https?:\/\//i.test(candidate)) return true; + if (/^[a-zA-Z]:[\\/]/.test(candidate)) return true; + if (candidate.startsWith("\\\\")) return true; if (candidate.startsWith("/")) return true; if (candidate.startsWith("./")) return true; if (candidate.startsWith("../")) return true; @@ -118,6 +130,7 @@ export function splitMediaFromOutput(raw: string): { trimmedPayload.startsWith("./") || trimmedPayload.startsWith("../") || trimmedPayload.startsWith("~") || + /^[a-zA-Z]:[\\/]/.test(trimmedPayload) || trimmedPayload.startsWith("file://"); if ( !unwrapped &&