Browse Source

Added document manipulation window and drag-drop from outlook.

Kenric Nugteren 2 years ago
parent
commit
f11ee9bd75

+ 87 - 0
prs.desktop/Panels/DataEntry/DocumentManipulationWindow.xaml

@@ -0,0 +1,87 @@
+<Window x:Name="Window"
+        x:Class="PRSDesktop.DocumentManipulationWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+        xmlns:local="clr-namespace:PRSDesktop"
+        xmlns:dataentry="clr-namespace:PRSDesktop.Panels.DataEntry"
+        mc:Ignorable="d"
+        Title="Group Documents" Height="600" Width="1000">
+    <Grid>
+        <Grid.RowDefinitions>
+            <RowDefinition Height="Auto"/>
+            <RowDefinition Height="*"/>
+            <RowDefinition Height="Auto"/>
+        </Grid.RowDefinitions>
+        <Grid.ColumnDefinitions>
+            <ColumnDefinition Width="*"/>
+            <ColumnDefinition Width="Auto"/>
+        </Grid.ColumnDefinitions>
+        <DockPanel Grid.Row="0" Grid.Column="0">
+            <Label Content="Select Documents" FontWeight="Bold"/>
+        </DockPanel>
+        <DockPanel Grid.Row="0" Grid.Column="1">
+            <Label Content="Groups" FontWeight="Bold"/>
+        </DockPanel>
+        <ScrollViewer Grid.Row="1" Grid.Column="0"
+                      Margin="5" 
+                      VerticalScrollBarVisibility="Auto"
+                      BorderBrush="Gray"
+                      BorderThickness="1">
+            <WrapPanel Name="Documents"
+                       Background="DimGray"
+                       AllowDrop="True"
+                       PreviewDragOver="Documents_DragOver"
+                       Drop="Documents_Drop"/>
+        </ScrollViewer>
+        <ListBox x:Name="GroupList" ItemsSource="{Binding ElementName=Window,Path=Groups}"
+                 Grid.Row="1" Grid.Column="1"
+                 HorizontalContentAlignment="Stretch"
+                 VerticalContentAlignment="Stretch">
+            <ListBox.ItemTemplate>
+                <DataTemplate DataType="{x:Type dataentry:DocumentGroup}">
+                    <Label Content="{Binding FileName}">
+                        <Label.ContextMenu>
+                            <ContextMenu>
+                                <MenuItem Header="Un-group Pages" Tag="{Binding}" Click="Ungroup_Click"/>
+                            </ContextMenu>
+                        </Label.ContextMenu>
+                    </Label>
+                </DataTemplate>
+            </ListBox.ItemTemplate>
+        </ListBox>
+        <DockPanel Grid.Row="2" Grid.Column="0">
+            <Label Content="Document Name"
+                   DockPanel.Dock="Left"
+                   Margin="5"
+                   VerticalContentAlignment="Center"/>
+            <Button x:Name="Group" Content="Group" 
+                    DockPanel.Dock="Right"
+                    Margin="0,5,5,5"
+                    Padding="5"
+                    Width="70"
+                    IsEnabled="False"
+                    Click="Group_Click"/>
+            <TextBox x:Name="GroupName" Background="LightYellow"
+                     DockPanel.Dock="Left"
+                     VerticalContentAlignment="Center"
+                     Margin="0,5,5,5"/>
+        </DockPanel>
+        <DockPanel Grid.Row="2" Grid.Column="1"
+                   LastChildFill="False">
+            <Button Content="Cancel" 
+                    DockPanel.Dock="Right"
+                    Margin="5"
+                    Padding="5"
+                    Width="70"
+                    Click="Cancel_Click"/>
+            <Button Content="OK" 
+                    DockPanel.Dock="Right"
+                    Margin="5,5,0,5"
+                    Padding="5"
+                    Width="70"
+                    Click="OK_Click"/>
+        </DockPanel>
+    </Grid>
+</Window>

+ 341 - 0
prs.desktop/Panels/DataEntry/DocumentManipulationWindow.xaml.cs

