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
170 changes: 170 additions & 0 deletions src/officecli.mobilecore/MobileDocumentSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using OfficeCli.Core;
using OfficeCli.Handlers;

namespace OfficeCli.MobileCore;

/// <summary>
/// In-process, mobile-safe facade over OfficeCLI's OOXML document engine.
/// It deliberately exposes no shell, process, pipe, plugin, installer, watch,
/// browser, arbitrary output path, or raw-package operations.
/// </summary>
public sealed class MobileDocumentSession : IDisposable
{
private readonly string _documentPath;
private readonly IDocumentHandler _handler;
private bool _disposed;

private MobileDocumentSession(string documentPath, IDocumentHandler handler)
{
_documentPath = documentPath;
_handler = handler;
}

public string DocumentPath => _documentPath;

public static MobileDocumentSession Open(string appPrivateDocumentPath)
{
var fullPath = Path.GetFullPath(appPrivateDocumentPath);
var extension = Path.GetExtension(fullPath);
if (extension is not (".docx" or ".xlsx" or ".pptx"))
throw new NotSupportedException("Only DOCX, XLSX, and PPTX are supported on mobile.");
return new MobileDocumentSession(fullPath, DocumentHandlerFactory.Open(fullPath, editable: true));
}

public static MobileDocumentSession Create(string appPrivateDocumentPath)
{
var fullPath = Path.GetFullPath(appPrivateDocumentPath);
var extension = Path.GetExtension(fullPath);
if (extension is not (".docx" or ".xlsx" or ".pptx"))
throw new NotSupportedException("Only DOCX, XLSX, and PPTX are supported on mobile.");
OfficeCli.BlankDocCreator.Create(fullPath);
return Open(fullPath);
}

public string RenderHtml()
{
ThrowIfDisposed();
return _handler switch
{
WordHandler word => word.ViewAsHtml(),
ExcelHandler excel => excel.ViewAsHtml(),
PowerPointHandler powerPoint => powerPoint.ViewAsHtml(),
_ => throw new NotSupportedException("This document type has no mobile HTML renderer.")
};
}

public JsonNode Outline()
{
ThrowIfDisposed();
return _handler.ViewAsOutlineJson();
}

public JsonNode Get(string selector, int depth = 1)
{
ThrowIfDisposed();
ValidateSelector(selector);
if (depth is < 0 or > 10) throw new ArgumentOutOfRangeException(nameof(depth));
return JsonSerializer.SerializeToNode(_handler.Get(selector, depth))!;
}

public IReadOnlyList<JsonNode> Query(string selector)
{
ThrowIfDisposed();
ValidateSelector(selector);
return _handler.Query(selector).Select(node => JsonSerializer.SerializeToNode(node)!).ToArray();
}

public MobileCommandResult Execute(MobileOfficeCommand command)
=> ExecuteCore(command, save: true);

public IReadOnlyList<MobileCommandResult> ExecuteBatch(IReadOnlyList<MobileOfficeCommand> commands)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(commands);
if (commands.Count is < 1 or > 100) throw new ArgumentException("A batch must contain between 1 and 100 commands.");
var results = new List<MobileCommandResult>(commands.Count);
foreach (var command in commands) results.Add(ExecuteCore(command, save: false));
_handler.Save();
return results;
}

private MobileCommandResult ExecuteCore(MobileOfficeCommand command, bool save)
{
ThrowIfDisposed();
ArgumentNullException.ThrowIfNull(command);
ValidateSelector(command.Path);
var properties = command.Properties ?? new Dictionary<string, string>();
if (properties.Count > 100) throw new ArgumentException("A command may contain at most 100 properties.");

string? message;
IReadOnlyList<string> unsupported = [];
switch (command.Operation)
{
case MobileOperation.Set:
unsupported = _handler.Set(command.Path, properties);
message = "Element updated.";
break;
case MobileOperation.Add:
if (string.IsNullOrWhiteSpace(command.Type)) throw new ArgumentException("Add requires an element type.");
message = _handler.Add(command.Path, command.Type, ToPosition(command), properties);
break;
case MobileOperation.Remove:
message = _handler.Remove(command.Path, properties);
break;
default:
throw new ArgumentOutOfRangeException(nameof(command.Operation));
}

if (save) _handler.Save();
return new MobileCommandResult(true, message, unsupported);
}

public void Save()
{
ThrowIfDisposed();
_handler.Save();
}

public void Dispose()
{
if (_disposed) return;
_handler.Dispose();
_disposed = true;
}

private static InsertPosition? ToPosition(MobileOfficeCommand command)
{
var count = (command.Index.HasValue ? 1 : 0) + (command.Before is not null ? 1 : 0) + (command.After is not null ? 1 : 0);
if (count > 1) throw new ArgumentException("Only one insertion position may be specified.");
if (command.Index.HasValue) return InsertPosition.AtIndex(command.Index.Value);
if (command.Before is not null) return InsertPosition.BeforeElement(command.Before);
if (command.After is not null) return InsertPosition.AfterElement(command.After);
return null;
}

