Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions src/officecli/CommandBuilder.Goto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,39 @@ static partial class CommandBuilder
// a separate top-level command that talks to watch over the named
// pipe (CONSISTENCY(watch-runtime-cmd)).
//
// Word: path like /body/p[5] or /body/table[2] — resolves via
// WatchMessage.ExtractWordScrollTarget. PPT/Excel: not yet wired in
// (anchor coverage is the gap, not the command itself).
// Paths are resolved by the watch server against its cached rendered
// HTML. Server-issued mark ids target viewer-only highlighted fragments.

private static Command BuildGotoCommand(Option<bool> jsonOption, string name = "goto")
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path (.docx)" };
var pathArg = new Argument<string>("path") { Description = "Element path to scroll to (e.g. /body/p[5], /body/table[1], /body/table[1]/tr[2]/tc[3])" };
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var pathArg = new Argument<string?>("path")
{
Description = "Element path to center (Word, Excel, or PowerPoint)",
Arity = ArgumentArity.ZeroOrOne,
};
var markIdOpt = new Option<string?>("--mark-id")
{
Description = "Server-issued viewer mark id to center",
};

var cmd = new Command(name,
"Scroll the running watch viewer(s) to the given element. Path resolves to an HTML anchor; broadcast to all SSE clients of the file. Word: paragraph, table, table row, table cell.");
"Center a document path or viewer mark in the running watch viewer(s). Broadcast to all SSE clients of the file.");
cmd.Add(fileArg);
cmd.Add(pathArg);
cmd.Add(markIdOpt);
cmd.Add(jsonOption);

cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
var path = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(pathArg)!)!;

var selector = WatchMessage.ExtractWordScrollTarget(path);
if (selector == null)
var path = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(pathArg));
var markId = result.GetValue(markIdOpt)?.Trim();
var hasPath = !string.IsNullOrWhiteSpace(path);
var hasMark = !string.IsNullOrWhiteSpace(markId);
if (hasPath == hasMark)
{
var err = $"Cannot resolve scroll target for path '{path}'. Supported: /body/p[N], /body/paragraph[N], /body/table[N], /body/table[N]/tr[R], /body/table[N]/tr[R]/tc[C].";
var err = "Specify exactly one document path or --mark-id.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 2;
Expand All @@ -53,14 +62,30 @@ private static Command BuildGotoCommand(Option<bool> jsonOption, string name = "
return 1;
}

// BUG-BT-R33-3: validate the selector against the watch server's
// cached HTML snapshot before reporting success. Previously goto
// exited 0 even when the anchor didn't exist (e.g. /body/p[99] in
// a 4-paragraph doc).
var scroll = WatchNotifier.TryScroll(file.FullName, selector);
ScrollResult scroll;
string target;
if (hasMark)
{
scroll = WatchNotifier.TryScrollMark(file.FullName, markId!);
target = $"mark {markId}";
}
else
{
var normalizedPath = path!.Trim();
// Preserve legacy Word paragraph/table aliases and anchors.
var legacySelector = file.Extension.Equals(
".docx",
StringComparison.OrdinalIgnoreCase)
? WatchMessage.ExtractWordScrollTarget(normalizedPath)
: null;
scroll = legacySelector != null
? WatchNotifier.TryScroll(file.FullName, legacySelector)
: WatchNotifier.TryScrollPath(file.FullName, normalizedPath);
target = normalizedPath;
}
if (scroll.Kind == ScrollResult.K.NotFound)
{
var err = $"Cannot scroll to '{path}': {scroll.Error}.";
var err = $"Cannot scroll to '{target}': {scroll.Error}.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
Expand All @@ -73,7 +98,7 @@ private static Command BuildGotoCommand(Option<bool> jsonOption, string name = "
return 1;
}

var msg = $"Scrolled watcher(s) to {path} ({selector})";
var msg = $"Scrolled watcher(s) to {target}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
else Console.WriteLine(msg);
return 0;
Expand Down
23 changes: 11 additions & 12 deletions src/officecli/CommandBuilder.Mark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,38 +241,37 @@ private static Command BuildUnmarkMarkCommand(Option<bool> jsonOption, string na
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var pathOpt = new Option<string?>("--path") { Description = "Element path to unmark" };
var idOpt = new Option<string?>("--id") { Description = "Exact server-issued mark id to unmark" };
var allOpt = new Option<bool>("--all") { Description = "Remove all marks for this file" };

var cmd = new Command(name,
"Remove marks from the watch process. Specify --path <data-path> or --all.");
"Remove marks from the watch process. Specify --id, --path, or --all.");
cmd.Add(fileArg);
cmd.Add(pathOpt);
cmd.Add(idOpt);
cmd.Add(allOpt);
cmd.Add(jsonOption);

cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
var pathVal = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(pathOpt));
var idVal = result.GetValue(idOpt)?.Trim();
var allVal = result.GetValue(allOpt);

// Require explicit choice — never silently default
if (allVal && !string.IsNullOrEmpty(pathVal))
// Require exactly one explicit choice — never silently default.
var choices = (allVal ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(pathVal) ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(idVal) ? 1 : 0);
if (choices != 1)
{
var err = "Specify either --path or --all, not both.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 2;
}
if (!allVal && string.IsNullOrEmpty(pathVal))
{
var err = "Must specify either --path <p> or --all.";
var err = "Specify exactly one of --id <id>, --path <path>, or --all.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 2;
}

var req = new UnmarkRequest { Path = pathVal, All = allVal };
var req = new UnmarkRequest { Id = idVal, Path = pathVal, All = allVal };
var removed = WatchNotifier.RemoveMarks(file.FullName, req);
if (removed == null)
{
Expand Down
3 changes: 3 additions & 0 deletions src/officecli/Core/Watch/WatchMark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ internal class MarkRequest
/// <summary>Request payload for the "unmark" pipe command.</summary>
internal class UnmarkRequest
{
[JsonPropertyName("id")]
public string? Id { get; set; }

[JsonPropertyName("path")]
public string? Path { get; set; }

Expand Down
69 changes: 69 additions & 0 deletions src/officecli/Core/Watch/WatchNotifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,75 @@ public static ScrollResult TryScroll(string filePath, string selector)
}
}

/// <summary>
/// Send a validated document data-path to the watch server. The path is
/// base64-wrapped for the one-line pipe protocol and is never interpreted
/// as CSS. The server resolves it against its cached HTML snapshot.
/// </summary>
public static ScrollResult TryScrollPath(string filePath, string path)
{
if (string.IsNullOrWhiteSpace(path)
|| path.Length > 4096
|| !path.StartsWith("/", StringComparison.Ordinal)
|| path.Any(char.IsControl))
return ScrollResult.NotFound("invalid document path");

var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(path));
return TryRuntimeScroll(filePath, "scroll-path " + encoded);
}

