diff --git a/UnitTests/Windows.Shell/ShellDataTests.cs b/UnitTests/Windows.Shell/ShellDataTests.cs new file mode 100644 index 00000000..faee5bdc --- /dev/null +++ b/UnitTests/Windows.Shell/ShellDataTests.cs @@ -0,0 +1,93 @@ +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using Vanara.Extensions; +using Vanara.PInvoke.Tests; +using static Vanara.PInvoke.Ole32; +using static Vanara.PInvoke.Shell32; + +namespace Vanara.Windows.Shell.Tests +{ + [TestFixture] + public class ShellDataTests + { + [Test] + public void FromFolderTest() + { + // Create empty table + var timer = Stopwatch.StartNew(); + var shData = new ShellDataTable(new ShellFolder(KNOWNFOLDERID.FOLDERID_Documents)); + TestContext.WriteLine($"{timer.ElapsedMilliseconds}\t** Init complete **"); + + // Get list of default and slow columns to fetch + var cols = new List(); + cols.AddRange(shData.Columns.Cast().Where(c => ((SHCOLSTATE)c.ExtendedProperties["ColState"]).IsFlagSet(SHCOLSTATE.SHCOLSTATE_ONBYDEFAULT))); + cols.AddRange(shData.Columns.Cast().Where(c => (bool)c.ExtendedProperties["Slow"] && c.ColumnName.StartsWith("System.Document."))); + TestContext.WriteLine(string.Join("\t", cols)); + + // Populate table + shData.RowChanged += ShData_RowChanged; + shData.AllFastRowsAdded += (s, e) => TestContext.WriteLine($"{timer.ElapsedMilliseconds}\t** Fast items complete **"); + shData.TableLoaded += (s, e) => TestContext.WriteLine($"{timer.ElapsedMilliseconds}\t** All done **"); + var ct = new CancellationTokenSource(); + shData.PopulateTableAsync(cols, ShellItemQueryOptions.ShowHidden, ct.Token).Wait(TimeSpan.FromSeconds(30)); + + timer.Stop(); + + void ShData_RowChanged(object sender, System.Data.DataRowChangeEventArgs e) + { + if (e.Action == DataRowAction.Add) + TestContext.WriteLine($"{timer.ElapsedMilliseconds,5}\t+\t" + GetItems()); + else if (e.Action == DataRowAction.Commit) + TestContext.WriteLine($"{timer.ElapsedMilliseconds,5}\t*\t" + GetItems()); + + string GetItems() => string.Join("\t", cols.Select(c => GetColVal(e.Row[c]))); + } + } + + [Test] + public void FromItemsTest() + { + // Create empty table + var timer = Stopwatch.StartNew(); + var items = new[] { ShellItem.Open(TestCaseSources.SmallFile), ShellItem.Open(TestCaseSources.TempDir), ShellItem.Open(TestCaseSources.BmpFile), ShellItem.Open(TestCaseSources.DummyFile), ShellItem.Open(TestCaseSources.LargeFile) }; + var shData = new ShellDataTable(items); + TestContext.WriteLine($"{timer.ElapsedMilliseconds}\t** Init complete **"); + + // Get list of default and slow columns to fetch + var cols = shData.Columns.Cast().Take(20).ToList(); + TestContext.WriteLine("\t\t" + string.Join("\t", cols)); + + // Populate table + shData.RowChanged += ShData_RowChanged; + shData.AllFastRowsAdded += (s, e) => TestContext.WriteLine($"{timer.ElapsedMilliseconds}\t** Fast items complete **"); + shData.TableLoaded += (s, e) => TestContext.WriteLine($"{timer.ElapsedMilliseconds}\t** All done **"); + var ct = new CancellationTokenSource(); + shData.PopulateTableAsync(cols, ShellItemQueryOptions.ShowHidden, ct.Token).Wait(TimeSpan.FromSeconds(30)); + + timer.Stop(); + + void ShData_RowChanged(object sender, System.Data.DataRowChangeEventArgs e) + { + if (e.Action == DataRowAction.Add) + TestContext.WriteLine($"{timer.ElapsedMilliseconds,5}\t+\t" + GetItems()); + else if (e.Action == DataRowAction.Commit) + TestContext.WriteLine($"{timer.ElapsedMilliseconds,5}\t*\t" + GetItems()); + + string GetItems() => string.Join("\t", cols.Select(c => GetColVal(e.Row[c]))); + } + } + + private static string GetColVal(object o) => o switch + { + null => string.Empty, + DBNull _ => string.Empty, + string[] a => string.Join(",", a), + _ => o.ToString() + }; + } +} \ No newline at end of file diff --git a/Windows.Shell/ShellData/ShellDataTable.cs b/Windows.Shell/ShellData/ShellDataTable.cs new file mode 100644 index 00000000..4197dd28 --- /dev/null +++ b/Windows.Shell/ShellData/ShellDataTable.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Vanara.Extensions; +using Vanara.PInvoke; +using static Vanara.PInvoke.Ole32; +using static Vanara.PInvoke.Shell32; + +namespace Vanara.Windows.Shell +{ + /// Options used when requesting items from a shell folder. + [Flags] + public enum ShellItemQueryOptions + { + /// Include hidden items. + ShowHidden = 0x01, + + /// Include empty drives. + ShowEmptyDrives = 0x02, + + /// Hide the extension, if known. + HideExtIfKnown = 0x04, + + /// Include system protected items. + ShowSystemProtected = 0x08, + } + + /// Represents a that is populated asynchronously with information about shell items. + /// + public class ShellDataTable : DataTable + { + private const string colId = "IDList"; + private const string extAlign = "ColAlign"; + private const string extPropKey = "PropertyKey"; + private const string extSlow = "Slow"; + private const string extState = "ColState"; + private readonly ShellFolder parent; + private List defCols; + private IEnumerable items; + + /// Initializes a new instance of the class with a list of shell items. + /// The items for which to collect information. + public ShellDataTable(IEnumerable items) : base() + { + BuildColumns(ShellFolder.Desktop); + this.items = items.Select(i => i.PIDL).ToArray(); + } + + /// Initializes a new instance of the class with the items from a shell folder. + /// The folder whose items are to be retrieved. + public ShellDataTable(ShellFolder folder) : base(folder.ParsingName) + { + BuildColumns(parent = folder); + } + + /// Occurs when all rows have been added in a call to populate the table with their fast properties. + public event EventHandler AllFastRowsAdded; + + /// Occurs when all rows have been added in a call to populate the table with all (fast and slow) properties. + public event EventHandler TableLoaded; + + /// Gets the columns that should be on by default in Details view. + /// The default columns. + public IReadOnlyList DefaultColumns => (IReadOnlyList)defCols; + + /// Gets a column's visual alignment. + /// The column to check. + /// The suggested alignment of the column when displayed. + public ComCtl32.ListViewColumnFormat GetColumnAlignment(DataColumn column) => (ComCtl32.ListViewColumnFormat)column.ExtendedProperties[extAlign]; + + /// Gets the column property key. + /// The column to check. + /// The property key associated with the column. + public PROPERTYKEY GetColumnPropertyKey(DataColumn column) => (PROPERTYKEY)column.ExtendedProperties[extPropKey]; + + /// Gets the state of the column. + /// The column to check. + /// A value describing how the column values should be treated. + public SHCOLSTATE GetColumnState(DataColumn column) => (SHCOLSTATE)column.ExtendedProperties[extState]; + + /// Determines whether the specified column takes longer to retrieve. + /// The column to check. + /// if the column takes longer to retrieve; otherwise, . + public bool IsColumnSlow(DataColumn column) => (bool)column.ExtendedProperties[extSlow]; + + /// Populates the table with all the requested shell items. + /// The names of the columns to populate. + /// The options for the query. + /// The cancellation token. + public async Task PopulateTableAsync(IEnumerable columns, ShellItemQueryOptions options, CancellationToken cancellationToken) + { + var columnsToGet = columns.Where(n => n != colId).Select(n => Columns[n]).ToArray(); + await PopulateTableAsync(columnsToGet, options, cancellationToken); + } + + /// Populates the table with all the requested shell items. + /// The PROPERTYKEY values of the columns to populate. + /// The options for the query. + /// The cancellation token. + public async Task PopulateTableAsync(IEnumerable columns, ShellItemQueryOptions options, CancellationToken cancellationToken) + { + var columnsToGet = columns.Join(Columns.Cast(), k => k, c => GetColumnPropertyKey(c), (k, c) => c).ToArray(); + await PopulateTableAsync(columnsToGet, options, cancellationToken); + } + + /// Populates the table with all the requested shell items. + /// The columns to populate. + /// The options for the query. + /// The cancellation token. + public async Task PopulateTableAsync(IEnumerable columns, ShellItemQueryOptions options, CancellationToken cancellationToken) + { + var columnsToGet = columns.ToArray(); + if (columnsToGet.Except(Columns.Cast()).Any()) + throw new ArgumentException("Columns specified that are not in table.", nameof(columnsToGet)); + + if (items is null && !(parent is null)) + { + var qFlags = FolderItemFilter.NonFolders | FolderItemFilter.Folders; + if (options.IsFlagSet(ShellItemQueryOptions.ShowHidden)) + qFlags |= FolderItemFilter.IncludeHidden; + if (options.IsFlagSet(ShellItemQueryOptions.ShowSystemProtected)) + qFlags |= FolderItemFilter.IncludeSuperHidden; + + items = parent.IShellFolder.EnumObjects((SHCONTF)qFlags); + } + + if (Rows.Count > 0) + Rows.Clear(); + + var f2 = parent?.IShellFolder as IShellFolder2; + if (!(items is null)) + { + var slowFetchItems = new List(); + var cInfo = columnsToGet.ToLookup(c => IsColumnSlow(c), c => ((PROPERTYKEY)c.ExtendedProperties[extPropKey], c)); + var fastCols = cInfo[false].ToList(); + var slowCols = cInfo[true].ToList(); + foreach (var i in items) + { + if (cancellationToken.IsCancellationRequested) break; + + // Add row with fast properties + var row = NewRow(); + row[colId] = i.GetBytes(); + foreach (var (pk, col) in fastCols) + row[col] = GetProp(pk, i) ?? DBNull.Value; + Rows.Add(row); + // If there are slow props, spawn thread to get them + if (slowCols.Count > 0) + slowFetchItems.Add(GetSlowProps(i, row, slowCols, cancellationToken)); + } + AllFastRowsAdded?.Invoke(this, EventArgs.Empty); + AcceptChanges(); + await TaskAgg.WhenAll(slowFetchItems); + } + TableLoaded?.Invoke(this, EventArgs.Empty); + return; + + object GetProp(in PROPERTYKEY pk, PIDL i) + { + object o = null; + try + { + if (f2 is null) + { + using var si = new ShellItem(i); + o = si.Properties[pk]; + } + else + { + f2.GetDetailsEx(i, pk, out o).ThrowIfFailed(); + } + } + catch { } + + return o switch + { + System.Runtime.InteropServices.ComTypes.FILETIME ft => ft.ToDateTime(), + _ => o + }; + } + + async Task GetSlowProps(PIDL i, DataRow row, IEnumerable<(PROPERTYKEY pk, DataColumn col)> props, CancellationToken cancellationToken) + { + await TaskAgg.Run(() => + { + row.BeginEdit(); + foreach (var (pk, col) in props) + { + if (cancellationToken.IsCancellationRequested) break; + row[col] = GetProp(pk, i) ?? DBNull.Value; + } + row.AcceptChanges(); + }, cancellationToken); + } + } + + private void BuildColumns(ShellFolder folder) + { + BeginInit(); + if (folder.IShellFolder is IShellFolder2 f2) + { + for (uint i = 0; true; i++) + { + var hr = f2.GetDefaultColumnState(i, out var cState); + if (hr == HRESULT.TYPE_E_OUTOFBOUNDS) + break; + if (hr.Failed || cState.IsFlagSet(SHCOLSTATE.SHCOLSTATE_HIDDEN)) + continue; + f2.MapColumnToSCID(i, out var pk); + PropertyDescription.TryCreate(pk, out var pd); + f2.GetDetailsOf(PIDL.Null, i, out var cDet); + var c = new DataColumn(pd?.ToString() ?? cDet.str.ToString(), GetPropType(pd) ?? TypeFromState(cState)) { Caption = pd?.DisplayName, AllowDBNull = true }; + SetExtProp(c, pk, cDet.fmt, cState); + Columns.Add(c); + } + } + Columns.Add(SetExtProp(new DataColumn(colId, typeof(byte[])))); + defCols = new List(Columns.Cast().Where(c => GetColumnState(c).IsFlagSet(SHCOLSTATE.SHCOLSTATE_ONBYDEFAULT))); + EndInit(); + + static Type GetPropType(PropertyDescription pd) + { + var t = pd?.PropertyType; + if (t.Equals(typeof(System.Runtime.InteropServices.ComTypes.FILETIME))) + t = typeof(DateTime); + return t; + } + + static DataColumn SetExtProp(DataColumn c, in PROPERTYKEY pk = default, ComCtl32.ListViewColumnFormat fmt = ComCtl32.ListViewColumnFormat.LVCFMT_LEFT, SHCOLSTATE state = SHCOLSTATE.SHCOLSTATE_PREFER_VARCMP) + { + c.ExtendedProperties.Add(extPropKey, pk); + c.ExtendedProperties.Add(extAlign, fmt); + c.ExtendedProperties.Add(extSlow, state.IsFlagSet(SHCOLSTATE.SHCOLSTATE_SLOW)); + c.ExtendedProperties.Add(extState, state); + return c; + } + + static Type TypeFromState(SHCOLSTATE st) => (st & SHCOLSTATE.SHCOLSTATE_TYPEMASK) switch + { + SHCOLSTATE.SHCOLSTATE_TYPE_INT => typeof(int), + SHCOLSTATE.SHCOLSTATE_TYPE_DATE => typeof(DateTime), + _ => typeof(string), + }; + } + } + + internal static class TaskAgg + { + public static Task CompletedTask + { + get + { +#if NET20 || NET35 || NET40 || NET45 + return TaskExEx.CompletedTask; +#else + return Task.CompletedTask; +#endif + } + } + + public static Task Run(Action action, CancellationToken cancellationToken) + { +#if NET20 || NET35 || NET40 + return TaskEx.Run(action, cancellationToken); +#else + return Task.Run(action, cancellationToken); +#endif + } + + public static Task Run(Func action, CancellationToken cancellationToken) + { +#if NET20 || NET35 || NET40 + return TaskEx.Run(action, cancellationToken); +#else + return Task.Run(action, cancellationToken); +#endif + } + + public static Task WhenAll(IEnumerable tasks) + { +#if NET20 || NET35 || NET40 + return TaskEx.WhenAll(tasks); +#else + return Task.WhenAll(tasks); +#endif + } + } +} \ No newline at end of file