Forráskód Böngészése

Merge remote-tracking branch 'origin/nick' into frank

# Conflicts:
#	inabox.client.websocket/InABox.Client.WebSocket.csproj
frogsoftware 1 éve
szülő
commit
ca77588fff

+ 0 - 2
InABox.Client.RPC/InABox.Client.RPC.csproj

@@ -8,13 +8,11 @@
     </PropertyGroup>
 
     <ItemGroup>
-      <ProjectReference Include="..\..\3rdpartylibs\websocket-sharp-master\websocket-sharp-standard\websocket-sharp-standard.csproj" />
       <ProjectReference Include="..\InABox.RPC.Shared\InABox.RPC.Shared.csproj" />
     </ItemGroup>
 
     <ItemGroup>
       <PackageReference Include="H.Pipes" Version="2.0.51" />
-      <PackageReference Include="Websocket.Client" Version="4.6.1" />
       <PackageReference Include="WebSocket4Net" Version="0.15.2" />
     </ItemGroup>
 

+ 8 - 2
InABox.Client.RPC/Transports/IRPCClientTransport.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Threading;
 using InABox.Clients;
 using InABox.Core;
 
@@ -8,9 +9,14 @@ namespace InABox.Rpc
     {
 
         bool Ping();
-        DatabaseInfo Info();
+        DatabaseInfo? Info();
         
-        void Connect();
+        /// <summary>
+        /// Connect to remote server.
+        /// </summary>
+        /// <param name="ct">Cancellation token to cancel the connection.</param>
+        /// <returns><see langword="true"/> if connection success, <see langword="false"/> otherwise.</returns>
+        bool Connect(CancellationToken ct = default);
 
         void Send(RpcMessage message);
         

+ 5 - 3
InABox.Client.RPC/Transports/Pipe/RPCClientPipeTransport.cs

@@ -1,4 +1,5 @@
 using System;
+using System.Threading;
 using H.Pipes;
 using InABox.Core;
 
@@ -7,7 +8,7 @@ namespace InABox.Rpc
     public class RpcClientPipeTransport : RpcClientTransport, IDisposable
     {
         private PipeClient<RpcMessage> _pipe;
-        private String _name;
+        private string _name;
         
         public RpcClientPipeTransport(string name)
         {
@@ -19,9 +20,10 @@ namespace InABox.Rpc
             _pipe.ExceptionOccurred += PipeExceptionOccurred;
         }
 
-        public override void Connect()
+        public override bool Connect(CancellationToken ct = default)
         {
-            _pipe.ConnectAsync().Wait();
+            _pipe.ConnectAsync(ct).Wait();
+            return _pipe.IsConnected;
         }
 
         public override bool IsConnected() => _pipe?.IsConnected == true;

+ 3 - 3
InABox.Client.RPC/Transports/RPCClientTransport.cs

@@ -37,7 +37,7 @@ namespace InABox.Rpc
         public event RpcTransportMessageEvent? OnMessage;
         protected void DoMessage(RpcMessage message) => OnMessage?.Invoke(this, new RpcTransportMessageArgs(_session, message));
         
-        public abstract void Connect();
+        public abstract bool Connect(CancellationToken ct = default);
         public abstract bool IsConnected();
         public abstract bool IsSecure();
         public abstract string? ServerName();
@@ -155,7 +155,7 @@ namespace InABox.Rpc
                 result = transport.IsConnected();
                 transport.Disconnect();
             }
-            catch (Exception e)
+            catch
             {
             }
             return result;
@@ -172,7 +172,7 @@ namespace InABox.Rpc
 
                 return result;
             }
-            catch (Exception e)
+            catch
             {
                 return null;
             }

+ 16 - 13
InABox.Client.RPC/Transports/Socket/RPCClientSocketTransport.cs

@@ -21,6 +21,8 @@ namespace InABox.Rpc
         
         private string[] _urls;
 
+        public string? Host => _host;
+
         public RpcClientSocketTransport(string[] urls)
         {
             _urls = urls;
@@ -99,7 +101,7 @@ namespace InABox.Rpc
             return true;
         }*/
         
