Skip to content

[PowerToysRun][OneNote] Improve the OneNote plugin #36813

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actions/spell-check/expect.txt
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,7 @@
INPUTTYPE
INSTALLDESKTOPSHORTCUT
INSTALLDIR
installdir

Check warning on line 720 in .github/actions/spell-check/expect.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

`INSTALLDIR` is ignored by check spelling because another more general variant is also in expect. (ignored-expect-variant)
INSTALLFOLDER
INSTALLFOLDERTOBOOTSTRAPPERINSTALLFOLDER
INSTALLFOLDERTOPREVIOUSINSTALLFOLDER
Expand Down Expand Up @@ -1099,9 +1099,13 @@
nullonfailure
numberbox
nwc
Objbase
objidl
ocid
ocr
Ocrsettings
odbccp
odotocodot
OEMCONVERT
officehubintl
OFN
Expand Down Expand Up @@ -1167,6 +1171,7 @@
pdisp
PDLL
pdo
pdpshare
pdto
pdtobj
pdw
Expand Down Expand Up @@ -1515,7 +1520,7 @@
SIGNINGSCENARIO
signtool
Signtool
SINGLEKEY

Check warning on line 1523 in .github/actions/spell-check/expect.txt

View workflow job for this annotation

GitHub Actions / Check Spelling

`Signtool` is ignored by check spelling because another more general variant is also in expect. (ignored-expect-variant)
sipolicy
SIZEBOX
Sizename
Expand Down Expand Up @@ -1949,6 +1954,7 @@
XElement
xfd
XFile
XIn
XIncrement
XNamespace
Xoshiro
Expand Down
7 changes: 3 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,8 @@
<PackageVersion Include="ControlzEx" Version="6.0.0" />
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
<PackageVersion Include="HelixToolkit.Core.Wpf" Version="2.24.0" />
<PackageVersion Include="Humanizer" Version="2.14.1" />
<PackageVersion Include="hyjiacan.pinyin4net" Version="4.1.1" />
<PackageVersion Include="Interop.Microsoft.Office.Interop.OneNote" Version="1.1.0.2" />
<PackageVersion Include="LazyCache" Version="2.4.0" />
<PackageVersion Include="Mages" Version="3.0.0" />
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
Expand All @@ -52,7 +51,7 @@
<!--
TODO: in Common.Dotnet.CsWinRT.props, on upgrade, verify RemoveCsWinRTPackageAnalyzer is no longer needed.
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
-->
-->
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22621.2428" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.7.250401001" />
Expand All @@ -65,9 +64,9 @@
<PackageVersion Include="NLog" Version="5.0.4" />
<PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" />
<PackageVersion Include="NLog.Schema" Version="5.2.8" />
<PackageVersion Include="Odotocodot.OneNote.Linq" Version="1.1.0" />
<PackageVersion Include="OpenAI" Version="2.0.0" />
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
<PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" />
<PackageVersion Include="SharpCompress" Version="0.37.2" />
<PackageVersion Include="StreamJsonRpc" Version="2.21.69" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
Expand Down
63 changes: 36 additions & 27 deletions doc/devdocs/modules/launcher/plugins/onenote.md
Original file line number Diff line number Diff line change
@@ -1,38 +1,47 @@
# OneNote Plugin

The OneNote plugin searches your locally synced OneNote notebooks based on the user query.

![Image of OneNote plugin](/doc/images/launcher/plugins/onenote.png)
<p>
<img src="/doc/images/launcher/plugins/onenote.png" alt="default menu" height="200"/>

<img src="/doc/images/launcher/plugins/onenote_notebook_explorer.png" alt="notebook explorer" height="200"/>
</p>