@@ -0,0 +1,341 @@
+using InABox.Core;
+using InABox.WPF;
+using Microsoft.Office.Interop.Outlook;
+using Motorola.Snapi.Attributes;
+using PRSDesktop.Panels.DataEntry;
+using Syncfusion.Pdf;
+using Syncfusion.Pdf.Graphics;
+using Syncfusion.Pdf.Parsing;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Drawing;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+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 DocumentPage = PRSDesktop.Panels.DataEntry.DocumentPage;
+using Image = System.Windows.Controls.Image;
+
+namespace PRSDesktop
+{
+    namespace Panels.DataEntry
+    {
+        public class DocumentGroup
+        {
+            public string FileName { get; set; }
+
+            public List<DocumentManipulationWindow.Page> Pages { get; set; }
+
+            public DocumentGroup(string fileName, List<DocumentManipulationWindow.Page> pages)
+            {
+                FileName = fileName;
+                Pages = pages;
+            }
+        }
+    }
+
+
+    /// <summary>
+    /// Interaction logic for DocumentManipulationWindow.xaml
+    /// </summary>
+    public partial class DocumentManipulationWindow : Window
+    {
+        public List<Page> Pages { get; set; }
+        public ObservableCollection<DocumentGroup> Groups { get; set; }
+
+        public class Page
+        {
+            public string FileName { get; set; }
+
+            public PdfLoadedDocument Pdf { get; set; }
+
+            public int PageIndex { get; set; }
+
+            public BitmapImage? Thumbnail { get; set; }
+
+            public Page(string fileName, PdfLoadedDocument pdf, int pageIndex)
+            {
+                FileName = fileName;
+                Pdf = pdf;
+                PageIndex = pageIndex;
+            }
+        }
+
+        public DocumentManipulationWindow(List<Page> pages, string filename)
+        {
+            InitializeComponent();
+
+            Groups = new();
+            GroupName.Text = Path.ChangeExtension(filename, ".pdf");
+            Pages = pages;
+            ReloadPages();
+        }
+
+        private void ReloadPages()
+        {
+            Documents.Children.Clear();
+            foreach (var page in Pages)
+            {
+                page.Thumbnail ??= page.Pdf.ToLoadedDocument().ExportAsImage(page.PageIndex).AsBitmapImage(false);
+
+                var width = page.Thumbnail.Width;
+                var height = page.Thumbnail.Height;
+
+                var aspectRatio = width / height;
+
+                if(height > 300)
+                {
+                    height = 300;
+                    width = aspectRatio * height;
+                }
+                if(width > 300)
+                {
+                    width = 300;
+                    height = width / aspectRatio;
+                }
+
+                var docPage = new DocumentPage(page) { Height = height, Margin = new Thickness(5) };
+                docPage.OnSelected += DocPage_OnSelected;
+
+                Documents.Children.Add(docPage);
+            }
+        }
+
+        private void DocPage_OnSelected(DocumentPage page, bool selected)
+        {
+            Group.IsEnabled = Documents.Children.Cast<DocumentPage>().Any(x => x.Selected);
+        }
+
+        private void Cancel_Click(object sender, RoutedEventArgs e)
+        {
+            DialogResult = false;
+        }
+
+        private void OK_Click(object sender, RoutedEventArgs e)
+        {
+            if (Pages.Any())
+            {
+                if (string.IsNullOrWhiteSpace(GroupName.Text))
+                {
+                    MessageBox.Show("You still have ungrouped pages. Please group them into a document and try again.");
+                    return;
+                }
+                else
+                {
+                    var filename = Path.ChangeExtension(GroupName.Text, ".pdf");
+                    if(MessageBox.Show($"You still have ungrouped pages. Do you wish to combine them into a document called \"{filename}\"?", "Combine remaining?", MessageBoxButton.YesNo) == MessageBoxResult.Yes)
+                    {
+                        var group = new DocumentGroup(filename, Pages.ToList());
+                        Pages.Clear();
+                        Groups.Add(group);
+                    }
+                    else
+                    {
+                        MessageBox.Show("Please group the remaining pages into a document and try again.");
+                        return;
+                    }
+                }
+            }
+
+            DialogResult = true;
+        }
+
+        #region Grouping
+
+        private void Group_Click(object sender, RoutedEventArgs e)
+        {
+            var filename = Path.ChangeExtension(GroupName.Text, ".pdf");
+            if (string.IsNullOrWhiteSpace(filename))
+            {
+                MessageBox.Show("Please specify a document name!");
+                GroupName.Focus();
+                return;
+            }
+
+            var selected = Documents.Children.Cast<DocumentPage>().Where(x => x.Selected).ToList();
+            foreach(var page in selected)
+            {
+                Documents.Children.Remove(page);
+                Pages.Remove(page.Page);
+            }
+            var group = new DocumentGroup(filename, selected.Select(x => x.Page).ToList());
+            Groups.Add(group);
+            GroupList.ItemsSource = Groups;
+            GroupName.Text = "";
+            Group.IsEnabled = false;
+        }
+
+        private void Ungroup_Click(object sender, RoutedEventArgs e)
+        {
+            if ((sender as MenuItem)?.Tag is not DocumentGroup group) return;
+
+            foreach(var page in group.Pages)
+            {
+                Pages.Add(page);
+            }
+            Groups.Remove(group);
+            GroupList.ItemsSource = Groups;
+            ReloadPages();
+        }
+
+        #endregion
+
+        #region Drag & Drop
+
+        private static PdfDocumentBase RenderToPDF(string filename, Stream stream)
+        {
+            PdfDocumentBase pdf;
+
+            var extension = Path.GetExtension(filename).ToLower();
+            if (extension == ".pdf")
+            {
+                pdf = new PdfLoadedDocument(stream);
+            }
+            else if (extension == ".jpg" || extension == ".jpeg" || extension == ".png" || extension == ".bmp")
+            {
+                var image = new PdfBitmap(stream);
+
+                var doc = new PdfDocument();
+
+                var section = doc.Sections.Add();
+                section.PageSettings.Margins.All = 0;
+                section.PageSettings.Width = image.Width;
+                section.PageSettings.Height = image.Height;
+
+                var page = section.Pages.Add();
+                page.Graphics.DrawImage(image, 0, 0, page.Size.Width, page.Size.Height);
+
+                pdf = doc;
+            }
+            else
+            {
+                var bytes = new byte[stream.Length];
+                stream.Read(bytes, 0, bytes.Length);
+                var text = Encoding.UTF8.GetString(bytes);
+
+                var doc = new PdfDocument();
+                var page = doc.Pages.Add();
+
+                var font = new PdfStandardFont(PdfFontFamily.Courier, 14);
+                var textElement = new PdfTextElement(text, font);
+                var layoutFormat = new PdfLayoutFormat
+                {
+                    Layout = PdfLayoutType.Paginate,
+                    Break = PdfLayoutBreakType.FitPage
+                };
+
+                textElement.Draw(page, new RectangleF(0, 0, page.GetClientSize().Width, page.GetClientSize().Height), layoutFormat);
+
+                pdf = doc;
+            }
+            return pdf;
+        }
+
+        private static PdfDocumentBase RenderToPDF(string filename)
+        {
+            using var stream = new FileStream(filename, FileMode.Open);
+            return RenderToPDF(filename, stream);
+        }
+
+        public static List<Page>? HandleFileDrop(DragEventArgs e, out string fileName)
+        {
+            var dataObject = new OutlookDataObject(e.Data);
+
+            string? desc = null;
+            if (dataObject.GetDataPresent("FileGroupDescriptor")) desc = "FileGroupDescriptor";
+            else if (dataObject.GetDataPresent("FileGroupDescriptorW")) desc = "FileGroupDescriptorW";
+
+            if (desc is not null)
+            {
+                var pages = new List<DocumentManipulationWindow.Page>();
+                var filenames = (string[])dataObject.GetData(desc);
+                var filestreams = (MemoryStream[])dataObject.GetData("FileContents");
+                fileName = "";
+                for (var i = 0; i < filenames.Length; i++)
+                {
+                    var filename = filenames[i];
+                    fileName = filename;
+                    var filestream = filestreams[i];
+                    var doc = RenderToPDF(filename, filestream);
+                    var loaded = doc.ToLoadedDocument();
+                    for (int j = 0; j < loaded.PageCount(); ++j)
+                    {
+                        pages.Add(new(filename, loaded, j));
+                    }
+                }
+                return pages;
+            }
+            else if (dataObject.GetDataPresent(DataFormats.FileDrop))
+            {
+                var pages = new List<Page>();
+                fileName = "";
+                foreach (var filename in (string[])dataObject.GetData(DataFormats.FileDrop))
+                {
+                    if (File.Exists(filename))
+                    {
+                        fileName = filename;
+                        var doc = RenderToPDF(filename);
+                        var loaded = doc.ToLoadedDocument();
+                        for (int i = 0; i < loaded.PageCount(); ++i)
+                        {
+                            pages.Add(new(filename, loaded, i));
+                        }
+                    }
+                }
+                return pages;
+            }
+            fileName = "";
+            return null;
+        }
+
+        private void Documents_Drop(object sender, DragEventArgs e)
+        {
+            Task.Run(() =>
+            {
+                var pages = HandleFileDrop(e, out var filename);
+                if (pages is not null)
+                {
+                    Dispatcher.Invoke(() =>
+                    {
+                        foreach (var page in pages)
+                        {
+                            Pages.Add(page);
+                            ReloadPages();
+                        }
+                        if (string.IsNullOrWhiteSpace(GroupName.Text))
+                        {
+                            GroupName.Text = Path.ChangeExtension(filename, ".pdf");
+                        }
+                    });
+                }
+            });
+        }
+
+        private void Documents_DragOver(object sender, DragEventArgs e)
+        {
+            if (e.Data.GetDataPresent(DataFormats.FileDrop) || e.Data.GetDataPresent("FileGroupDescriptor"))
+            {
+                e.Effects = DragDropEffects.Copy;
+            }
+            else
+            {
+                e.Effects = DragDropEffects.None;
+            }
+            e.Handled = true;
+        }
+
+        #endregion
+
+    }
+}