-        private WebSocket? CreateSocket(string url, bool secure)
+        private WebSocket? CreateSocket(string url, bool secure, CancellationToken ct)
         {
             //WebsocketClient client;
 
@@ -109,15 +111,15 @@ namespace InABox.Rpc
 
             var openEvent = new ManualResetEventSlim();
             var open = false;
-            EventHandler onOpen = (s, e) =>
+            void onOpen(object s, EventArgs e)
             {
                 open = true;
                 openEvent.Set();
-            };
-            EventHandler onClose = (s, e) =>
+            }
+            void onClose(object s, EventArgs e)
             {
                 openEvent.Set();
-            };
+            }
 
             client.Opened += onOpen;
             client.Closed += onClose;
@@ -127,7 +129,7 @@ namespace InABox.Rpc
             client.DataReceived += Client_DataReceived;
             client.Open();
 
-            openEvent.Wait();
+            openEvent.Wait(ct);
 
             if (!open)
             {
@@ -198,21 +200,22 @@ namespace InABox.Rpc
             openEvent.Set();
         }
 
-        public override void Connect()
+        public override bool Connect(CancellationToken ct = default)
         {
-            if(_socket != null)
+            if (_socket != null)
             {
                 openEvent.Reset();
                 _socket.Open();
-                openEvent.Wait();
+                openEvent.Wait(ct);
+                return _connected;
             }
             else
             {
                 var tasks = new List<Task<WebSocket?>>();
                 foreach (var url in _urls)
                 {
-                    tasks.Add(Task.Run(() => CreateSocket(url, true)));
-                    tasks.Add(Task.Run(() => CreateSocket(url, false)));
+                    tasks.Add(Task.Run(() => CreateSocket(url, true, ct)));
+                    tasks.Add(Task.Run(() => CreateSocket(url, false, ct)));
                 }
                 while (tasks.Count > 0)
                 {
@@ -235,11 +238,11 @@ namespace InABox.Rpc
                             }
                         });*/
 
-                        return;
+                        return _connected;
                     }
                 }
+                return _connected;
             }
-
         }
 
         public override bool IsConnected() => _connected;//_socket?.State == WebSocketState.Open;

+ 3 - 1
InABox.Core/DatabaseSchema/DatabaseSchema.cs

@@ -226,7 +226,9 @@ namespace InABox.Core
         private static void CheckProperties(Type type)
         {
             var entityName = type.EntityName();
-            if (type.IsSubclassOf(typeof(BaseObject)) && _Properties.GetValueOrDefault(entityName)?.Any(x => x.Value is StandardProperty) != true)
+            var props = _Properties.GetValueOrDefault(entityName);
+            var hasprops = props?.Any(x => x.Value is StandardProperty) == true;
+            if (type.IsSubclassOf(typeof(BaseObject)) && !hasprops)
                 RegisterProperties(type);
         }
 

+ 0 - 1
InABox.Server/InABox.Server.csproj

@@ -15,7 +15,6 @@
     </ItemGroup>
 
     <ItemGroup>
-        <ProjectReference Include="..\..\3rdpartylibs\websocket-sharp-master\websocket-sharp-standard\websocket-sharp-standard.csproj" />
         <ProjectReference Include="..\InABox.Configuration\InABox.Configuration.csproj" />
         <ProjectReference Include="..\InABox.Core\InABox.Core.csproj" />
         <ProjectReference Include="..\InABox.Database\InABox.Database.csproj" />

+ 1 - 26
InABox.Server/Rest/RestHandler.cs

@@ -16,12 +16,8 @@ namespace InABox.API
         private readonly List<string> endpoints;
         private readonly List<string> operations;
 
-        //private int? WebSocketPort;
-
-        public RestHandler(IHandler parent) //, int? webSocketPort)
+        public RestHandler(IHandler parent)
         {
-            // WebSocketPort = webSocketPort;
-
             Parent = parent;
 
             endpoints = new();
@@ -116,7 +112,6 @@ namespace InABox.API
                             {
                                 "validate" => new ValueTask<IResponse?>(Validate(request, data).Build()),
                                 "check_2fa" => new ValueTask<IResponse?>(Check2FA(request, data).Build()),
-                                //"notify" or "push" => new ValueTask<IResponse?>(GetPush(request, data).Build()),
                                 _ => HandleDatabaseRequest(request, data),
                             };
                         }
