First pass at adding support for Windows Search

pull/83/head
David Hall 2019-10-23 13:42:37 -06:00
parent 4097090539
commit 8302691c8e
6 changed files with 389 additions and 60 deletions

View File

@ -1,8 +1,8 @@
using System;
using NUnit.Framework;
using static Vanara.PInvoke.Shell32;
using NUnit.Framework;
using System;
using System.IO;
using System.Linq;
using static Vanara.PInvoke.Shell32;
namespace Vanara.Windows.Shell.Tests
{
@ -10,17 +10,13 @@ namespace Vanara.Windows.Shell.Tests
public class ShellFolderTests
{
private const string testFile = ShellItemTests.testDoc;
private static string testFld = Path.GetDirectoryName(testFile);
private static readonly string testFld = Path.GetDirectoryName(testFile);
[Test]
public void ShellFolderTest1()
{
Assert.That(() =>
{
var i = new ShellFolder(testFld);
Assert.That(i.FileSystemPath, Is.EqualTo(testFld));
}, Throws.Nothing);
Assert.That(() => new ShellFolder((string) null), Throws.Exception);
Assert.That(() => { Assert.That(new ShellFolder(testFld).FileSystemPath, Is.EqualTo(testFld)); }, Throws.Nothing);
Assert.That(() => new ShellFolder((string)null), Throws.Exception);
Assert.That(() => new ShellFolder(@"C:\Tamp"), Throws.Exception);
Assert.That(() => new ShellFolder(testFile), Throws.Nothing);
}
@ -41,12 +37,8 @@ namespace Vanara.Windows.Shell.Tests
[Test]
public void ShellFolderTest3()
{
var pidl = new PIDL(testFld);
Assert.That(() =>
{
var i = new ShellFolder(pidl);
Assert.That(i.FileSystemPath, Is.EqualTo(testFld));
}, Throws.Nothing);
using var pidl = new PIDL(testFld);
Assert.That(() => { Assert.That(new ShellFolder(pidl).FileSystemPath, Is.EqualTo(testFld)); }, Throws.Nothing);
Assert.That(() => new ShellFolder((PIDL)null), Throws.Exception);
Assert.That(() => new ShellFolder(new PIDL(@"C:\Tamp")), Throws.Exception);
Assert.That(() => new ShellFolder(new PIDL(testFile)), Throws.Nothing);
@ -55,12 +47,8 @@ namespace Vanara.Windows.Shell.Tests
[Test]
public void ShellFolderTest4()
{
Assert.That(() =>
{
var i = new ShellFolder(new ShellItem(testFld));
Assert.That(i.FileSystemPath, Is.EqualTo(testFld));
}, Throws.Nothing);
Assert.That(() => new ShellFolder((ShellItem) null), Throws.Exception);
Assert.That(() => { Assert.That(new ShellFolder(new ShellItem(testFld)).FileSystemPath, Is.EqualTo(testFld)); }, Throws.Nothing);
Assert.That(() => new ShellFolder((ShellItem)null), Throws.Exception);
Assert.That(() => new ShellFolder(new ShellItem(testFile)), Throws.Nothing);
}
@ -69,21 +57,19 @@ namespace Vanara.Windows.Shell.Tests
{
using (var si = new ShellItem(testFile))
{
using (var i = new ShellFolder(testFld))
{
Assert.That(i[Path.GetFileName(testFile)], Is.EqualTo(si));
Assert.That(() => i[testFile], Throws.Exception);
Assert.That(() => i[(string)null], Throws.Exception);
Assert.That(() => i[""], Throws.Exception);
Assert.That(() => i["bad.bad"], Throws.Exception);
using var i = new ShellFolder(testFld);
Assert.That(i[Path.GetFileName(testFile)], Is.EqualTo(si));
Assert.That(() => i[testFile], Throws.Exception);
Assert.That(() => i[(string)null], Throws.Exception);
Assert.That(() => i[""], Throws.Exception);
Assert.That(() => i["bad.bad"], Throws.Exception);
using (var pidl = new PIDL(testFile))
{
Assert.That(i[pidl.LastId], Is.EqualTo(si));
Assert.That(i[pidl], Is.EqualTo(si));
}
Assert.That(() => i[(PIDL)null], Throws.Exception);
using (var pidl = new PIDL(testFile))
{
Assert.That(i[pidl.LastId], Is.EqualTo(si));
Assert.That(i[pidl], Is.EqualTo(si));
}
Assert.That(() => i[(PIDL)null], Throws.Exception);
}
using (var i = new ShellFolder(KNOWNFOLDERID.FOLDERID_Desktop))
Assert.That(i, Is.EqualTo(ShellFolder.Desktop));
@ -99,34 +85,27 @@ namespace Vanara.Windows.Shell.Tests
var ie2 = ie1.EnumerateChildren(FolderItemFilter.NonFolders);
Assert.That(ie1.Intersect(ie2).OrderBy(s => s.Name), Is.EquivalentTo(ie2.OrderBy(s => s.Name)));
}
using (var d = new ShellFolder(@"C:\"))
{
using (var libs = (ShellFolder)d["Temp"])
{
Assert.That(libs, Is.Not.Null.And.InstanceOf<ShellFolder>());
using (var lnk = libs["Test.lnk"])
Assert.That(lnk, Is.Not.Null.And.InstanceOf<ShellLink>());
}
}
using var d = new ShellFolder(@"C:\");
using var libs = (ShellFolder)d["Temp"];
Assert.That(libs, Is.Not.Null.And.InstanceOf<ShellFolder>());
using var lnk = libs["Test.lnk"];
Assert.That(lnk, Is.Not.Null.And.InstanceOf<ShellLink>());
}, Throws.Nothing);
Assert.That(() => new ShellFolder(KNOWNFOLDERID.FOLDERID_Windows).EnumerateChildren((FolderItemFilter)0x80000), Is.Empty);
Assert.That(() => new ShellFolder(KNOWNFOLDERID.FOLDERID_ComputerFolder).EnumerateChildren(), Is.Not.Empty);
}
[Test]
public void GetObjectTest()
{
using (var f = new ShellFolder(testFld))
using (var i = new ShellItem(testFile))
{
var qi = f.GetChildrenUIObjects<IQueryInfo>(null, i);
Assert.That(qi, Is.Not.Null.And.InstanceOf<IQueryInfo>());
System.Runtime.InteropServices.Marshal.ReleaseComObject(qi);
var sv = f.GetViewObject<IShellView>(null);
Assert.That(sv, Is.Not.Null.And.InstanceOf<IShellView>());
Assert.That(() => f.GetChildrenUIObjects<IShellLibrary>(null, i), Throws.TypeOf<NotImplementedException>());
Assert.That(() => f.GetViewObject<IShellLibrary>(null), Throws.TypeOf<NotImplementedException>());
}
using var f = new ShellFolder(testFld);
using var i = new ShellItem(testFile);
var qi = f.GetChildrenUIObjects<IQueryInfo>(null, i);
Assert.That(qi, Is.Not.Null.And.InstanceOf<IQueryInfo>());
System.Runtime.InteropServices.Marshal.ReleaseComObject(qi);
var sv = f.GetViewObject<IShellView>(null);
Assert.That(sv, Is.Not.Null.And.InstanceOf<IShellView>());
Assert.That(() => f.GetChildrenUIObjects<IShellLibrary>(null, i), Throws.TypeOf<NotImplementedException>());
Assert.That(() => f.GetViewObject<IShellLibrary>(null), Throws.TypeOf<NotImplementedException>());
}
}
}

View File

@ -0,0 +1,42 @@
using NUnit.Framework;
using System;
using System.Runtime.InteropServices;
using System.Text;
using Vanara.InteropServices;
using Vanara.PInvoke;
using Vanara.PInvoke.Tests;
using static Vanara.PInvoke.Shell32;
namespace Vanara.Windows.Shell.Tests
{
[TestFixture]
public class ShellSearchTests
{
[Test]
public void SearchTests()
{
using var c = SearchCondition.CreateFromStructuredQuery("customer *.pptx");
GetResults(c);
using var shf = ShellSearch.GetSearchResults(c, "Test", new[] { ShellFolder.Desktop }) as ShellFolder;
var i = 50;
foreach (var item in shf)
if (--i > 0)
TestContext.WriteLine(item.FileSystemPath);
else
break;
}
[Test]
public void ConditionTest()
{
using var c = SearchCondition.CreateFromStructuredQuery("LONG kind:text");
GetResults(c);
}
private static void GetResults(SearchCondition c)
{
foreach (var r in c.GetLeveledResults())
TestContext.WriteLine(r);
}
}
}

View File

@ -46,6 +46,7 @@
</PackageReference>
</ItemGroup>
<ItemGroup>
<Compile Include="ShellSearchTests.cs" />
<Compile Include="ControlPanelTests.cs" />
<Compile Include="ShellAssocTests.cs" />
<Compile Include="ShellFileOperationsTests.cs" />

View File

@ -0,0 +1,96 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using Vanara.InteropServices;
using Vanara.PInvoke;
using static Vanara.PInvoke.Ole32;
using static Vanara.PInvoke.SearchApi;
using static Vanara.PInvoke.Shell32;
namespace Vanara.Windows.Shell
{
/// <summary>Represents functionality of the Windows Search Service.</summary>
public static class ShellSearch
{
private const string systemCatalog = "SystemIndex";
private static readonly ISearchCatalogManager catMgr;
private static readonly ISearchManager mgr = new ISearchManager();
private static readonly IQueryParserManager queryMgr = new IQueryParserManager();
static ShellSearch() => catMgr = mgr.GetCatalog(systemCatalog);
public static ShellItem GetSearchResults(SearchCondition condition, string displayName, IEnumerable<ShellFolder> searchFolders, ShellSearchViewSettings settings = null)
{
using var cfactory = ComReleaserFactory.Create(new ISearchFolderItemFactory());
cfactory.Item.SetPaths(searchFolders);
return GetSearchResults(cfactory.Item, condition, displayName, settings);
}
public static ShellItem GetSearchResults(SearchCondition condition, string displayName, IEnumerable<string> searchFolders, ShellSearchViewSettings settings = null)
{
using var cfactory = ComReleaserFactory.Create(new ISearchFolderItemFactory());
cfactory.Item.SetPaths(searchFolders);
return GetSearchResults(cfactory.Item, condition, displayName, settings);
}
private static ShellItem GetSearchResults(ISearchFolderItemFactory factory, SearchCondition condition, string displayName, ShellSearchViewSettings settings)
{
factory.SetDisplayName(displayName ?? "");
factory.SetCondition(condition?.condition ?? throw new ArgumentNullException(nameof(condition)));
if (settings != null)
{
factory.SetFolderTypeID(settings.FolderTypeID.Guid());
if (settings.FolderLogicalViewMode.HasValue) factory.SetFolderLogicalViewMode(settings.FolderLogicalViewMode.Value);
if (settings.IconSize.HasValue) factory.SetIconSize(settings.IconSize.Value);
if (settings.VisibleColumns != null) factory.SetVisibleColumns((uint)settings.VisibleColumns.Length, settings.VisibleColumns);
if (settings.SortColumns != null) factory.SetSortColumns((uint)settings.SortColumns.Length, settings.SortColumns);
if (settings.GroupColumn.HasValue) factory.SetGroupColumn(settings.GroupColumn.Value);
if (settings.StackKeys != null) factory.SetStacks((uint)settings.StackKeys.Length, settings.StackKeys);
}
return factory.GetShellItem();
}
private static ShellItem GetShellItem(this ISearchFolderItemFactory f) => ShellItem.Open(f.GetShellItem<IShellItem>());
private static void SetPaths(this ISearchFolderItemFactory f, IEnumerable<string> paths) =>
SetPaths(f, paths.Select(p => new ShellFolder(p)));
private static void SetPaths(this ISearchFolderItemFactory f, IEnumerable<ShellFolder> paths)
{
var pa = paths?.ToArray();
if (pa != null && pa.Length > 0)
{
SHCreateShellItemArrayFromIDLists((uint)pa.Length, Array.ConvertAll(pa, i => (IntPtr)i.PIDL), out var ia).ThrowIfFailed();
f.SetScope(ia);
}
else
f.SetScope(null);
}
}
/// <summary>Settings that change the folder view of a search.</summary>
public class ShellSearchViewSettings
{
/// <summary>The folder logical view mode. The default settings are based on the which is set by the FolderTypeID property.</summary>
public FOLDERLOGICALVIEWMODE? FolderLogicalViewMode { get; set; }
/// <summary>The search folder type ID.</summary>
public FOLDERTYPEID FolderTypeID { get; set; } = FOLDERTYPEID.FOLDERTYPEID_GenericLibrary;
/// <summary>The group column. If no group column is specified, no grouping occurs.</summary>
public PROPERTYKEY? GroupColumn { get; set; }
/// <summary>The search folder icon size. The default settings are based on the which is set by the FolderTypeID property.</summary>
public int? IconSize { get; set; }
/// <summary>A list of sort column directions.</summary>
public SORTCOLUMN[] SortColumns { get; set; }
/// <summary>A list of stack keys, as specified. If <see langword="null"/>, the folder will not be stacked.</summary>
public PROPERTYKEY[] StackKeys { get; set; }
/// <summary>A list of which columns are all visible. The default is based on <c>FolderTypeID</c>.</summary>
public PROPERTYKEY[] VisibleColumns { get; set; }
}
}

View File

@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using Vanara.InteropServices;
using Vanara.PInvoke;
using static Vanara.PInvoke.Ole32;
using static Vanara.PInvoke.SearchApi;
namespace Vanara.Windows.Shell
{
/// <summary>Provides properties and methods for retrieving information about a search condition.</summary>
/// <seealso cref="System.ICloneable"/>
public class SearchCondition : ICloneable, IDisposable
{
internal ICondition condition;
private const string systemCatalog = "SystemIndex";
internal SearchCondition(ICondition c) => condition = c;
/// <summary>Gets the CLSID for the search condition.</summary>
/// <value>The CLSID.</value>
public Guid CLSID => condition.GetClassID();
/// <summary>Retrieves the property name, operation, and value from a leaf search condition node.</summary>
/// <value>The comparison information.</value>
public (string propName, CONDITION_OPERATION op, object propValue) ComparisonInfo
{
get
{
var pv = new PROPVARIANT();
condition.GetComparisonInfo(out var n, out var o, pv);
return (n, o, pv.Value);
}
}
/// <summary>
/// Retrieves the condition type for this search condition node, identifying it as a logical AND, OR, or NOT, or as a leaf node.
/// </summary>
public CONDITION_TYPE ConditionType => condition.GetConditionType();
/// <summary>Retrieves the property name, operation, and value from a leaf search condition node.</summary>
/// <value>The leaf condition information.</value>
public (PROPERTYKEY propKey, CONDITION_OPERATION op, object propValue) LeafConditionInfo
{
get
{
var pv = new PROPVARIANT();
((ICondition2)condition).GetLeafConditionInfo(out var n, out var o, pv);
return (n, o, pv.Value);
}
}
/// <summary>Retrieves the size of the stream needed to save the object.</summary>
/// <value>The size in bytes of the stream needed to save this object, in bytes.</value>
public long MaxSize => (long)condition.GetSizeMax();
/// <summary>
/// Retrieves a collection of the subconditions of the search condition node and the IID of the interface for enumerating the collection.
/// </summary>
/// <value>
/// A collection of zero or more SearchCondition objects. Each object is a subcondition of this condition node. If this is a negation
/// condition, this parameter receives the single subcondition.
/// </value>
public IEnumerable<SearchCondition> SubConditions => condition.GetSubConditions<IEnumUnknown>().Enumerate<ICondition>().Select(ic => new SearchCondition(ic));
/// <summary>Retrieves the character-normalized value of the search condition node.</summary>
/// <value>The normalized value of the condition.</value>
public string ValueNormalization => condition.GetValueNormalization();
/// <summary>Retrieves the semantic type of the value of the search condition node.</summary>
/// <value>The semantic type of the value.</value>
public string ValueType => condition.GetValueType();
/// <summary>Creates a condition node that is a logical conjunction (AND) or disjunction (OR) of a collection of subconditions.</summary>
/// <param name="conditionType">
/// The CONDITION_TYPE of the condition node. The CONDITION_TYPE must be either CT_AND_CONDITION or CT_OR_CONDITION.
/// </param>
/// <param name="simplify">
/// <see langword="true"/> to logically simplify the result, if possible; then the result will not necessarily to be of the specified
/// kind. <see langword="false"/> if the result should have exactly the prescribed structure.
/// <para>
/// An application that plans to execute a query based on the condition tree would typically benefit from setting this parameter to <see langword="true"/>.
/// </para>
/// </param>
/// <param name="subconditions">The SearchCondition sub-objects. This list can be left empty.</param>
/// <returns>The new <see cref="SearchCondition"/> node.</returns>
public static SearchCondition CreateAndOrCondition(CONDITION_TYPE conditionType, bool simplify, params SearchCondition[] subconditions)
{
using var ifactory = ComReleaserFactory.Create(new IConditionFactory());
var ienumunk = new IEnumUnknownImpl<ICondition>(subconditions.Select(c => c.condition));
var icond = ifactory.Item.MakeAndOr(conditionType, ienumunk, simplify);
return new SearchCondition(icond);
}
/// <summary>Creates a condition node from a structured query.</summary>
/// <param name="query">An input string to be parsed.</param>
/// <param name="cultureInfo">Used to select the localized language for keywords. By default, the current UI culture is used.</param>
/// <returns>The new <see cref="SearchCondition"/> node.</returns>
public static SearchCondition CreateFromStructuredQuery(string query, CultureInfo cultureInfo = null)
{
if (cultureInfo is null) cultureInfo = CultureInfo.CurrentUICulture;
using var qm = ComReleaserFactory.Create(new IQueryParserManager());
using var qp = ComReleaserFactory.Create(qm.Item.CreateLoadedParser<IQueryParser>(systemCatalog, (uint)cultureInfo.LCID));
qm.Item.InitializeOptions(false, true, qp.Item);
using var qs = ComReleaserFactory.Create(qp.Item.Parse(query));
qs.Item.GetQuery(out var pc, out _);
using var rpc = ComReleaserFactory.Create(pc);
if (Environment.OSVersion.Version >= new Version(6, 1))
{
using var pcf = ComReleaserFactory.Create((IConditionFactory2)qs.Item);
return new SearchCondition(pcf.Item.ResolveCondition<ICondition>(pc));
}
else
{
Kernel32.GetLocalTime(out var st);
return new SearchCondition(qs.Item.Resolve(pc, STRUCTURED_QUERY_RESOLVE_OPTION.SQRO_DONT_SPLIT_WORDS, st));
}
}
/// <summary>Creates a leaf condition node that represents a comparison of property value and constant value.</summary>
/// <typeparam name="T">The type of the property value.</typeparam>
/// <param name="propertyName">
/// The name of a property to be compared, or <see langword="null"/> for an unspecified property. The locale name of the leaf node is LOCALE_NAME_USER_DEFAULT.
/// </param>
/// <param name="value">The constant value against which the property value should be compared.</param>
/// <param name="operation">A CONDITION_OPERATION enumeration.</param>
/// <returns>The new <see cref="SearchCondition"/> node.</returns>
public static SearchCondition CreateLeafCondition<T>(string propertyName, T value, CONDITION_OPERATION operation)
{
using var ifactory = ComReleaserFactory.Create(new IConditionFactory());
if (string.IsNullOrEmpty(propertyName) || propertyName.ToUpperInvariant() == "SYSTEM.NULL")
propertyName = null;
var pv = new PROPVARIANT(value);
var valType = pv.VarType switch
{
VarEnum.VT_I4 => "System.StructuredQuery.CustomProperty.Integer",
VarEnum.VT_R8 => "System.StructuredQuery.CustomProperty.FloatingPoint",
VarEnum.VT_DATE => "System.StructuredQuery.CustomProperty.DateTime",
VarEnum.VT_BOOL => "System.StructuredQuery.CustomProperty.Boolean",
VarEnum.VT_LPWSTR => null,
_ => throw new ArgumentException("Type cannot be used as a condition.", nameof(value)),
};
var icond = ifactory.Item.MakeLeaf(propertyName, operation, valType, pv);
return new SearchCondition(icond);
}
/// <summary>Creates a condition node that is a logical negation (NOT) of another condition (a subnode of this node).</summary>
/// <param name="conditionToNegate">The condition to negate.</param>
/// <param name="simplify">
/// <see langword="true"/> to logically simplify the result if possible; <see langword="false"/> otherwise. In a query builder
/// scenario, <paramref name="simplify"/> should typically be set to <see langword="false"/>.
/// </param>
/// <returns>The new <see cref="SearchCondition"/> node.</returns>
/// <exception cref="ArgumentNullException">conditionToNegate</exception>
public static SearchCondition CreateNotCondition(SearchCondition conditionToNegate, bool simplify)
{
using var ifactory = ComReleaserFactory.Create(new IConditionFactory());
var icond = ifactory.Item.MakeNot(conditionToNegate?.condition ?? throw new ArgumentNullException(nameof(conditionToNegate)), simplify);
return new SearchCondition(icond);
}
/// <summary>Creates a deep copy of this instance.</summary>
/// <returns>A copy of this search condition.</returns>
public SearchCondition Clone() => new SearchCondition(condition.Clone());
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
public void Dispose()
{
if (condition != null)
{
Marshal.ReleaseComObject(condition);
condition = null;
}
}
/// <summary>Gets the results of the condition by level.</summary>
/// <returns>An enumeration of the leaf nodes with their level and data.</returns>
public IEnumerable<(int level, string propName, string semanticType, object propVar)> GetLeveledResults() => GetResults(condition);
private static IEnumerable<(int level, string propName, string semanticType, object propVar)> GetResults(ICondition pc, int l = 0)
{
switch (pc.GetConditionType())
{
case CONDITION_TYPE.CT_AND_CONDITION:
case CONDITION_TYPE.CT_OR_CONDITION:
foreach (var pcsub in pc.GetSubConditions<IEnumUnknown>().Enumerate<ICondition>().Where(i => i != null))
foreach (var r in GetResults(pcsub, l + 1))
yield return r;
break;
case CONDITION_TYPE.CT_NOT_CONDITION:
foreach (var r in GetResults(pc.GetSubConditions<ICondition>(), l + 1))
yield return r;
break;
case CONDITION_TYPE.CT_LEAF_CONDITION:
var propvar = new PROPVARIANT();
((ICondition2)pc).GetLeafConditionInfo(out var propkey, out _, propvar);
yield return (l, propkey.GetCononicalName(), pc.GetValueType() ?? "", propvar.Value);
break;
default:
break;
}
}
object ICloneable.Clone() => Clone();
}
}

View File

@ -56,7 +56,6 @@ ChangeFilters, ExecutableType, FolderItemFilter, LibraryFolderFilter, LibraryVie
</PackageReference>
</ItemGroup>
<ItemGroup>
<Compile Remove="ShellObjects\~ShellSearch.cs" />
<Compile Remove="TaskBar\AssocUtil.cs" />
<Compile Remove="TaskBar\ImageIndexer.cs" />
<Compile Remove="TaskBar\JumpList.cs" />
@ -75,10 +74,8 @@ ChangeFilters, ExecutableType, FolderItemFilter, LibraryFolderFilter, LibraryVie
<Compile Remove="TaskBar\~ThumbnailToolbar.cs" />
<Compile Remove="TaskBar\~ThumbnailToolbarButton.cs" />
<Compile Remove="TaskBar\~ThumbnailToolbarButtonCollection.cs" />
<Compile Remove="~ShellSearchConditions.cs" />
</ItemGroup>
<ItemGroup>
<None Include="ShellObjects\~ShellSearch.cs" />
<None Include="TaskBar\~AssocUtil.cs" />
<None Include="TaskBar\~ImageIndexer.cs" />
<None Include="TaskBar\~JumpList.cs" />
@ -88,12 +85,12 @@ ChangeFilters, ExecutableType, FolderItemFilter, LibraryFolderFilter, LibraryVie
<None Include="TaskBar\~ThumbnailToolbar.cs" />
<None Include="TaskBar\~ThumbnailToolbarButton.cs" />
<None Include="TaskBar\~ThumbnailToolbarButtonCollection.cs" />
<None Include="~ShellSearchConditions.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Vanara.Core.csproj" />
<ProjectReference Include="..\PInvoke\Shared\Vanara.PInvoke.Shared.csproj" />
<ProjectReference Include="..\PInvoke\ComCtl32\Vanara.PInvoke.ComCtl32.csproj" />
<ProjectReference Include="..\PInvoke\Ole\Vanara.PInvoke.Ole.csproj" />
<ProjectReference Include="..\PInvoke\Shell32\Vanara.PInvoke.Shell32.csproj" />
<ProjectReference Include="..\PInvoke\User32\Vanara.PInvoke.User32.csproj" />
<ProjectReference Include="..\PInvoke\SearchApi\Vanara.PInvoke.SearchApi.csproj" />