+ 19 - 0
prs.desktop/Panels/DataEntry/DocumentPage.xaml

@@ -0,0 +1,19 @@
+<UserControl x:Name="Control"
+             x:Class="PRSDesktop.Panels.DataEntry.DocumentPage"
+             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" 
+             xmlns:local="clr-namespace:PRSDesktop.Panels.DataEntry"
+             mc:Ignorable="d" 
+             d:DesignHeight="450" d:DesignWidth="800">
+    <Border x:Name="Selection"
+            Padding="15"
+            BorderBrush="{Binding ElementName=Control,Path=SelectionBorder}" BorderThickness="1"
+            Background="{Binding ElementName=Control,Path=SelectionBackground}"
+            MouseDown="Border_MouseDown">
+        <Border BorderBrush="Black" BorderThickness="1">
+            <Image x:Name="Image" Source="{Binding ElementName=Control,Path=Page.Thumbnail}"/>
+        </Border>
+    </Border>
+</UserControl>

+ 77 - 0
prs.desktop/Panels/DataEntry/DocumentPage.xaml.cs

@@ -0,0 +1,77 @@
+using netDxf.Units;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+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 PRSDesktop.Panels.DataEntry
+{
+    /// <summary>
+    /// Interaction logic for DocumentPage.xaml
+    /// </summary>
+    public partial class DocumentPage : UserControl, INotifyPropertyChanged
+    {
+        public DocumentManipulationWindow.Page Page { get; set; }
+
+        private static readonly Brush SelectedBorderBrush = new SolidColorBrush(Colors.LightBlue);
+        private static readonly Brush SelectedBackgroundBrush = new SolidColorBrush(new Color
+        {
+            A = 128,
+            R = Colors.LightBlue.R,
+            G = Colors.LightBlue.G,
+            B = Colors.LightBlue.B,
+        });
+
+        private static readonly Brush TransparentBrush = new SolidColorBrush(Colors.Transparent);
+
+        public Brush SelectionBorder => Selected ? SelectedBorderBrush : TransparentBrush;
+        public Brush SelectionBackground => Selected ? SelectedBackgroundBrush : TransparentBrush;
+
+        private bool selected;
+        public bool Selected
+        {
+            get => selected;
+            set
+            {
+                selected = value;
+                OnPropertyChanged(nameof(SelectionBorder));
+                OnPropertyChanged(nameof(SelectionBackground));
+                OnSelected?.Invoke(this, Selected);
+            }
+        }
+
+        public delegate void OnSelectedHandler(DocumentPage page, bool selected);
+        public event OnSelectedHandler? OnSelected;
+
+        public DocumentPage(DocumentManipulationWindow.Page page)
+        {
+            Page = page;
+
+            InitializeComponent();
+        }
+
+        public event PropertyChangedEventHandler? PropertyChanged;
+
+        public void OnPropertyChanged([CallerMemberName] string name = "")
+        {
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+        }
+
+        private void Border_MouseDown(object sender, MouseButtonEventArgs e)
+        {
+            Selected = !Selected;
+        }
+    }
+}

+ 558 - 0
prs.desktop/Panels/DataEntry/OutlookDataObject.cs