@@ -170,26 +165,6 @@ namespace InABox.API
             return SerializeResponse(request, data.ResponseFormat, data.BinarySerializationSettings, response);
         }
 
-        /// <summary>
-        /// Gets port for web socket
-        /// </summary>
-        /// <param name="request"></param>
-        /// <returns></returns>
-        // private IResponseBuilder GetPush(IRequest request, RequestData data)
-        // {
-        //     var requestObj = Deserialize<PushRequest>(request.Content, data.RequestFormat, data.BinarySerializationSettings, true);
-        //     if (!CredentialsCache.SessionExists(requestObj.Credentials.Session))
-        //     {
-        //         return request.Respond().Status(ResponseStatus.NotFound);
-        //     }
-        //     var response = new PushResponse
-        //     {
-        //         Status = StatusCode.OK,
-        //         SocketPort = WebSocketPort
-        //     };
-        //     return SerializeResponse(request, data.ResponseFormat, data.BinarySerializationSettings, response);
-        // }
-
         #region Authentication
 
         private IResponseBuilder Validate(IRequest request, RequestData data)

+ 1 - 8
InABox.Server/Rest/RestHandlerBuilder.cs

@@ -6,13 +6,6 @@ namespace InABox.API
     {
         private readonly List<IConcernBuilder> _Concerns = new();
 
-        //private int? WebSocketPort;
-
-        // public RestHandlerBuilder(int? webSocketPort)
-        // {
-        //     WebSocketPort = webSocketPort;
-        // }
-
         public RestHandlerBuilder Add(IConcernBuilder concern)
         {
             _Concerns.Add(concern);
@@ -21,7 +14,7 @@ namespace InABox.API
 
         public IHandler Build(IHandler parent)
         {
-            return Concerns.Chain(parent, _Concerns, p => new RestHandler(p)); //, WebSocketPort));
+            return Concerns.Chain(parent, _Concerns, p => new RestHandler(p));
         }
     }
 }

+ 0 - 33
InABox.Server/Rest/RestListener.cs

@@ -9,20 +9,17 @@ namespace InABox.API
     public static class RestListener
     {
         private static IServerHost? _host;
-        //private static RestPusher? _pusher;
 
         public static X509Certificate2? Certificate { get; private set; }
 
         public static void Start()
         {
             _host?.Start();
-            //_pusher?.Start();
         }
 
         public static void Stop()
         {
             _host?.Stop();
-            //_pusher?.Stop();
         }
 
         public static void Init(ushort port, X509Certificate2? cert)
@@ -38,35 +35,5 @@ namespace InABox.API
             else
                 _host?.Bind(IPAddress.Any, port);
         }
-        
-        // /// <summary>
-        // /// Clears certificate and host information, and stops the listener.
-        // /// </summary>
-        // public static void Clear()
-        // {
-        //     _host?.Stop();
-        //     _host = null;
-        //
-        //     //_pusher?.Stop();
-        //     //_pusher = null;
-        //
-        //     Certificate = null;
-        // }
-
-        /// <summary>
-        /// Initialise rest listener, and set up web socket port if non-zero.
-        /// </summary>
-        /// <param name="webSocketPort">The web-socket port to use, or 0 for no websocket.</param>
-        // public static void Init() //int webSocketPort)
-        // {
-        //     if(webSocketPort != 0)
-        //     {
-        //         _pusher = new RestPusher(webSocketPort);
-        //         PushManager.AddPusher(_pusher);
-        //     }
-        //
-        //     _host = Host.Create();
-        //     _host.Handler(new RestHandlerBuilder(_pusher?.Port)).Defaults().Backlog(1024);
-        // }
     }
 }

+ 0 - 57
InABox.Server/Rest/RestPusher.cs