/// <summary>
/// Navigate to a server-issued viewer mark without accepting a selector
/// from the caller.
/// </summary>
public static ScrollResult TryScrollMark(string filePath, string markId)
{
if (string.IsNullOrWhiteSpace(markId)
|| markId.Length > 20
|| !markId.All(char.IsDigit))
return ScrollResult.NotFound("invalid mark id");
return TryRuntimeScroll(filePath, "scroll-mark " + markId);
}

private static ScrollResult TryRuntimeScroll(string filePath, string message)
{
try
{
ScrollResult result = ScrollResult.NoWatch();
RunWithTimeout(() =>
{
var pipeName = WatchServer.GetWatchPipeName(filePath);
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut);
client.Connect(200);

var noBom = new UTF8Encoding(false);
using var writer = new StreamWriter(client, noBom, leaveOpen: true) { AutoFlush = true };
writer.WriteLine(message);
writer.Flush();

using var reader = new StreamReader(
client,
noBom,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
var resp = reader.ReadLine();
if (string.IsNullOrEmpty(resp)) { result = ScrollResult.NoWatch(); return; }
if (resp == "ok") { result = ScrollResult.Ok(); return; }
if (resp.StartsWith("err:", StringComparison.Ordinal))
{
result = ScrollResult.NotFound(resp.Substring(4));
return;
}
result = ScrollResult.NoWatch();
}, PipeTimeout);
return result;
}
catch
{
return ScrollResult.NoWatch();
}
}

