221 lines
6.7 KiB
TypeScript
221 lines
6.7 KiB
TypeScript
// Shared helpers for parsing MEDIA tokens from command/stdout text.
|
|
|
|
import { parseFenceSpans } from "../markdown/fences.js";
|
|
import { parseAudioTag } from "./audio-tags.js";
|
|
|
|
// Allow optional wrapping backticks and punctuation after the token; capture the core token.
|
|
export const MEDIA_TOKEN_RE = /\bMEDIA:\s*`?([^\n]+)`?/gi;
|
|
|
|
export function normalizeMediaSource(src: string) {
|
|
return src.startsWith("file://") ? src.replace("file://", "") : src;
|
|
}
|
|
|
|
function cleanCandidate(raw: string) {
|
|
return raw.replace(/^[`"'[{(]+/, "").replace(/[`"'\\})\],]+$/, "");
|
|
}
|
|
|
|
function isValidMedia(candidate: string, opts?: { allowSpaces?: boolean }) {
|
|
if (!candidate) {
|
|
return false;
|
|
}
|
|
if (candidate.length > 4096) {
|
|
return false;
|
|
}
|
|
if (!opts?.allowSpaces && /\s/.test(candidate)) {
|
|
return false;
|
|
}
|
|
if (/^https?:\/\//i.test(candidate)) {
|
|
return true;
|
|
}
|
|
|
|
// Local paths: only allow safe relative paths starting with ./ that do not traverse upwards.
|
|
return candidate.startsWith("./") && !candidate.includes("..");
|
|
}
|
|
|
|
function unwrapQuoted(value: string): string | undefined {
|
|
const trimmed = value.trim();
|
|
if (trimmed.length < 2) {
|
|
return undefined;
|
|
}
|
|
const first = trimmed[0];
|
|
const last = trimmed[trimmed.length - 1];
|
|
if (first !== last) {
|
|
return undefined;
|
|
}
|
|
if (first !== `"` && first !== "'" && first !== "`") {
|
|
return undefined;
|
|
}
|
|
return trimmed.slice(1, -1).trim();
|
|
}
|
|
|
|
// Check if a character offset is inside any fenced code block
|
|
function isInsideFence(fenceSpans: Array<{ start: number; end: number }>, offset: number): boolean {
|
|
return fenceSpans.some((span) => offset >= span.start && offset < span.end);
|
|
}
|
|
|
|
export function splitMediaFromOutput(raw: string): {
|
|
text: string;
|
|
mediaUrls?: string[];
|
|
mediaUrl?: string; // legacy first item for backward compatibility
|
|
audioAsVoice?: boolean; // true if [[audio_as_voice]] tag was found
|
|
} {
|
|
// KNOWN: Leading whitespace is semantically meaningful in Markdown (lists, indented fences).
|
|
// We only trim the end; token cleanup below handles removing `MEDIA:` lines.
|
|
const trimmedRaw = raw.trimEnd();
|
|
if (!trimmedRaw.trim()) {
|
|
return { text: "" };
|
|
}
|
|
|
|
const media: string[] = [];
|
|
let foundMediaToken = false;
|
|
|
|
// Parse fenced code blocks to avoid extracting MEDIA tokens from inside them
|
|
const fenceSpans = parseFenceSpans(trimmedRaw);
|
|
|
|
// Collect tokens line by line so we can strip them cleanly.
|
|
const lines = trimmedRaw.split("\n");
|
|
const keptLines: string[] = [];
|
|
|
|
let lineOffset = 0; // Track character offset for fence checking
|
|
for (const line of lines) {
|
|
// Skip MEDIA extraction if this line is inside a fenced code block
|
|
if (isInsideFence(fenceSpans, lineOffset)) {
|
|
keptLines.push(line);
|
|
lineOffset += line.length + 1; // +1 for newline
|
|
continue;
|
|
}
|
|
|
|
const trimmedStart = line.trimStart();
|
|
if (!trimmedStart.startsWith("MEDIA:")) {
|
|
keptLines.push(line);
|
|
lineOffset += line.length + 1; // +1 for newline
|
|
continue;
|
|
}
|
|
|
|
const matches = Array.from(line.matchAll(MEDIA_TOKEN_RE));
|
|
if (matches.length === 0) {
|
|
keptLines.push(line);
|
|
lineOffset += line.length + 1; // +1 for newline
|
|
continue;
|
|
}
|
|
|
|
const pieces: string[] = [];
|
|
let cursor = 0;
|
|
|
|
for (const match of matches) {
|
|
const start = match.index ?? 0;
|
|
pieces.push(line.slice(cursor, start));
|
|
|
|
const payload = match[1];
|
|
const unwrapped = unwrapQuoted(payload);
|
|
const payloadValue = unwrapped ?? payload;
|
|
const parts = unwrapped ? [unwrapped] : payload.split(/\s+/).filter(Boolean);
|
|
const mediaStartIndex = media.length;
|
|
let validCount = 0;
|
|
const invalidParts: string[] = [];
|
|
let hasValidMedia = false;
|
|
for (const part of parts) {
|
|
const candidate = normalizeMediaSource(cleanCandidate(part));
|
|
if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) {
|
|
media.push(candidate);
|
|
hasValidMedia = true;
|
|
foundMediaToken = true;
|
|
validCount += 1;
|
|
} else {
|
|
invalidParts.push(part);
|
|
}
|
|
}
|
|
|
|
const trimmedPayload = payloadValue.trim();
|
|
const looksLikeLocalPath =
|
|
trimmedPayload.startsWith("/") ||
|
|
trimmedPayload.startsWith("./") ||
|
|
trimmedPayload.startsWith("../") ||
|
|
trimmedPayload.startsWith("~") ||
|
|
trimmedPayload.startsWith("file://");
|
|
if (
|
|
!unwrapped &&
|
|
validCount === 1 &&
|
|
invalidParts.length > 0 &&
|
|
/\s/.test(payloadValue) &&
|
|
looksLikeLocalPath
|
|
) {
|
|
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
|
|
if (isValidMedia(fallback, { allowSpaces: true })) {
|
|
media.splice(mediaStartIndex, media.length - mediaStartIndex, fallback);
|
|
hasValidMedia = true;
|
|
foundMediaToken = true;
|
|
validCount = 1;
|
|
invalidParts.length = 0;
|
|
}
|
|
}
|
|
|
|
if (!hasValidMedia) {
|
|
const fallback = normalizeMediaSource(cleanCandidate(payloadValue));
|
|
if (isValidMedia(fallback, { allowSpaces: true })) {
|
|
media.push(fallback);
|
|
hasValidMedia = true;
|
|
foundMediaToken = true;
|
|
invalidParts.length = 0;
|
|
}
|
|
}
|
|
|
|
if (hasValidMedia) {
|
|
if (invalidParts.length > 0) {
|
|
pieces.push(invalidParts.join(" "));
|
|
}
|
|
} else {
|
|
// If no valid media was found in this match, keep the original token text.
|
|
pieces.push(match[0]);
|
|
}
|
|
|
|
cursor = start + match[0].length;
|
|
}
|
|
|
|
pieces.push(line.slice(cursor));
|
|
|
|
const cleanedLine = pieces
|
|
.join("")
|
|
.replace(/[ \t]{2,}/g, " ")
|
|
.trim();
|
|
|
|
// If the line becomes empty, drop it.
|
|
if (cleanedLine) {
|
|
keptLines.push(cleanedLine);
|
|
}
|
|
lineOffset += line.length + 1; // +1 for newline
|
|
}
|
|
|
|
let cleanedText = keptLines
|
|
.join("\n")
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/[ \t]{2,}/g, " ")
|
|
.replace(/\n{2,}/g, "\n")
|
|
.trim();
|
|
|
|
// Detect and strip [[audio_as_voice]] tag
|
|
const audioTagResult = parseAudioTag(cleanedText);
|
|
const hasAudioAsVoice = audioTagResult.audioAsVoice;
|
|
if (audioTagResult.hadTag) {
|
|
cleanedText = audioTagResult.text.replace(/\n{2,}/g, "\n").trim();
|
|
}
|
|
|
|
if (media.length === 0) {
|
|
const result: ReturnType<typeof splitMediaFromOutput> = {
|
|
// Return cleaned text if we found a media token OR audio tag, otherwise original
|
|
text: foundMediaToken || hasAudioAsVoice ? cleanedText : trimmedRaw,
|
|
};
|
|
if (hasAudioAsVoice) {
|
|
result.audioAsVoice = true;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return {
|
|
text: cleanedText,
|
|
mediaUrls: media,
|
|
mediaUrl: media[0],
|
|
...(hasAudioAsVoice ? { audioAsVoice: true } : {}),
|
|
};
|
|
}
|