@@ -1,57 +0,0 @@
-namespace InABox.API
-{
-
-    // class RestPusher : IPusher
-    // {
-    //     private WebSocketServer SocketServer;
-    //
-    //     public int Port => SocketServer.Port;
-    //
-    //     public RestPusher(int port)
-    //     {
-    //         SocketServer = new WebSocketServer(port);
-    //         SocketServer.Poll += SocketServer_Poll;
-    //     }
-    //
-    //     private void SocketServer_Poll(PushState.Session session)
-    //     {
-    //         PushManager.Poll(session.SessionID);
-    //     }
-    //
-    //     public void Start()
-    //     {
-    //         SocketServer.Start();
-    //     }
-    //
-    //     public void Stop()
-    //     {
-    //         SocketServer.Stop();
-    //     }
-    //
-    //     public void PushToAll<TPush>(TPush push) where TPush : BaseObject
-    //     {
-    //         SocketServer.Push(push);
-    //     }
-    //
-    //     public void PushToSession(Guid session, Type TPush, BaseObject push)
-    //     {
-    //         SocketServer.Push(session, TPush, push);
-    //     }
-    //
-    //     public void PushToSession<TPush>(Guid session, TPush push) where TPush : BaseObject
-    //     {
-    //         SocketServer.Push(session, push);
-    //     }
-    //
-    //     public IEnumerable<Guid> GetUserSessions(Guid userID)
-    //     {
-    //         return CredentialsCache.GetUserSessions(userID);
-    //     }
-    //
-    //     public IEnumerable<Guid> GetSessions(Platform platform)
-    //     {
-    //         return SocketServer.GetSessions(platform);
-    //     }
-    // }
-    
-}

+ 1 - 1
inabox.client.ipc/IPCClient.cs

@@ -38,7 +38,7 @@ namespace InABox.IPC
             {
                 var request = new InfoRequest();
                 PrepareRequest(request, false);
-                var response = Send(IPCMessage.Info(request)).GetResponse<InfoResponse>();
+                var response = Send(IPCMessage.Info(request), 5000).GetResponse<InfoResponse>();
                 return response.Info;
             }
             catch (Exception)

+ 0 - 17
inabox.client.rest/InABox.Client.Rest/RestClient.cs

@@ -7,7 +7,6 @@ using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using System.Threading.Tasks;
-using InABox.WebSocket.Shared;
 
 
 namespace InABox.Clients