@@ -0,0 +1,558 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.ComTypes;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+
+namespace PRSDesktop.Panels.DataEntry
+{
+    // https://gist.github.com/MattyBoy4444/521547
+    public class OutlookDataObject : System.Windows.IDataObject
+    {
+        #region NativeMethods
+
+        private class NativeMethods
+        {
+            [DllImport("kernel32.dll")]
+            static extern IntPtr GlobalLock(IntPtr hMem);
+
+            [DllImport("ole32.dll", PreserveSig = false)]
+            public static extern ILockBytes CreateILockBytesOnHGlobal(IntPtr hGlobal, bool fDeleteOnRelease);
+
+            [DllImport("OLE32.DLL", CharSet = CharSet.Auto, PreserveSig = false)]
+            public static extern IntPtr GetHGlobalFromILockBytes(ILockBytes pLockBytes);
+
+            [DllImport("OLE32.DLL", CharSet = CharSet.Unicode, PreserveSig = false)]
+            public static extern IStorage StgCreateDocfileOnILockBytes(ILockBytes plkbyt, uint grfMode, uint reserved);
+
+            [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("0000000B-0000-0000-C000-000000000046")]
+            public interface IStorage
+            {
+                [return: MarshalAs(UnmanagedType.Interface)]
+                IStream CreateStream([In, MarshalAs(UnmanagedType.BStr)] string pwcsName, [In, MarshalAs(UnmanagedType.U4)] int grfMode, [In, MarshalAs(UnmanagedType.U4)] int reserved1, [In, MarshalAs(UnmanagedType.U4)] int reserved2);
+                [return: MarshalAs(UnmanagedType.Interface)]
+                IStream OpenStream([In, MarshalAs(UnmanagedType.BStr)] string pwcsName, IntPtr reserved1, [In, MarshalAs(UnmanagedType.U4)] int grfMode, [In, MarshalAs(UnmanagedType.U4)] int reserved2);
+                [return: MarshalAs(UnmanagedType.Interface)]
+                IStorage CreateStorage([In, MarshalAs(UnmanagedType.BStr)] string pwcsName, [In, MarshalAs(UnmanagedType.U4)] int grfMode, [In, MarshalAs(UnmanagedType.U4)] int reserved1, [In, MarshalAs(UnmanagedType.U4)] int reserved2);
+                [return: MarshalAs(UnmanagedType.Interface)]
+                IStorage OpenStorage([In, MarshalAs(UnmanagedType.BStr)] string pwcsName, IntPtr pstgPriority, [In, MarshalAs(UnmanagedType.U4)] int grfMode, IntPtr snbExclude, [In, MarshalAs(UnmanagedType.U4)] int reserved);
+                void CopyTo(int ciidExclude, [In, MarshalAs(UnmanagedType.LPArray)] Guid[] pIIDExclude, IntPtr snbExclude, [In, MarshalAs(UnmanagedType.Interface)] IStorage stgDest);
+                void MoveElementTo([In, MarshalAs(UnmanagedType.BStr)] string pwcsName, [In, MarshalAs(UnmanagedType.Interface)] IStorage stgDest, [In, MarshalAs(UnmanagedType.BStr)] string pwcsNewName, [In, MarshalAs(UnmanagedType.U4)] int grfFlags);
+                void Commit(int grfCommitFlags);
+                void Revert();
+                void EnumElements([In, MarshalAs(UnmanagedType.U4)] int reserved1, IntPtr reserved2, [In, MarshalAs(UnmanagedType.U4)] int reserved3, [MarshalAs(UnmanagedType.Interface)] out object ppVal);
+                void DestroyElement([In, MarshalAs(UnmanagedType.BStr)] string pwcsName);
+                void RenameElement([In, MarshalAs(UnmanagedType.BStr)] string pwcsOldName, [In, MarshalAs(UnmanagedType.BStr)] string pwcsNewName);
+                void SetElementTimes([In, MarshalAs(UnmanagedType.BStr)] string pwcsName, [In] System.Runtime.InteropServices.ComTypes.FILETIME pctime, [In] System.Runtime.InteropServices.ComTypes.FILETIME patime, [In] System.Runtime.InteropServices.ComTypes.FILETIME pmtime);
+                void SetClass([In] ref Guid clsid);
+                void SetStateBits(int grfStateBits, int grfMask);
+                void Stat([Out] out System.Runtime.InteropServices.ComTypes.STATSTG pStatStg, int grfStatFlag);
+            }
+
+            [ComImport, Guid("0000000A-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+            public interface ILockBytes
+            {
+                void ReadAt([In, MarshalAs(UnmanagedType.U8)] long ulOffset, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] byte[] pv, [In, MarshalAs(UnmanagedType.U4)] int cb, [Out, MarshalAs(UnmanagedType.LPArray)] int[] pcbRead);
+                void WriteAt([In, MarshalAs(UnmanagedType.U8)] long ulOffset, IntPtr pv, [In, MarshalAs(UnmanagedType.U4)] int cb, [Out, MarshalAs(UnmanagedType.LPArray)] int[] pcbWritten);
+                void Flush();
+                void SetSize([In, MarshalAs(UnmanagedType.U8)] long cb);
+                void LockRegion([In, MarshalAs(UnmanagedType.U8)] long libOffset, [In, MarshalAs(UnmanagedType.U8)] long cb, [In, MarshalAs(UnmanagedType.U4)] int dwLockType);
+                void UnlockRegion([In, MarshalAs(UnmanagedType.U8)] long libOffset, [In, MarshalAs(UnmanagedType.U8)] long cb, [In, MarshalAs(UnmanagedType.U4)] int dwLockType);
+                void Stat([Out] out System.Runtime.InteropServices.ComTypes.STATSTG pstatstg, [In, MarshalAs(UnmanagedType.U4)] int grfStatFlag);
+            }
+
+            [StructLayout(LayoutKind.Sequential)]
+            public sealed class POINTL
+            {
+                public int x;
+                public int y;
+            }
+
+            [StructLayout(LayoutKind.Sequential)]
+            public sealed class SIZEL
+            {
+                public int cx;
+                public int cy;
+            }
+
+            [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
+            public sealed class FILEGROUPDESCRIPTORA
+            {
+                public uint cItems;
+                public FILEDESCRIPTORA fgd;
+            }
+
+            [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
+            public sealed class FILEDESCRIPTORA
+            {
+                public uint dwFlags;
+                public Guid clsid;
+                public SIZEL sizel;
+                public POINTL pointl;
+                public uint dwFileAttributes;
+                public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
+                public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
+                public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
+                public uint nFileSizeHigh;
+                public uint nFileSizeLow;
+                [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+                public string cFileName;
+            }
+
+            [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+            public sealed class FILEGROUPDESCRIPTORW
+            {
+                public uint cItems;
+                public FILEDESCRIPTORW fgd;
+            }
+
+            [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+            public sealed class FILEDESCRIPTORW
+            {
+                public uint dwFlags;
+                public Guid clsid;
+                public SIZEL sizel;
+                public POINTL pointl;
+                public uint dwFileAttributes;
+                public System.Runtime.InteropServices.ComTypes.FILETIME ftCreationTime;
+                public System.Runtime.InteropServices.ComTypes.FILETIME ftLastAccessTime;
+                public System.Runtime.InteropServices.ComTypes.FILETIME ftLastWriteTime;
+                public uint nFileSizeHigh;
+                public uint nFileSizeLow;
+                [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
+                public string cFileName;
+            }
+        }
+
+        #endregion
+
+        #region Property(s)
+
+        /// <summary>
+        /// Holds the <see cref="System.Windows.IDataObject"/> that this class is wrapping
+        /// </summary>
+        private System.Windows.IDataObject underlyingDataObject;
+
+        /// <summary>
+        /// Holds the <see cref="System.Runtime.InteropServices.ComTypes.IDataObject"/> interface to the <see cref="System.Windows.IDataObject"/> that this class is wrapping.
+        /// </summary>
+        private System.Runtime.InteropServices.ComTypes.IDataObject comUnderlyingDataObject;
+
+        /// <summary>
+        /// Holds the internal ole <see cref="System.Windows.IDataObject"/> to the <see cref="System.Windows.IDataObject"/> that this class is wrapping.
+        /// </summary>
+        private System.Windows.IDataObject oleUnderlyingDataObject;
+
+        /// <summary>
+        /// Holds the <see cref="MethodInfo"/> of the "GetDataFromHGLOBAL" method of the internal ole <see cref="System.Windows.IDataObject"/>.
+        /// </summary>
+        private MethodInfo getDataFromHGLOBALMethod;
+
+        #endregion
+
+        #region Constructor(s)
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="OutlookDataObject"/> class.
+        /// </summary>
+        /// <param name="underlyingDataObject">The underlying data object to wrap.</param>
+        public OutlookDataObject(System.Windows.IDataObject underlyingDataObject)
+        {
+            //get the underlying dataobject and its ComType IDataObject interface to it
+            this.underlyingDataObject = underlyingDataObject;
+            this.comUnderlyingDataObject = (System.Runtime.InteropServices.ComTypes.IDataObject)this.underlyingDataObject;
+
+            //get the internal ole dataobject and its GetDataFromHGLOBAL so it can be called later
+            FieldInfo innerDataField = this.underlyingDataObject.GetType().GetField("_innerData", BindingFlags.NonPublic | BindingFlags.Instance);
+            this.oleUnderlyingDataObject = (System.Windows.IDataObject)innerDataField.GetValue(this.underlyingDataObject);
+            this.getDataFromHGLOBALMethod = this.oleUnderlyingDataObject.GetType().GetMethod("GetDataFromHGLOBAL", BindingFlags.NonPublic | BindingFlags.Instance);
+        }
+
+        #endregion
+
+        #region IDataObject Members
+
+        /// <summary>
+        /// Retrieves the data associated with the specified class type format.
+        /// </summary>
+        /// <param name="format">A <see cref="T:System.Type"></see> representing the format of the data to retrieve. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <returns>
+        /// The data associated with the specified format, or null.
+        /// </returns>
+        public object GetData(Type format)
+        {
+            return this.GetData(format.FullName);
+        }
+
+        /// <summary>
+        /// Retrieves the data associated with the specified data format.
+        /// </summary>
+        /// <param name="format">The format of the data to retrieve. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <returns>
+        /// The data associated with the specified format, or null.
+        /// </returns>
+        public object GetData(string format)
+        {
+            return this.GetData(format, true);
+        }
+
+        /// <summary>
+        /// Retrieves the data associated with the specified data format, using a Boolean to determine whether to convert the data to the format.
+        /// </summary>
+        /// <param name="format">The format of the data to retrieve. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <param name="autoConvert">true to convert the data to the specified format; otherwise, false.</param>
+        /// <returns>
+        /// The data associated with the specified format, or null.
+        /// </returns>
+        public object GetData(string format, bool autoConvert)
+        {
+            //handle the "FileGroupDescriptor" and "FileContents" format request in this class otherwise pass through to underlying IDataObject 
+            switch (format)
+            {
+                case "FileGroupDescriptor":
+                    //override the default handling of FileGroupDescriptor which returns a
+                    //MemoryStream and instead return a string array of file names
+                    IntPtr fileGroupDescriptorAPointer = IntPtr.Zero;
+                    try
+                    {
+                        //use the underlying IDataObject to get the FileGroupDescriptor as a MemoryStream
+                        MemoryStream fileGroupDescriptorStream = (MemoryStream)this.underlyingDataObject.GetData("FileGroupDescriptor", autoConvert);
+                        byte[] fileGroupDescriptorBytes = new byte[fileGroupDescriptorStream.Length];
+                        fileGroupDescriptorStream.Read(fileGroupDescriptorBytes, 0, fileGroupDescriptorBytes.Length);
+                        fileGroupDescriptorStream.Close();
+
+                        //copy the file group descriptor into unmanaged memory 
+                        fileGroupDescriptorAPointer = Marshal.AllocHGlobal(fileGroupDescriptorBytes.Length);
+                        Marshal.Copy(fileGroupDescriptorBytes, 0, fileGroupDescriptorAPointer, fileGroupDescriptorBytes.Length);
+
+                        ////marshal the unmanaged memory to to FILEGROUPDESCRIPTORA struct
+                        //FIX FROM - https://stackoverflow.com/questions/27173844/accessviolationexception-after-copying-a-file-from-inside-a-zip-archive-to-the-c
+                        int ITEMCOUNT = Marshal.ReadInt32(fileGroupDescriptorAPointer);
+
+                        //create a new array to store file names in of the number of items in the file group descriptor
+                        string[] fileNames = new string[ITEMCOUNT];
+
+                        //get the pointer to the first file descriptor
+                        IntPtr fileDescriptorPointer = (IntPtr)((long)fileGroupDescriptorAPointer + Marshal.SizeOf(ITEMCOUNT));
+
+                        //loop for the number of files acording to the file group descriptor
+                        for (int fileDescriptorIndex = 0; fileDescriptorIndex < ITEMCOUNT; fileDescriptorIndex++)
+                        {
+                            //marshal the pointer top the file descriptor as a FILEDESCRIPTORA struct and get the file name
+                            NativeMethods.FILEDESCRIPTORA fileDescriptor = (NativeMethods.FILEDESCRIPTORA)Marshal.PtrToStructure(fileDescriptorPointer, typeof(NativeMethods.FILEDESCRIPTORA));
+                            fileNames[fileDescriptorIndex] = fileDescriptor.cFileName;
+
+                            //move the file descriptor pointer to the next file descriptor
+                            fileDescriptorPointer = (IntPtr)((long)fileDescriptorPointer + Marshal.SizeOf(fileDescriptor));
+                        }
+
+                        //return the array of filenames
+                        return fileNames;
+                    }
+                    finally
+                    {
+                        //free unmanaged memory pointer
+                        Marshal.FreeHGlobal(fileGroupDescriptorAPointer);
+                    }
+
+                case "FileGroupDescriptorW":
+                    //override the default handling of FileGroupDescriptorW which returns a
+                    //MemoryStream and instead return a string array of file names
+                    IntPtr fileGroupDescriptorWPointer = IntPtr.Zero;
+                    try
+                    {
+                        //use the underlying IDataObject to get the FileGroupDescriptorW as a MemoryStream
+                        MemoryStream fileGroupDescriptorStream = (MemoryStream)this.underlyingDataObject.GetData("FileGroupDescriptorW");
+                        byte[] fileGroupDescriptorBytes = new byte[fileGroupDescriptorStream.Length];
+                        fileGroupDescriptorStream.Read(fileGroupDescriptorBytes, 0, fileGroupDescriptorBytes.Length);
+                        fileGroupDescriptorStream.Close();
+
+                        //copy the file group descriptor into unmanaged memory
+                        fileGroupDescriptorWPointer = Marshal.AllocHGlobal(fileGroupDescriptorBytes.Length);
+                        Marshal.Copy(fileGroupDescriptorBytes, 0, fileGroupDescriptorWPointer, fileGroupDescriptorBytes.Length);
+
+                        //marshal the unmanaged memory to to FILEGROUPDESCRIPTORW struct
+                        //FIX FROM - https://stackoverflow.com/questions/27173844/accessviolationexception-after-copying-a-file-from-inside-a-zip-archive-to-the-c
+                        int ITEMCOUNT = Marshal.ReadInt32(fileGroupDescriptorWPointer);
+
+                        //create a new array to store file names in of the number of items in the file group descriptor
+                        string[] fileNames = new string[ITEMCOUNT];
+
+                        //get the pointer to the first file descriptor
+                        IntPtr fileDescriptorPointer = (IntPtr)((long)fileGroupDescriptorWPointer + Marshal.SizeOf(ITEMCOUNT));
+
+                        //loop for the number of files acording to the file group descriptor
+                        for (int fileDescriptorIndex = 0; fileDescriptorIndex < ITEMCOUNT; fileDescriptorIndex++)
+                        {
+                            //marshal the pointer top the file descriptor as a FILEDESCRIPTORW struct and get the file name
+                            NativeMethods.FILEDESCRIPTORW fileDescriptor = (NativeMethods.FILEDESCRIPTORW)Marshal.PtrToStructure(fileDescriptorPointer, typeof(NativeMethods.FILEDESCRIPTORW));
+                            fileNames[fileDescriptorIndex] = fileDescriptor.cFileName;
+
+                            //move the file descriptor pointer to the next file descriptor
+                            fileDescriptorPointer = (IntPtr)((long)fileDescriptorPointer + Marshal.SizeOf(fileDescriptor));
+                        }
+
+                        //return the array of filenames
+                        return fileNames;
+                    }
+                    finally
+                    {
+                        //free unmanaged memory pointer
+                        Marshal.FreeHGlobal(fileGroupDescriptorWPointer);
+                    }
+
+                case "FileContents":
+                    //override the default handling of FileContents which returns the
+                    //contents of the first file as a memory stream and instead return                    
+                    //a array of MemoryStreams containing the data to each file dropped                    
+                    //
+                    // FILECONTENTS requires a companion FILEGROUPDESCRIPTOR to be                     
+                    // available so we bail out if we don't find one in the data object.
+
+                    string fgdFormatName;
+                    if (GetDataPresent("FileGroupDescriptorW"))
+                        fgdFormatName = "FileGroupDescriptorW";
+                    else if (GetDataPresent("FileGroupDescriptor"))
+                        fgdFormatName = "FileGroupDescriptor";
+                    else
+                        return null;
+                    //get the array of filenames which lets us know how many file contents exist                    
+                    string[] fileContentNames = (string[])this.GetData(fgdFormatName);
+
+                    //create a MemoryStream array to store the file contents
+                    MemoryStream[] fileContents = new MemoryStream[fileContentNames.Length];
+
+                    //loop for the number of files acording to the file names
+                    for (int fileIndex = 0; fileIndex < fileContentNames.Length; fileIndex++)
+                    {
+                        //get the data at the file index and store in array
+                        fileContents[fileIndex] = this.GetData(format, fileIndex);
+                    }
+
+                    //return array of MemoryStreams containing file contents
+                    return fileContents;
+            }
+
+            //use underlying IDataObject to handle getting of data
+            return this.underlyingDataObject.GetData(format, autoConvert);
+        }
+
+        /// <summary>
+        /// Retrieves the data associated with the specified data format at the specified index.
+        /// </summary>
+        /// <param name="format">The format of the data to retrieve. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <param name="index">The index of the data to retrieve.</param>
+        /// <returns>
+        /// A <see cref="MemoryStream"/> containing the raw data for the specified data format at the specified index.
+        /// </returns>
+        public MemoryStream GetData(string format, int index)
+        {
+            //create a FORMATETC struct to request the data with
+            FORMATETC formatetc = new FORMATETC();
+            formatetc.cfFormat = (short)DataFormats.GetDataFormat(format).Id;
+            formatetc.dwAspect = DVASPECT.DVASPECT_CONTENT;
+            formatetc.lindex = index;
+            formatetc.ptd = new IntPtr(0);
+            formatetc.tymed = TYMED.TYMED_ISTREAM | TYMED.TYMED_ISTORAGE | TYMED.TYMED_HGLOBAL;
+
+            //create STGMEDIUM to output request results into
+            STGMEDIUM medium = new STGMEDIUM();
+
+            //using the Com IDataObject interface get the data using the defined FORMATETC
+            this.comUnderlyingDataObject.GetData(ref formatetc, out medium);
+
+            //retrieve the data depending on the returned store type
+            switch (medium.tymed)
+            {
+                case TYMED.TYMED_ISTORAGE:
+                    //to handle a IStorage it needs to be written into a second unmanaged
+                    //memory mapped storage and then the data can be read from memory into
+                    //a managed byte and returned as a MemoryStream
+
+                    NativeMethods.IStorage iStorage = null;
+                    NativeMethods.IStorage iStorage2 = null;
+                    NativeMethods.ILockBytes iLockBytes = null;
+                    System.Runtime.InteropServices.ComTypes.STATSTG iLockBytesStat;
+                    try
+                    {
+                        //marshal the returned pointer to a IStorage object
+                        iStorage = (NativeMethods.IStorage)Marshal.GetObjectForIUnknown(medium.unionmember);
+                        Marshal.Release(medium.unionmember);
+
+                        //create a ILockBytes (unmanaged byte array) and then create a IStorage using the byte array as a backing store
+                        iLockBytes = NativeMethods.CreateILockBytesOnHGlobal(IntPtr.Zero, true);
+                        iStorage2 = NativeMethods.StgCreateDocfileOnILockBytes(iLockBytes, 0x00001012, 0);
+
+                        //copy the returned IStorage into the new IStorage
+                        iStorage.CopyTo(0, null, IntPtr.Zero, iStorage2);
+                        iLockBytes.Flush();
+                        iStorage2.Commit(0);
+
+                        //get the STATSTG of the ILockBytes to determine how many bytes were written to it
+                        iLockBytesStat = new System.Runtime.InteropServices.ComTypes.STATSTG();
+                        iLockBytes.Stat(out iLockBytesStat, 1);
+                        int iLockBytesSize = (int)iLockBytesStat.cbSize;
+
+                        //read the data from the ILockBytes (unmanaged byte array) into a managed byte array
+                        byte[] iLockBytesContent = new byte[iLockBytesSize];
+                        iLockBytes.ReadAt(0, iLockBytesContent, iLockBytesContent.Length, null);
+
+                        //wrapped the managed byte array into a memory stream and return it
+                        return new MemoryStream(iLockBytesContent);
+                    }
+                    finally
+                    {
+                        //release all unmanaged objects
+                        Marshal.ReleaseComObject(iStorage2);
+                        Marshal.ReleaseComObject(iLockBytes);
+                        Marshal.ReleaseComObject(iStorage);
+                    }
+
+                case TYMED.TYMED_ISTREAM:
+                    //to handle a IStream it needs to be read into a managed byte and
+                    //returned as a MemoryStream
+
+                    IStream iStream = null;
+                    System.Runtime.InteropServices.ComTypes.STATSTG iStreamStat;
+                    try
+                    {
+                        //marshal the returned pointer to a IStream object
+                        iStream = (IStream)Marshal.GetObjectForIUnknown(medium.unionmember);
+                        Marshal.Release(medium.unionmember);
+
+                        //get the STATSTG of the IStream to determine how many bytes are in it
+                        iStreamStat = new System.Runtime.InteropServices.ComTypes.STATSTG();
+                        iStream.Stat(out iStreamStat, 0);
+                        int iStreamSize = (int)iStreamStat.cbSize;
+
+                        //read the data from the IStream into a managed byte array
+                        byte[] iStreamContent = new byte[iStreamSize];
+                        iStream.Read(iStreamContent, iStreamContent.Length, IntPtr.Zero);
+
+                        //wrapped the managed byte array into a memory stream and return it
+                        return new MemoryStream(iStreamContent);
+                    }
+                    finally
+                    {
+                        //release all unmanaged objects
+                        Marshal.ReleaseComObject(iStream);
+                    }
+
+                case TYMED.TYMED_HGLOBAL:
+                    //to handle a HGlobal the exisitng "GetDataFromHGLOBAL" method is invoked via
+                    //reflection
+
+                    return (MemoryStream)this.getDataFromHGLOBALMethod.Invoke(this.oleUnderlyingDataObject, new object[] { DataFormats.GetDataFormat((short)formatetc.cfFormat).Name, medium.unionmember });
+            }
+
+            return null;
+        }
+
+        /// <summary>
+        /// Determines whether data stored in this instance is associated with, or can be converted to, the specified format.
+        /// </summary>
+        /// <param name="format">A <see cref="T:System.Type"></see> representing the format for which to check. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <returns>
+        /// true if data stored in this instance is associated with, or can be converted to, the specified format; otherwise, false.
+        /// </returns>
+        public bool GetDataPresent(Type format)
+        {
+            return this.underlyingDataObject.GetDataPresent(format);
+        }
+
+        /// <summary>
+        /// Determines whether data stored in this instance is associated with, or can be converted to, the specified format.
+        /// </summary>
+        /// <param name="format">The format for which to check. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <returns>
+        /// true if data stored in this instance is associated with, or can be converted to, the specified format; otherwise false.
+        /// </returns>
+        public bool GetDataPresent(string format)
+        {
+            return this.underlyingDataObject.GetDataPresent(format);
+        }
+
+        /// <summary>
+        /// Determines whether data stored in this instance is associated with the specified format, using a Boolean value to determine whether to convert the data to the format.
+        /// </summary>
+        /// <param name="format">The format for which to check. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <param name="autoConvert">true to determine whether data stored in this instance can be converted to the specified format; false to check whether the data is in the specified format.</param>
+        /// <returns>
+        /// true if the data is in, or can be converted to, the specified format; otherwise, false.
+        /// </returns>
+        public bool GetDataPresent(string format, bool autoConvert)
+        {
+            return this.underlyingDataObject.GetDataPresent(format, autoConvert);
+        }
+
+        /// <summary>
+        /// Returns a list of all formats that data stored in this instance is associated with or can be converted to.
+        /// </summary>
+        /// <returns>
+        /// An array of the names that represents a list of all formats that are supported by the data stored in this object.
+        /// </returns>
+        public string[] GetFormats()
+        {
+            return this.underlyingDataObject.GetFormats();
+        }
+
+        /// <summary>
+        /// Gets a list of all formats that data stored in this instance is associated with or can be converted to, using a Boolean value to determine whether to retrieve all formats that the data can be converted to or only native data formats.
+        /// </summary>
+        /// <param name="autoConvert">true to retrieve all formats that data stored in this instance is associated with or can be converted to; false to retrieve only native data formats.</param>
+        /// <returns>
+        /// An array of the names that represents a list of all formats that are supported by the data stored in this object.
+        /// </returns>
+        public string[] GetFormats(bool autoConvert)
+        {
+            return this.underlyingDataObject.GetFormats(autoConvert);
+        }
+
+        /// <summary>
+        /// Stores the specified data in this instance, using the class of the data for the format.
+        /// </summary>
+        /// <param name="data">The data to store.</param>
+        public void SetData(object data)
+        {
+            this.underlyingDataObject.SetData(data);
+        }
+
+        /// <summary>
+        /// Stores the specified data and its associated class type in this instance.
+        /// </summary>
+        /// <param name="format">A <see cref="T:System.Type"></see> representing the format associated with the data. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <param name="data">The data to store.</param>
+        public void SetData(Type format, object data)
+        {
+            this.underlyingDataObject.SetData(format, data);
+        }
+
+        /// <summary>
+        /// Stores the specified data and its associated format in this instance.
+        /// </summary>
+        /// <param name="format">The format associated with the data. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <param name="data">The data to store.</param>
+        public void SetData(string format, object data)
+        {
+            this.underlyingDataObject.SetData(format, data);
+        }
+
+        /// <summary>
+        /// Stores the specified data and its associated format in this instance, using a Boolean value to specify whether the data can be converted to another format.
+        /// </summary>
+        /// <param name="format">The format associated with the data. See <see cref="T:System.Windows.DataFormats"></see> for predefined formats.</param>
+        /// <param name="autoConvert">true to allow the data to be converted to another format; otherwise, false.</param>
+        /// <param name="data">The data to store.</param>
+        public void SetData(string format, object data, bool autoConvert)
+        {
+            this.underlyingDataObject.SetData(format, data, autoConvert);
+        }
+
+        #endregion
+    }
+}

+ 67 - 0
prs.desktop/Panels/DataEntry/ScanGrid.cs

@@ -1,9 +1,13 @@
 using Comal.Classes;
+using InABox.Clients;
 using InABox.Core;
 using InABox.DynamicGrid;
+using InABox.WPF;
 using NPOI.SS.Formula.Functions;
+using Syncfusion.Pdf;
 using System;
 using System.Collections.Generic;
+using System.IO;
 using System.Linq;
 using System.Text;
 using System.Threading.Tasks;
@@ -35,6 +39,69 @@ namespace PRSDesktop
             DragTable(typeof(Document), table);
         }
 
+        private void UploadDocument(string filename, byte[] data)
+        {
+            var document = new Document
+            {
+                FileName = filename,
+                CRC = CoreUtils.CalculateCRC(data),
+                TimeStamp = DateTime.Now,
+                Data = data
+            };
+
+            new Client<Document>().Save(document, "");
+
+            var scan = new Scan();
+            scan.Document.ID = document.ID;
+            scan.AppliesTo = AppliesTo ?? "";
+            new Client<Scan>().Save(scan, "");
+
+            Dispatcher.Invoke(() =>
+            {
+                Refresh(false, true);
+            });
+        }
+
+        private static PdfDocumentBase CombinePages(IEnumerable<DocumentManipulationWindow.Page> pages)
+        {
+            var document = new PdfDocument();
+            foreach (var page in pages)
+            {
+                document.ImportPage(page.Pdf, page.PageIndex);
+            }
+            return document;
+        }
+
+        public void ShowDocumentWindow(List<DocumentManipulationWindow.Page> pages, string filename)
+        {
+            var window = new DocumentManipulationWindow(pages, filename);
+            if (window.ShowDialog() == true)
+            {
+                Progress.ShowModal("Uploading Files", (progress) =>
+                {
+                    foreach (var group in window.Groups)
+                    {
+                        progress.Report($"Uploading '{group.FileName}'");
+                        var doc = CombinePages(group.Pages);
+
+                        byte[] data;
+                        using (var ms = new MemoryStream())
+                        {
+                            doc.Save(ms);
+                            data = ms.ToArray();
+                        }
+
+                        UploadDocument(group.FileName, data);
+                    }
+                });
+            }
+        }
+
+        protected override void DoAdd()
+        {
+            ShowDocumentWindow(new(), "");
+        }
+
         protected override Scan CreateItem()
         {
             var scan = base.CreateItem();

+ 3 - 3
prs.desktop/Panels/DataEntry/ScanPanel.xaml

@@ -9,9 +9,9 @@
              d:DesignHeight="450" d:DesignWidth="800">
     <dynamic:DynamicTabControl x:Name="TabControl" SelectionChanged="TabControl_SelectionChanged">
         <dynamic:DynamicTabItem Header="Scans"
-                                            AllowDrop="True"
-                                            PreviewDragOver="DynamicTabItem_DragOver"
-                                            Drop="DynamicTabItem_Drop">
+                                AllowDrop="True"
+                                PreviewDragOver="DynamicTabItem_DragOver"
+                                Drop="DynamicTabItem_Drop">
             <TabItem.Content>
                 <local:ScanGrid x:Name="ScanGrid"/>
             </TabItem.Content>

+ 49 - 55
prs.desktop/Panels/DataEntry/ScanPanel.xaml.cs

@@ -26,9 +26,52 @@ using InABox.WPF;
 using Encoder = System.Drawing.Imaging.Encoder;
 using Path = System.IO.Path;
 using Image = System.Windows.Controls.Image;
+using com.sun.tools.doclets.@internal.toolkit.util;
+using Syncfusion.Windows.PdfViewer;
 
 namespace PRSDesktop
 {
+    public static class PDFExtensions
+    {
+        public static IEnumerable<PdfPageBase> GetPages(this PdfDocumentBase doc)
+        {
+            if (doc is PdfLoadedDocument lDoc)
+                return lDoc.Pages.Cast<PdfPageBase>();
+            if (doc is PdfDocument pdfDoc)
+                return pdfDoc.Pages.Cast<PdfPageBase>();
+            throw new Exception($"Unsupported PDF Document type {doc.GetType()}");
+        }
+        public static PdfPageBase GetPage(this PdfDocumentBase doc, int index)
+        {
+            if (doc is PdfLoadedDocument lDoc)
+                return lDoc.Pages[index];
+            if (doc is PdfDocument pdfDoc)
+                return pdfDoc.Pages[index];
+            throw new Exception($"Unsupported PDF Document type {doc.GetType()}");
+        }
+        public static int PageCount(this PdfDocumentBase doc)
+        {
+            if (doc is PdfLoadedDocument lDoc)
+                return lDoc.Pages.Count;
+            if (doc is PdfDocument pdfDoc)
+                return pdfDoc.Pages.Count;
+            throw new Exception($"Unsupported PDF Document type {doc.GetType()}");
+        }
+        public static PdfLoadedDocument ToLoadedDocument(this PdfDocumentBase doc)
+        {
+            if (doc is PdfLoadedDocument lDoc)
+                return lDoc;
+            if (doc is PdfDocument pdfDoc)
+            {
+                using var ms = new MemoryStream();
+                pdfDoc.Save(ms);
+                var array = ms.ToArray();
+                return new PdfLoadedDocument(array);
+            }
+            throw new Exception($"Unsupported PDF Document type {doc.GetType()}");
+        }
+    }
+
     /// <summary>
     /// Interaction logic for ScanPanel.xaml
     /// </summary>
@@ -202,68 +245,19 @@ namespace PRSDesktop
 
         #region Uploading
 
-        private void UploadDocument(string[] filenames)
+        private void DynamicTabItem_Drop(object sender, DragEventArgs e)
         {
-            Dispatcher.Invoke(() => Progress.Show("Uploading files"));
-
-            var shouldRefresh = false;
-
-            foreach (var docPath in filenames)
+            Task.Run(() =>
             {
-                if (File.Exists(docPath))
+                var pages = DocumentManipulationWindow.HandleFileDrop(e, out var filename);
+                if (pages is not null)
                 {
-                    var data = File.ReadAllBytes(docPath);
-                    var document = new Document
-                    {
-                        FileName = Path.GetFileName(docPath),
-                        CRC = CoreUtils.CalculateCRC(data),
-                        TimeStamp = new FileInfo(docPath).LastWriteTime,
-                        Data = data
-                    };
-
-                    Document? newDocument = null;
-                    bool shouldSave = false;
-
                     Dispatcher.Invoke(() =>
                     {
-                        newDocument = DocumentConfirm.CheckDocument(document, (filename) =>
-                        {
-                            return new Client<Document>().Load(new Filter<Document>(x => x.FileName).IsEqualTo(filename)).FirstOrDefault();
-                        }, out shouldSave);
+                        ScanGrid.ShowDocumentWindow(pages, filename);
                     });
-
-                    if (newDocument is not null)
-                    {
-                        if (shouldSave)
-                        {
-                            new Client<Document>().Save(newDocument, "");
-                        }
-
-                        var scan = new Scan();
-                        scan.Document.ID = newDocument.ID;
-                        scan.AppliesTo = AppliesTo?.EntityName() ?? "";
-                        new Client<Scan>().Save(scan, "");
-
-                        shouldRefresh = true;
-                    }
                 }
-            }
-            if (shouldRefresh)
-            {
-                Dispatcher.Invoke(() =>
-                {
-                    ScanGrid.Refresh(false, true);
-                });
-            }
-            Dispatcher.Invoke(() => Progress.Close());
-        }
-
-        private void DynamicTabItem_Drop(object sender, DragEventArgs e)
-        {
-            if (e.Data.GetDataPresent(DataFormats.FileDrop))
-            {
-                Task.Run(() => UploadDocument((string[])e.Data.GetData(DataFormats.FileDrop)));
-            }
+            });
         }
 
         private void DynamicTabItem_DragOver(object sender, DragEventArgs e)