mirror of
https://github.com/iOfficeAI/OfficeCLI
synced 2026-04-21 21:47:43 +00:00
Redirecting the resident child's stdout caused Windows CreateProcess to set bInheritHandles=TRUE, leaking the outer shell's pipe handle into the resident. The shell then blocked for ~60s waiting for EOF on a pipe the resident still owned, even though officecli main had already exited. - Remove RedirectStandardOutput from the resident ProcessStartInfo (stderr redirect is kept for startup failure diagnostics) - Dispose the Process on all exit paths (success/exited/timeout) to release the remaining pipe handle promptly - Wrap resident-side background Console.Error.WriteLine calls in a LogStderr helper that swallows IOException, so the resident does not crash when writing diagnostics to a stderr pipe whose read-end was closed by the parent exiting
1121 lines
50 KiB
C#
1121 lines
50 KiB
C#
// Copyright 2025 OfficeCli (officecli.ai)
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
using System.CommandLine;
|
|
using System.Diagnostics;
|
|
using System.Text;
|
|
using OfficeCli.Core;
|
|
using OfficeCli.Handlers;
|
|
|
|
namespace OfficeCli;
|
|
|
|
static partial class CommandBuilder
|
|
{
|
|
public static RootCommand BuildRootCommand()
|
|
{
|
|
var jsonOption = new Option<bool>("--json") { Description = "Output as JSON (AI-friendly)" };
|
|
|
|
var rootCommand = new RootCommand("""
|
|
officecli: AI-friendly CLI for Office documents (.docx, .xlsx, .pptx)
|
|
|
|
Help navigation (start from the deepest level you know):
|
|
officecli pptx set All settable elements and their properties
|
|
officecli pptx set shape Shape properties in detail
|
|
officecli pptx set shape.fill Specific property format and examples
|
|
|
|
Replace 'pptx' with 'docx' or 'xlsx'. Commands: view, get, query, set, add, raw.
|
|
""");
|
|
rootCommand.Add(jsonOption);
|
|
|
|
// ==================== open command (start resident) ====================
|
|
var openFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
|
|
var openCommand = new Command("open", "Start a resident process to keep the document in memory for faster subsequent commands");
|
|
openCommand.Add(openFileArg);
|
|
openCommand.Add(jsonOption);
|
|
|
|
openCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
|
|
{
|
|
var file = result.GetValue(openFileArg)!;
|
|
var filePath = file.FullName;
|
|
|
|
// If already running, reuse the existing resident. This covers
|
|
// two cases with the same code path:
|
|
// (a) user previously called `open` explicitly, or
|
|
// (b) `create` just auto-started a short-lived (60s) resident.
|
|
// In either case we upgrade the idle timeout to the default 12min
|
|
// via the __set-idle-timeout__ ping RPC. Failure is non-fatal —
|
|
// the resident is still usable, it'll just exit on its original
|
|
// schedule. `open` is idempotent, so repeated calls are safe.
|
|
const int DefaultOpenIdleSeconds = 12 * 60;
|
|
if (ResidentClient.TryConnect(filePath, out _))
|
|
{
|
|
ResidentClient.SendSetIdleTimeout(filePath, DefaultOpenIdleSeconds);
|
|
var msg = $"Opened {file.Name} (reusing running resident, idle timeout set to 12min)";
|
|
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
|
|
else Console.WriteLine(msg);
|
|
return 0;
|
|
}
|
|
|
|
if (!TryStartResidentProcess(filePath, idleSeconds: null, out var startError))
|
|
throw new InvalidOperationException(startError);
|
|
|
|
var startedMsg = $"Opened {file.Name} (remember to call close when done)";
|
|
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(startedMsg));
|
|
else Console.WriteLine(startedMsg);
|
|
return 0;
|
|
}, json); });
|
|
|
|
rootCommand.Add(openCommand);
|
|
|
|
// ==================== close command (stop resident) ====================
|
|
var closeFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
|
|
var closeCommand = new Command("close", "Stop the resident process for the document");
|
|
closeCommand.Add(closeFileArg);
|
|
closeCommand.Add(jsonOption);
|
|
|
|
closeCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
|
|
{
|
|
var file = result.GetValue(closeFileArg)!;
|
|
if (ResidentClient.SendClose(file.FullName))
|
|
{
|
|
var msg = $"Resident closed for {file.Name}";
|
|
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
|
|
else Console.WriteLine(msg);
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException($"No resident running for {file.Name}");
|
|
}
|
|
return 0;
|
|
}, json); });
|
|
|
|
rootCommand.Add(closeCommand);
|
|
|
|
// ==================== __resident-serve__ (internal, hidden) ====================
|
|
var serveFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
|
|
var serveCommand = new Command("__resident-serve__", "Internal: run resident server (do not call directly)");
|
|
serveCommand.Hidden = true;
|
|
serveCommand.Add(serveFileArg);
|
|
|
|
serveCommand.SetAction(result =>
|
|
{
|
|
var file = result.GetValue(serveFileArg)!;
|
|
using var server = new ResidentServer(file.FullName);
|
|
server.RunAsync().GetAwaiter().GetResult();
|
|
});
|
|
|
|
rootCommand.Add(serveCommand);
|
|
|
|
// Register commands from partial files
|
|
rootCommand.Add(BuildWatchCommand());
|
|
rootCommand.Add(BuildUnwatchCommand());
|
|
rootCommand.Add(BuildMarkCommand(jsonOption));
|
|
rootCommand.Add(BuildUnmarkMarkCommand(jsonOption));
|
|
rootCommand.Add(BuildGetMarksCommand(jsonOption));
|
|
rootCommand.Add(BuildViewCommand(jsonOption));
|
|
rootCommand.Add(BuildGetCommand(jsonOption));
|
|
rootCommand.Add(BuildQueryCommand(jsonOption));
|
|
rootCommand.Add(BuildSetCommand(jsonOption));
|
|
rootCommand.Add(BuildAddCommand(jsonOption));
|
|
rootCommand.Add(BuildRemoveCommand(jsonOption));
|
|
rootCommand.Add(BuildMoveCommand(jsonOption));
|
|
rootCommand.Add(BuildSwapCommand(jsonOption));
|
|
rootCommand.Add(BuildRawCommand(jsonOption));
|
|
rootCommand.Add(BuildRawSetCommand(jsonOption));
|
|
rootCommand.Add(BuildAddPartCommand(jsonOption));
|
|
rootCommand.Add(BuildValidateCommand(jsonOption));
|
|
rootCommand.Add(BuildCheckCommand(jsonOption));
|
|
rootCommand.Add(BuildBatchCommand(jsonOption));
|
|
rootCommand.Add(BuildImportCommand(jsonOption));
|
|
rootCommand.Add(BuildCreateCommand(jsonOption));
|
|
rootCommand.Add(BuildMergeCommand(jsonOption));
|
|
|
|
HelpCommands.Register(rootCommand);
|
|
|
|
return rootCommand;
|
|
}
|
|
|
|
// ==================== Helper: fork a __resident-serve__ subprocess ====================
|
|
//
|
|
// Used by both `open` (explicit) and `create` (auto-start after
|
|
// creating a blank file). Forks the current executable with the
|
|
// internal __resident-serve__ verb and waits up to 5s for the ping
|
|
// pipe to respond, so callers get a definitive success/fail answer.
|
|
//
|
|
// `idleSeconds` overrides the child's idle-exit timeout via the
|
|
// OFFICECLI_RESIDENT_IDLE_SECONDS env var (1..86400). Passing null
|
|
// inherits the server default (12 minutes). `create` passes 60 so
|
|
// an auto-started resident that nobody follows up on exits quickly.
|
|
//
|
|
// Caller must first verify no resident is already running for this
|
|
// file (e.g. via ResidentClient.TryConnect) — this helper always
|
|
// starts a fresh child.
|
|
internal static bool TryStartResidentProcess(string filePath, int? idleSeconds, out string? error)
|
|
{
|
|
error = null;
|
|
var exePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName;
|
|
if (exePath == null)
|
|
{
|
|
error = "Cannot determine executable path.";
|
|
return false;
|
|
}
|
|
|
|
var startInfo = new ProcessStartInfo
|
|
{
|
|
FileName = exePath,
|
|
Arguments = $"__resident-serve__ \"{filePath}\"",
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
// Do NOT redirect stdout: on Windows, RedirectStandardOutput
|
|
// causes bInheritHandles=TRUE which leaks the outer shell's
|
|
// pipe handle into the resident, blocking the caller for ~60s
|
|
// until the resident's idle timeout. Stderr is still redirected
|
|
// to capture diagnostics if the resident fails during startup.
|
|
RedirectStandardError = true
|
|
};
|
|
|
|
if (idleSeconds.HasValue)
|
|
startInfo.Environment["OFFICECLI_RESIDENT_IDLE_SECONDS"] = idleSeconds.Value.ToString();
|
|
|
|
var process = Process.Start(startInfo);
|
|
if (process == null)
|
|
{
|
|
error = "Failed to start resident process.";
|
|
return false;
|
|
}
|
|
|
|
// Wait briefly for the server to start accepting connections.
|
|
for (int i = 0; i < 50; i++) // up to 5 seconds
|
|
{
|
|
Thread.Sleep(100);
|
|
if (ResidentClient.TryConnect(filePath, out _))
|
|
{
|
|
process.Dispose();
|
|
return true;
|
|
}
|
|
if (process.HasExited)
|
|
{
|
|
var stderr = process.StandardError.ReadToEnd();
|
|
error = $"Resident process exited. {stderr}";
|
|
process.Dispose();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
error = "Resident process started but not responding.";
|
|
process.Dispose();
|
|
return false;
|
|
}
|
|
|
|
// ==================== Helper: try forwarding to resident ====================
|
|
//
|
|
// Two-step protocol (CONSISTENCY(resident-two-step): same shape as
|
|
// CommandBuilder.Batch.cs's resident branch):
|
|
// 1. Ping-pipe probe via TryConnect — fast (100ms) and isolated from the
|
|
// main command queue, so it stays responsive even under flood. Tells
|
|
// us definitively whether a resident owns this file.
|
|
// 2. If yes, send the command on the main pipe with a generous connect
|
|
// timeout + a few retries. If the send STILL fails, surface a
|
|
// distinct "busy" error (exit code 3) instead of falling back to
|
|
// DocumentHandlerFactory.Open — the old silent fallback could race
|
|
// the live resident and lose writes.
|
|
// 3. If no resident, return null so the caller opens the file directly.
|
|
//
|
|
// Exit code 3 is reserved for "resident is alive but couldn't deliver the
|
|
// command" so callers can distinguish it from a command-level failure.
|
|
private const int ResidentBusyExitCode = 3;
|
|
private const int ResidentBusyConnectTimeoutMs = 30000;
|
|
private const int ResidentBusyMaxRetries = 3;
|
|
|
|
internal static int? TryResident(string filePath, Action<ResidentRequest> configure, bool json = false)
|
|
{
|
|
// Step 1: does a resident own this file? Probe via the -ping pipe,
|
|
// which is never serialized behind main-pipe commands.
|
|
if (!ResidentClient.TryConnect(filePath, out _))
|
|
{
|
|
// No resident running — auto-start one to avoid file-lock conflicts
|
|
// when multiple commands hit the same file in parallel.
|
|
// Opt-out: OFFICECLI_NO_AUTO_RESIDENT=1 disables auto-start (e.g.
|
|
// sandbox environments where named pipes may not work reliably).
|
|
var noAuto = Environment.GetEnvironmentVariable("OFFICECLI_NO_AUTO_RESIDENT");
|
|
if (noAuto == "1" || string.Equals(noAuto, "true", StringComparison.OrdinalIgnoreCase))
|
|
return null;
|
|
|
|
if (!TryStartResidentProcess(filePath, idleSeconds: 60, out _))
|
|
{
|
|
// Startup failed — maybe another process just started a resident
|
|
// for the same file (parallel race). Re-probe before giving up.
|
|
if (!ResidentClient.TryConnect(filePath, out _))
|
|
return null; // truly no resident → caller falls back to direct file access
|
|
}
|
|
}
|
|
|
|
var request = new ResidentRequest();
|
|
configure(request);
|
|
if (json) request.Json = true;
|
|
|
|
// Step 2: resident is confirmed alive — wait for our turn in the main
|
|
// pipe queue. Do NOT silently fall back on failure; letting a second
|
|
// writer touch the file while the resident holds it in memory loses
|
|
// data on the resident's eventual save.
|
|
var response = ResidentClient.TrySend(
|
|
filePath, request,
|
|
maxRetries: ResidentBusyMaxRetries,
|
|
connectTimeoutMs: ResidentBusyConnectTimeoutMs);
|
|
|
|
if (response == null)
|
|
{
|
|
var fileName = Path.GetFileName(filePath);
|
|
var msg = $"Resident for {fileName} is running but the command could not be delivered (main pipe busy or unresponsive). Retry, or run 'officecli close {fileName}' and try again.";
|
|
if (json)
|
|
Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg));
|
|
else
|
|
Console.Error.WriteLine($"Error: {msg}");
|
|
return ResidentBusyExitCode;
|
|
}
|
|
|
|
if (json)
|
|
{
|
|
// JSON mode: resident already built the envelope, just pass through
|
|
if (!string.IsNullOrEmpty(response.Stdout))
|
|
Console.WriteLine(response.Stdout);
|
|
}
|
|
else
|
|
{
|
|
if (!string.IsNullOrEmpty(response.Stdout))
|
|
Console.WriteLine(response.Stdout);
|
|
if (!string.IsNullOrEmpty(response.Stderr))
|
|
Console.Error.WriteLine(response.Stderr);
|
|
}
|
|
|
|
return response.ExitCode;
|
|
}
|
|
|
|
|
|
internal static int SafeRun(Func<int> action, bool json = false)
|
|
{
|
|
if (!OfficeCli.Core.CliLogger.Enabled)
|
|
{
|
|
try
|
|
{
|
|
return action();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
WriteError(ex, json);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Logging enabled: capture stdout/stderr
|
|
var stdoutWriter = new StringWriter();
|
|
var stderrWriter = new StringWriter();
|
|
var origOut = Console.Out;
|
|
var origErr = Console.Error;
|
|
Console.SetOut(new TeeWriter(origOut, stdoutWriter));
|
|
Console.SetError(new TeeWriter(origErr, stderrWriter));
|
|
try
|
|
{
|
|
var code = action();
|
|
var stdout = stdoutWriter.ToString().TrimEnd('\r', '\n');
|
|
OfficeCli.Core.CliLogger.LogOutput(stdout);
|
|
return code;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
WriteError(ex, json);
|
|
var stderr = stderrWriter.ToString().TrimEnd('\r', '\n');
|
|
OfficeCli.Core.CliLogger.LogError(stderr);
|
|
return 1;
|
|
}
|
|
finally
|
|
{
|
|
Console.SetOut(origOut);
|
|
Console.SetError(origErr);
|
|
}
|
|
}
|
|
|
|
private static void WriteError(Exception ex, bool json)
|
|
{
|
|
if (json)
|
|
{
|
|
// JSON mode: structured error envelope to stdout so AI agents get it in the same stream
|
|
WarningContext.End(); // discard any partial warnings
|
|
Console.WriteLine(OutputFormatter.WrapErrorEnvelope(ex));
|
|
}
|
|
else
|
|
{
|
|
Console.Error.WriteLine($"Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
internal static string ExecuteBatchItem(OfficeCli.Core.IDocumentHandler handler, BatchItem item, bool json)
|
|
{
|
|
var format = json ? OfficeCli.Core.OutputFormat.Json : OfficeCli.Core.OutputFormat.Text;
|
|
var props = item.Props ?? new Dictionary<string, string>();
|
|
|
|
switch (item.Command.ToLowerInvariant())
|
|
{
|
|
case "get":
|
|
{
|
|
var path = item.Path ?? "/";
|
|
var depth = item.Depth ?? 1;
|
|
var node = handler.Get(path, depth);
|
|
return OfficeCli.Core.OutputFormatter.FormatNode(node, format);
|
|
}
|
|
case "query":
|
|
{
|
|
var selector = item.Selector ?? "";
|
|
var filters = OfficeCli.Core.AttributeFilter.Parse(selector);
|
|
var (results, warnings) = OfficeCli.Core.AttributeFilter.ApplyWithWarnings(handler.Query(selector), filters);
|
|
if (item.Text is { } textFilter && !string.IsNullOrEmpty(textFilter))
|
|
results = results.Where(n => n.Text != null && n.Text.Contains(textFilter, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
foreach (var w in warnings) Console.Error.WriteLine(w);
|
|
return OfficeCli.Core.OutputFormatter.FormatNodes(results, format);
|
|
}
|
|
case "set":
|
|
{
|
|
if (string.IsNullOrEmpty(item.Path))
|
|
throw new ArgumentException("'set' command requires 'path' field. Example: {\"command\": \"set\", \"path\": \"/slide[1]\", \"props\": {\"bold\": \"true\"}}");
|
|
var path = item.Path;
|
|
var unsupported = handler.Set(path, props);
|
|
var applied = props.Where(kv => !unsupported.Contains(kv.Key)).ToList();
|
|
var parts = new List<string>();
|
|
if (applied.Count > 0)
|
|
{
|
|
var msg = $"Updated {path}: {string.Join(", ", applied.Select(kv => $"{kv.Key}={kv.Value}"))}";
|
|
if (props.ContainsKey("find"))
|
|
{
|
|
var matched = handler switch
|
|
{
|
|
OfficeCli.Handlers.WordHandler wh => wh.LastFindMatchCount,
|
|
OfficeCli.Handlers.PowerPointHandler ph => ph.LastFindMatchCount,
|
|
OfficeCli.Handlers.ExcelHandler eh => eh.LastFindMatchCount,
|
|
_ => 0
|
|
};
|
|
msg += $" ({matched} matched)";
|
|
}
|
|
parts.Add(msg);
|
|
}
|
|
if (unsupported.Count > 0)
|
|
{
|
|
string? batchScope = handler switch
|
|
{
|
|
OfficeCli.Handlers.ExcelHandler => "excel",
|
|
OfficeCli.Handlers.WordHandler => "word",
|
|
OfficeCli.Handlers.PowerPointHandler => "pptx",
|
|
_ => null,
|
|
};
|
|
parts.Add(FormatUnsupported(unsupported, batchScope));
|
|
}
|
|
return string.Join("\n", parts);
|
|
}
|
|
case "add":
|
|
{
|
|
var parentPath = item.Parent ?? item.Path;
|
|
if (string.IsNullOrEmpty(parentPath))
|
|
throw new ArgumentException("'add' command requires 'parent' field. Example: {\"command\": \"add\", \"parent\": \"/slide[1]\", \"type\": \"shape\", \"props\": {\"text\": \"Hello\"}}");
|
|
if (string.IsNullOrEmpty(item.Type) && string.IsNullOrEmpty(item.From))
|
|
throw new ArgumentException("'add' command requires 'type' or 'from' field. Example: {\"command\": \"add\", \"parent\": \"/\", \"type\": \"slide\"}");
|
|
InsertPosition? pos = null;
|
|
if (item.Index.HasValue) pos = InsertPosition.AtIndex(item.Index.Value);
|
|
else if (!string.IsNullOrEmpty(item.After)) pos = InsertPosition.AfterElement(item.After);
|
|
else if (!string.IsNullOrEmpty(item.Before)) pos = InsertPosition.BeforeElement(item.Before);
|
|
|
|
if (!string.IsNullOrEmpty(item.From))
|
|
{
|
|
var resultPath = handler.CopyFrom(item.From, parentPath, pos);
|
|
return $"Copied to {resultPath}";
|
|
}
|
|
else
|
|
{
|
|
var type = item.Type ?? "";
|
|
var resultPath = handler.Add(parentPath, type, pos, props);
|
|
return $"Added {type} at {resultPath}";
|
|
}
|
|
}
|
|
case "remove":
|
|
{
|
|
if (string.IsNullOrEmpty(item.Path))
|
|
throw new ArgumentException("'remove' command requires 'path' field. Example: {\"command\": \"remove\", \"path\": \"/slide[1]/shape[2]\"}");
|
|
var path = item.Path;
|
|
var warning = handler.Remove(path);
|
|
var msg = $"Removed {path}";
|
|
if (warning != null) msg += $"\n{warning}";
|
|
return msg;
|
|
}
|
|
case "move":
|
|
{
|
|
var path = item.Path ?? "/";
|
|
InsertPosition? movePos = null;
|
|
if (item.Index.HasValue) movePos = InsertPosition.AtIndex(item.Index.Value);
|
|
else if (!string.IsNullOrEmpty(item.After)) movePos = InsertPosition.AfterElement(item.After);
|
|
else if (!string.IsNullOrEmpty(item.Before)) movePos = InsertPosition.BeforeElement(item.Before);
|
|
var resultPath = handler.Move(path, item.To, movePos);
|
|
return $"Moved to {resultPath}";
|
|
}
|
|
case "swap":
|
|
{
|
|
if (string.IsNullOrEmpty(item.Path) || string.IsNullOrEmpty(item.To))
|
|
throw new ArgumentException("'swap' command requires 'path' and 'to' fields. Example: {\"command\": \"swap\", \"path\": \"/slide[1]\", \"to\": \"/slide[2]\"}");
|
|
var (p1, p2) = handler switch
|
|
{
|
|
OfficeCli.Handlers.PowerPointHandler ppt => ppt.Swap(item.Path, item.To),
|
|
OfficeCli.Handlers.WordHandler word => word.Swap(item.Path, item.To),
|
|
OfficeCli.Handlers.ExcelHandler excel => excel.Swap(item.Path, item.To),
|
|
_ => throw new InvalidOperationException("swap not supported for this document type")
|
|
};
|
|
return $"Swapped {p1} <-> {p2}";
|
|
}
|
|
case "view":
|
|
{
|
|
var mode = item.Mode ?? "text";
|
|
if (mode.ToLowerInvariant() is "html" or "h")
|
|
{
|
|
if (handler is OfficeCli.Handlers.PowerPointHandler pptH)
|
|
return pptH.ViewAsHtml();
|
|
if (handler is OfficeCli.Handlers.ExcelHandler excelH)
|
|
return excelH.ViewAsHtml();
|
|
if (handler is OfficeCli.Handlers.WordHandler wordH)
|
|
return wordH.ViewAsHtml();
|
|
}
|
|
if (mode.ToLowerInvariant() is "svg" or "g" && handler is OfficeCli.Handlers.PowerPointHandler pptSvg)
|
|
{
|
|
return pptSvg.ViewAsSvg(1);
|
|
}
|
|
return mode.ToLowerInvariant() switch
|
|
{
|
|
"text" or "t" => handler.ViewAsText(null, null, null, null),
|
|
"annotated" or "a" => handler.ViewAsAnnotated(null, null, null, null),
|
|
"outline" or "o" => handler.ViewAsOutline(),
|
|
"stats" or "s" => handler.ViewAsStats(),
|
|
"issues" or "i" => OfficeCli.Core.OutputFormatter.FormatIssues(handler.ViewAsIssues(null, null), format),
|
|
_ => $"Unknown mode: {mode}"
|
|
};
|
|
}
|
|
case "raw":
|
|
{
|
|
if (string.IsNullOrEmpty(item.Part))
|
|
throw new ArgumentException("'raw' command requires 'part' field. Example: {\"command\": \"raw\", \"part\": \"/document\"} (docx), {\"command\": \"raw\", \"part\": \"/presentation\"} (pptx), {\"command\": \"raw\", \"part\": \"/sheet[1]\"} (xlsx)");
|
|
return handler.Raw(item.Part, null, null, null);
|
|
}
|
|
case "raw-set":
|
|
{
|
|
var partPath = item.Part ?? "/document";
|
|
var xpath = item.Xpath ?? "";
|
|
var action = item.Action ?? "";
|
|
handler.RawSet(partPath, xpath, action, item.Xml);
|
|
return $"raw-set {action} applied";
|
|
}
|
|
case "validate":
|
|
{
|
|
var errors = handler.Validate();
|
|
if (errors.Count == 0) return "Validation passed: no errors found.";
|
|
var lines = new List<string> { $"Found {errors.Count} validation error(s):" };
|
|
foreach (var err in errors)
|
|
{
|
|
lines.Add($" [{err.ErrorType}] {err.Description}");
|
|
if (err.Path != null) lines.Add($" Path: {err.Path}");
|
|
if (err.Part != null) lines.Add($" Part: {err.Part}");
|
|
}
|
|
return string.Join("\n", lines);
|
|
}
|
|
default:
|
|
if (string.IsNullOrEmpty(item.Command))
|
|
throw new InvalidOperationException(
|
|
"Batch item missing required 'command' field. " +
|
|
"Valid commands: get, query, set, add, remove, move, view, raw, validate. " +
|
|
"Example: {\"command\": \"set\", \"path\": \"/Sheet1/A1\", \"props\": {\"value\": \"hello\"}}");
|
|
throw new InvalidOperationException($"Unknown command: '{item.Command}'. Valid commands: get, query, set, add, remove, move, swap, view, raw, validate.");
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, string> ParsePropsArray(string[]? props)
|
|
{
|
|
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var prop in props ?? Array.Empty<string>())
|
|
{
|
|
var eqIdx = prop.IndexOf('=');
|
|
if (eqIdx > 0)
|
|
dict[prop[..eqIdx]] = prop[(eqIdx + 1)..];
|
|
}
|
|
return dict;
|
|
}
|
|
|
|
internal static void PrintBatchResults(List<BatchResult> results, bool json, int totalCount = 0, TextWriter? output = null)
|
|
{
|
|
var @out = output ?? Console.Out;
|
|
if (totalCount == 0) totalCount = results.Count;
|
|
|
|
if (json)
|
|
{
|
|
var succeeded = results.Count(r => r.Success);
|
|
var failed = results.Count - succeeded;
|
|
var skipped = totalCount - results.Count;
|
|
|
|
using var ms = new System.IO.MemoryStream();
|
|
using (var writer = new System.Text.Json.Utf8JsonWriter(ms))
|
|
{
|
|
writer.WriteStartObject();
|
|
writer.WritePropertyName("results");
|
|
System.Text.Json.JsonSerializer.Serialize(writer, results, BatchJsonContext.Default.ListBatchResult);
|
|
writer.WriteStartObject("summary");
|
|
writer.WriteNumber("total", totalCount);
|
|
writer.WriteNumber("executed", results.Count);
|
|
writer.WriteNumber("succeeded", succeeded);
|
|
writer.WriteNumber("failed", failed);
|
|
writer.WriteNumber("skipped", skipped);
|
|
writer.WriteEndObject();
|
|
writer.WriteEndObject();
|
|
}
|
|
|
|
var fullBytes = ms.ToArray();
|
|
if (fullBytes.Length <= 8192)
|
|
{
|
|
@out.WriteLine(System.Text.Encoding.UTF8.GetString(fullBytes));
|
|
}
|
|
else
|
|
{
|
|
// Spill full output to temp file
|
|
var tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"officecli_batch_{Guid.NewGuid():N}.json");
|
|
System.IO.File.WriteAllBytes(tempPath, fullBytes);
|
|
|
|
// Write slim envelope
|
|
using var slimMs = new System.IO.MemoryStream();
|
|
using (var slimWriter = new System.Text.Json.Utf8JsonWriter(slimMs))
|
|
{
|
|
slimWriter.WriteStartObject();
|
|
slimWriter.WriteString("outputFile", tempPath);
|
|
slimWriter.WriteNumber("outputSize", fullBytes.Length);
|
|
slimWriter.WriteStartArray("results");
|
|
foreach (var r in results)
|
|
{
|
|
slimWriter.WriteStartObject();
|
|
slimWriter.WriteNumber("index", r.Index);
|
|
slimWriter.WriteBoolean("success", r.Success);
|
|
if (r.Error != null)
|
|
{
|
|
slimWriter.WriteString("error", r.Error);
|
|
if (r.Item != null)
|
|
{
|
|
slimWriter.WritePropertyName("item");
|
|
System.Text.Json.JsonSerializer.Serialize(slimWriter, r.Item, BatchJsonContext.Default.BatchItem);
|
|
}
|
|
}
|
|
slimWriter.WriteEndObject();
|
|
}
|
|
slimWriter.WriteEndArray();
|
|
slimWriter.WriteStartObject("summary");
|
|
slimWriter.WriteNumber("total", totalCount);
|
|
slimWriter.WriteNumber("executed", results.Count);
|
|
slimWriter.WriteNumber("succeeded", succeeded);
|
|
slimWriter.WriteNumber("failed", failed);
|
|
slimWriter.WriteNumber("skipped", skipped);
|
|
slimWriter.WriteEndObject();
|
|
slimWriter.WriteEndObject();
|
|
}
|
|
@out.WriteLine(System.Text.Encoding.UTF8.GetString(slimMs.ToArray()));
|
|
}
|
|
}
|
|
else
|
|
{
|
|
for (int i = 0; i < results.Count; i++)
|
|
{
|
|
var r = results[i];
|
|
var prefix = $"[{i + 1}] ";
|
|
if (r.Success)
|
|
{
|
|
if (!string.IsNullOrEmpty(r.Output))
|
|
@out.WriteLine($"{prefix}{r.Output}");
|
|
else
|
|
@out.WriteLine($"{prefix}OK");
|
|
}
|
|
else
|
|
{
|
|
@out.WriteLine($"{prefix}ERROR: {r.Error}");
|
|
}
|
|
}
|
|
|
|
var succeeded = results.Count(r => r.Success);
|
|
var failed = results.Count - succeeded;
|
|
@out.WriteLine($"\nBatch complete: {succeeded} succeeded, {failed} failed, {results.Count} total");
|
|
}
|
|
}
|
|
|
|
private static string FormatValidationErrors(List<ValidationError> errors)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append("{\"count\":").Append(errors.Count).Append(",\"errors\":[");
|
|
for (int i = 0; i < errors.Count; i++)
|
|
{
|
|
if (i > 0) sb.Append(',');
|
|
var e = errors[i];
|
|
sb.Append("{\"type\":\"").Append(EscapeJson(e.ErrorType)).Append('"');
|
|
sb.Append(",\"description\":\"").Append(EscapeJson(e.Description)).Append('"');
|
|
if (e.Path != null) sb.Append(",\"path\":\"").Append(EscapeJson(e.Path)).Append('"');
|
|
if (e.Part != null) sb.Append(",\"part\":\"").Append(EscapeJson(e.Part)).Append('"');
|
|
sb.Append('}');
|
|
}
|
|
sb.Append("]}");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string EscapeJson(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
|
|
|
|
internal static List<CliWarning>? ReportNewErrorsAsWarnings(OfficeCli.Core.IDocumentHandler handler, HashSet<string> errorsBefore)
|
|
{
|
|
var errorsAfter = handler.Validate();
|
|
var newErrors = errorsAfter.Where(e => !errorsBefore.Contains(e.Description)).ToList();
|
|
if (newErrors.Count == 0) return null;
|
|
return newErrors.Select(err => new CliWarning
|
|
{
|
|
Message = $"[{err.ErrorType}] {err.Description}" +
|
|
(err.Path != null ? $" (Path: {err.Path})" : "") +
|
|
(err.Part != null ? $" (Part: {err.Part})" : ""),
|
|
Code = "validation_error"
|
|
}).ToList();
|
|
}
|
|
|
|
internal static void ReportNewErrors(OfficeCli.Core.IDocumentHandler handler, HashSet<string> errorsBefore, List<CliWarning>? preComputed = null)
|
|
{
|
|
var warnings = preComputed ?? ReportNewErrorsAsWarnings(handler, errorsBefore);
|
|
if (warnings is { Count: > 0 })
|
|
{
|
|
Console.WriteLine($"VALIDATION: {warnings.Count} new error(s) introduced:");
|
|
foreach (var w in warnings)
|
|
Console.WriteLine($" {w.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detect bare key=value tokens and --key value flag patterns in unmatched arguments (user forgot --prop).
|
|
/// Returns a list of "key=value" strings suitable for --prop suggestions.
|
|
/// </summary>
|
|
internal static List<string> DetectUnmatchedKeyValues(System.CommandLine.ParseResult parseResult)
|
|
{
|
|
var result = new List<string>();
|
|
var tokens = parseResult.UnmatchedTokens;
|
|
var knownPropsLower = new HashSet<string>(KnownProps.Select(p => p.ToLowerInvariant()));
|
|
|
|
for (int i = 0; i < tokens.Count; i++)
|
|
{
|
|
var token = tokens[i];
|
|
|
|
// Pattern 1: bare key=value (e.g. "text=Hello")
|
|
if (System.Text.RegularExpressions.Regex.IsMatch(token, @"^[A-Za-z_.][A-Za-z0-9_.]*=.+$"))
|
|
{
|
|
result.Add(token);
|
|
continue;
|
|
}
|
|
|
|
// Pattern 2: --key value (e.g. "--text Hello" or "--fill yellow")
|
|
// Only match if the key (without --) is a known property name
|
|
if (token.StartsWith("--") && token.Length > 2)
|
|
{
|
|
var key = token[2..];
|
|
if (knownPropsLower.Contains(key.ToLowerInvariant()) && i + 1 < tokens.Count)
|
|
{
|
|
var nextToken = tokens[i + 1];
|
|
// Don't consume the next token if it also looks like a flag
|
|
if (!nextToken.StartsWith("--"))
|
|
{
|
|
result.Add($"{key}={nextToken}");
|
|
i++; // skip the value token
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pattern 3 (BUG-BT-R6): common typos for the `--prop` option name.
|
|
// `--props '{"k":"v"}'` is silently swallowed by System.CommandLine
|
|
// because `--props` (with trailing s) is not a known option, so the
|
|
// JSON value goes into UnmatchedTokens too. Catch the typo so the
|
|
// existing warning machinery emits a clear hint instead of letting
|
|
// the agent ship a shape with no text.
|
|
if (token is "--props" or "-props" or "--prop=" && i + 1 < tokens.Count)
|
|
{
|
|
var nextToken = tokens[i + 1];
|
|
if (!nextToken.StartsWith("--"))
|
|
{
|
|
result.Add($"--prop {nextToken}");
|
|
i++;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
internal static string FormatUnsupported(IEnumerable<string> unsupported, string? scope = null)
|
|
{
|
|
var parts = new List<string>();
|
|
foreach (var prop in unsupported)
|
|
{
|
|
var suggestion = SuggestPropertyScoped(prop, scope);
|
|
parts.Add(suggestion != null ? $"{prop} (did you mean: {suggestion}?)" : prop);
|
|
}
|
|
return $"UNSUPPORTED props: {string.Join(", ", parts)}. Use 'officecli help <format>-set' to see available properties, or use raw-set for direct XML manipulation.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property keys that belong to PPTX shape/text semantics and should not
|
|
/// be offered as suggestions when the caller is operating on an Excel
|
|
/// document (R2-4). Keep the list conservative — only keys whose presence
|
|
/// in an Excel error message would be clearly misleading.
|
|
/// </summary>
|
|
internal static readonly HashSet<string> PptxOnlyProps = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"rotation", "opacity", "glow", "shadow",
|
|
"firstSliceAngle", "holeSize", "bubbleScale", "explosion",
|
|
"view3d", "varyColors",
|
|
};
|
|
|
|
/// <summary>
|
|
/// Property keys exclusive to Word document-level concerns that should
|
|
/// not bleed into Excel suggestions.
|
|
/// </summary>
|
|
internal static readonly HashSet<string> WordOnlyProps = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"pageWidth", "pageHeight", "orientation",
|
|
};
|
|
|
|
internal static readonly string[] KnownProps = new[]
|
|
{
|
|
"text", "bold", "italic", "underline", "strike", "font", "size", "color",
|
|
"highlight", "alignment", "spacing", "indent", "shd", "border",
|
|
"width", "height", "valign", "header", "formula", "value", "type",
|
|
"fill", "src", "path", "title", "name", "style", "caps", "smallcaps",
|
|
"lineSpacing", "listStyle", "start", "level", "cols", "rows",
|
|
"gridspan", "vmerge", "nowrap", "padding", "margin",
|
|
"orientation", "pageWidth", "pageHeight",
|
|
"x", "y", "cx", "cy", "rotation", "opacity",
|
|
"border.color", "border.width", "border.style",
|
|
"font.color", "font.size", "font.name", "font.bold", "font.italic",
|
|
"hyperlink", "link", "tooltip", "alt", "description",
|
|
"font.strike", "font.underline", "tabColor", "shadow", "glow", "numberformat",
|
|
// Chart properties
|
|
"chartType", "title", "legend", "dataLabels", "labelPos", "labelFont",
|
|
"axisFont", "axisTitle", "catTitle", "axisMin", "axisMax", "majorUnit", "minorUnit",
|
|
"axisNumFmt", "axisVisible", "majorTickMark", "minorTickMark", "tickLabelPos",
|
|
"axisPosition", "crosses", "crossesAt", "crossBetween", "axisOrientation", "logBase",
|
|
"dispUnits", "labelOffset", "tickLabelSkip",
|
|
"gridlines", "minorGridlines", "plotFill", "chartFill",
|
|
"colors", "gradient", "gradients", "lineWidth", "lineDash",
|
|
"marker", "markerSize", "transparency", "smooth", "showMarker",
|
|
"scatterStyle", "radarStyle", "varyColors", "dispBlanksAs",
|
|
"roundedCorners", "plotVisOnly", "trendline", "invertIfNeg", "explosion",
|
|
"errBars", "gapWidth", "overlap", "secondaryAxis", "dataTable",
|
|
"firstSliceAngle", "holeSize", "bubbleScale", "shape", "gapDepth",
|
|
"dropLines", "hiLowLines", "upDownBars", "serLines",
|
|
"plotArea.border", "chartArea.border", "legend.overlay",
|
|
"plotArea.x", "plotArea.y", "plotArea.w", "plotArea.h",
|
|
"title.x", "title.y", "title.w", "title.h",
|
|
"legend.x", "legend.y", "legend.w", "legend.h",
|
|
"datalabels.separator", "datalabels.numfmt", "leaderLines",
|
|
"view3d", "categories", "data",
|
|
"referenceLine", "refLine", "targetLine", "preset", "colorRule",
|
|
"conditionalColor", "comboTypes", "axisLine",
|
|
};
|
|
|
|
internal static string? SuggestProperty(string input)
|
|
{
|
|
var (best, _, _) = SuggestPropertyWithDistance(input);
|
|
return best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Scoped variant: filters the suggestion pool against a target document
|
|
/// format ("excel", "word", "pptx", or null for unscoped) to avoid
|
|
/// cross-format leakage such as suggesting PPTX 'rotation' for an
|
|
/// Excel pivot property (R2-4).
|
|
/// </summary>
|
|
internal static string? SuggestPropertyScoped(string input, string? scope)
|
|
{
|
|
var (best, _, _) = SuggestPropertyWithDistance(input, scope);
|
|
return best;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns (bestMatch, distance, isUnique) where isUnique means no other candidate shares the same distance.
|
|
/// </summary>
|
|
internal static (string? Best, int Distance, bool IsUnique) SuggestPropertyWithDistance(string input, string? scope = null)
|
|
{
|
|
// Strip help text suffix if present (e.g. "key (valid props: ...)")
|
|
var rawInput = input.Contains(' ') ? input[..input.IndexOf(' ')] : input;
|
|
var lower = rawInput.ToLowerInvariant();
|
|
string? best = null;
|
|
int bestDist = int.MaxValue;
|
|
int bestCount = 0; // how many props share the best distance
|
|
|
|
HashSet<string>? exclude = null;
|
|
switch (scope?.ToLowerInvariant())
|
|
{
|
|
case "excel":
|
|
exclude = new HashSet<string>(PptxOnlyProps, StringComparer.OrdinalIgnoreCase);
|
|
foreach (var w in WordOnlyProps) exclude.Add(w);
|
|
break;
|
|
case "word":
|
|
exclude = PptxOnlyProps;
|
|
break;
|
|
case "pptx":
|
|
exclude = WordOnlyProps;
|
|
break;
|
|
}
|
|
|
|
foreach (var prop in KnownProps)
|
|
{
|
|
if (exclude != null && exclude.Contains(prop)) continue;
|
|
var dist = LevenshteinDistance(lower, prop.ToLowerInvariant());
|
|
if (dist > 0 && dist <= Math.Max(2, rawInput.Length / 3))
|
|
{
|
|
if (dist < bestDist)
|
|
{
|
|
bestDist = dist;
|
|
best = prop;
|
|
bestCount = 1;
|
|
}
|
|
else if (dist == bestDist)
|
|
{
|
|
bestCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
return best != null ? (best, bestDist, bestCount == 1) : (null, int.MaxValue, false);
|
|
}
|
|
|
|
internal static int LevenshteinDistance(string s, string t)
|
|
{
|
|
if (s.Length == 0) return t.Length;
|
|
if (t.Length == 0) return s.Length;
|
|
|
|
var d = new int[s.Length + 1, t.Length + 1];
|
|
for (int i = 0; i <= s.Length; i++) d[i, 0] = i;
|
|
for (int j = 0; j <= t.Length; j++) d[0, j] = j;
|
|
|
|
for (int i = 1; i <= s.Length; i++)
|
|
{
|
|
for (int j = 1; j <= t.Length; j++)
|
|
{
|
|
int cost = s[i - 1] == t[j - 1] ? 0 : 1;
|
|
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
|
|
}
|
|
}
|
|
|
|
return d[s.Length, t.Length];
|
|
}
|
|
|
|
// ==================== PPT spatial info helpers ====================
|
|
|
|
/// <summary>
|
|
/// Check if a .docx file has document protection enforced.
|
|
/// Returns 0 if no protection or if the path targets an editable element.
|
|
/// Returns 1 with error output if the document is protected and the target is not an editable region.
|
|
/// </summary>
|
|
private static int CheckDocxProtection(string filePath, string path, bool json)
|
|
{
|
|
try
|
|
{
|
|
using var handler = DocumentHandlerFactory.Open(filePath, editable: false);
|
|
var root = handler.Get("/");
|
|
var protection = root.Format.TryGetValue("protection", out var pVal) ? pVal?.ToString() : "none";
|
|
var enforced = root.Format.TryGetValue("protectionEnforced", out var eVal) && eVal is true;
|
|
|
|
if (!enforced || protection == "none")
|
|
return 0;
|
|
|
|
// Allow writes to formfield and SDT paths (they handle their own editable check)
|
|
if (path.StartsWith("/formfield[", StringComparison.OrdinalIgnoreCase))
|
|
return 0;
|
|
if (path.Contains("/sdt[", StringComparison.OrdinalIgnoreCase))
|
|
return 0;
|
|
|
|
// Document is protected — block the write
|
|
var msg = $"Document is protected (mode: {protection}). " +
|
|
"Use Query(\"editable\") to find editable fields, or use --force to override protection.";
|
|
if (json)
|
|
Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg, new List<OfficeCli.Core.CliWarning>()));
|
|
else
|
|
Console.Error.WriteLine($"ERROR: {msg}");
|
|
return 1;
|
|
}
|
|
catch
|
|
{
|
|
// If we can't read protection info, allow the write to proceed
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private static readonly HashSet<string> PositionKeys = new(StringComparer.OrdinalIgnoreCase)
|
|
{ "x", "left", "y", "top", "width", "w", "height", "h" };
|
|
|
|
/// <summary>
|
|
/// For PPT spatial elements, return coordinate string like "x: 0cm y: 5cm width: 33.87cm height: 5cm".
|
|
/// Returns null for non-spatial elements (slide, Word, Excel).
|
|
/// </summary>
|
|
private static string? GetPptSpatialLine(IDocumentHandler handler, string path)
|
|
{
|
|
if (handler is not OfficeCli.Handlers.PowerPointHandler) return null;
|
|
try
|
|
{
|
|
var node = handler.Get(path);
|
|
if (node == null) return null;
|
|
// Only for spatial types (shape, textbox, picture, table, chart, connector, group, equation)
|
|
if (node.Type is "slide" or "paragraph" or "run" or "cell" or "row") return null;
|
|
if (!node.Format.ContainsKey("x") || !node.Format.ContainsKey("y")) return null;
|
|
var x = node.Format.TryGetValue("x", out var xv) ? xv : "?";
|
|
var y = node.Format.TryGetValue("y", out var yv) ? yv : "?";
|
|
var w = node.Format.TryGetValue("width", out var wv) ? wv : "?";
|
|
var h = node.Format.TryGetValue("height", out var hv) ? hv : "?";
|
|
return $"x: {x} y: {y} width: {w} height: {h}";
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if the element at <paramref name="path"/> has the same (x,y) as any sibling.
|
|
/// Returns list of overlapping sibling names, or empty.
|
|
/// </summary>
|
|
private static List<string> CheckPositionOverlap(IDocumentHandler handler, string path)
|
|
{
|
|
var overlaps = new List<string>();
|
|
if (handler is not OfficeCli.Handlers.PowerPointHandler) return overlaps;
|
|
try
|
|
{
|
|
var node = handler.Get(path);
|
|
if (node == null || !node.Format.ContainsKey("x") || !node.Format.ContainsKey("y")) return overlaps;
|
|
var myX = node.Format["x"]?.ToString();
|
|
var myY = node.Format["y"]?.ToString();
|
|
if (myX == null || myY == null) return overlaps;
|
|
|
|
// Get parent (slide) to enumerate siblings
|
|
var slidePathMatch = System.Text.RegularExpressions.Regex.Match(path, @"^(/slide\[\d+\])");
|
|
if (!slidePathMatch.Success) return overlaps;
|
|
var slidePath = slidePathMatch.Value;
|
|
var slideNode = handler.Get(slidePath);
|
|
if (slideNode == null) return overlaps;
|
|
|
|
foreach (var child in slideNode.Children)
|
|
{
|
|
if (child.Path == path) continue;
|
|
if (!child.Format.ContainsKey("x") || !child.Format.ContainsKey("y")) continue;
|
|
var cx = child.Format["x"]?.ToString();
|
|
var cy = child.Format["y"]?.ToString();
|
|
if (cx == myX && cy == myY)
|
|
{
|
|
// Skip false positive: both shapes at default (0,0) means neither was explicitly positioned
|
|
if (myX == "0cm" && myY == "0cm" && cx == "0cm" && cy == "0cm") continue;
|
|
var name = child.Format.TryGetValue("name", out var n) ? n?.ToString() : child.Path;
|
|
overlaps.Add(name ?? child.Path);
|
|
}
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
return overlaps;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if a shape's text overflows its bounds using CJK-aware character measurement.
|
|
/// Returns a warning message or null.
|
|
/// </summary>
|
|
private static string? CheckTextOverflow(IDocumentHandler handler, string path)
|
|
{
|
|
if (handler is not OfficeCli.Handlers.PowerPointHandler pptHandler) return null;
|
|
try
|
|
{
|
|
return pptHandler.CheckShapeTextOverflow(path);
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify watch server with pre-rendered HTML from the handler.
|
|
/// Call this while the handler is still open (before Dispose).
|
|
/// </summary>
|
|
private static void NotifyWatch(IDocumentHandler handler, string filePath, string? changedPath)
|
|
{
|
|
if (handler is OfficeCli.Handlers.ExcelHandler excel)
|
|
{
|
|
string? scrollTo = null;
|
|
var sheetName = WatchMessage.ExtractSheetName(changedPath);
|
|
if (sheetName != null)
|
|
{
|
|
var idx = excel.GetSheetIndex(sheetName);
|
|
if (idx >= 0) scrollTo = $".sheet-content[data-sheet=\"{idx}\"]";
|
|
}
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = excel.ViewAsHtml(), ScrollTo = scrollTo });
|
|
return;
|
|
}
|
|
if (handler is OfficeCli.Handlers.WordHandler word)
|
|
{
|
|
var scrollTo = WatchMessage.ExtractWordScrollTarget(changedPath);
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = word.ViewAsHtml(), ScrollTo = scrollTo });
|
|
return;
|
|
}
|
|
if (handler is not OfficeCli.Handlers.PowerPointHandler ppt) return;
|
|
var slideNum = WatchMessage.ExtractSlideNum(changedPath);
|
|
if (slideNum > 0)
|
|
{
|
|
var html = ppt.RenderSlideHtml(slideNum);
|
|
if (html != null)
|
|
{
|
|
// Slide-scoped replace: the watch server patches its cached _currentHtml in
|
|
// place via PatchSlideInHtml; bundling a full ViewAsHtml() here is redundant
|
|
// (and ResidentServer.NotifyWatchSlideChanged already omits it).
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "replace", Slide = slideNum, Html = html });
|
|
return;
|
|
}
|
|
}
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = ppt.ViewAsHtml() });
|
|
}
|
|
|
|
private static void NotifyWatchRoot(IDocumentHandler handler, string filePath, int oldSlideCount)
|
|
{
|
|
if (handler is OfficeCli.Handlers.ExcelHandler excel)
|
|
{
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = excel.ViewAsHtml() });
|
|
return;
|
|
}
|
|
if (handler is OfficeCli.Handlers.WordHandler word)
|
|
{
|
|
// Scroll to last page (new content is typically appended)
|
|
var html = word.ViewAsHtml();
|
|
var pageCount = System.Text.RegularExpressions.Regex.Matches(html, @"data-page=""\d+""").Count;
|
|
var scrollTo = pageCount > 0 ? $".page[data-page=\"{pageCount}\"]" : null;
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = html, ScrollTo = scrollTo });
|
|
return;
|
|
}
|
|
if (handler is not OfficeCli.Handlers.PowerPointHandler ppt) return;
|
|
var newCount = ppt.GetSlideCount();
|
|
if (newCount > oldSlideCount)
|
|
{
|
|
var html = ppt.RenderSlideHtml(newCount);
|
|
if (html != null)
|
|
{
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "add", Slide = newCount, Html = html, FullHtml = ppt.ViewAsHtml() });
|
|
return;
|
|
}
|
|
}
|
|
else if (newCount < oldSlideCount)
|
|
{
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "remove", Slide = oldSlideCount, FullHtml = ppt.ViewAsHtml() });
|
|
return;
|
|
}
|
|
WatchNotifier.NotifyIfWatching(filePath, new WatchMessage { Action = "full", FullHtml = ppt.ViewAsHtml() });
|
|
}
|
|
|
|
/// <summary>
|
|
/// TextWriter that writes to two targets simultaneously (tee pattern).
|
|
/// </summary>
|
|
private class TeeWriter : TextWriter
|
|
{
|
|
private readonly TextWriter _a;
|
|
private readonly TextWriter _b;
|
|
public TeeWriter(TextWriter a, TextWriter b) { _a = a; _b = b; }
|
|
public override Encoding Encoding => _a.Encoding;
|
|
public override void Write(char value) { _a.Write(value); _b.Write(value); }
|
|
public override void Write(string? value) { _a.Write(value); _b.Write(value); }
|
|
public override void WriteLine(string? value) { _a.WriteLine(value); _b.WriteLine(value); }
|
|
public override void Flush() { _a.Flush(); _b.Flush(); }
|
|
}
|
|
}
|