diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index ba78d83..1aba38f 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -6,7 +6,7 @@ on: jobs: pre-commit: - runs-on: ubuntu-latest + runs-on: windows-2025 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v3 diff --git a/PasteIntoFile.Core/PasteIntoFile.Core.csproj b/PasteIntoFile.Core/PasteIntoFile.Core.csproj new file mode 100644 index 0000000..b7f60bb --- /dev/null +++ b/PasteIntoFile.Core/PasteIntoFile.Core.csproj @@ -0,0 +1,33 @@ + + + + net9.0-windows + enable + enable + PasteIntoFile.Core + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + True + True + Settings.settings + + + + + True + True + Resources.resx + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + diff --git a/PasteIntoFile.Core/Properties/Resources.Designer.cs b/PasteIntoFile.Core/Properties/Resources.Designer.cs new file mode 100644 index 0000000..734b04f --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.Designer.cs @@ -0,0 +1,107 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace PasteIntoFile.Core.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + public Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("PasteIntoFile.Core.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Paste into file. + /// + public static string str_contextentry { + get { + return ResourceManager.GetString("str_contextentry", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Append. + /// + public static string str_contextentry_append { + get { + return ResourceManager.GetString("str_contextentry_append", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Copy file content. + /// + public static string str_contextentry_copyfromfile { + get { + return ResourceManager.GetString("str_contextentry_copyfromfile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Paste into this file. + /// + public static string str_contextentry_paste_into_existing_file { + get { + return ResourceManager.GetString("str_contextentry_paste_into_existing_file", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Replace. + /// + public static string str_contextentry_replace { + get { + return ResourceManager.GetString("str_contextentry_replace", resourceCulture); + } + } + } +} diff --git a/PasteIntoFile.Core/Properties/Resources.de.resx b/PasteIntoFile.Core/Properties/Resources.de.resx new file mode 100644 index 0000000..dc137cb --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.de.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Dateiinhalt kopieren + Context menu entry for copying + + + + Einfügen in diese Datei + + + Einfügen als Datei + Context menu entry for pasting + + + Anfügen + + + Ersetzen + + diff --git a/PasteIntoFile.Core/Properties/Resources.es-es.resx b/PasteIntoFile.Core/Properties/Resources.es-es.resx new file mode 100644 index 0000000..0220df3 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.es-es.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Copiar contenido del archivo + Context menu entry for copying + + + + Pegar en este archivo + + + Pegar en fichero + Context menu entry for pasting + + + Anexar + + + Reemplazar + + diff --git a/PasteIntoFile.Core/Properties/Resources.fr.resx b/PasteIntoFile.Core/Properties/Resources.fr.resx new file mode 100644 index 0000000..0b3615f --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.fr.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Copier le contenu du fichier + Context menu entry for copying + + + + Coller dans ce fichier + + + Coller dans un fichier + Context menu entry for pasting + + + Ajouter + + + Remplacer + + diff --git a/PasteIntoFile.Core/Properties/Resources.it.resx b/PasteIntoFile.Core/Properties/Resources.it.resx new file mode 100644 index 0000000..b60db7e --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.it.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Copia il contenuto del file + Context menu entry for copying + + + + Incolla in questo file + + + Incolla nel file + Context menu entry for pasting + + + Aggiungi + + + Sostituisci + + diff --git a/PasteIntoFile.Core/Properties/Resources.ja-jp.resx b/PasteIntoFile.Core/Properties/Resources.ja-jp.resx new file mode 100644 index 0000000..31c0b32 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.ja-jp.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + ファイルの内容をコピー + Context menu entry for copying + + + + このファイルに貼り付け + + + ファイルに貼り付け + Context menu entry for pasting + + + 追記 + + + 置き換え + + diff --git a/PasteIntoFile.Core/Properties/Resources.ko.resx b/PasteIntoFile.Core/Properties/Resources.ko.resx new file mode 100644 index 0000000..b7080d2 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.ko.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 파일 내용 복사 + Context menu entry for copying + + + + 이 파일에 붙여넣기 + + + 파일에 붙여넣기 + Context menu entry for pasting + + + 추가 + + + 바꾸기 + + diff --git a/PasteIntoFile.Core/Properties/Resources.pt-br.resx b/PasteIntoFile.Core/Properties/Resources.pt-br.resx new file mode 100644 index 0000000..652144d --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.pt-br.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Copiar conteúdo do arquivo + Context menu entry for copying + + + + Colar nesse arquivo + + + Colar no arquivo + Context menu entry for pasting + + + Acrescentar + + + Substituir + + diff --git a/PasteIntoFile.Core/Properties/Resources.pt-pt.resx b/PasteIntoFile.Core/Properties/Resources.pt-pt.resx new file mode 100644 index 0000000..e7620a2 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.pt-pt.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Copiar conteúdo do ficheiro + Context menu entry for copying + + + + Colar neste ficheiro + + + Colar no ficheiro + Context menu entry for pasting + + + Acrescentar + + + Substituir + + diff --git a/PasteIntoFile.Core/Properties/Resources.resx b/PasteIntoFile.Core/Properties/Resources.resx new file mode 100644 index 0000000..7d9a4ac --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.resx @@ -0,0 +1,38 @@ + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Copy file content + Context menu entry for copying + + + + Paste into this file + + + Paste into file + Context menu entry for pasting + + + Append + + + Replace + + diff --git a/PasteIntoFile.Core/Properties/Resources.ru-ru.resx b/PasteIntoFile.Core/Properties/Resources.ru-ru.resx new file mode 100644 index 0000000..541a3c8 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.ru-ru.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Копировать содержимое файла + Context menu entry for copying + + + + Вставить в этот файл + + + Вставить в файл + Context menu entry for pasting + + + Дополнить + + + Заменить + + diff --git a/PasteIntoFile.Core/Properties/Resources.uk-ua.resx b/PasteIntoFile.Core/Properties/Resources.uk-ua.resx new file mode 100644 index 0000000..ae40532 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.uk-ua.resx @@ -0,0 +1,19 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Вставити до файлу + Context menu entry for pasting + + diff --git a/PasteIntoFile.Core/Properties/Resources.zh-CN.resx b/PasteIntoFile.Core/Properties/Resources.zh-CN.resx new file mode 100644 index 0000000..24a58d2 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.zh-CN.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 复制文件内容 + Context menu entry for copying + + + + 粘贴到此文件中 + + + Paste Into File + Context menu entry for pasting + + + 追加 + + + 替换 + + diff --git a/PasteIntoFile.Core/Properties/Resources.zh-TW.resx b/PasteIntoFile.Core/Properties/Resources.zh-TW.resx new file mode 100644 index 0000000..f895455 --- /dev/null +++ b/PasteIntoFile.Core/Properties/Resources.zh-TW.resx @@ -0,0 +1,33 @@ + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 複製檔案內容 + Context menu entry for copying + + + + 貼入此檔案 + + + Paste Into File + Context menu entry for pasting + + + 追加 + + + 取代 + + diff --git a/PasteIntoFile/Properties/Settings.Designer.cs b/PasteIntoFile.Core/Properties/Settings.Designer.cs similarity index 98% rename from PasteIntoFile/Properties/Settings.Designer.cs rename to PasteIntoFile.Core/Properties/Settings.Designer.cs index 7188d72..98e496a 100644 --- a/PasteIntoFile/Properties/Settings.Designer.cs +++ b/PasteIntoFile.Core/Properties/Settings.Designer.cs @@ -7,12 +7,12 @@ // //------------------------------------------------------------------------------ -namespace PasteIntoFile.Properties { +namespace PasteIntoFile.Core { [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.9.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + public sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); @@ -25,36 +25,36 @@ public static Settings Default { [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("False")] - public bool clrClipboard { + public bool autoSave { get { - return ((bool)(this["clrClipboard"])); + return ((bool)(this["autoSave"])); } set { - this["clrClipboard"] = value; + this["autoSave"] = value; } } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("True")] - public bool firstLaunch { + [global::System.Configuration.DefaultSettingValueAttribute("False")] + public bool clrClipboard { get { - return ((bool)(this["firstLaunch"])); + return ((bool)(this["clrClipboard"])); } set { - this["firstLaunch"] = value; + this["clrClipboard"] = value; } } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Configuration.DefaultSettingValueAttribute("False")] - public bool autoSave { + [global::System.Configuration.DefaultSettingValueAttribute("True")] + public bool firstLaunch { get { - return ((bool)(this["autoSave"])); + return ((bool)(this["firstLaunch"])); } set { - this["autoSave"] = value; + this["firstLaunch"] = value; } } diff --git a/PasteIntoFile/Properties/Settings.settings b/PasteIntoFile.Core/Properties/Settings.settings similarity index 89% rename from PasteIntoFile/Properties/Settings.settings rename to PasteIntoFile.Core/Properties/Settings.settings index 5104419..5a7de8b 100644 --- a/PasteIntoFile/Properties/Settings.settings +++ b/PasteIntoFile.Core/Properties/Settings.settings @@ -1,17 +1,16 @@ - - - + + + + + False + False True - - False - {0:yyyy-MM-dd HH-mm-ss} @@ -34,10 +33,10 @@ False - + - + 2000-01-01 @@ -52,7 +51,7 @@ False - + True diff --git a/PasteIntoFile.Core/Settings.cs b/PasteIntoFile.Core/Settings.cs new file mode 100644 index 0000000..bb215ad --- /dev/null +++ b/PasteIntoFile.Core/Settings.cs @@ -0,0 +1,9 @@ +namespace PasteIntoFile.Core; + +/// +/// Shared settings class that both the main app and shell extension use. +/// Properties are generated by Settings.Designer.cs from Settings.settings. +/// Call SettingsUtil.Apply(Settings.Default) before accessing any settings. +/// +public sealed partial class Settings { +} diff --git a/PasteIntoFile.Core/SettingsUtil.cs b/PasteIntoFile.Core/SettingsUtil.cs new file mode 100644 index 0000000..d0931c8 --- /dev/null +++ b/PasteIntoFile.Core/SettingsUtil.cs @@ -0,0 +1,81 @@ +using System; +using System.Configuration; +using System.IO; +using Bluegrams.Application; + +namespace PasteIntoFile.Core; + +/// +/// Utility to configure and apply PortableSettingsProvider with the correct +/// settings file path. In installer mode, settings are stored in +/// %APPDATA%\PasteIntoFile\user.config. In portable mode (#if PORTABLE), +/// settings are stored next to the executable. +/// +public static class SettingsUtil { + private const string SettingsFileName = "user.config"; + private const string AppDataFolderName = "PasteIntoFile"; + + /// + /// Applies the PortableSettingsProvider to the given settings instance, + /// configuring it to use the shared settings file path. + /// Call this once at application startup before accessing any settings. + /// + public static void Apply(ApplicationSettingsBase settings) { + PortableSettingsProvider.SettingsFileName = SettingsFileName; + PortableSettingsProvider.SettingsDirectory = GetSettingsDirectory(); + PortableSettingsProvider.ApplyProvider(settings); + } + + /// + /// Returns the full path to the shared settings file. + /// + public static string SettingsFilePath => Path.Combine(GetSettingsDirectory(), SettingsFileName); + + private static string GetSettingsDirectory() { +#if PORTABLE + var assemblyPath = typeof(SettingsUtil).Assembly.Location; + return Path.GetDirectoryName(assemblyPath) ?? AppContext.BaseDirectory; +#else + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var settingsDir = Path.Combine(appData, AppDataFolderName); + if (!Directory.Exists(settingsDir)) { + Directory.CreateDirectory(settingsDir); + } + return settingsDir; +#endif + } + + /// + /// Reloads the settings from disk only if the settings file has been modified + /// since the last successful reload. This avoids expensive file I/O on slow + /// storage (USB flash drives, network shares) when the file hasn't changed. + /// Returns true if a reload was performed, false if the cached value was still valid. + /// + public static bool ReloadIfChanged(ApplicationSettingsBase settings) { + var filePath = SettingsFilePath; + try { + if (!File.Exists(filePath)) { + // File doesn't exist — nothing to reload + return false; + } + + var lastWriteTime = File.GetLastWriteTimeUtc(filePath); + + if (lastWriteTime <= sLastReloadTime) { + // File hasn't changed since last reload — skip the expensive Reload() + return false; + } + + settings.Reload(); + sLastReloadTime = lastWriteTime; + return true; + } catch { + // If we can't check the file, fall back to always reloading + // to ensure we get the latest value + settings.Reload(); + return true; + } + } + + private static DateTime sLastReloadTime; +} diff --git a/PasteIntoFile.ShellExtension/AppendCommand.cs b/PasteIntoFile.ShellExtension/AppendCommand.cs new file mode 100644 index 0000000..20d1fae --- /dev/null +++ b/PasteIntoFile.ShellExtension/AppendCommand.cs @@ -0,0 +1,66 @@ +using System.Runtime.InteropServices; +using PasteIntoFile.Core.Properties; + +namespace PasteIntoFileShellExtension; + +[ComVisible(true)] +[Guid(Clsids.AppendCommand)] +[ProgId("PasteIntoFile.ShellExtension.AppendCommand")] +[ClassInterface(ClassInterfaceType.None)] +[ComDefaultInterface(typeof(IExplorerCommand))] +public sealed class AppendCommand : BaseExplorerCommand { + + public override string Clsid => Clsids.AppendCommand; + + public override int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName) { + ppszName = Marshal.StringToCoTaskMemUni(Resources.str_contextentry_append); + return HResult.S_OK; + } + + // File extensions that do NOT support appending (binary / image formats) + private static readonly string[] AppendNotSupportedExtensions = new[] { + "bmp", "gif", "ico", "jpeg", "jpg", "png", "tif", "tiff", "webp", // images + "svg", // vector + "url" // shortcuts + }; + + // Check if appending is supported for the given file path. + private static bool IsAppendSupported(string? path) { + if (string.IsNullOrEmpty(path)) { + return true; + } + var ext = Path.GetExtension(path)?.TrimStart('.').ToLowerInvariant(); + return string.IsNullOrEmpty(ext) || Array.IndexOf(AppendNotSupportedExtensions, ext) < 0; + } + + // Check if exactly one file is selected and append is supported for it. + // Returns ECS_ENABLED if so, otherwise ECS_HIDDEN. + private static EXPCMDSTATE CheckSingleFileSelectedWithAppendSupport(IShellItemArray psiItemArray) { + if (psiItemArray != null && psiItemArray.GetCount(out var count) == HResult.S_OK && count == 1) { + psiItemArray.GetItemAt(0, out IShellItem? shellItem); + if (shellItem != null) { + try { + if (shellItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pathPtr) == HResult.S_OK && pathPtr != IntPtr.Zero) { + var path = Marshal.PtrToStringUni(pathPtr); + Marshal.FreeCoTaskMem(pathPtr); + if (!string.IsNullOrEmpty(path) && IsAppendSupported(path)) { + return EXPCMDSTATE.ECS_ENABLED; + } + } + } finally { + Marshal.ReleaseComObject(shellItem); + } + } + } + return EXPCMDSTATE.ECS_HIDDEN; + } + + public override int GetState(IShellItemArray psiItemArray, bool fOkToBeSlow, out EXPCMDSTATE pCmdState) { + pCmdState = CheckSingleFileSelectedWithAppendSupport(psiItemArray); + return HResult.S_OK; + } + + public override int Invoke(IShellItemArray psiItemArray, IntPtr pbc) { + return InvokePasteIntoFile(psiItemArray, PasteMode.Append); + } +} diff --git a/PasteIntoFile.ShellExtension/AppxManifest.xml b/PasteIntoFile.ShellExtension/AppxManifest.xml new file mode 100644 index 0000000..db8507a --- /dev/null +++ b/PasteIntoFile.ShellExtension/AppxManifest.xml @@ -0,0 +1,44 @@ + + + + + PasteIntoFile + PasteIntoFile Dev + icon.png + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PasteIntoFile.ShellExtension/BaseExplorerCommand.cs b/PasteIntoFile.ShellExtension/BaseExplorerCommand.cs new file mode 100644 index 0000000..4b7716f --- /dev/null +++ b/PasteIntoFile.ShellExtension/BaseExplorerCommand.cs @@ -0,0 +1,393 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using PasteIntoFile.Core; + +namespace PasteIntoFileShellExtension; + + +// Shared COM class IDs — must match AppxManifest.xml registrations +static class Clsids { + public const string PasteCommand = "A6F24F95-0E3D-4A37-8E58-1F05D66E76A5"; + public const string CopyCommand = "41C9024D-4DCC-4CFE-B19B-C267B2525F63"; + public const string ReplaceCommand = "90220591-C089-4812-AF4B-510694581EF4"; + public const string AppendCommand = "668F5257-E4B7-4DE7-84AE-B1AA710F38F5"; +} + + +// ─── Abstract base for all explorer commands ────────────────────────────── +// Implements the common IExplorerCommand boilerplate (GetIcon, GetToolTip, +// GetCanonicalName, GetFlags, EnumSubCommands) so each derived command only +// provides its title, state check, and invoke logic. + +public enum PasteMode { Paste, Copy, Replace, Append } + +public abstract class BaseExplorerCommand : IExplorerCommand { + static BaseExplorerCommand() { + // Configure settings provider to use the shared settings file path + SettingsUtil.Apply(Settings.Default); + } + + public abstract string Clsid { get; } + + // ─── Shared helper methods ─── + + // Get the path to the PasteIntoFile executable relative to the assembly location. + protected static string? GetPasteIntoFileExecutablePath() { + var extensionDirectory = Path.GetDirectoryName(typeof(BaseExplorerCommand).Assembly.Location); + if (string.IsNullOrWhiteSpace(extensionDirectory)) { + extensionDirectory = AppContext.BaseDirectory; + } + if (string.IsNullOrWhiteSpace(extensionDirectory)) { + return null; + } + return Path.Combine(extensionDirectory, "PasteIntoFile.exe"); + } + + // Check if exactly one file is selected and it exists on disk. + // Returns ECS_ENABLED if so, otherwise ECS_HIDDEN. + protected static EXPCMDSTATE CheckSingleFileSelected(IShellItemArray psiItemArray) { + if (psiItemArray != null && psiItemArray.GetCount(out var count) == HResult.S_OK && count == 1) { + psiItemArray.GetItemAt(0, out IShellItem? shellItem); + if (shellItem != null) { + try { + if (shellItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pathPtr) == HResult.S_OK && pathPtr != IntPtr.Zero) { + var path = Marshal.PtrToStringUni(pathPtr); + Marshal.FreeCoTaskMem(pathPtr); + if (!string.IsNullOrEmpty(path) && File.Exists(path)) { + return EXPCMDSTATE.ECS_ENABLED; + } + } + } finally { + Marshal.ReleaseComObject(shellItem); + } + } + } + return EXPCMDSTATE.ECS_HIDDEN; + } + + // Extract the file system path of the first item in the array. + // Returns null if the array is empty or the path cannot be retrieved. + protected static string? GetSingleItemPath(IShellItemArray? psiItemArray) { + if (psiItemArray?.GetCount(out var count) != HResult.S_OK || count == 0) { + return null; + } + psiItemArray.GetItemAt(0, out IShellItem? shellItem); + if (shellItem == null) { + return null; + } + try { + if (shellItem.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out var pathPtr) != HResult.S_OK || pathPtr == IntPtr.Zero) { + return null; + } + var path = Marshal.PtrToStringUni(pathPtr); + Marshal.FreeCoTaskMem(pathPtr); + return path; + } finally { + Marshal.ReleaseComObject(shellItem); + } + } + + // Invoke PasteIntoFile with the specified mode. + // Handles all four command types: paste to directory, copy file contents, replace, and append. + protected static int InvokePasteIntoFile(IShellItemArray psiItemArray, PasteMode mode) { + try { + var exePath = GetPasteIntoFileExecutablePath(); + if (string.IsNullOrWhiteSpace(exePath) || !File.Exists(exePath)) { + return HResult.E_FAIL; + } + + var startInfo = new ProcessStartInfo { + FileName = exePath, + UseShellExecute = false, + CreateNoWindow = true + }; + + var path = GetSingleItemPath(psiItemArray); + + switch (mode) { + case PasteMode.Paste: + startInfo.ArgumentList.Add("paste"); + if (!string.IsNullOrWhiteSpace(path)) { + startInfo.ArgumentList.Add(path); + } + break; + + case PasteMode.Copy: + if (string.IsNullOrEmpty(path)) { + return HResult.E_FAIL; + } + startInfo.ArgumentList.Add("copy"); + startInfo.ArgumentList.Add(path); + break; + + case PasteMode.Replace: + case PasteMode.Append: + var directory = Path.GetDirectoryName(path); + var file = Path.GetFileName(path); + if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(file)) { + return HResult.E_FAIL; + } + startInfo.ArgumentList.Add("paste"); + startInfo.ArgumentList.Add(directory); + startInfo.ArgumentList.Add("--filename"); + startInfo.ArgumentList.Add(file); + startInfo.ArgumentList.Add("--autosave=true"); + startInfo.ArgumentList.Add(mode == PasteMode.Replace ? "--overwrite=true" : "--append"); + break; + } + + Process.Start(startInfo); + return HResult.S_OK; + } catch { + return HResult.E_FAIL; + } + } + + // ─── IExplorerCommand implementation ─── + + public abstract int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName); + + public int GetIcon(IShellItemArray psiItemArray, out IntPtr ppszIcon) { + var exePath = GetPasteIntoFileExecutablePath(); + if (!string.IsNullOrWhiteSpace(exePath) && File.Exists(exePath)) { + ppszIcon = Marshal.StringToCoTaskMemUni(exePath + ",0"); + return HResult.S_OK; + } + ppszIcon = IntPtr.Zero; + return HResult.E_NOTIMPL; + } + + public int GetToolTip(IShellItemArray psiItemArray, out IntPtr ppszInfotip) { + ppszInfotip = IntPtr.Zero; + return HResult.E_NOTIMPL; + } + + public int GetCanonicalName(out Guid pguidCommandName) { + pguidCommandName = new Guid(Clsid); + return HResult.S_OK; + } + + public abstract int GetState(IShellItemArray psiItemArray, bool fOkToBeSlow, out EXPCMDSTATE pCmdState); + + public abstract int Invoke(IShellItemArray psiItemArray, IntPtr pbc); + + public virtual int GetFlags(out EXPCMDFLAGS pFlags) { + pFlags = EXPCMDFLAGS.ECF_DEFAULT; + return HResult.S_OK; + } + + public virtual int EnumSubCommands(out IEnumExplorerCommand ppEnum) { + ppEnum = null!; + return HResult.E_NOTIMPL; + } +} + +[ComVisible(true)] +[Guid("A08CE4D0-FA25-44AB-B57C-C7B1C323E0B9")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public interface IExplorerCommand { + [PreserveSig] + int GetTitle( + [MarshalAs(UnmanagedType.Interface)] IShellItemArray psiItemArray, + out IntPtr ppszName + ); + + [PreserveSig] + int GetIcon( + [MarshalAs(UnmanagedType.Interface)] IShellItemArray psiItemArray, + out IntPtr ppszIcon + ); + + [PreserveSig] + int GetToolTip( + [MarshalAs(UnmanagedType.Interface)] IShellItemArray psiItemArray, + out IntPtr ppszInfotip + ); + + [PreserveSig] + int GetCanonicalName(out Guid pguidCommandName); + + [PreserveSig] + int GetState( + [MarshalAs(UnmanagedType.Interface)] IShellItemArray psiItemArray, + [MarshalAs(UnmanagedType.Bool)] bool fOkToBeSlow, + out EXPCMDSTATE pCmdState + ); + + [PreserveSig] + int Invoke( + [MarshalAs(UnmanagedType.Interface)] IShellItemArray psiItemArray, + IntPtr pbc + ); + + [PreserveSig] + int GetFlags(out EXPCMDFLAGS pFlags); + + [PreserveSig] + int EnumSubCommands( + [MarshalAs(UnmanagedType.Interface)] out IEnumExplorerCommand ppEnum + ); +} + +[ComImport] +[Guid("B63EA76D-1F85-456F-A19C-48159EFA858B")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public interface IShellItemArray { + [PreserveSig] + int BindToHandler( + IntPtr pbc, + ref Guid bhid, + ref Guid riid, + out IntPtr ppvOut + ); + + [PreserveSig] + int GetPropertyStore( + GETPROPERTYSTOREFLAGS flags, + ref Guid riid, + out IntPtr ppv + ); + + [PreserveSig] + int GetPropertyDescriptionList( + ref PROPERTYKEY keyType, + ref Guid riid, + out IntPtr ppv + ); + + [PreserveSig] + int GetAttributes( + SIATTRIBFLAGS attribFlags, + uint sfgaoMask, + out uint psfgaoAttribs + ); + + [PreserveSig] + int GetCount(out uint pdwNumItems); + + [PreserveSig] + int GetItemAt( + uint dwIndex, + [MarshalAs(UnmanagedType.Interface)] out IShellItem ppsi + ); + + [PreserveSig] + int EnumItems(out IntPtr ppenumShellItems); +} + +[ComImport] +[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public interface IShellItem { + [PreserveSig] + int BindToHandler( + IntPtr pbc, + ref Guid bhid, + ref Guid riid, + out IntPtr ppv + ); + + [PreserveSig] + int GetParent( + [MarshalAs(UnmanagedType.Interface)] out IShellItem ppsi + ); + + [PreserveSig] + int GetDisplayName( + SIGDN sigdnName, + out IntPtr ppszName + ); + + [PreserveSig] + int GetAttributes( + uint sfgaoMask, + out uint psfgaoAttribs + ); + + [PreserveSig] + int Compare( + [MarshalAs(UnmanagedType.Interface)] IShellItem psi, + uint hint, + out int piOrder + ); +} + +[ComImport] +[Guid("A88826F8-186F-4987-AADE-EA0CEF8FBFE8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +public interface IEnumExplorerCommand { + [PreserveSig] + int Next(uint celt, out IExplorerCommand rgelt, out uint pceltFetched); + + [PreserveSig] + int Skip(uint celt); + + [PreserveSig] + int Reset(); + + [PreserveSig] + int Clone(out IEnumExplorerCommand ppEnum); +} + + +static class HResult { + public const int S_OK = 0; + public const int S_FALSE = 1; + public const int E_NOTIMPL = unchecked((int)0x80004001); + public const int E_FAIL = unchecked((int)0x80004005); +} + +public enum EXPCMDSTATE { + ECS_ENABLED = 0, + ECS_DISABLED = 1, + ECS_HIDDEN = 2, + ECS_CHECKBOX = 4, + ECS_CHECKED = 8, + ECS_RADIOCHECK = 16 +} + +[Flags] +public enum EXPCMDFLAGS { + ECF_DEFAULT = 0, + ECF_HASSUBCOMMANDS = 1, + ECF_HASSPLITBUTTON = 2, + ECF_HIDELABEL = 4, + ECF_ISSEPARATOR = 8, + ECF_HASLUASHIELD = 16, + ECF_SEPARATORBEFORE = 32, + ECF_SEPARATORAFTER = 64, + ECF_ISDROPDOWN = 128, + ECF_TOGGLEABLE = 256, + ECF_AUTOMENUICONS = 512 +} + +public enum SIGDN : uint { + SIGDN_NORMALDISPLAY = 0x00000000, + SIGDN_PARENTRELATIVEPARSING = 0x80018001, + SIGDN_DESKTOPABSOLUTEPARSING = 0x80028000, + SIGDN_PARENTRELATIVEEDITING = 0x80031001, + SIGDN_DESKTOPABSOLUTEEDITING = 0x8004C000, + SIGDN_FILESYSPATH = 0x80058000, + SIGDN_URL = 0x80068000, + SIGDN_PARENTRELATIVEFORADDRESSBAR = 0x8007C001, + SIGDN_PARENTRELATIVE = 0x80080001, + SIGDN_PARENTRELATIVEFORUI = 0x80094001 +} + +public enum GETPROPERTYSTOREFLAGS { + GPS_DEFAULT = 0 +} + +public enum SIATTRIBFLAGS { + SIATTRIBFLAGS_AND = 1, + SIATTRIBFLAGS_OR = 2, + SIATTRIBFLAGS_APPCOMPAT = 3 +} + +[StructLayout(LayoutKind.Sequential, Pack = 4)] +public struct PROPERTYKEY { + public Guid fmtid; + public uint pid; +} diff --git a/PasteIntoFile.ShellExtension/CopyCommand.cs b/PasteIntoFile.ShellExtension/CopyCommand.cs new file mode 100644 index 0000000..91fbd6c --- /dev/null +++ b/PasteIntoFile.ShellExtension/CopyCommand.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; +using PasteIntoFile.Core.Properties; + +namespace PasteIntoFileShellExtension; + +[ComVisible(true)] +[Guid(Clsids.CopyCommand)] +[ProgId("PasteIntoFile.ShellExtension.CopyCommand")] +[ClassInterface(ClassInterfaceType.None)] +[ComDefaultInterface(typeof(IExplorerCommand))] +public sealed class CopyCommand : BaseExplorerCommand { + + public override string Clsid => Clsids.CopyCommand; + + public override int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName) { + ppszName = Marshal.StringToCoTaskMemUni(Resources.str_contextentry_copyfromfile); + return HResult.S_OK; + } + + public override int GetState(IShellItemArray psiItemArray, bool fOkToBeSlow, out EXPCMDSTATE pCmdState) { + // Show if exactly one item is selected + if (psiItemArray != null && psiItemArray.GetCount(out var count) == HResult.S_OK && count == 1) { + pCmdState = EXPCMDSTATE.ECS_ENABLED; + } else { + pCmdState = EXPCMDSTATE.ECS_HIDDEN; + } + return HResult.S_OK; + } + + public override int Invoke(IShellItemArray psiItemArray, IntPtr pbc) { + return InvokePasteIntoFile(psiItemArray, PasteMode.Copy); + } +} diff --git a/PasteIntoFile.ShellExtension/ExplorerCommandEnumerator.cs b/PasteIntoFile.ShellExtension/ExplorerCommandEnumerator.cs new file mode 100644 index 0000000..bc87eae --- /dev/null +++ b/PasteIntoFile.ShellExtension/ExplorerCommandEnumerator.cs @@ -0,0 +1,60 @@ +using System.Collections; +using System.Runtime.InteropServices; + +namespace PasteIntoFileShellExtension; + +/// +/// COM-visible enumerator for IExplorerCommand subcommands. +/// Wraps a list of IExplorerCommand instances and exposes them via IEnumExplorerCommand. +/// +[ComVisible(true)] +[Guid("B2E4F8A1-3D5C-4B9E-A7F1-9C8D6E5A4B3C")] +[ClassInterface(ClassInterfaceType.None)] +[ComDefaultInterface(typeof(IEnumExplorerCommand))] +public class ExplorerCommandEnumerator : IEnumExplorerCommand { + private readonly IEnumerator _enumerator; + private IExplorerCommand? _current; + + public ExplorerCommandEnumerator(IList commands) { + _enumerator = commands.GetEnumerator(); + } + + public int Next(uint celt, out IExplorerCommand rgelt, out uint pceltFetched) { + rgelt = null!; + pceltFetched = 0; + + if (celt == 0) { + return HResult.S_OK; + } + + if (_enumerator.MoveNext()) { + _current = (IExplorerCommand)_enumerator.Current; + rgelt = _current; + pceltFetched = 1; + return HResult.S_OK; + } + + return HResult.S_FALSE; + } + + public int Skip(uint celt) { + for (uint i = 0; i < celt; i++) { + if (!_enumerator.MoveNext()) { + return HResult.S_FALSE; + } + } + return HResult.S_OK; + } + + public int Reset() { + _enumerator.Reset(); + _current = null; + return HResult.S_OK; + } + + public int Clone(out IEnumExplorerCommand ppEnum) { + // Clone is not supported; return E_NOTIMPL + ppEnum = null!; + return HResult.E_NOTIMPL; + } +} diff --git a/PasteIntoFile.ShellExtension/FileGroupCommand.cs b/PasteIntoFile.ShellExtension/FileGroupCommand.cs new file mode 100644 index 0000000..46da7ae --- /dev/null +++ b/PasteIntoFile.ShellExtension/FileGroupCommand.cs @@ -0,0 +1,61 @@ +using System.Runtime.InteropServices; +using PasteIntoFile.Core.Properties; + +namespace PasteIntoFileShellExtension; + +// Shared COM class ID for the file group command +static class FileGroupClsid { + public const string Value = "109EFE3F-786B-4DFA-B639-4B673C05B932"; +} + +/// +/// Parent group command for file context menus. +/// Displays "Paste Into File" with a right-arrow submenu containing Copy, Replace, and Append. +/// +[ComVisible(true)] +[Guid(FileGroupClsid.Value)] +[ProgId("PasteIntoFile.ShellExtension.FileGroupCommand")] +[ClassInterface(ClassInterfaceType.None)] +[ComDefaultInterface(typeof(IExplorerCommand))] +public sealed class FileGroupCommand : BaseExplorerCommand { + + public override string Clsid => FileGroupClsid.Value; + + public override int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName) { + ppszName = Marshal.StringToCoTaskMemUni(Resources.str_contextentry); + return HResult.S_OK; + } + + public override int GetState(IShellItemArray psiItemArray, bool fOkToBeSlow, out EXPCMDSTATE pCmdState) { + // Show the group if exactly one item is selected + if (psiItemArray != null && psiItemArray.GetCount(out var count) == HResult.S_OK && count == 1) { + pCmdState = EXPCMDSTATE.ECS_ENABLED; + } else { + pCmdState = EXPCMDSTATE.ECS_HIDDEN; + } + return HResult.S_OK; + } + + public override int Invoke(IShellItemArray psiItemArray, IntPtr pbc) { + // The group itself is not invokable; only its subcommands are. + return HResult.E_NOTIMPL; + } + + public override int GetFlags(out EXPCMDFLAGS pFlags) { + // Mark this command as having subcommands so Explorer shows the right-arrow indicator + pFlags = EXPCMDFLAGS.ECF_HASSUBCOMMANDS; + return HResult.S_OK; + } + + public override int EnumSubCommands(out IEnumExplorerCommand ppEnum) { + // Return the subcommands in the desired order: Copy, Replace, Append + var subCommands = new List { + new ReplaceCommand(), + new AppendCommand(), + new CopyCommand() + }; + + ppEnum = new ExplorerCommandEnumerator(subCommands); + return HResult.S_OK; + } +} diff --git a/PasteIntoFile.ShellExtension/PasteCommand.cs b/PasteIntoFile.ShellExtension/PasteCommand.cs new file mode 100644 index 0000000..f3b0c76 --- /dev/null +++ b/PasteIntoFile.ShellExtension/PasteCommand.cs @@ -0,0 +1,37 @@ +using System.Runtime.InteropServices; +using PasteIntoFile.Core; +using PasteIntoFile.Core.Properties; + +namespace PasteIntoFileShellExtension; + +[ComVisible(true)] +[Guid(Clsids.PasteCommand)] +[ProgId("PasteIntoFile.ShellExtension.Command")] +[ClassInterface(ClassInterfaceType.None)] +[ComDefaultInterface(typeof(IExplorerCommand))] +public sealed class PasteCommand : BaseExplorerCommand { + + public override string Clsid => Clsids.PasteCommand; + + public override int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName) { + // Reload if the settings file has changed to reflect current autoSave mode + // while avoiding expensive file I/O on slow storage. + SettingsUtil.ReloadIfChanged(Settings.Default); + + var title = Resources.str_contextentry; + if (!Settings.Default.autoSave) { + title += "…"; // opens a dialog + } + ppszName = Marshal.StringToCoTaskMemUni(title); + return HResult.S_OK; + } + + public override int GetState(IShellItemArray psiItemArray, bool fOkToBeSlow, out EXPCMDSTATE pCmdState) { + pCmdState = EXPCMDSTATE.ECS_ENABLED; + return HResult.S_OK; + } + + public override int Invoke(IShellItemArray psiItemArray, IntPtr pbc) { + return InvokePasteIntoFile(psiItemArray, PasteMode.Paste); + } +} diff --git a/PasteIntoFile.ShellExtension/PasteIntoFile.ShellExtension.csproj b/PasteIntoFile.ShellExtension/PasteIntoFile.ShellExtension.csproj new file mode 100644 index 0000000..e8b9c62 --- /dev/null +++ b/PasteIntoFile.ShellExtension/PasteIntoFile.ShellExtension.csproj @@ -0,0 +1,27 @@ + + + + net9.0-windows + enable + enable + + true + + x64 + win-x64 + + true + PasteIntoFileShellExtension + + + + + + + + + + + + + diff --git a/PasteIntoFile.ShellExtension/ReplaceCommand.cs b/PasteIntoFile.ShellExtension/ReplaceCommand.cs new file mode 100644 index 0000000..e57a823 --- /dev/null +++ b/PasteIntoFile.ShellExtension/ReplaceCommand.cs @@ -0,0 +1,33 @@ +using System.Runtime.InteropServices; +using PasteIntoFile.Core.Properties; + +namespace PasteIntoFileShellExtension; + +[ComVisible(true)] +[Guid(Clsids.ReplaceCommand)] +[ProgId("PasteIntoFile.ShellExtension.ReplaceCommand")] +[ClassInterface(ClassInterfaceType.None)] +[ComDefaultInterface(typeof(IExplorerCommand))] +public sealed class ReplaceCommand : BaseExplorerCommand { + + public override string Clsid => Clsids.ReplaceCommand; + + public override int GetTitle(IShellItemArray psiItemArray, out IntPtr ppszName) { + ppszName = Marshal.StringToCoTaskMemUni(Resources.str_contextentry_replace); + return HResult.S_OK; + } + + public override int GetState(IShellItemArray psiItemArray, bool fOkToBeSlow, out EXPCMDSTATE pCmdState) { + // Show if exactly one item is selected + if (psiItemArray != null && psiItemArray.GetCount(out var count) == HResult.S_OK && count == 1) { + pCmdState = EXPCMDSTATE.ECS_ENABLED; + } else { + pCmdState = EXPCMDSTATE.ECS_HIDDEN; + } + return HResult.S_OK; + } + + public override int Invoke(IShellItemArray psiItemArray, IntPtr pbc) { + return InvokePasteIntoFile(psiItemArray, PasteMode.Replace); + } +} diff --git a/PasteIntoFile.sln b/PasteIntoFile.sln index 07216d8..97b0c3e 100644 --- a/PasteIntoFile.sln +++ b/PasteIntoFile.sln @@ -3,7 +3,18 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.28729.10 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasteIntoFile.Core", "PasteIntoFile.Core\PasteIntoFile.Core.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasteIntoFile", "PasteIntoFile\PasteIntoFile.csproj", "{F6F4215C-6CD7-4279-9C4C-C2DA9A823D0C}" + ProjectSection(ProjectDependencies) = postProject + {3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2} = {3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2} + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PasteIntoFile.ShellExtension", "PasteIntoFile.ShellExtension\PasteIntoFile.ShellExtension.csproj", "{3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2}" + ProjectSection(ProjectDependencies) = postProject + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,10 +22,18 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU {F6F4215C-6CD7-4279-9C4C-C2DA9A823D0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F6F4215C-6CD7-4279-9C4C-C2DA9A823D0C}.Debug|Any CPU.Build.0 = Debug|Any CPU {F6F4215C-6CD7-4279-9C4C-C2DA9A823D0C}.Release|Any CPU.ActiveCfg = Release|Any CPU {F6F4215C-6CD7-4279-9C4C-C2DA9A823D0C}.Release|Any CPU.Build.0 = Release|Any CPU + {3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3FF1060D-CD0D-43BE-8E4F-61D2C0475AF2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PasteIntoFile/App.config b/PasteIntoFile/App.config index caa4704..fcc0bcf 100644 --- a/PasteIntoFile/App.config +++ b/PasteIntoFile/App.config @@ -5,12 +5,6 @@
- - - - - - @@ -69,4 +63,4 @@ - \ No newline at end of file + diff --git a/PasteIntoFile/ClipboardContents.cs b/PasteIntoFile/ClipboardContents.cs index 33c1c2d..1e8271a 100644 --- a/PasteIntoFile/ClipboardContents.cs +++ b/PasteIntoFile/ClipboardContents.cs @@ -14,10 +14,9 @@ using System.Text.RegularExpressions; using System.Windows.Forms; using Ical.Net.DataTypes; -using LINQtoCSV; +using Csv; +using PasteIntoFile.Core; using PasteIntoFile.Properties; -using PdfSharp.Drawing; -using PdfSharp.Pdf; namespace PasteIntoFile { @@ -173,7 +172,7 @@ public abstract class ImageLikeContent : BaseContent { public class ImageContent : ImageLikeContent { - public static readonly string[] EXTENSIONS = { "png", "bmp", "gif", "jpg", "pdf", "tif", "ico" }; + public static readonly string[] EXTENSIONS = { "png", "bmp", "gif", "jpg", "tif", "ico" }; public ImageContent(Image image) { Data = image; } @@ -188,24 +187,6 @@ public override void SaveAs(string path, string extension, bool append = false) throw new FormatException(string.Format(Resources.str_error_cliboard_format_missmatch, extension)); switch (NormalizeExtension(extension)) { - case "pdf": - // convert image to ximage - var stream = new MemoryStream(); - image.Save(stream, image.RawFormat); - stream.Position = 0; - XImage img = XImage.FromStream(stream); - // create pdf document - PdfDocument document = new PdfDocument(); - document.Info.Creator = Resources.app_title; - PdfPage page = document.AddPage(); - page.Width = XUnit.FromPoint(img.PointWidth); - page.Height = XUnit.FromPoint(img.PointHeight); - // insert image and save - XGraphics gfx = XGraphics.FromPdfPage(page); - gfx.DrawImage(img, 0, 0); - document.Save(path); - return; - case "ico": using (var fs = new FileStream(path, FileMode.Create)) { ImageAsIcon.Save(fs); @@ -227,7 +208,6 @@ public override PreviewHolder Preview(string extension) { extension = NormalizeExtension(extension); // Special formats with intermediate conversion types switch (extension) { - case "pdf": extension = "png"; break; case "ico": return PreviewHolder.ForImage(ImageAsIcon.ToBitmap()); } // Find suitable codec and convert image @@ -279,7 +259,7 @@ public Icon ImageAsIcon { /// Like ImageContent, but only for formats which support alpha channel /// public class TransparentImageContent : ImageContent { - public static new readonly string[] EXTENSIONS = { "png", "gif", "pdf", "tif", "ico" }; + public static new readonly string[] EXTENSIONS = { "png", "gif", "tif", "ico" }; public TransparentImageContent(Image image) : base(image) { } public override string[] Extensions => EXTENSIONS; // Note: gif has only alpha 100% or 0% } @@ -535,19 +515,21 @@ public char Delimiter { } } - public class Row : List, IDataRow { } - /// /// Parse the CVS data /// /// List of rows - public IEnumerable Parse() { - var context = new CsvContext(); - var readConfig = new CsvFileDescription(); - readConfig.FirstLineHasColumnNames = false; - readConfig.SeparatorChar = Delimiter; - var readStream = new StreamReader(new MemoryStream(readConfig.TextEncoding.GetBytes(Text))); - return context.Read(readStream, readConfig); + public IEnumerable Parse() { + var options = new CsvOptions { + HeaderMode = HeaderMode.HeaderAbsent, + AllowNewLineInEnclosedFieldValues = true, + AllowBackSlashToEscapeQuote = true, + }; + return CsvReader.ReadFromText(Text, options).Select(o => o.Values); + } + + public string WithDelimeter(char delimeter) { + return CsvWriter.WriteToText(Parse(), delimeter); } /// @@ -559,9 +541,9 @@ private string AsMarkdown() { var markdown = ""; foreach (var row in Parse()) { - ncol = Math.Max(ncol, row.Count); + ncol = Math.Max(ncol, row.Length); foreach (var item in row) { - markdown += "|" + (item.Value ?? "").PadRight(10); + markdown += "|" + (item ?? "").PadRight(10); } markdown += "|\n"; } @@ -576,6 +558,10 @@ public override string TextFor(string extension) { switch (NormalizeExtension(extension)) { case "md": return AsMarkdown(); + case "tsv": + case "tab": + return WithDelimeter('\t'); + case "csv": default: return base.TextFor(extension); } @@ -632,7 +618,7 @@ public override PreviewHolder Preview(string extension) { + "\n\n\n", description ); - } catch (SerializationException e) { + } catch (SerializationException) { // ignored (default to text preview below) } } diff --git a/PasteIntoFile/Dialog.Designer.cs b/PasteIntoFile/Dialog.Designer.cs index 5aa91e9..912a1ab 100644 --- a/PasteIntoFile/Dialog.Designer.cs +++ b/PasteIntoFile/Dialog.Designer.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Drawing; +using PasteIntoFile.Core; using PasteIntoFile.Properties; namespace PasteIntoFile { diff --git a/PasteIntoFile/Dialog.cs b/PasteIntoFile/Dialog.cs index 8cea0ed..8aea0e0 100644 --- a/PasteIntoFile/Dialog.cs +++ b/PasteIntoFile/Dialog.cs @@ -6,8 +6,8 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; +using PasteIntoFile.Core; using PasteIntoFile.Properties; -using WK.Libraries.BetterFolderBrowserNS; namespace PasteIntoFile { public sealed partial class Dialog : MasterForm { @@ -489,16 +489,12 @@ async Task CheckForUpdates() { } private void btnBrowseForFolder_Click(object sender, EventArgs e) { - BetterFolderBrowser betterFolderBrowser = new BetterFolderBrowser(); + using var dialog = new FolderBrowserDialog(); + dialog.Description = Resources.str_select_folder; + dialog.SelectedPath = txtCurrentLocation.Text; - betterFolderBrowser.Title = Resources.str_select_folder; - betterFolderBrowser.RootFolder = txtCurrentLocation.Text ?? Environment.GetFolderPath(Environment.SpecialFolder.Desktop); - - // Allow multi-selection of folders. - betterFolderBrowser.Multiselect = false; - - if (betterFolderBrowser.ShowDialog(this) == DialogResult.OK) { - txtCurrentLocation.Text = betterFolderBrowser.SelectedFolder; + if (dialog.ShowDialog(this) == DialogResult.OK) { + txtCurrentLocation.Text = dialog.SelectedPath; } } diff --git a/PasteIntoFile/ExplorerUtil.cs b/PasteIntoFile/ExplorerUtil.cs index c861512..8a510b4 100644 --- a/PasteIntoFile/ExplorerUtil.cs +++ b/PasteIntoFile/ExplorerUtil.cs @@ -1,9 +1,11 @@ using System; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; using System.Threading.Tasks; +using System.Windows.Forms; using SHDocVw; using Shell32; @@ -438,6 +440,128 @@ public static void MoveToRecycleBin(string path) { } + + private const string Win11ContextMenuPackageName = "PasteIntoFile.Dev.ShellExtension"; + + public static bool IsWin11ContextMenuRegistered() { + string output; + var exitCode = RunPowerShell( + "if (Get-AppxPackage -Name " + PowerShellQuote(Win11ContextMenuPackageName) + ") { exit 0 } else { exit 1 }", + out output, + false + ); + + return exitCode == 0; + } + + public static void SetWin11ContextMenuRegistered(bool registered) { + if (registered) { + RegisterWin11ContextMenu(); + } else { + UnregisterWin11ContextMenu(); + } + } + + public static void RegisterWin11ContextMenu() { + var directory = Path.GetDirectoryName(Application.ExecutablePath); + + if (string.IsNullOrWhiteSpace(directory)) { + throw new InvalidOperationException("Der Installationsordner konnte nicht ermittelt werden."); + } + + var manifest = Path.Combine(directory, "AppxManifest.xml"); + + if (!File.Exists(manifest)) { + throw new FileNotFoundException( + "Das AppxManifest.xml wurde nicht gefunden. Die Win11-Menüintegration kann nicht registriert werden.", + manifest + ); + } + + string output; + RunPowerShell( + "$ErrorActionPreference = 'Stop'; " + + "Add-AppxPackage " + + "-Register " + PowerShellQuote(manifest) + " " + + "-ExternalLocation " + PowerShellQuote(directory) + " " + + "-ForceApplicationShutdown", + out output, + true + ); + + //RestartExplorer(); + } + + public static void UnregisterWin11ContextMenu() { + string output; + RunPowerShell( + "$ErrorActionPreference = 'Stop'; " + + "$package = Get-AppxPackage -Name " + PowerShellQuote(Win11ContextMenuPackageName) + "; " + + "if ($package) { $package | Remove-AppxPackage }", + out output, + true + ); + + //RestartExplorer(); + } + + private static string PowerShellQuote(string value) { + return "'" + value.Replace("'", "''") + "'"; + } + + private static int RunPowerShell(string command, out string output, bool throwOnError) { + var encodedCommand = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + + var startInfo = new ProcessStartInfo { + FileName = "powershell.exe", + Arguments = "-NoProfile -ExecutionPolicy Bypass -EncodedCommand " + encodedCommand, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using (var process = Process.Start(startInfo)) { + if (process == null) { + throw new InvalidOperationException("PowerShell konnte nicht gestartet werden."); + } + + var stdout = process.StandardOutput.ReadToEnd(); + var stderr = process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + output = stdout; + + if (!string.IsNullOrWhiteSpace(stderr)) { + output += Environment.NewLine + stderr; + } + + if (throwOnError && process.ExitCode != 0) { + throw new InvalidOperationException(output.Trim()); + } + + return process.ExitCode; + } + } + + private static void RestartExplorer() { + foreach (var process in Process.GetProcessesByName("explorer")) { + try { + process.Kill(); + } catch { + // Ignorieren: Explorer wird gleich neu gestartet. + } + } + + Process.Start("explorer.exe"); + } + + + + + + private static IntPtr PathToPidl(string path, bool special = false) { if (special) { // Will use special paths, e.g. "::{374DE290-123F-4565-9164-39C4925E467B}" for Downloads diff --git a/PasteIntoFile/Main.cs b/PasteIntoFile/Main.cs index 09f7a79..417981d 100644 --- a/PasteIntoFile/Main.cs +++ b/PasteIntoFile/Main.cs @@ -12,11 +12,8 @@ using System.Threading; using CommandLine; using CommandLine.Text; -using Microsoft.Toolkit.Uwp.Notifications; +using PasteIntoFile.Core; using PasteIntoFile.Properties; -#if PORTABLE -using Bluegrams.Application; -#endif namespace PasteIntoFile { static class Program { @@ -121,11 +118,8 @@ static int Main(string[] args) { // ignored (probably non-console mode) } -#if PORTABLE - // Portable settings - PortableSettingsProvider.SettingsFileName = "settings.config"; - PortableSettingsProvider.ApplyProvider(Settings.Default); -#endif + // Configure settings provider to use the shared settings file path + SettingsUtil.Apply(Settings.Default); // Localization try { @@ -149,6 +143,8 @@ static int Main(string[] args) { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + ApplicationConfiguration.Initialize(); // parse command line arguments var parseResult = new Parser(with => with.HelpWriter = null) @@ -191,8 +187,8 @@ static int RunPaste(ArgsPaste args) { bool? showDialogOverwrite = null; // filename and directory (one time options) - var directory = args.Directory ?? args.DirectoryFallback; - var filename = args.Filename; + var directory = (args.Directory ?? args.DirectoryFallback)?.Trim('\'', '"', ' '); + var filename = args.Filename?.Trim('\'', '"', ' '); if (!string.IsNullOrWhiteSpace(Path.GetDirectoryName(filename))) { // filename contains full path, so use that instead directory = Path.GetDirectoryName(filename); @@ -327,11 +323,14 @@ static int RunTray(ArgsTray args = null) { NotifyIcon icon = new NotifyIcon(); icon.Icon = Resources.app_icon; icon.Text = Resources.app_title; - icon.ContextMenu = new ContextMenu(new[] { - new MenuItem(Resources.str_open_paste_into_file, (s, e) => new Dialog(showDialogOverwrite: true).Show()), - new MenuItem(Resources.str_settings, (s, e) => new Wizard().Show()), - new MenuItem(Resources.str_exit, (s, e) => { Application.Exit(); }), - }); + var menu = new ContextMenuStrip(); + menu.Items.Add(Resources.str_open_paste_into_file, null, + (_, _) => new Dialog(showDialogOverwrite: true).Show()); + menu.Items.Add(Resources.str_settings, null, + (_, _) => new Wizard().Show()); + menu.Items.Add(Resources.str_exit, null, + (_, _) => Application.Exit()); + icon.ContextMenuStrip = menu; icon.MouseClick += (sender, eventArgs) => { if (eventArgs.Button == MouseButtons.Left) new Dialog(showDialogOverwrite: true).Show(); }; @@ -466,45 +465,6 @@ public static void RestartAppElevated(string location) { Application.Exit(); } - /// - /// Shows a balloon message in the windows notification bar - /// - /// Title of the message - /// Body of the message - /// Duration after which message is dismissed in second - /// Optional link to visit when clicking the balloon - /// If true, make a silent balloon (default) - public static void ShowBalloon(string title, string[] message, ushort expire = 5, string link = null, bool silent = true) { - try { - var builder = new ToastContentBuilder().AddText(title); - foreach (var s in message) { - builder.AddText(s); - } - - if (silent) - builder.AddAudio(null, null, true); - - if (link != null) - builder.AddButton(Resources.str_open, ToastActivationType.Protocol, link); - - builder.Show(toast => { - if (expire > 0) { - toast.ExpirationTime = DateTime.Now.AddSeconds(expire); - } - }); - - } catch (SystemException) { - // Microsoft.Toolkit.Uwp requires Windows version 1809 (build 17763) or higher - // if that's not available, print to console instead - Console.WriteLine(title); - foreach (var s in message) { - Console.WriteLine(s); - } - - } - } - - /// /// Checks for updates /// To reduce server load, results are cached and frequent queries skipped @@ -512,7 +472,6 @@ public static void ShowBalloon(string title, string[] message, ushort expire = 5 /// If an update is available public static async Task CheckForUpdates() { if (!Settings.Default.updateChecksEnabled) return false; - bool newReleaseFound = false; if ((DateTime.Now - Settings.Default.updateLatestVersionLastCheck).TotalDays > 30) { // Last check outdated, check again Settings.Default.updateLatestVersionLastCheck = DateTime.Now; @@ -523,7 +482,6 @@ public static async Task CheckForUpdates() { var data = await client.GetStringAsync(new Uri("https://api.github.com/repos/eltos/PasteIntoFile/releases/latest")); var match = Regex.Match(data, "\"(https://github.com/eltos/PasteIntoFile/releases/tag/v(\\d+(\\.\\d+)*))\""); if (match.Success && match.Groups[2].Value != Settings.Default.updateLatestVersion) { - newReleaseFound = true; Settings.Default.updateLatestVersion = match.Groups[2].Value; Settings.Default.updateLatestVersionLink = match.Groups[1].Value; Settings.Default.Save(); @@ -538,10 +496,6 @@ public static async Task CheckForUpdates() { var latestVersion = Version.Parse(Settings.Default.updateLatestVersion); if (latestVersion.CompareTo(thisVersion) > 0) { // Update available - if (newReleaseFound) { - ShowBalloon(string.Format(Resources.str_version_update_available, Application.ProductVersion, Settings.Default.updateLatestVersion), - new[] { Settings.Default.updateLatestVersionLink }, 0, Settings.Default.updateLatestVersionLink, false); - } return true; } } catch { /* ignore errors due to parsing of fetched version */ } diff --git a/PasteIntoFile/MasterForm.cs b/PasteIntoFile/MasterForm.cs index b4cb745..ab24052 100644 --- a/PasteIntoFile/MasterForm.cs +++ b/PasteIntoFile/MasterForm.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Windows.Forms; +using PasteIntoFile.Core; using PasteIntoFile.Properties; namespace PasteIntoFile { diff --git a/PasteIntoFile/PasteIntoFile.csproj b/PasteIntoFile/PasteIntoFile.csproj index ec7043b..2c86668 100644 --- a/PasteIntoFile/PasteIntoFile.csproj +++ b/PasteIntoFile/PasteIntoFile.csproj @@ -1,16 +1,21 @@ - - - + + - {F6F4215C-6CD7-4279-9C4C-C2DA9A823D0C} + PasteIntoFile + PasteIntoFile + Paste Clipboard Contents Into Files + github.com/eltos + PasteIntoFile + Copyright © PasteIntoFile GitHub contributors + 6.0.0.0 + 6.0.0.0 + 6.0.0 WinExe - Properties + net9.0-windows + true PasteIntoFile - PasteIntoFile - v4.8 - 512 - PasteIntoFile.Program app.manifest + true Resources/icon.ico @@ -46,87 +51,18 @@ - + + - - - - - - - + - - - - - - - - - - - - - - + - - - - - - - UserControl - - - Form - - - TemplateEdit.cs - - - Form - - - Wizard.cs - - - Form - - - Dialog.cs - - - - - - - ResXFileCodeGenerator - Resources.Designer.cs - Designer - - - True - Resources.resx - True - Designer - - SettingsSingleFileGenerator - Settings.Designer.cs - - - True - Settings.settings - True - - - Designer @@ -155,7 +91,12 @@ True - + + + + + +