@@ -136,22 +135,6 @@ namespace InABox.Clients
             if (response != null)
                 if (response.Status.Equals(StatusCode.OK))
                 {
-                    if(response.Session != Guid.Empty)
-                    {
-                        var pushRequest = new PushRequest();
-                        PrepareRequest(pushRequest);
-
-                        // Session is required so that the server can exclude any requests from bad actors
-                        pushRequest.Credentials.Session = response.Session;
-                        var pushResponse = SendRequest<PushRequest, PushResponse>(pushRequest, "push", SerializationFormat.Binary, SerializationFormat.Binary, false);
-                        if(pushResponse != null && pushResponse.Status.Equals(StatusCode.OK))
-                        {
-                            if (pushResponse.SocketPort.HasValue)
-                            {
-                                SocketClientCache.StartWebSocket(_server, pushResponse.SocketPort.Value, response.Session);
-                            }
-                        }
-                    }
                     PasswordCache.Password = password;
                     return new ValidationData(
                         response.ValidationStatus,

+ 0 - 22
inabox.client.rest/InABox.Client.Rest/SocketClientCache.cs

@@ -1,22 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using InABox.Client.WebSocket;
-
-namespace InABox.Clients
-{
-    public static class SocketClientCache
-    {
-        private static Dictionary<string, WebSocketClient> Clients = new Dictionary<string, WebSocketClient>();
-
-        public static void StartWebSocket(string url, int port, Guid session)
-        {
-            var host = url.Split(new[] { "://" }, StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
-
-            Uri uri = new Uri($"http://{host}");
-            var key = $"{uri.Host}:{uri.Port}${session}";
-            if (!Clients.ContainsKey(key))
-                Clients[key] = new WebSocketClient(uri.Host, port, session);
-        }
-    }
-}

+ 0 - 85
inabox.client.websocket/WebSocketClient.cs

@@ -1,85 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using WebSocketSharp;
-using Socket = WebSocketSharp.WebSocket;
-using Logger = InABox.Core.Logger;
-using InABox.Core;
-using InABox.Clients;
-using InABox.WebSocket.Shared;
-
-namespace InABox.Client.WebSocket
-{
-    public class WebSocketClient : IDisposable
-    {
-        private Socket Socket;
-        private Guid Session;
-        private bool Closed = false;
-
-        public WebSocketClient(string url, int port, Guid session)
-        {
-            Session = session;
-            Uri uri = new Uri("http://" + url);
-            Socket = new Socket($"ws://{uri.Host}:{port}/push");
-            Socket.OnOpen += Socket_OnOpen;
-            Socket.OnError += Socket_OnError;
-            Socket.OnClose += Socket_OnClose;
-            Socket.OnMessage += Socket_OnMessage;
-            Socket.Connect();
-
-            // Time to wait before disconnect - the default meant that the client disconnected during debugging, since the ping would fail
-            Socket.WaitTime = TimeSpan.FromMinutes(10); 
-        }
-
-        private void Socket_OnMessage(object? sender, MessageEventArgs e)
-        {
-            var message = SocketMessage.ReadMessage(e.RawData);
-            if(message is PushMessage pushMessage)
-            {
-                var pushType = CoreUtils.GetEntity(pushMessage.EntityType);
-                var pushObject = Serialization.Deserialize(pushType, pushMessage.EntityData);
-                ClientFactory.PushHandlers.Push(pushType, pushObject);
-            }
-            else if(message is InitialMessage)
-            {
-            }
-        }
-
-        private void Socket_OnOpen(object? sender, EventArgs e)
-        {
-            Logger.Send(LogType.Information, "", "WebSocket connected to server");
-
-            var initial = new InitialMessage(Session, ClientFactory.Platform);
-            Socket.Send(initial.WriteToBytes());
-        }
-
-        private void Socket_OnClose(object? sender, CloseEventArgs e)
-        {
-            Logger.Send(LogType.Information, "", "WebSocket disconnected from server");
-            if (!Closed)
-            {
-                Task.Run(() =>
-                {
-                    if (!Socket.IsAlive)
-                    {
-                        Task.Delay(30_000).Wait(); // Try to reconnect after 30 seconds
-                        Socket.Connect();
-                    }
-                });
-            }
-        }
-
-        private void Socket_OnError(object? sender, ErrorEventArgs e)
-        {
-            Logger.Send(LogType.Error, "", $"WebSocket Error: {e.Message}");
-        }
-
-        public void Dispose()
-        {
-            Closed = true;
-            if (Socket.IsAlive)
-            {
-                Socket.Close();
-            }
-        }
-    }
-}

+ 6 - 0
inabox.wpf/DynamicGrid/DynamicGrid.cs

@@ -414,6 +414,9 @@ namespace InABox.DynamicGrid
         public override event EntitySaveEvent? OnBeforeSave;
         public override event EntitySaveEvent? OnAfterSave;
 
+        public delegate void EditorLoaded(IDynamicEditorForm editor, T[] items);
+        public event EditorLoaded OnEditorLoaded;
+
         #endregion
 
         private DynamicGridCellStyleConverter<System.Windows.Media.Brush?> CellBackgroundConverter;
@@ -2050,6 +2053,7 @@ namespace InABox.DynamicGrid
                 cursor.Dispose();
                 cursor = null;
             }
+            bRefreshing = false;
         }
 
         protected override bool OnBeforeRefresh()
@@ -2673,6 +2677,7 @@ namespace InABox.DynamicGrid
             OnBeforeSave?.Invoke(editor, items);
         }
 
+
         public override bool EditItems(T[] items, Func<Type, CoreTable>? PageDataHandler = null, bool PreloadPages = false)
         {
             DynamicEditorForm editor;
@@ -2682,6 +2687,7 @@ namespace InABox.DynamicGrid
                 editor.SetValue(Panel.ZIndexProperty, 999);
 
                 InitialiseEditorForm(editor, items, PageDataHandler, PreloadPages);
+                OnEditorLoaded?.Invoke(editor, items);
             }
 
             return editor.ShowDialog() == true;