/// <summary>
/// Query the running watch process for the current selection.
/// Returns:
Expand Down
112 changes: 108 additions & 4 deletions src/officecli/Core/Watch/WatchServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -609,11 +609,98 @@ private async Task HandleSinglePipeClientAsync(System.IO.Pipes.NamedPipeServerSt
}
else if (message != null && message.StartsWith("unmark ", StringComparison.Ordinal))
{
// "unmark <json>" — remove marks by path or all
// "unmark <json>" — remove marks by id, path, or all
var payload = message.Substring(7);
var resp = HandleMarkRemove(payload);
await writer.WriteLineAsync(resp.AsMemory(), token);
}
else if (message != null && message.StartsWith("scroll-mark ", StringComparison.Ordinal))
{
// Validate a server-issued mark id before constructing the
// fixed-form viewer selector.
var markId = message.Substring(12).Trim();
WatchMark? mark;
lock (_marksLock)
{
mark = _currentMarks.FirstOrDefault(m =>
string.Equals(m.Id, markId, StringComparison.Ordinal));
}
if (mark == null || mark.Stale)
{
await writer.WriteLineAsync(
("err:mark not found or stale: " + markId).AsMemory(),
token);
}
else
{
await writer.WriteLineAsync("ok".AsMemory(), token);
SendSseEvent(
"scroll",
0,
null,
$"[data-mark-id=\"{markId}\"]",
_version);
}
}
else if (message != null && message.StartsWith("scroll-path ", StringComparison.Ordinal))
{
// A path-aware channel avoids accepting arbitrary CSS.
// Resolve only against the already-cached HTML snapshot.
string path;
try
{
path = Encoding.UTF8.GetString(
Convert.FromBase64String(message.Substring(12)));
}
catch
{
path = "";
}
var validPath = path.Length is > 0 and <= 4096
&& path.StartsWith("/", StringComparison.Ordinal)
&& !path.Any(char.IsControl);
var slideMatch = validPath
? System.Text.RegularExpressions.Regex.Match(
path, @"^/slide\[([1-9]\d{0,5})\]$")
: System.Text.RegularExpressions.Match.Empty;
if (slideMatch.Success)
{
var slide = slideMatch.Groups[1].Value;
var slideMarker = $"data-slide=\"{slide}\"";
if (_currentHtml.IndexOf(slideMarker, StringComparison.Ordinal) < 0)
{
await writer.WriteLineAsync(
("err:path not found in current HTML: " + path).AsMemory(),
token);
}
else
{
await writer.WriteLineAsync("ok".AsMemory(), token);
SendSseEvent(
"scroll",
0,
null,
$".main > .slide-container[data-slide=\"{slide}\"]",
_version);
}
}
else if (!validPath || FindDataPathInHtml(_currentHtml, path) == null)
{
await writer.WriteLineAsync(
("err:path not found in current HTML: " + path).AsMemory(),
token);
}
else
{
await writer.WriteLineAsync("ok".AsMemory(), token);
SendSseEvent(
"scroll",
0,
null,
scrollPath: path,
version: _version);
}
}
else if (message != null && message.StartsWith("scroll ", StringComparison.Ordinal))
{
// "scroll <selector>" — validate the CSS selector against
Expand Down Expand Up @@ -842,8 +929,8 @@ internal string HandleMarkAdd(string json)
}

/// <summary>
/// Remove marks. UnmarkRequest must have either Path set, or All=true,
/// not both. Returns the number of marks removed.
/// Remove marks. UnmarkRequest must have exactly one of Id, Path, or
/// All=true. Returns the number of marks removed.
/// </summary>
internal string HandleMarkRemove(string json)
{
Expand All @@ -861,6 +948,12 @@ internal string HandleMarkRemove(string json)
removed = _currentMarks.Count;
_currentMarks.Clear();
}
else if (!string.IsNullOrWhiteSpace(req.Id))
{
var unmarkId = req.Id.Trim();
removed = _currentMarks.RemoveAll(m =>
string.Equals(m.Id, unmarkId, StringComparison.Ordinal));
}
else
{
// BUG-FUZZER-003/004: Trim and require leading '/' for symmetry
Expand Down Expand Up @@ -1796,7 +1889,13 @@ private void SendSseExcelPatch(List<(string Op, string Row, string? Html)> patch
BroadcastSse(sb.ToString());
}

private void SendSseEvent(string action, int slideNum, string? html, string? scrollTo = null, int version = 0)
private void SendSseEvent(
string action,
int slideNum,
string? html,
string? scrollTo = null,
int version = 0,
string? scrollPath = null)
{
// Build JSON manually to avoid dependency
var sb = new StringBuilder();
Expand All @@ -1813,6 +1912,11 @@ private void SendSseEvent(string action, int slideNum, string? html, string? scr
sb.Append(",\"scrollTo\":");
AppendJsonString(sb, scrollTo);
}
if (scrollPath != null)
{
sb.Append(",\"scrollPath\":");
AppendJsonString(sb, scrollPath);
}
sb.Append('}');

BroadcastSse(sb.ToString());
Expand Down
3 changes: 3 additions & 0 deletions src/officecli/Handlers/Word/WordHandler.HtmlPreview.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1355,6 +1355,9 @@ function renderNewContent(){
else{_t=document.querySelector(_sel);if(!_t){var _ap=document.querySelectorAll('.page');_t=_ap[_ap.length-1];}}
if(_t)_t.scrollIntoView({behavior:_beh,block:'center'});
}
else if(typeof window._watchRestorePendingViewport==='function'){
window._watchRestorePendingViewport();
}
var _frz=document.getElementById('_sse_freeze');
if(_frz)_frz.remove();
}
Expand Down
Loading