This is essentially a port of this [OneNote plugin](https://github.com/Odotocodot/Flow.Launcher.Plugin.OneNote) for [Flow Launcher](https://github.com/Flow-Launcher/Flow.Launcher) (also built on Wox) directly into PowerToys Run.

The code is largely a wrapper around the [Linq2OneNote](https://github.com/Odotocodot/Linq2OneNote) library.

## OneNote Interop COM Object

Typically the slowest part of the plugin is acquiring the OneNote COM object (via `OneNoteApplication.InitComObject` or lazily), and once acquired it stays in memory and is visible in the task manager (See [Linq2OneNote docs](https://odotocodot.github.io/Linq2OneNote/articles/memory_management.html) for more info).

The code itself is very simple, basically just a call into OneNote interop via the https://github.com/scipbe/ScipBe-Common-Office library.
To avoid this, once the COM object is acquired there is a timer (`_comObjectTimeout`) that starts, which is reset whenever the user continues searching. When this timer reaches zero, the COM object is released, freeing it from memory and removing it from the task manager.

```csharp
var pages = OneNoteProvider.FindPages(query.Search);
```
This is done in [`Main.cs`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Main.cs).

The query results will be cached for 1 day, and if cached results are found they'll be returned in the initial `Query()` call, otherwise OneNote itself will be queried in the `delayedExecution:true` overload.
The COM Object is also released when the user selects a result that would close the PowerToys Run window, this is done in [`ResultCreator.ResultAction`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Components/ResultCreator.cs)

If the user actions on a result, it'll open it in the OneNote app, and restore and/or focus the app as well if necessary.
The timeout is used because there is currently no way to know when the PTRun window has been closed when the user is querying.

```csharp
if (PInvoke.IsIconic(handle))
{
PInvoke.ShowWindow(handle, SHOW_WINDOW_CMD.SW_RESTORE);
}
## Technical Details

PInvoke.SetForegroundWindow(handle);
```
### [`SearchManager.cs`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Components/SearchManager.cs)
- Responsible for converting the user query into the appropriate OneNote items.
- [`SearchManager.NotebookExplorer.cs`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Components/SearchManager.NotebookExplorer.cs)
- Handles the "notebook explorer" feature which allows the user to navigate their OneNote items like Windows File Explorer.
- The function `AddCreateNewOneNoteItemResults` is responsible for allowing the user to create new OneNote items quickly, the type of items that can be created are dependent on the current parent in the notebook explorer.
- There are 3 main types of searching:
- `DefaultSearch` - Uses `OneNoteApplication.FindPages(query)`, which relies on Windows Indexing and is essentially the same as using the search box inside of OneNote. (Only returns OneNote pages)
- `TitleSearch` - Searches items only by their title, can return OneNote pages, sections, section groups etc, uses `StringMatcher.FuzzySearch`.
- `ScopedSearch` - Similar to `DefaultSearch` but only searches within a specific item. For instance, searching pages only in a certain section group.
- The function `SettingsCheck` is used to filter the search results by the user configured settings.

The plugin attempts to call the library in the constructor, and if it fails with a COMException then it'll note that OneNote isn't available and not attempt to query it again.
### [`ResultCreator.cs`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Components/ResultCreator.cs)
- Responsible for taking any data and converting it into a Wox `Result` for displaying.

```csharp
try
{
_ = OneNoteProvider.PageItems.Any();
_oneNoteInstalled = true;
}
catch (COMException)
{
// OneNote isn't installed, plugin won't do anything.
_oneNoteInstalled = false;
}
```
### [`IconProvider.cs`](/src/modules/launcher/Plugins/Microsoft.PowerToys.Run.Plugin.OneNote/Components/IconProvider.cs)
- Responsible for providing all the icons for the plugin.
- If the user has set the `ColoredIcons` setting to true. Colored icons are created and saved/cached for OneNote notebooks and sections.
- Supports light and dark theme.
- When the user sets `ColoredIcons` to false, the colored icons are deleted when the PowerToys Run is closed.
Binary file modified doc/images/launcher/plugins/onenote.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Runtime.InteropServices;
using System.Windows.Media.Imaging;
using ManagedCommon;
using Odotocodot.OneNote.Linq;
using Wox.Infrastructure.Image;
using Wox.Plugin;
using Wox.Plugin.Logger;

namespace Microsoft.PowerToys.Run.Plugin.OneNote.Components
{
public class IconProvider
{
private readonly PluginInitContext _context;
private readonly OneNoteSettings _settings;
private readonly string _imagesDirectory;
private readonly string _generatedImagesDirectory;
private readonly ConcurrentDictionary<string, BitmapSource> _imageCache = new();

private bool _deleteColoredIconsOnCleanup;

private string _powerToysTheme;
private string _pluginTheme;

internal string NewPage => $"Images/page_new.{_pluginTheme}.png";

internal string NewSection => $"Images/section_new.{_pluginTheme}.png";

internal string NewSectionGroup => $"Images/section_group_new.{_pluginTheme}.png";

internal string NewNotebook => $"Images/notebook_new.{_pluginTheme}.png";

internal string Page => $"Images/page.{_pluginTheme}.png";

internal string Recent => $"Images/page_recent.{_pluginTheme}.png";

internal string Sync => $"Images/sync.{_pluginTheme}.png";

internal string Search => $"Images/search.{_pluginTheme}.png";

internal string NotebookExplorer => $"Images/notebook_explorer.{_pluginTheme}.png";

internal string Warning => $"Images/warning.{_powerToysTheme}.png";

internal string QuickNote => NewPage;

internal IconProvider(PluginInitContext context, OneNoteSettings settings)
{
_settings = settings;
_context = context;
_settings.ColoredIconsSettingChanged += OnColoredIconsSettingChanged;
_context.API.ThemeChanged += OnThemeChanged;

_imagesDirectory = $"{_context.CurrentPluginMetadata.PluginDirectory}/Images/";
_generatedImagesDirectory = $"{_context.CurrentPluginMetadata.PluginDirectory}/Images/Generated/";

if (!Directory.Exists(_generatedImagesDirectory))
{
Directory.CreateDirectory(_generatedImagesDirectory);
}

foreach (var imagePath in Directory.EnumerateFiles(_generatedImagesDirectory))
{
_imageCache.TryAdd(Path.GetFileNameWithoutExtension(imagePath), Path2Bitmap(imagePath));
}

UpdatePowerToysTheme(_context.API.GetCurrentTheme());
}

private void OnColoredIconsSettingChanged()
{
_deleteColoredIconsOnCleanup = !_settings.ColoredIcons;
UpdatePluginTheme();
}

private void OnThemeChanged(Theme oldTheme, Theme newTheme) => UpdatePowerToysTheme(newTheme);

[MemberNotNull(nameof(_powerToysTheme))]
[MemberNotNull(nameof(_pluginTheme))]
private void UpdatePowerToysTheme(Theme theme)
{
_powerToysTheme = theme == Theme.Light || theme == Theme.HighContrastWhite ? "light" : "dark";
UpdatePluginTheme();
}

[MemberNotNull(nameof(_pluginTheme))]
private void UpdatePluginTheme() => _pluginTheme = _settings.ColoredIcons ? "color" : _powerToysTheme;

private BitmapSource GetColoredIcon(string itemType, Color itemColor)
{
return _imageCache.GetOrAdd($"{itemType}.{itemColor.ToArgb()}", key =>
{
var color = itemColor;
using var bitmap = new Bitmap($"{_imagesDirectory}{itemType}.dark.png");
BitmapData bitmapData = bitmap.LockBits(new Rectangle(0, 0, bitmap.Width, bitmap.Height), ImageLockMode.ReadWrite, bitmap.PixelFormat);

int bytesPerPixel = Image.GetPixelFormatSize(bitmap.PixelFormat) / 8;
byte[] pixels = new byte[bitmapData.Stride * bitmap.Height];
IntPtr pointer = bitmapData.Scan0;
Marshal.Copy(pointer, pixels, 0, pixels.Length);
int bytesWidth = bitmapData.Width * bytesPerPixel;

for (int j = 0; j < bitmapData.Height; j++)
{
int line = j * bitmapData.Stride;
for (int i = 0; i < bytesWidth; i += bytesPerPixel)
{
pixels[line + i] = color.B;
pixels[line + i + 1] = color.G;
pixels[line + i + 2] = color.R;
}
}

Marshal.Copy(pixels, 0, pointer, pixels.Length);
bitmap.UnlockBits(bitmapData);

var filePath = $"{_generatedImagesDirectory}{key}.png";
bitmap.Save(filePath, ImageFormat.Png);
return Path2Bitmap(filePath);
});
}

private BitmapSource Path2Bitmap(string path) => WindowsThumbnailProvider.GetThumbnail(path, Constant.ThumbnailSize, Constant.ThumbnailSize, ThumbnailOptions.ThumbnailOnly);

internal System.Windows.Media.ImageSource GetIcon(IOneNoteItem item)
{
string key;
switch (item)
{
case OneNoteNotebook notebook:
if (!_settings.ColoredIcons || notebook.Color is null)
{
key = $"{nameof(notebook)}.{_powerToysTheme}";
break;
}
else
{
return GetColoredIcon(nameof(notebook), notebook.Color.Value);
}

case OneNoteSectionGroup sectionGroup:
key = $"{(sectionGroup.IsRecycleBin ? $"recycle_bin" : $"section_group")}.{_pluginTheme}";
break;

case OneNoteSection section:
if (!_settings.ColoredIcons || section.Color is null)
{
key = $"{nameof(section)}.{_powerToysTheme}";
break;
}
else
{
return GetColoredIcon(nameof(section), section.Color.Value);
}

case OneNotePage:
key = Path.GetFileNameWithoutExtension(Page);
break;

default:
throw new NotImplementedException();
}

return _imageCache.GetOrAdd(key, key => Path2Bitmap($"{_imagesDirectory}{key}.png"));
}

internal void Cleanup()
{
_imageCache.Clear();
if (_deleteColoredIconsOnCleanup)
{
foreach (var file in new DirectoryInfo(_generatedImagesDirectory).EnumerateFiles())
{
try
{
file.Delete();
}
catch (Exception ex) when (ex is DirectoryNotFoundException || ex is IOException)
{
Log.Error($"Failed to delete icon at \"{file}\"", GetType());
}
}
}

if (_settings != null)
{
_settings.ColoredIconsSettingChanged -= OnColoredIconsSettingChanged;
}

if (_context != null && _context.API != null)
{
_context.API.ThemeChanged -= OnThemeChanged;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

namespace Microsoft.PowerToys.Run.Plugin.OneNote.Components
{
public class Keywords
{
internal const string NotebookExplorerSeparator = @"\";

internal const string NotebookExplorer = $"nb:{NotebookExplorerSeparator}";

internal const string RecentPages = "rp:";

internal const string ScopedSearch = ">";

internal const string TitleSearch = "*";
}
}
Loading
Loading