+ 31 - 0
inabox.wpf/DynamicGrid/PDF/DocumentApprovalControl.xaml

@@ -0,0 +1,31 @@
+<UserControl x:Class="InABox.Wpf.DocumentApprovalControl"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
+             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
+             mc:Ignorable="d" 
+             d:DesignHeight="450" d:DesignWidth="800">
+    <Grid>
+        <Grid.RowDefinitions>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="auto"/>
+        </Grid.RowDefinitions>
+        <ScrollViewer VerticalScrollBarVisibility="Auto" Grid.Row="0">
+            <StackPanel x:Name="viewer" Orientation="Vertical" Margin="10"/>
+        </ScrollViewer>
+
+        <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="0.5">
+            <Grid>
+                <Grid.ColumnDefinitions>
+                    <ColumnDefinition Width="auto"/>
+                    <ColumnDefinition Width="*"/>
+                </Grid.ColumnDefinitions>
+                <Button Grid.Column="0" Content="Mark Up" HorizontalAlignment="Left" Height="35" Padding="20, 5, 20, 5" Margin="5" x:Name="markUpButton" Click="MarkUpButton_Click" IsEnabled="False"/>
+                <StackPanel Grid.Column="1" Orientation="Horizontal"  HorizontalAlignment="Right">
+                    <Button Grid.Column="1" Content="Approve" HorizontalAlignment="Right" Height="35" Padding="20, 5, 20, 5" Margin="5" x:Name="approveButton" Click="ApproveButton_Click"/>
+                    <Button Grid.Column="2" Content="Reject" HorizontalAlignment="Right" Height="35" Padding="20, 5, 20, 5" Margin="5, 5, 15, 5" x:Name="rejectButton" Click="RejectButton_Click" IsEnabled="False"/>
+                </StackPanel>
+            </Grid>
+        </Border>
+    </Grid>
+</UserControl>

+ 135 - 0
inabox.wpf/DynamicGrid/PDF/DocumentApprovalControl.xaml.cs

@@ -0,0 +1,135 @@
+using InABox.Clients;
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.WPF;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Navigation;
+using System.Windows.Shapes;
+
+namespace InABox.Wpf
+{
+    /// <summary>
+    /// Interaction logic for DocumentApprovalControl.xaml
+    /// </summary>
+    public partial class DocumentApprovalControl : UserControl, IDocumentEditor
+    {
+        public delegate void MarkupSelected(IEntityDocument document);
+        public event MarkupSelected OnMarkupSelected;
+
+        public delegate void MarkupComplete(IEntityDocument document);
+        public event MarkupComplete OnMarkupComplete;
+
+        public delegate void Approved(IEntityDocument document);
+        public event Approved OnApproved;
+
+        public delegate void Rejected(IEntityDocument document);
+        public event Rejected OnRejected;
+        public enum ControlMode
+        {
+            Markup,
+            Complete
+        }
+
+        ControlMode _mode;
+
+        public ControlMode Mode
+        {
+            get => _mode;
+            set
+            {
+                _mode = value;
+                if (_mode == ControlMode.Markup)
+                {
+                    markUpButton.Content = "Mark Up";
+                    markUpButton.IsEnabled = Document != null;
+                    approveButton.IsEnabled = Document != null;
+                    rejectButton.IsEnabled = Document != null;
+                }
+                else if (_mode == ControlMode.Complete)
+                {
+                    markUpButton.Content = "Complete";
+                    approveButton.IsEnabled = false;
+                    rejectButton.IsEnabled = false;
+                }
+            }
+        }
+
+        public void SetupButtons(bool markupVisible = true, bool approveVisible = true, bool rejectVisible = true)
+        { 
+            markUpButton.Visibility = markupVisible? Visibility.Visible : Visibility.Collapsed;
+            approveButton.Visibility = approveVisible? Visibility.Visible : Visibility.Collapsed;
+            rejectButton.Visibility = rejectVisible? Visibility.Visible : Visibility.Collapsed;
+        }
+
+        private IEntityDocument _document;
+        public IEntityDocument Document
+        {
+            get => _document;
+            set
+            {
+                _document = value;
+                Mode = ControlMode.Markup;
+                Render();
+            }
+        }
+        public DocumentApprovalControl()
+        {
+            InitializeComponent();
+            Mode = ControlMode.Markup;
+        }
+
+        private void Render()
+        {
+            viewer.Children.Clear();
+            var table = new Client<Document>().Query(new Filter<Document>(x => x.ID).IsEqualTo(_document.DocumentLink.ID));
+            if (!table.Rows.Any())
+                return;
+            var data = table.Rows.FirstOrDefault().Get<Document, byte[]>(x => x.Data);
+            var images = ImageUtils.RenderPDFToImages(data);
+            foreach (var image in images)
+            {
+                viewer.Children.Add(new Image
+                {
+                    Source = ImageUtils.LoadImage(image),
+                    Margin = new Thickness(0, 0, 0, 20)
+                });
+            }
+        }
+
+        private void MarkUpButton_Click(object sender, RoutedEventArgs e)
+        {
+            if (Mode == ControlMode.Markup)
+            {
+                Mode = ControlMode.Complete;
+                MessageBox.Show("IMPORTANT - please save and overwrite the original file in the setouts folder when done, and press complete to refresh");
+                OnMarkupSelected?.Invoke(_document);
+            }
+            else
+            {
+                OnMarkupComplete?.Invoke(_document);
+                Mode = ControlMode.Markup;
+            }
+        }
+
+        private void ApproveButton_Click(object sender, RoutedEventArgs e)
+        {
+            OnApproved?.Invoke(_document);
+        }
+
+        private void RejectButton_Click(object sender, RoutedEventArgs e)
+        {
+            OnRejected?.Invoke(_document);
+        }
+    }
+}