private static void ValidateSelector(string selector)
{
if (string.IsNullOrWhiteSpace(selector) || !selector.StartsWith('/'))
throw new ArgumentException("An OfficeCLI document selector beginning with '/' is required.");
if (selector.Length > 2048) throw new ArgumentException("The selector is too long.");
if (selector.Contains("..", StringComparison.Ordinal) || selector.Contains('\0'))
throw new ArgumentException("The selector contains a forbidden sequence.");
}

private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this);
}

public enum MobileOperation { Set, Add, Remove }

public sealed record MobileOfficeCommand(
MobileOperation Operation,
string Path,
string? Type = null,
Dictionary<string, string>? Properties = null,
int? Index = null,
string? Before = null,
string? After = null);

public sealed record MobileCommandResult(bool Success, string? Message, IReadOnlyList<string> UnsupportedProperties);
17 changes: 17 additions & 0 deletions src/officecli.mobilecore/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# OfficeCLI MobileCore

An experimental in-process facade for Android/iOS hosts. It references the
existing OfficeCLI assembly but exposes only document-local operations:

- open an OOXML file from app-private storage;
- render its HTML preview;
- read outline/get/query data;
- set, add, and remove document elements;
- save back to the same file.

It intentionally excludes CLI parsing, MCP stdio, named pipes, watch servers,
plugins, installers, browsers, subprocesses, raw package writes, and arbitrary
output paths.

The AI layer should deserialize function calls into `MobileOfficeCommand` and
invoke `Execute`; it should never receive a shell or CLI command-string tool.
16 changes: 16 additions & 0 deletions src/officecli.mobilecore/officecli.mobilecore.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net10.0-android</TargetFrameworks>
<SupportedOSPlatformVersion Condition="'$(TargetFramework)' == 'net10.0-android'">24</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- officecli's net10.0-android target is opt-in (see officecli.csproj);
mobilecore always needs it, so request it explicitly here instead
of requiring every caller to remember to pass the property. -->
<ProjectReference Include="..\officecli\officecli.csproj">
<Properties>IncludeAndroidTarget=true</Properties>
</ProjectReference>
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions src/officecli/GlobalUsings.Android.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#if ANDROID
global using TableRow = DocumentFormat.OpenXml.Wordprocessing.TableRow;
global using TableLayout = DocumentFormat.OpenXml.Wordprocessing.TableLayout;
global using CheckBox = DocumentFormat.OpenXml.Wordprocessing.CheckBox;
global using Filter = DocumentFormat.OpenXml.Spreadsheet.Filter;
#endif
25 changes: 20 additions & 5 deletions src/officecli/officecli.csproj
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<!-- The Android target is opt-in behind IncludeAndroidTarget: it needs
the Android SDK/workload, which most desktop-CLI consumers (this
project's own release CI included) don't have installed. Building,
running, or publishing this project with no extra property behaves
exactly as it did before this target existed - single net10.0
target, no Android SDK required. -->
<TargetFrameworks Condition="'$(IncludeAndroidTarget)' == 'true'">net10.0;net10.0-android</TargetFrameworks>
<TargetFramework Condition="'$(IncludeAndroidTarget)' != 'true'">net10.0</TargetFramework>
<OutputType Condition="'$(TargetFramework)' == 'net10.0'">Exe</OutputType>
<OutputType Condition="'$(TargetFramework)' == 'net10.0-android'">Library</OutputType>
<SupportedOSPlatformVersion Condition="'$(TargetFramework)' == 'net10.0-android'">24</SupportedOSPlatformVersion>
<RootNamespace>OfficeCli</RootNamespace>
<AssemblyName>officecli</AssemblyName>
<Version>1.0.143</Version>
<Authors>goworm</Authors>
<Company>OfficeCLI</Company>
<Copyright>Copyright © 2026 OfficeCLI (https://OfficeCLI.AI)</Copyright>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<PublishTrimmed>true</PublishTrimmed>
<PublishSingleFile Condition="'$(TargetFramework)' == 'net10.0'">true</PublishSingleFile>
<SelfContained Condition="'$(TargetFramework)' == 'net10.0'">true</SelfContained>
<PublishTrimmed Condition="'$(TargetFramework)' == 'net10.0'">true</PublishTrimmed>
<CETCompat>false</CETCompat>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand All @@ -23,6 +32,12 @@
<PackageReference Include="System.CommandLine" Version="3.0.0-preview.2.26159.112" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-android'">
<!-- Android hosts the document engine in-process; the desktop CLI entry
point is neither needed nor supported in a library target. -->
<Compile Remove="Program.cs" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Resources/preview.css" />
<EmbeddedResource Include="Resources/preview.js" />
Expand Down