mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
fix(media): preserve scanner prose ownership
This commit is contained in:
committed by
nesquena-hermes
parent
e13a75b7b1
commit
946f7dbc2b
+70
-29
@@ -4261,6 +4261,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const parserFor = (data)=>{
|
||||
return _smdParserKey(data, el);
|
||||
};
|
||||
const writeFadeText=(writeParent, writeData, writeText)=>{
|
||||
if(!writeParent||_streamFadeSkipNode(writeParent)){
|
||||
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
|
||||
return;
|
||||
}
|
||||
_streamFadeAppendText(writeParent, writeText);
|
||||
};
|
||||
renderer.add_text=(data,text)=>{
|
||||
const parent=data&&data.nodes&&data.nodes[data.index];
|
||||
if(!parent||_streamFadeSkipNode(parent)){baseAddText(data,text);return;}
|
||||
@@ -4273,7 +4280,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const value=String(text||'');
|
||||
const hasMediaPrefixTail=!!_smdMediaPrefixTail(value);
|
||||
if(/MEDIA:/.test(value)||hasMediaTail||hasMediaPrefixTail){
|
||||
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser);
|
||||
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText);
|
||||
return;
|
||||
}
|
||||
const frag=document.createDocumentFragment();
|
||||
@@ -4338,8 +4345,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
// Without this, streamed prose shows MEDIA:C:\... as literal text until the
|
||||
// turn settles and the full re-render swaps it for the real <img>.
|
||||
// SAFETY & CROSS-CHUNK SPLITS (Greptile #1 + #2):
|
||||
// 1. Prose slices go back to baseAddText (which calls createTextNode),
|
||||
// NOT through DOMParser — mixed prose with HTML entities /
|
||||
// 1. Prose slices go back to the owning text writer (text nodes or
|
||||
// fade spans), NOT through DOMParser — mixed prose with HTML entities /
|
||||
// malicious <img onerror> stays as literal text.
|
||||
// 2. Each MEDIA token's HTML (from _inlineMediaHtmlForRef) is handed
|
||||
// to DOMParser one at a time — only trusted markup is parsed.
|
||||
@@ -4357,57 +4364,88 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data){
|
||||
if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data});
|
||||
function _smdAppendPlainText(parent, data, text, baseAddText){
|
||||
const value=String(text||'');
|
||||
if(parent&&parent.appendChild&&typeof document!=='undefined'&&document.createTextNode){
|
||||
parent.appendChild(document.createTextNode(value));
|
||||
return;
|
||||
}
|
||||
if(baseAddText) baseAddText(data,value);
|
||||
}
|
||||
function _smdMediaWriteText(parent, data, baseAddText, writeText, text){
|
||||
if(writeText){
|
||||
writeText(parent, data, String(text||''));
|
||||
return;
|
||||
}
|
||||
if(baseAddText) baseAddText(data,String(text||''));
|
||||
}
|
||||
function _smdMediaTailSet(tailMap, parser, chunk, parent, baseAddText, data, writeText){
|
||||
if(!tailMap||!parser) return;
|
||||
if(chunk) tailMap.set(parser, {chunk, parent, baseAddText, data, writeText});
|
||||
else tailMap.delete(parser);
|
||||
}
|
||||
function _smdMediaTailEntryChunk(entry){
|
||||
return entry && typeof entry==='object' && Object.prototype.hasOwnProperty.call(entry,'chunk') ? entry.chunk : entry;
|
||||
}
|
||||
function _smdMediaTailSameOwner(entry, parent, baseAddText, writeText){
|
||||
return !!entry && entry.parent===parent && entry.baseAddText===baseAddText && entry.writeText===writeText;
|
||||
}
|
||||
function _smdMediaRefHasReliableBoundary(rawRef){
|
||||
const raw=String(rawRef||'');
|
||||
if(/[?#]$/.test(raw)) return false;
|
||||
const ref=raw.split(/[?#]/,1)[0];
|
||||
return /\.(?:png|jpe?g|gif|webp|bmp|ico|svg|avif|mp4|webm|mov|m4v|mkv|avi|ogv|mp3|wav|ogg|m4a|aac|wma|opus|flac|oga|pdf|html?|csv|diff|patch|excalidraw)$/i.test(ref);
|
||||
}
|
||||
function _smdMediaTailFlushEntry(entry){
|
||||
const chunk=_smdMediaTailEntryChunk(entry);
|
||||
if(!chunk) return;
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
|
||||
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
|
||||
if(!emitted && entry) _smdMediaWriteText(entry.parent, entry.data, entry.baseAddText, entry.writeText, chunk);
|
||||
}
|
||||
function _smdMediaTailFlush(parser){
|
||||
if(!_SMD_MEDIA_TAIL||!parser||!_SMD_MEDIA_TAIL.get) return;
|
||||
const entry=_SMD_MEDIA_TAIL.get(parser);
|
||||
const chunk=_smdMediaTailEntryChunk(entry);
|
||||
if(!chunk) return;
|
||||
if(!entry) return;
|
||||
_SMD_MEDIA_TAIL.delete(parser);
|
||||
const m=/^MEDIA:([^\s\)\]]+)$/.exec(String(chunk));
|
||||
const emitted=!!(m && entry && entry.parent && _smdAppendMediaNode(entry.parent, m[1]));
|
||||
if(!emitted && entry && entry.baseAddText) entry.baseAddText(entry.data, chunk);
|
||||
_smdMediaTailFlushEntry(entry);
|
||||
}
|
||||
function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser){
|
||||
function _smdMediaAwareAddText(baseAddText, parent, data, text, tailMap, parser, writeText){
|
||||
const value=String(text||'');
|
||||
const tails=tailMap||(typeof _SMD_MEDIA_TAIL!=='undefined'&&_SMD_MEDIA_TAIL)||null;
|
||||
const writeCurrent=(chunk)=>_smdMediaWriteText(parent, data, baseAddText, writeText, chunk);
|
||||
if(!value){
|
||||
if(baseAddText) baseAddText(data,'');
|
||||
writeCurrent('');
|
||||
return;
|
||||
}
|
||||
// Pull any pending tail from a previous (split) chunk, then clear it;
|
||||
// this call will either complete it, re-buffer it, or flush it as text.
|
||||
const leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
|
||||
const lead = _smdMediaTailEntryChunk(leadEntry);
|
||||
if(lead && tails && parser && tails.delete) tails.delete(parser);
|
||||
let leadEntry = tails && parser && tails.get ? tails.get(parser) : null;
|
||||
let lead = _smdMediaTailEntryChunk(leadEntry);
|
||||
if(lead && !_smdMediaTailSameOwner(leadEntry, parent, baseAddText, writeText)){
|
||||
if(tails && parser && tails.delete) tails.delete(parser);
|
||||
_smdMediaTailFlushEntry(leadEntry);
|
||||
leadEntry=null;
|
||||
lead='';
|
||||
}else if(lead && tails && parser && tails.delete){
|
||||
tails.delete(parser);
|
||||
}
|
||||
const combined = lead ? lead + value : value;
|
||||
// Fast path: no MEDIA tokens in the (possibly combined) string.
|
||||
if(!/MEDIA:/.test(combined)){
|
||||
const prefixTail=_smdMediaPrefixTail(combined);
|
||||
if(prefixTail && tails && parser && prefixTail.length < _MEDIA_TAIL_MAX){
|
||||
const stable=combined.slice(0, combined.length-prefixTail.length);
|
||||
if(stable && baseAddText) baseAddText(data, stable);
|
||||
_smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data);
|
||||
if(stable) writeCurrent(stable);
|
||||
_smdMediaTailSet(tails, parser, prefixTail, parent, baseAddText, data, writeText);
|
||||
return;
|
||||
}
|
||||
if(baseAddText) baseAddText(data, combined);
|
||||
writeCurrent(combined);
|
||||
return;
|
||||
}
|
||||
// Walk the combined string, slicing into prose + MEDIA token runs.
|
||||
// Prose runs go through baseAddText (text nodes). MEDIA tokens go
|
||||
// through the single-token DOMParser helper only after a delimiter or
|
||||
// Prose runs go through the owning text writer. MEDIA tokens go through
|
||||
// the single-token DOMParser helper only after a delimiter or
|
||||
// reliable filename suffix proves the ref is complete.
|
||||
const re=/MEDIA:([^\s\)\]]+)/g;
|
||||
let last=0, m;
|
||||
@@ -4416,19 +4454,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const matchEnd = m.index + m[0].length;
|
||||
if(m.index>last){
|
||||
const slice = combined.slice(last, m.index);
|
||||
if(baseAddText) baseAddText(data, slice);
|
||||
writeCurrent(slice);
|
||||
}
|
||||
if(matchEnd===combined.length && !_smdMediaRefHasReliableBoundary(m[1])){
|
||||
const candidate = combined.slice(m.index);
|
||||
if(candidate.length < _MEDIA_TAIL_MAX){
|
||||
unmatchedTail = candidate;
|
||||
} else if(baseAddText){
|
||||
baseAddText(data, candidate);
|
||||
} else {
|
||||
writeCurrent(candidate);
|
||||
}
|
||||
last = combined.length;
|
||||
break;
|
||||
}
|
||||
if(!_smdAppendMediaNode(parent, m[1]) && baseAddText) baseAddText(data, m[0]);
|
||||
if(!_smdAppendMediaNode(parent, m[1])) writeCurrent(m[0]);
|
||||
last = matchEnd;
|
||||
}
|
||||
// Tail buffer — hold trailing bytes that look like an unterminated
|
||||
@@ -4440,14 +4478,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const tailValue = tailMatch ? tailMatch[0] : prefixTail;
|
||||
if(tailValue && rest.length < _MEDIA_TAIL_MAX){
|
||||
const tailStart = tailMatch ? tailMatch.index : rest.length-prefixTail.length;
|
||||
if(tailStart>0 && baseAddText) baseAddText(data, rest.slice(0, tailStart));
|
||||
if(tailStart>0) writeCurrent(rest.slice(0, tailStart));
|
||||
unmatchedTail = tailValue;
|
||||
} else if(baseAddText){
|
||||
baseAddText(data, rest);
|
||||
} else {
|
||||
writeCurrent(rest);
|
||||
}
|
||||
}
|
||||
if(tails && parser){
|
||||
_smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data);
|
||||
_smdMediaTailSet(tails, parser, unmatchedTail, parent, baseAddText, data, writeText);
|
||||
}
|
||||
}
|
||||
// Single-token DOM splice. Only ever fed the output of
|
||||
@@ -4516,12 +4554,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
const renderer=window.smd.default_renderer(el);
|
||||
const baseSetAttr=renderer.set_attr;
|
||||
const baseAddText=renderer.add_text;
|
||||
const writePlainText=(writeParent, writeData, writeText)=>{
|
||||
_smdAppendPlainText(writeParent, writeData, writeText, baseAddText);
|
||||
};
|
||||
const parserFor = (data)=>{
|
||||
return _smdParserKey(data, el);
|
||||
};
|
||||
renderer.add_text=(data,text)=>{
|
||||
const parent=data&&data.nodes&&data.nodes[data.index];
|
||||
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data));
|
||||
_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parserFor(data), writePlainText);
|
||||
};
|
||||
renderer.set_attr=(data,attr,value)=>{
|
||||
const isHref=window.smd&&attr===window.smd.HREF;
|
||||
|
||||
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
@@ -43,8 +44,11 @@ NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _extract_js_function(src: str, name: str) -> str:
|
||||
start = src.index(f"function {name}")
|
||||
brace = src.index("{", start)
|
||||
match = re.search(rf"function\s+{re.escape(name)}\s*\(", src)
|
||||
if not match:
|
||||
raise ValueError(f"Could not find JS function {name}")
|
||||
start = match.start()
|
||||
brace = src.index("{", match.end())
|
||||
depth = 1
|
||||
pos = brace + 1
|
||||
while pos < len(src) and depth:
|
||||
@@ -60,9 +64,13 @@ def _run_real_smd_media_cases() -> dict:
|
||||
helpers = "\n".join(
|
||||
[
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaPrefixTail"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdAppendPlainText"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaWriteText"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailSet"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailEntryChunk"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailSameOwner"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaRefHasReliableBoundary"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlushEntry"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaTailFlush"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdMediaAwareAddText"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdAppendMediaNode"),
|
||||
@@ -73,6 +81,7 @@ def _run_real_smd_media_cases() -> dict:
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeSkipNode"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeReduceMotionEnabled"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeBindCleanup"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeAppendText"),
|
||||
_extract_js_function(MESSAGES_JS, "_streamFadeRenderer"),
|
||||
_extract_js_function(MESSAGES_JS, "_safeSmdRenderer"),
|
||||
_extract_js_function(MESSAGES_JS, "_smdRendererWithoutUnderscoreEmphasis"),
|
||||
@@ -113,6 +122,7 @@ def _run_real_smd_media_cases() -> dict:
|
||||
" }\n"
|
||||
" get childNodes(){ return this.children; }\n"
|
||||
" get firstChild(){ return this.children[0] || null; }\n"
|
||||
" get parentElement(){ return this.parentNode; }\n"
|
||||
" get className(){ return this.attributes.class || ''; }\n"
|
||||
" set className(value){ this.attributes.class=String(value); }\n"
|
||||
" appendChild(child){\n"
|
||||
@@ -131,6 +141,12 @@ def _run_real_smd_media_cases() -> dict:
|
||||
" visit(this); return out;\n"
|
||||
" }\n"
|
||||
" get textContent(){ return this.nodeType===3 ? this.data : this.children.map(c=>c.textContent).join(''); }\n"
|
||||
" set textContent(value){\n"
|
||||
" if(this.nodeType===3){ this.data=String(value); return; }\n"
|
||||
" this.children=[];\n"
|
||||
" const text=String(value);\n"
|
||||
" if(text) this.appendChild(document.createTextNode(text));\n"
|
||||
" }\n"
|
||||
" get outerHTML(){\n"
|
||||
" if(this.nodeType===3) return esc(this.data);\n"
|
||||
" if(this.nodeType===11) return this.children.map(c=>c.outerHTML).join('');\n"
|
||||
@@ -154,6 +170,12 @@ def _run_real_smd_media_cases() -> dict:
|
||||
" return { body: { firstChild: host } };\n"
|
||||
"} };\n"
|
||||
f"{helpers}\n"
|
||||
"function collectTagTexts(root, tag){\n"
|
||||
" const out=[]; const wanted=String(tag).toLowerCase();\n"
|
||||
" const visit=node=>{ for(const child of node.children){ if(String(child.tagName||'').toLowerCase()===wanted) out.push(child.textContent); visit(child); } };\n"
|
||||
" visit(root); return out;\n"
|
||||
"}\n"
|
||||
"function collectClassTexts(root, cls){ return root.querySelectorAll('.'+cls).map(node=>node.textContent); }\n"
|
||||
"function renderChunks(chunks, mode){\n"
|
||||
" postProcessCalls = 0; playbackCalls = 0;\n"
|
||||
" const root=document.createElement('div');\n"
|
||||
@@ -165,7 +187,7 @@ def _run_real_smd_media_cases() -> dict:
|
||||
" smd.parser_end(parser);\n"
|
||||
" _smdMediaTailFlush(parser);\n"
|
||||
" _smdMediaTailClear(parser);\n"
|
||||
" return { html: root.outerHTML, text: root.textContent, postProcessCalls, playbackCalls };\n"
|
||||
" return { html: root.outerHTML, text: root.textContent, liTexts: collectTagTexts(root, 'li'), fadeWords: collectClassTexts(root, 'stream-fade-word'), postProcessCalls, playbackCalls };\n"
|
||||
"}\n"
|
||||
"function renderModes(chunks){ return { safe: renderChunks(chunks, 'safe'), fade: renderChunks(chunks, 'fade') }; }\n"
|
||||
"const marker='MEDIA:';\n"
|
||||
@@ -174,7 +196,9 @@ def _run_real_smd_media_cases() -> dict:
|
||||
"const refSplit=renderModes(['MEDIA:C:/tmp/li', 've.png ']);\n"
|
||||
"const finalExtensionless=renderModes(['MEDIA:https://fal.media/generated']);\n"
|
||||
"const pdf=renderModes(['MEDIA:C:/tmp/report.pdf ']);\n"
|
||||
"console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf}));\n"
|
||||
"const falsePrefix=renderModes(['M', 'aybe plain prose ']);\n"
|
||||
"const crossParent=renderModes(['- ME', '\\n- ow']);\n"
|
||||
"console.log(JSON.stringify({prefixSplits, refSplit, finalExtensionless, pdf, falsePrefix, crossParent}));\n"
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[NODE, "--input-type=module", "-e", script],
|
||||
@@ -272,16 +296,14 @@ class TestSmdMediaInStream(unittest.TestCase):
|
||||
|
||||
def test_media_interceptor_falls_back_to_base_when_no_token(self):
|
||||
# Fast path: when the chunk + buffered tail carries no MEDIA token,
|
||||
# the wrapper should delegate to the underlying add_text unchanged
|
||||
# so the fade renderer's word-by-word animation is preserved for
|
||||
# plain prose.
|
||||
# the wrapper should delegate to the injected text writer so the
|
||||
# owning renderer's semantics (plain text for safe mode, word fade for
|
||||
# fade mode) survive for plain prose.
|
||||
idx = MESSAGES_JS.index("function _smdMediaAwareAddText")
|
||||
block = MESSAGES_JS[idx:idx + 6500]
|
||||
self.assertTrue(
|
||||
"baseAddText(data,value)" in block or "baseAddText(data," in block,
|
||||
"Interceptor must delegate to baseAddText for plain text so the "
|
||||
"underlying renderer's semantics survive",
|
||||
)
|
||||
self.assertIn("const writeCurrent=", block)
|
||||
self.assertIn("writeCurrent(combined)", block)
|
||||
self.assertIn("function _smdMediaWriteText", MESSAGES_JS)
|
||||
|
||||
def test_no_recursive_infinite_loop_via_baseAddText(self):
|
||||
# Regression guard: the fade renderer's add_text is itself a wrapper.
|
||||
@@ -418,6 +440,29 @@ class TestSmdMediaInStream(unittest.TestCase):
|
||||
"concurrent streams don't cross-pollinate",
|
||||
)
|
||||
|
||||
def test_tail_entries_preserve_original_text_owner(self):
|
||||
# A buffered MEDIA-looking suffix belongs to the parent/writer that
|
||||
# produced it. If the next smd add_text callback is for a different
|
||||
# parent, the scanner must flush through the original owner instead
|
||||
# of concatenating across DOM nodes.
|
||||
idx = MESSAGES_JS.index("function _smdMediaTailSet")
|
||||
block = MESSAGES_JS[idx:idx + 3500]
|
||||
self.assertIn("writeText", block)
|
||||
self.assertIn("function _smdMediaTailSameOwner", MESSAGES_JS)
|
||||
self.assertIn("entry.parent===parent", MESSAGES_JS)
|
||||
self.assertIn("entry.writeText===writeText", MESSAGES_JS)
|
||||
self.assertIn("_smdMediaTailFlushEntry(leadEntry)", MESSAGES_JS)
|
||||
|
||||
def test_stream_fade_media_scanner_preserves_plain_prose_fade(self):
|
||||
# False MEDIA-prefix tails (for example "M" + "aybe") still enter
|
||||
# the scanner. Those plain prose writes must use the non-recursive
|
||||
# fade appender, not the raw default_renderer text writer.
|
||||
idx = MESSAGES_JS.index("function _streamFadeRenderer")
|
||||
block = MESSAGES_JS[idx:idx + 7000]
|
||||
self.assertIn("const writeFadeText=", block)
|
||||
self.assertIn("_streamFadeAppendText(writeParent, writeText)", block)
|
||||
self.assertIn("_smdMediaAwareAddText(baseAddText, parent, data, text, _SMD_MEDIA_TAIL, parser, writeFadeText)", block)
|
||||
|
||||
def test_smd_parser_identity_is_bound_to_real_parser(self):
|
||||
# smd's renderer.data does not expose a parser by default. Bind the
|
||||
# created parser onto both renderer.data and the owning element so every
|
||||
@@ -514,6 +559,20 @@ class TestSmdMediaRealParserBehaviour(unittest.TestCase):
|
||||
self.assertGreaterEqual(result["postProcessCalls"], 1)
|
||||
self.assertGreaterEqual(result["playbackCalls"], 1)
|
||||
|
||||
def test_real_smd_parser_false_prefix_plain_prose_keeps_fade(self):
|
||||
result = self.cases["falsePrefix"]["fade"]
|
||||
self.assertEqual(result["text"], "Maybe plain prose ")
|
||||
self.assertEqual(result["fadeWords"], ["Maybe", "plain", "prose"])
|
||||
self.assertIn('class="stream-fade-word is-new"', result["html"])
|
||||
|
||||
def test_real_smd_parser_refuses_cross_parent_tail_concat(self):
|
||||
for mode, result in self.cases["crossParent"].items():
|
||||
with self.subTest(mode=mode):
|
||||
self.assertEqual(result["liTexts"], ["ME", "ow"])
|
||||
if mode == "fade":
|
||||
self.assertTrue(result["fadeWords"])
|
||||
self.assertEqual("".join(result["fadeWords"]), "MEow")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import unittest
|
||||
|
||||
Reference in New Issue
Block a user