+ 52 - 2
inabox.wpf/ProgressWindow/Progress.cs

@@ -1,6 +1,7 @@
 using System;
 using System.ComponentModel;
 using System.Linq;
+using System.Threading;
 using System.Windows.Media.Imaging;
 
 namespace InABox.WPF
@@ -51,16 +52,65 @@ namespace InABox.WPF
             progress.ShowDialog();
         }
 
+        private static void RunShowModal(ProgressForm progress, Action<IProgress<string>> work)
+        {
+        }
+
+        /// <summary>
+        /// Shows progress dialog modally, with a cancel button.
+        /// </summary>
+        /// <param name="message"></param>
+        /// <param name="cancelButtonText"></param>
+        /// <param name="work"></param>
+        /// <returns>Whether the progress was completed without cancelling.</returns>
+        public static bool ShowModal(string message, string cancelButtonText, Action<IProgress<string>, CancellationToken> work)
+        {
+            var cancellationTokenSource = new CancellationTokenSource();
+
+            var progress = new ProgressForm(cancelButtonText)
+            {
+                DisplayImage = DisplayImage
+            };
+            progress.Progress.Content = message;
+
+            progress.Loaded += (_, args) =>
+            {
+                var worker = new BackgroundWorker();
+                var update = new Progress<string>(data => progress.Progress.Content = data);
+
+                progress.OnCancelled += () =>
+                {
+                    cancellationTokenSource.Cancel();
+                    progress.Close();
+                };
+
+                worker.DoWork += (o, e) => work(update, cancellationTokenSource.Token);
+                worker.RunWorkerCompleted +=
+                    (o, e) => progress.Close();
+                worker.RunWorkerAsync();
+            };
+
+            progress.ShowDialog();
+            return !cancellationTokenSource.IsCancellationRequested;
+        }
+
         public static void ShowModal(string message, Action<IProgress<string>> work)
         {
-            var progress = new ProgressForm();
-            progress.DisplayImage = DisplayImage;
+            var progress = new ProgressForm
+            {
+                DisplayImage = DisplayImage
+            };
             progress.Progress.Content = message;
             progress.Loaded += (_, args) =>
             {
                 var worker = new BackgroundWorker();
                 var update = new Progress<string>(data => progress.Progress.Content = data);
 
+                progress.OnCancelled += () =>
+                {
+                    worker.CancelAsync();
+                };
+
                 worker.DoWork += (o, e) => work(update);
                 worker.RunWorkerCompleted +=
                     (o, e) => progress.Close();

+ 29 - 4
inabox.wpf/ProgressWindow/ProgressForm.xaml

@@ -13,17 +13,34 @@
         AllowsTransparency="True"
         Background="Transparent"
         MouseDown="Window_MouseDown"
-        Width="350" Height="200">
+        Width="350" Height="200"
+                    x:Name="Window">
+    <Window.Resources>
+        <Style TargetType="Button" x:Key="RoundButton">
+            <Style.Resources>
+                <Style TargetType="Border">
+                    <Setter Property="CornerRadius" Value="10"/>
+                </Style>
+            </Style.Resources>
+        </Style>
+    </Window.Resources>
     <Border CornerRadius="10" BorderBrush="Gray" BorderThickness="0.75" Padding="5,5,5,0" Background="White">
         <Grid Margin="5">
             <Grid.RowDefinitions>
                 <RowDefinition Height="*" />
                 <RowDefinition Height="Auto" />
+                <RowDefinition Height="Auto" />
             </Grid.RowDefinitions>
-            <Image x:Name="Splash" Margin="20,10,20,10" Stretch="Uniform" Source="../Resources/splash.png" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
-            
+            <Grid.ColumnDefinitions>
+                <ColumnDefinition Width="*"/>
+                <ColumnDefinition Width="Auto"/>
+                <ColumnDefinition Width="*"/>
+            </Grid.ColumnDefinitions>
+            <Image Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3"
+                   x:Name="Splash" Margin="20,10,20,10" Stretch="Uniform" Source="../Resources/splash.png" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" />
+
             <Label
-                Grid.Row="1"
+                Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3"
                 FontSize="14"
                 FontStyle="Oblique"
                 x:Name="Progress"
@@ -33,6 +50,14 @@
                 HorizontalContentAlignment="Center"
                 VerticalContentAlignment="Center"
                 Background="Transparent" />
+
+            <Button x:Name="CancelButton" Style="{StaticResource RoundButton}"
+                    Background="LightYellow"
+                    Content="{Binding ElementName=Window,Path=CancelText}"
+                    Visibility="{Binding ElementName=Window,Path=CancelButtonVisibility}"
+                    Padding="5" Margin="5"
+                    Grid.Row="2" Grid.Column="1"
+                    Click="CancelButton_Click"/>
         </Grid>
     </Border>
 </wpf:ThemableWindow>

+ 22 - 1
inabox.wpf/ProgressWindow/ProgressForm.xaml.cs

@@ -16,8 +16,20 @@ namespace InABox.WPF
 
         private DoubleAnimation _fader = new DoubleAnimation(1d, 0.3d, new Duration(TimeSpan.FromMilliseconds(3000))) { AutoReverse = true };
 
-        public ProgressForm()
+        public string CancelText { get; init; }
+        public bool HasCancelButton { get; init; }
+
+        public delegate void CancelledEvent();
+
+        public event CancelledEvent? OnCancelled;
+
+        public Visibility CancelButtonVisibility => HasCancelButton ? Visibility.Visible : Visibility.Collapsed;
+
+        private ProgressForm(string cancelText, bool hasCancelButton)
         {
+            CancelText = cancelText;
+            HasCancelButton = hasCancelButton;
+
             InitializeComponent();
             Topmost = true;
             Loaded += (sender, args) =>
@@ -26,6 +38,10 @@ namespace InABox.WPF
                 Splash.BeginAnimation(Image.OpacityProperty, _fader);
             };
         }
+
+        public ProgressForm(): this("", false) { }
+
+        public ProgressForm(string cancelText): this(cancelText, true) { }
         
         public ImageSource? DisplayImage
         {
@@ -61,5 +77,10 @@ namespace InABox.WPF
                 DragMove();
             }
         }
+
+        private void CancelButton_Click(object sender, RoutedEventArgs e)
+        {
+            OnCancelled?.Invoke();
+        }
     }
 }