diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index 56c903a4..24eaf959 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -125,9 +125,6 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 - - uses: Jimver/cuda-toolkit@v0.2.36 - with: - cuda: '12.9.0' - run: | dotnet publish WindowTranslator -c Release -o publish --sc ${{ matrix.self }} ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` @@ -137,15 +134,14 @@ jobs: env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: | - $folders = Get-ChildItem -Path "Plugins" -Directory | Where-Object { $_.Name -notmatch "Dummy" -and $_.Name -notmatch "Tests?" } - for ($i = 0; $i -lt $folders.Count; $i++) { - dotnet publish $folders[$i].FullName -c Release -o "publish\plugins\$($folders[$i].Name)" ` - -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` - -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` - -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` - -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" - } + dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:PublishAppBundle ` + -p:Configuration=Release ` + -p:AppBundleOutputPath="$PWD\publish\plugins" ` + -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` + -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` + -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` + -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" - uses: actions/download-artifact@v8 if: ${{ needs.docs.result == 'success' }} with: diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index 2f73507f..a0dacd41 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -1,12 +1,16 @@ name: .NET Core Package on: + pull_request: push: tags: [v*] jobs: build: runs-on: windows-latest + permissions: + contents: read + id-token: write steps: - uses: actions/checkout@v7 with: @@ -25,10 +29,24 @@ jobs: versionSpec: "6.x" - id: gitversion uses: gittools/actions/gitversion/execute@v4.7.0 + - uses: Jimver/cuda-toolkit@v0.2.30 + with: + cuda: '12.9.0' - run: | - dotnet pack WindowTranslator.Abstractions -c Release -o pack ` + dotnet msbuild Plugins\WindowTranslator.Plugins.proj -target:Pack ` + -p:Configuration=Release ` + -p:PackageOutputPath="$PWD\pack" ` -p:Version=${{ steps.gitversion.outputs.fullSemVer }} ` -p:AssemblyVersion=${{ steps.gitversion.outputs.assemblySemVer }} ` -p:FileVersion=${{ steps.gitversion.outputs.assemblySemFileVer }} ` - -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} - dotnet nuget push pack\*.nupkg -k ${{ secrets.NUGET_KEY }} -s https://api.nuget.org/v3/index.json --skip-duplicate + -p:InformationalVersion=${{ steps.gitversion.outputs.informationalVersion }} ` + -p:DecryptKey="${{ secrets.WINDOWTRANSLATOR_DECRYPTKEY }}" + - name: NuGet login + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + id: nuget_login + uses: NuGet/login@v1 + with: + user: Freesia + - name: NuGet push + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + run: dotnet nuget push pack\*.nupkg --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/ColorThief/ColorThief/ColorThief.csproj b/ColorThief/ColorThief/ColorThief.csproj index 556393bf..2c3c567c 100644 --- a/ColorThief/ColorThief/ColorThief.csproj +++ b/ColorThief/ColorThief/ColorThief.csproj @@ -2,6 +2,12 @@ net10.0-windows10.0.20348.0 + WindowTranslator.ColorThief + WindowTranslator ColorThief Support Library + Color extraction support library for the WindowTranslator ColorThief plugin. + $(PackageTags);ColorThief + true + false enable enable StudioFreesia.ColorThief diff --git a/Directory.Packages.props b/Directory.Packages.props index baa2da3e..558cbc42 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,7 @@ + @@ -34,6 +35,10 @@ + + + + diff --git a/Plugins/Directory.Build.props b/Plugins/Directory.Build.props index ffcb5d5e..81d8978b 100644 --- a/Plugins/Directory.Build.props +++ b/Plugins/Directory.Build.props @@ -4,8 +4,19 @@ net10.0 true + $(PackageTags);windowtranslator-plugin + README.md + plugin-icon.png + + + + + + + + @@ -22,4 +33,4 @@ runtime - \ No newline at end of file + diff --git a/Plugins/Directory.Build.targets b/Plugins/Directory.Build.targets index 24869907..3244b5a0 100644 --- a/Plugins/Directory.Build.targets +++ b/Plugins/Directory.Build.targets @@ -2,7 +2,33 @@ - + + $(TargetsForTfmSpecificBuildOutput);AddPluginRuntimeAssetsToPackage + + + + + + <_PluginWinX64RuntimeAsset Include="$(TargetDir)runtimes\win-x64\**\*" /> + <_PluginWinRuntimeAsset Include="$(TargetDir)runtimes\win\**\*" /> + <_PluginAnyRuntimeAsset Include="$(TargetDir)runtimes\any\**\*" /> + + + + + + + - \ No newline at end of file + diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md new file mode 100644 index 00000000..ef3e498a --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/README.md @@ -0,0 +1,257 @@ +# WindowTranslator Bergamot Translator Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、Bergamotによるニューラル機械翻訳をローカル実行するプラグインです。 + +## 機能 + +- 翻訳テキストを外部サービスへ送信せず、オフラインで翻訳 +- 対応する言語ペアのモデルを初回利用時に自動取得 +- 直接変換できない言語ペアでは、利用可能な場合に英語を経由して翻訳 +- ネットワーク状況や翻訳回数の上限に影響されないローカル処理 + +## 必要条件 + +- 対応するBergamot翻訳モデルが提供されている言語ペア +- モデルを初めて取得するときのインターネット接続 + +モデルの取得後はオフラインで利用できます。モデルが提供されていない言語ペアでは、このモジュールを選択できません。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin that runs Bergamot neural machine translation locally. + +## Features + +- Translates offline without sending text to an external service +- Downloads the model for a supported language pair when it is first used +- Translates through English when a direct model is unavailable and a suitable route exists +- Runs locally without network instability or translation quotas + +## Requirements + +- A language pair for which a compatible Bergamot model is available +- An internet connection when downloading a model for the first time + +After a model has been downloaded, translation can run offline. The module cannot be selected for language pairs that have no available model. + +## ar + +وحدة ترجمة آلية تعمل دون اتصال. + +### المزايا +- **مجاني تماماً**: بدون أي رسوم +- **بدون حد للترجمة**: ترجم بقدر ما تريد +- **سريع**: المعالجة المحلية للترجمة السريعة +- **الخصوصية**: لا حاجة لاتصال بالإنترنت، البيانات لا تُرسل للخارج +- **الاستقرار**: لا يتأثر بالشبكة + +## cs + +Modul strojového překladu fungující offline. + +### Výhody +- **Zcela zdarma**: Bez jakýchkoli poplatků +- **Bez omezení překladu**: Můžete překládat kolikrát chcete +- **Rychlý**: Překlad je rychlý, protože se zpracovává lokálně +- **Soukromí**: Nevyžaduje připojení k internetu, data se neodesílají ven +- **Stabilita**: Není ovlivněn síťovými podmínkami + +## de + +Ein Maschinenübersetzungsmodul, das offline funktioniert. + +### Vorteile +- **Völlig kostenlos**: Keinerlei Kosten +- **Keine Übersetzungslimits**: Sie können beliebig oft übersetzen +- **Schnell**: Übersetzung ist schnell, da sie lokal verarbeitet wird +- **Datenschutz**: Keine Internetverbindung erforderlich, Daten werden nicht extern gesendet +- **Stabilität**: Nicht von Netzwerkbedingungen betroffen + +## es + +Un módulo de traducción automática que funciona sin conexión. + +### Ventajas +- **Completamente gratis**: Sin cargos +- **Sin límite de traducción**: Traduzca cuanto quiera +- **Rápido**: Procesamiento local para traducción rápida +- **Privacidad**: No se necesita conexión a Internet, los datos no se envían al exterior +- **Estabilidad**: No afectado por la red + +## fa + +ماژول ترجمه ماشینی که بدون اتصال به اینترنت کار می‌کند. + +### مزایا +- **کاملاً رایگان**: بدون هیچ هزینه‌ای +- **بدون محدودیت ترجمه**: هر مقدار که بخواهید ترجمه کنید +- **سریع**: پردازش محلی برای ترجمه سریع +- **حریم خصوصی**: نیاز به اتصال به اینترنت ندارد، داده‌ها به خارج ارسال نمی‌شوند +- **پایداری**: تحت تأثیر شبکه نیست + +## fil + +Isang modyul ng machine translation na gumagana offline. + +### Mga Bentahe +- **Lubos na Libre**: Walang anumang bayad +- **Walang Limitasyon sa Pagsasalin**: Maaari kang magsalin ng maraming beses hangga't gusto mo +- **Mabilis**: Ang pagsasalin ay mabilis dahil ito ay naproseso nang lokal +- **Privacy**: Walang kailangang koneksyon sa internet, ang data ay hindi ipinapadala sa labas +- **Katatagan**: Hindi apektado ng mga kondisyon ng network + +## fr + +Un module de traduction automatique qui fonctionne hors ligne. + +### Avantages +- **Complètement gratuit**: Aucun frais +- **Pas de limite de traduction**: Traduisez autant que vous voulez +- **Rapide**: Traitement local pour une traduction rapide +- **Confidentialité**: Pas de connexion Internet nécessaire, les données ne sont pas envoyées à l'extérieur +- **Stabilité**: Non affecté par le réseau + +## hi + +ऑफ़लाइन काम करने वाला मशीन अनुवाद मॉड्यूल। + +### फायदे +- **पूर्ण रूप से निःशुल्क**: बिल्कुल कोई शुल्क नहीं लगता +- **अनुवाद की कोई सीमा नहीं**: कितनी भी बार अनुवाद कर सकते हैं +- **तेज़**: स्थानीय प्रसंस्करण के कारण अनुवाद तेज़ है +- **गोपनीयता**: इंटरनेट कनेक्शन की आवश्यकता नहीं, डेटा बाहर नहीं भेजा जाता +- **स्थिरता**: नेटवर्क के प्रभाव से मुक्त + +## hu + +Offline gépi fordítási modul. + +### Előnyök +- **Teljesen ingyenes**: Semmiféle díj +- **Korlátlan fordítás**: Annyiszor fordíthat, amennyiszer szeretne +- **Gyors**: Helyileg feldolgozott, gyors fordítás +- **Adatvédelem**: Nem igényel internetkapcsolatot, adatokat nem küldi el +- **Stabilitás**: Nincs hatással a hálózati feltételek + +## id + +Modul terjemahan mesin yang berfungsi offline. + +### Keuntungan +- **Sepenuhnya Gratis**: Tidak ada biaya sama sekali +- **Tidak Ada Batas Terjemahan**: Anda dapat menerjemahkan sebanyak yang Anda inginkan +- **Cepat**: Terjemahan cepat karena diproses secara lokal +- **Privasi**: Tidak ada koneksi internet yang diperlukan, data tidak dikirim ke luar +- **Stabilitas**: Tidak terpengaruh oleh kondisi jaringan + +## ko + +오프라인에서 동작하는 기계 번역 모듈입니다. + +### 장점 +- **완전 무료**: 비용이 전혀 들지 않습니다 +- **번역 제한 없음**: 몇 번이라도 번역할 수 있습니다 +- **빠름**: 로컬에서 처리되므로 번역이 빠릅니다 +- **프라이버시**: 인터넷 연결이 불필요하며, 데이터가 외부로 전송되지 않습니다 +- **안정성**: 네트워크의 영향을 받지 않습니다 + +## ms + +Modul terjemahan mesin yang berfungsi di luar talian. + +### Kelebihan +- **Percuma Sepenuhnya**: Tiada caj sama sekali +- **Tiada Had Terjemahan**: Anda boleh menterjemah sebanyak yang anda mahu +- **Pantas**: Terjemahan pantas kerana diproses secara tempatan +- **Privasi**: Tiada sambungan internet diperlukan, data tidak dihantar ke luar +- **Kestabilan**: Tidak terjejas oleh keadaan rangkaian + +## pl + +Moduł tłumaczenia maszynowego działający offline. + +### Zalety +- **Całkowicie darmowe**: Bez żadnych opłat +- **Brak ograniczeń tłumaczenia**: Możesz tłumaczyć tyle razy, ile chcesz +- **Szybkie**: Tłumaczenie jest szybkie, ponieważ przetwarzane jest lokalnie +- **Prywatność**: Nie wymaga połączenia z internetem, dane nie są przesyłane na zewnątrz +- **Stabilność**: Nie zależy od warunków sieciowych + +## pt-BR + +Módulo de tradução automática que funciona offline. + +### Vantagens +- **Totalmente gratuito**: Sem custos +- **Sem limite de tradução**: Traduza quantas vezes quiser +- **Rápido**: Tradução rápida pois é processado localmente +- **Privacidade**: Não requer conexão à internet, dados não são enviados externamente +- **Estabilidade**: Não afetado pela rede + +## ru + +Модуль машинного перевода, работающий в автономном режиме. + +### Преимущества +- **Полностью бесплатно**: Никаких платежей +- **Без ограничений на перевод**: Вы можете переводить столько раз, сколько хотите +- **Быстро**: Перевод быстрый, так как обрабатывается локально +- **Конфиденциальность**: Не требуется подключение к интернету, данные не передаются наружу +- **Стабильность**: Не зависит от состояния сети + +## th + +โมดูลการแปลด้วยเครื่องที่ทำงานแบบออฟไลน์ + +### ข้อดี +- **ฟรีทั้งหมด**: ไม่มีค่าใช้จ่ายใดๆ +- **ไม่มีข้อจำกัดในการแปล**: คุณสามารถแปลได้มากเท่าที่ต้องการ +- **เร็ว**: การแปลรวดเร็วเนื่องจากประมวลผลในเครื่อง +- **ความเป็นส่วนตัว**: ไม่ต้องการการเชื่อมต่ออินเทอร์เน็ต ข้อมูลไม่ถูกส่งออกภายนอก +- **เสถียรภาพ**: ไม่ได้รับผลกระทบจากสภาพเครือข่าย + +## tr + +Çevrimdışı çalışan bir makine çevirisi modülü. + +### Avantajlar +- **Tamamen Ücretsiz**: Hiçbir ücret yok +- **Çeviri Sınırı Yok**: İstediğiniz kadar çeviri yapabilirsiniz +- **Hızlı**: Yerel olarak işlendiği için çeviri hızlıdır +- **Gizlilik**: İnternet bağlantısı gerekmez, veriler dışarıya gönderilmez +- **Kararlılık**: Ağ koşullarından etkilenmez + +## vi + +Mô-đun dịch máy hoạt động ngoại tuyến. + +### Ưu điểm +- **Hoàn toàn miễn phí**: Hoàn toàn không tốn phí +- **Không giới hạn dịch**: Có thể dịch không giới hạn lần +- **Nhanh**: Dịch nhanh vì được xử lý cục bộ +- **Quyền riêng tư**: Không cần kết nối internet, dữ liệu không được gửi ra bên ngoài +- **Ổn định**: Không bị ảnh hưởng bởi mạng + +## zh-CN + +离线工作的机器翻译模块。 + +### 优点 +- **完全免费**:完全不产生费用 +- **无翻译限制**:可以无限次翻译 +- **快速**:本地处理,翻译速度快 +- **隐私保护**:无需互联网连接,数据不会发送到外部 +- **稳定性**:不受网络影响 + +## zh-TW + +離線工作的機器翻譯模組。 + +### 優點 +- **完全免費**:完全不產生費用 +- **無翻譯限制**:可以無限次翻譯 +- **快速**:本地處理,翻譯速度快 +- **隱私保護**:無需網際網路連線,資料不會傳送到外部 +- **穩定性**:不受網路影響 diff --git a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj index b13dc3e6..a9ee952e 100644 --- a/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.BergamotTranslatorPlugin/WindowTranslator.Plugin.BergamotTranslatorPlugin.csproj @@ -2,6 +2,10 @@ net10.0 + Bergamot Translator Plugin + Offline neural machine translation for WindowTranslator using Bergamot. + true + true enable enable true diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md new file mode 100644 index 00000000..60bda2cf --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/README.md @@ -0,0 +1,225 @@ +# WindowTranslator ColorThief Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、キャプチャ画像から翻訳テキストに適した前景色と背景色を推定するカラープラグインです。 + +## 機能 + +- OCR領域周辺の代表色から背景色を推定 +- 背景との明度差を考慮して読みやすい文字色を選択 +- 回転したテキスト領域にも対応 + +外部APIや追加設定は必要ありません。対象設定のカラーモジュールで「近似カラー」を選択してください。 + +## en + +A color plugin for [WindowTranslator](https://github.com/Freeesia/WindowTranslator) that estimates readable foreground and background colors for translated text from the captured image. + +## Features + +- Estimates the background color from representative colors around the OCR region +- Selects a readable text color based on its brightness contrast with the background +- Supports rotated text regions + +No external API or additional configuration is required. Select **Approximate Color** as the color module in the target settings. + +## ar + +مكوّن ألوان إضافي لـ[WindowTranslator](https://github.com/Freeesia/WindowTranslator) يقدّر ألوان المقدمة والخلفية المناسبة للنص المترجم من الصورة الملتقطة. + +### الميزات + +- يقدّر لون الخلفية من الألوان المحيطة بمنطقة OCR +- يختار لون نص مقروء بناءً على فرق السطوع مع الخلفية +- يدعم مناطق النص المدورة + +## cs + +Barevný plugin pro [WindowTranslator](https://github.com/Freeesia/WindowTranslator), který z pořízeného snímku odhaduje vhodné barvy popředí a pozadí přeloženého textu. + +### Funkce + +- Odhaduje barvu pozadí z barev v okolí oblasti OCR +- Vybírá čitelnou barvu textu podle rozdílu jasu oproti pozadí +- Podporuje otočené oblasti textu + +## de + +Ein Farb-Plugin für [WindowTranslator](https://github.com/Freeesia/WindowTranslator), das aus dem aufgenommenen Bild geeignete Vorder- und Hintergrundfarben für übersetzten Text ermittelt. + +### Funktionen + +- Ermittelt die Hintergrundfarbe aus den Farben um den OCR-Bereich +- Wählt anhand des Helligkeitsunterschieds zum Hintergrund eine gut lesbare Textfarbe +- Unterstützt gedrehte Textbereiche + +## es + +Un complemento de color para [WindowTranslator](https://github.com/Freeesia/WindowTranslator) que estima colores de primer plano y de fondo adecuados para el texto traducido a partir de la imagen capturada. + +### Funciones + +- Estima el color de fondo a partir de los colores alrededor del área de OCR +- Selecciona un color de texto legible según la diferencia de brillo con el fondo +- Admite áreas de texto giradas + +## fa + +افزونهٔ رنگ برای [WindowTranslator](https://github.com/Freeesia/WindowTranslator) که از تصویر گرفته‌شده، رنگ‌های مناسب متن و پس‌زمینه را برای ترجمه برآورد می‌کند. + +### ویژگی‌ها + +- رنگ پس‌زمینه را از رنگ‌های اطراف ناحیهٔ OCR برآورد می‌کند +- بر اساس اختلاف روشنایی با پس‌زمینه، رنگ خوانایی برای متن انتخاب می‌کند +- از نواحی متن چرخیده پشتیبانی می‌کند + +## fil + +Isang color plugin para sa [WindowTranslator](https://github.com/Freeesia/WindowTranslator) na tumataya ng angkop na kulay ng teksto at background mula sa nakuhang larawan. + +### Mga tampok + +- Tinataya ang kulay ng background mula sa mga kulay sa paligid ng OCR area +- Pumipili ng nababasang kulay ng teksto batay sa diperensya ng liwanag sa background +- Sinusuportahan ang mga iniikot na text area + +## fr + +Un plugin de couleur pour [WindowTranslator](https://github.com/Freeesia/WindowTranslator) qui estime, à partir de l’image capturée, les couleurs de premier plan et d’arrière-plan adaptées au texte traduit. + +### Fonctionnalités + +- Estime la couleur d’arrière-plan à partir des couleurs autour de la zone OCR +- Sélectionne une couleur de texte lisible selon l’écart de luminosité avec l’arrière-plan +- Prend en charge les zones de texte pivotées + +## hi + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator) के लिए एक रंग प्लगइन, जो कैप्चर की गई छवि से अनुवादित पाठ के लिए उपयुक्त अग्रभूमि और पृष्ठभूमि रंग का अनुमान लगाता है। + +### सुविधाएँ + +- OCR क्षेत्र के आसपास के रंगों से पृष्ठभूमि रंग का अनुमान लगाता है +- पृष्ठभूमि के साथ चमक के अंतर के आधार पर पढ़ने योग्य पाठ रंग चुनता है +- घुमाए गए पाठ क्षेत्रों का समर्थन करता है + +## hu + +Színbővítmény a [WindowTranslator](https://github.com/Freeesia/WindowTranslator) alkalmazáshoz, amely a rögzített képből megbecsüli a lefordított szöveg megfelelő előtér- és háttérszínét. + +### Funkciók + +- Az OCR-terület körüli színekből megbecsüli a háttérszínt +- A háttérhez viszonyított fényerőkülönbség alapján jól olvasható szövegszínt választ +- Támogatja az elforgatott szövegterületeket + +## id + +Plugin warna untuk [WindowTranslator](https://github.com/Freeesia/WindowTranslator) yang memperkirakan warna latar depan dan latar belakang yang sesuai untuk teks terjemahan dari gambar tangkapan. + +### Fitur + +- Memperkirakan warna latar belakang dari warna di sekitar area OCR +- Memilih warna teks yang mudah dibaca berdasarkan perbedaan kecerahan dengan latar belakang +- Mendukung area teks yang diputar + +## ko + +캡처 이미지에서 번역문에 적합한 전경색과 배경색을 추정하는 [WindowTranslator](https://github.com/Freeesia/WindowTranslator)용 색상 플러그인입니다. + +### 기능 + +- OCR 영역 주변의 색상으로 배경색을 추정 +- 배경과의 밝기 차이를 기준으로 읽기 쉬운 글자색을 선택 +- 회전된 텍스트 영역 지원 + +## ms + +Pemalam warna untuk [WindowTranslator](https://github.com/Freeesia/WindowTranslator) yang menganggarkan warna latar depan dan latar belakang yang sesuai untuk teks terjemahan daripada imej tangkapan. + +### Ciri + +- Menganggarkan warna latar belakang daripada warna di sekitar kawasan OCR +- Memilih warna teks yang mudah dibaca berdasarkan perbezaan kecerahan dengan latar belakang +- Menyokong kawasan teks yang diputar + +## pl + +Wtyczka kolorów dla [WindowTranslator](https://github.com/Freeesia/WindowTranslator), która na podstawie przechwyconego obrazu szacuje odpowiednie kolory tekstu i tła tłumaczenia. + +### Funkcje + +- Szacuje kolor tła na podstawie kolorów wokół obszaru OCR +- Wybiera czytelny kolor tekstu według różnicy jasności względem tła +- Obsługuje obrócone obszary tekstu + +## pt-BR + +Um plugin de cores para o [WindowTranslator](https://github.com/Freeesia/WindowTranslator) que estima cores adequadas de primeiro plano e de fundo para o texto traduzido a partir da imagem capturada. + +### Recursos + +- Estima a cor de fundo usando as cores ao redor da área de OCR +- Seleciona uma cor de texto legível com base na diferença de brilho em relação ao fundo +- Oferece suporte a áreas de texto giradas + +## ru + +Цветовой плагин для [WindowTranslator](https://github.com/Freeesia/WindowTranslator), который по захваченному изображению определяет подходящие цвета текста и фона для перевода. + +### Возможности + +- Определяет цвет фона по цветам вокруг области OCR +- Выбирает читаемый цвет текста с учётом разницы яркости относительно фона +- Поддерживает повёрнутые области текста + +## th + +ปลั๊กอินสีสำหรับ [WindowTranslator](https://github.com/Freeesia/WindowTranslator) ที่ประเมินสีข้อความและสีพื้นหลังที่เหมาะสมสำหรับข้อความแปลจากภาพที่จับไว้ + +### คุณสมบัติ + +- ประเมินสีพื้นหลังจากสีรอบบริเวณ OCR +- เลือกสีข้อความที่อ่านง่ายตามความแตกต่างของความสว่างกับพื้นหลัง +- รองรับบริเวณข้อความที่หมุน + +## tr + +Yakalanan görüntüden çevrilmiş metin için uygun ön plan ve arka plan renklerini tahmin eden bir [WindowTranslator](https://github.com/Freeesia/WindowTranslator) renk eklentisidir. + +### Özellikler + +- OCR alanının çevresindeki renklerden arka plan rengini tahmin eder +- Arka planla parlaklık farkına göre okunabilir bir metin rengi seçer +- Döndürülmüş metin alanlarını destekler + +## vi + +Plugin màu cho [WindowTranslator](https://github.com/Freeesia/WindowTranslator), ước tính màu chữ và màu nền phù hợp cho văn bản dịch từ ảnh đã chụp. + +### Tính năng + +- Ước tính màu nền từ các màu xung quanh vùng OCR +- Chọn màu chữ dễ đọc dựa trên độ chênh lệch sáng với nền +- Hỗ trợ vùng văn bản bị xoay + +## zh-CN + +一个用于 [WindowTranslator](https://github.com/Freeesia/WindowTranslator) 的颜色插件,可从捕获的图像中估算适合译文的前景色和背景色。 + +### 功能 + +- 根据 OCR 区域周围的颜色估算背景色 +- 根据与背景的亮度差选择易读的文字颜色 +- 支持旋转的文本区域 + +## zh-TW + +適用於 [WindowTranslator](https://github.com/Freeesia/WindowTranslator) 的色彩外掛程式,可從擷取的影像估算適合譯文的前景色與背景色。 + +### 功能 + +- 根據 OCR 區域周圍的色彩估算背景色 +- 根據與背景的亮度差選擇易讀的文字色彩 +- 支援旋轉的文字區域 diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj index 4fe79cd6..3e49347c 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.ColorThiefPlugin/WindowTranslator.Plugin.ColorThiefPlugin.csproj @@ -1,6 +1,10 @@ net10.0-windows10.0.20348.0 + ColorThief Plugin + Detects readable foreground and background colors for translated text. + true + true diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md new file mode 100644 index 00000000..920e8bf0 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/README.md @@ -0,0 +1,232 @@ +# WindowTranslator DeepL Translator Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でDeepL APIを利用する翻訳プラグインです。 + +## 機能 + +- DeepL APIによる自然で高品質な翻訳 +- WindowTranslatorから渡された文脈を翻訳リクエストへ反映 +- CSV用語集による表記の統一 +- 設定画面からAPI利用量を確認 + +## 設定 + +- DeepL APIの認証キーが必要です。 +- 用語集を利用する場合は、ヘッダーなしの`原文,訳文`形式のCSVファイルを指定します。 +- 利用可能な言語、料金、上限はDeepL APIの契約内容に従います。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses the DeepL API. + +## Features + +- Natural, high-quality translation through the DeepL API +- Includes context supplied by WindowTranslator in translation requests +- Maintains consistent terminology with a CSV glossary +- Shows API usage from the settings screen + +## Configuration + +- A DeepL API authentication key is required. +- To use a glossary, specify a headerless CSV file in `source,target` format. +- Available languages, pricing, and usage limits depend on your DeepL API plan. + +## ar + +وحدة تستخدم خدمة ترجمة DeepL، المعروفة بالترجمات عالية الجودة. + +### المزايا +- **دقة عالية**: ترجمات طبيعية وعالية الجودة +- **طبقة مجانية كبيرة**: حتى 500,000 حرف مجاناً شهرياً (API مجاني) +- **سريع**: معالجة ترجمة سريعة +- **دعم المسرد**: حافظ على اتساق الترجمة مع المسارد + +## cs + +Překladový modul využívající překladatelskou službu DeepL. + +### Výhody +- **Vysoká přesnost překladu**: Vysoká kvalita překladů +- **Velká bezplatná kvóta**: Velkorysý bezplatný limit +- **Rychlost**: Rychlý překlad + +## de + +Ein Modul, das DeepLs Übersetzungsdienst verwendet, bekannt für hochwertige Übersetzungen. + +### Vorteile +- **Hohe Genauigkeit**: Liefert natürliche, hochwertige Übersetzungen +- **Großzügiges kostenloses Kontingent**: Bis zu 500.000 Zeichen pro Monat kostenlos (Free API) +- **Schnell**: Schnelle Übersetzungsverarbeitung +- **Glossar-Unterstützung**: Kann Übersetzungskonsistenz durch Glossare aufrechterhalten + +## es + +Un módulo que utiliza el servicio de traducción DeepL, conocido por traducciones de alta calidad. + +### Ventajas +- **Alta precisión**: Traducciones naturales y de alta calidad +- **Nivel gratuito sustancial**: Hasta 500,000 caracteres gratis por mes (API gratuita) +- **Rápido**: Procesamiento de traducción rápido +- **Soporte de glosario**: Mantenga la consistencia de traducción con glosarios + +## fa + +ماژولی که از سرویس ترجمه DeepL، معروف به ترجمه‌های با کیفیت بالا، استفاده می‌کند. + +### مزایا +- **دقت بالا**: ترجمه‌های طبیعی و با کیفیت بالا +- **طبقه رایگان بزرگ**: تا 500,000 کاراکتر در ماه رایگان (API رایگان) +- **سریع**: پردازش ترجمه سریع +- **پشتیبانی از واژه‌نامه**: حفظ ثبات ترجمه با واژه‌نامه‌ها + +## fil + +Isang modyul na gumagamit ng serbisyo ng pagsasalin ng DeepL, kilala sa mataas na kalidad ng mga pagsasalin. + +### Mga Bentahe +- **Mataas na Katumpakan**: Nagbibigay ng natural at mataas na kalidad ng mga pagsasalin +- **Mapagbigay na Libreng Tier**: Hanggang 500,000 character bawat buwan nang libre (Free API) +- **Mabilis**: Mabilis na pagproseso ng pagsasalin +- **Suporta sa Glossary**: Maaaring mapanatili ang consistency ng pagsasalin gamit ang mga glossary + +## fr + +Un module utilisant le service de traduction DeepL, connu pour des traductions de haute qualité. + +### Avantages +- **Haute précision**: Traductions naturelles et de haute qualité +- **Offre gratuite substantielle**: Jusqu'à 500 000 caractères gratuits par mois (API gratuite) +- **Rapide**: Traitement de traduction rapide +- **Support de glossaire**: Maintenez la cohérence de traduction avec des glossaires + +## hi + +उच्च गुणवत्ता अनुवाद के लिए प्रसिद्ध DeepL अनुवाद सेवा का उपयोग करने वाला मॉड्यूल। + +### फायदे +- **उच्च सटीकता**: प्राकृतिक, उच्च गुणवत्ता अनुवाद प्रदान करता है +- **उदार निःशुल्क सीमा**: मासिक 5 लाख वर्ण तक निःशुल्क उपयोग (निःशुल्क API) +- **तेज़**: तेज़ अनुवाद प्रसंस्करण +- **शब्दावली समर्थन**: शब्दावली का उपयोग करके अनुवाद की स्थिरता बनाए रख सकते हैं + +## hu + +A DeepL fordítási szolgáltatását használó fordítási modul. + +### Előnyök +- **Magas fordítási pontosság**: Magas minőségű fordítások +- **Nagy ingyenes kvóta**: Nagyvonalú ingyenes limit +- **Sebesség**: Gyors fordítás + +## id + +Modul menggunakan layanan terjemahan DeepL, terkenal dengan terjemahan berkualitas tinggi. + +### Keuntungan +- **Akurasi Tinggi**: Menyediakan terjemahan alami berkualitas tinggi +- **Tingkat Gratis yang Murah Hati**: Hingga 500.000 karakter per bulan secara gratis (API Gratis) +- **Cepat**: Pemrosesan terjemahan cepat +- **Dukungan Glosarium**: Dapat mempertahankan konsistensi terjemahan menggunakan glosarium + +## ko + +고품질 번역으로 알려진 DeepL의 번역 서비스를 사용하는 모듈입니다. + +### 장점 +- **높은 정확도**: 자연스럽고 고품질의 번역을 제공합니다 +- **넉넉한 무료 제공량**: 월 50만 자까지 무료로 사용할 수 있습니다 (무료 API) +- **빠름**: 번역 처리가 빠릅니다 +- **용어집 지원**: 용어집을 이용하여 번역의 일관성을 유지할 수 있습니다 + +## ms + +Modul menggunakan perkhidmatan terjemahan DeepL, terkenal dengan terjemahan berkualiti tinggi. + +### Kelebihan +- **Ketepatan Tinggi**: Menyediakan terjemahan semula jadi berkualiti tinggi +- **Peringkat Percuma yang Murah Hati**: Sehingga 500,000 aksara sebulan secara percuma (API Percuma) +- **Pantas**: Pemprosesan terjemahan pantas +- **Sokongan Glosari**: Boleh mengekalkan konsistensi terjemahan menggunakan glosari + +## pl + +Moduł tłumaczeniowy wykorzystujący usługę tłumaczeniową DeepL. + +### Zalety +- **Wysoka dokładność tłumaczenia**: Wysoka jakość tłumaczeń +- **Duży darmowy plan**: Hojny darmowy limit +- **Szybkość**: Szybkie tłumaczenie + +## pt-BR + +Módulo que utiliza o serviço de tradução DeepL, conhecido por traduções de alta qualidade. + +### Vantagens +- **Alta precisão**: Obtenha traduções naturais e de alta qualidade +- **Ampla cota gratuita**: Use gratuitamente até 500.000 caracteres por mês (API gratuita) +- **Rápido**: Processamento de tradução rápido +- **Suporte a glossário**: Use glossários para manter consistência na tradução + +## ru + +Модуль, использующий службу перевода DeepL, известную высококачественными переводами. + +### Преимущества +- **Высокая точность**: Обеспечивает естественные высококачественные переводы +- **Щедрый бесплатный тариф**: До 500 000 символов в месяц бесплатно (Free API) +- **Быстро**: Быстрая обработка перевода +- **Поддержка глоссария**: Может поддерживать согласованность перевода с использованием глоссариев + +## th + +โมดูลที่ใช้บริการแปลของ DeepL ซึ่งเป็นที่รู้จักในด้านการแปลคุณภาพสูง + +### ข้อดี +- **ความแม่นยำสูง**: ให้การแปลที่เป็นธรรมชาติและมีคุณภาพสูง +- **แผนฟรีที่ใจกว้าง**: สูงสุด 500,000 ตัวอักษรต่อเดือนฟรี (Free API) +- **เร็ว**: การประมวลผลการแปลรวดเร็ว +- **รองรับอภิธานศัพท์**: สามารถรักษาความสอดคล้องของการแปลโดยใช้อภิธานศัพท์ + +## tr + +Yüksek kaliteli çevirilerle tanınan DeepL'in çeviri hizmetini kullanan bir modül. + +### Avantajlar +- **Yüksek Doğruluk**: Doğal, yüksek kaliteli çeviriler sağlar +- **Cömert Ücretsiz Katman**: Ayda 500.000 karaktere kadar ücretsiz (Free API) +- **Hızlı**: Hızlı çeviri işleme +- **Sözlük Desteği**: Sözlükler kullanarak çeviri tutarlılığını koruyabilir + +## vi + +Mô-đun sử dụng dịch vụ dịch thuật DeepL được biết đến với chất lượng cao. + +### Ưu điểm +- **Độ chính xác cao**: Cung cấp bản dịch tự nhiên và chất lượng cao +- **Hạn mức miễn phí hào phóng**: Tối đa 500.000 ký tự mỗi tháng miễn phí (API miễn phí) +- **Nhanh**: Xử lý dịch nhanh +- **Hỗ trợ thuật ngữ**: Có thể duy trì tính nhất quán trong dịch thuật bằng cách sử dụng thuật ngữ + +## zh-CN + +使用以高质量翻译闻名的 DeepL 翻译服务的模块。 + +### 优点 +- **高准确度**:提供自然、高质量的翻译 +- **充足的免费额度**:每月最多50万字符免费(免费 API) +- **快速**:翻译处理速度快 +- **术语表支持**:可以使用术语表保持翻译一致性 + +## zh-TW + +使用以高品質翻譯聞名的 DeepL 翻譯服務的模組。 + +### 優點 +- **高準確度**:提供自然、高品質的翻譯 +- **充足的免費額度**:每月最多50萬字元免費(免費 API) +- **快速**:翻譯處理速度快 +- **術語表支援**:可以使用術語表保持翻譯一致性 diff --git a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj index 1cd83d92..2afc9efa 100644 --- a/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj @@ -1,5 +1,12 @@  + + DeepL Translator Plugin + Translation for WindowTranslator using the DeepL API. + true + false + + diff --git a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj index 61718a1f..e37e8a3a 100644 --- a/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.DummyPlugin/WindowTranslator.Plugin.DummyPlugin.csproj @@ -1,3 +1,9 @@ + + + false + false + + diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md b/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md new file mode 100644 index 00000000..da2cbdf4 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/README.md @@ -0,0 +1,221 @@ +# WindowTranslator Fields of Mistria Filter Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)の翻訳をゲーム「Fields of Mistria」向けに補助するフィルタープラグインです。 + +## 機能 + +- 実行中の`FieldsOfMistria.exe`を検出した場合だけ有効化 +- ゲームの`localization.json`を参照してOCR結果を補正 +- キャラクター、シーン、会話の情報を翻訳コンテキストとして追加 +- キャラクター名やアイテム名を選択中の翻訳モジュールへ用語集として登録 + +## 設定 + +- OCR補正の有効化 +- 公式日本語テキストの利用 +- プレイヤー名と農場名 +- ゲームデータに存在しないテキストの除外 + +このプラグイン自体は翻訳サービスを提供しません。WindowTranslatorで別途、翻訳モジュールを選択してください。 + +## en + +A filter plugin that adapts [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translations for the game **Fields of Mistria**. + +## Features + +- Activates only while `FieldsOfMistria.exe` is running +- Corrects OCR results using the game's `localization.json` +- Adds character, scene, and dialogue information to the translation context +- Registers character and item names as glossary entries with the selected translation module + +## Configuration + +- Enable or disable OCR correction +- Use the official Japanese text +- Set the player and farm names +- Exclude text that is not present in the game data + +This plugin does not provide a translation service by itself. Select a translation module separately in WindowTranslator. + +## ar + +مخصص لـ Fields of Mistria + +- استخدام التصحيح بناءً على موارد اللعبة +- استخدام الموارد اليابانية للعبة +- استبعاد النص غير المحدد +- اسم اللاعب / اسم المزرعة + +## cs + +Pouze pro Fields of Mistria + +- Použít opravy se zdroji obsaženými ve hře. +- Použít japonské zdroje obsažené ve hře +- Vyloučit nerozpoznaný text +- Jméno hráče / Název farmy + +## de + +Exklusiv bei Fields of Mistria + +- Verwendung der im Spiel enthaltenen ressourcenbasierten Korrekturen. +- Verwenden Sie die im Spiel enthaltenen japanischen Ressourcen. +- Nicht identifizierbaren Text ausschließen +- Spieler Name / Name des Betriebs + +## es + +Dedicado a Fields of Mistria + +- Usar corrección basada en recursos del juego +- Usar recursos japoneses del juego +- Excluir texto no identificable +- Nombre del jugador / Nombre de la granja + +## fa + +مخصوص Fields of Mistria + +- استفاده از تصحیح بر اساس منابع بازی +- استفاده از منابع ژاپنی بازی +- حذف متن مشخص‌نشده +- نام بازیکن / نام مزرعه + +## fil + +Eksklusibo para sa Fields of Mistria + +- Gamitin ang pagwawasto gamit ang mga mapagkukunang kasama sa laro +- Gamitin ang mga Japanese na mapagkukunang kasama sa laro +- Ibukod ang hindi matukoy na teksto +- Pangalan ng Manlalaro / Pangalan ng Farm + +## fr + +Dédié à Fields of Mistria + +- Utiliser la correction basée sur les ressources du jeu +- Utiliser les ressources japonaises du jeu +- Exclure le texte non identifiable +- Nom du joueur / Nom de la ferme + +## hi + +फील्ड्स ऑफ मिस्ट्रिया के लिए विशेष + +- गेम में शामिल संसाधनों के साथ क्षतिपूर्ति का उपयोग करें। +- गेम में शामिल जापानी संसाधनों का उपयोग करें +- अज्ञात टेक्स्ट बाहर करें +- खिलाड़ी का नाम / फार्म का नाम + +## hu + +Fields of Mistria kizárólagos + +- Javítás használata a játékban lévő erőforrásokkal +- A játékban lévő japán erőforrások használata +- Ismeretlen szöveg kizárása +- Játékos neve / Farm neve + +## id + +Eksklusif untuk Fields of Mistria + +- Gunakan kompensasi dengan sumber daya yang disertakan dalam game. +- Gunakan sumber daya Jepang yang disertakan dalam game +- Kecualikan teks yang tidak teridentifikasi +- Player Name / Nama Pertanian + +## ko + +Fields of Mistria 전용 + +- 게임에 포함된 리소스를 이용한 보정 활용하기 +- 게임에 포함된 일본어 리소스 이용하기 +- 특정할 수 없는 텍스트 제외 +- 플레이어 이름 / 농장명 + +## ms + +Eksklusif untuk Fields of Mistria + +- Gunakan pampasan dengan sumber yang disertakan dalam permainan. +- Gunakan sumber Jepun yang disertakan dalam permainan +- Kecualikan teks tidak dikenal pasti +- Player Name / Nama Ladang + +## pl + +Wyłącznie dla Fields of Mistria + +- Użyj rekompensaty z zasobami zawartymi w grze. +- Użyj japońskich zasobów zawartych w grze +- Wyklucz niezidentyfikowany tekst +- Nazwa gracza / Nazwa farmy + +## pt-BR + +Exclusivo para Fields of Mistria + +- Use compensação com recursos incluídos no jogo. +- Use recursos japoneses incluídos no jogo +- Excluir texto não identificado +- Player Name / Nome da Fazenda + +## ru + +Только для Fields of Mistria + +- Использовать исправление с помощью ресурсов, включенных в игру +- Использовать японские ресурсы, включенные в игру +- Исключить неопределенный текст +- Имя игрока / Название фермы + +## th + +สำหรับ Fields of Mistria เท่านั้น + +- ใช้การแก้ไขด้วยทรัพยากรที่รวมอยู่ในเกม +- ใช้ทรัพยากรภาษาญี่ปุ่นที่รวมอยู่ในเกม +- ยกเว้นข้อความที่ระบุไม่ได้ +- ชื่อผู้เล่น / ชื่อฟาร์ม + +## tr + +Yalnızca Fields of Mistria için + +- Oyunda bulunan kaynakları kullanarak düzeltme kullan +- Oyunda bulunan Japonca kaynakları kullan +- Belirtilemeyen metni hariç tut +- Oyuncu Adı / Çiftlik Adı + +## vi + +Fields of Mistria専用 + +- ゲームに含まれているリソースを利用した補正を利用する +- ゲームに含まれている日本語リソースを利用する +- 特定できないテキストを除外 +- プレイヤー名 / 農場名 + +## zh-CN + +迷雾之地》独有 + +- 使用游戏中包含的基于资源的修正。 +- 使用游戏中包含的日语资源。 +- 排除无法识别的文本。 +- 球员姓名 / 农场名称 + +## zh-TW + +專屬於 Fields of Mistria + +- 使用遊戲中包含的資源型修正。 +- 使用遊戲中包含的日文資源。 +- 排除無法辨識的文字。 +- 玩家名稱 / 農場名稱 diff --git a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj index 7b0dffe8..79cd0353 100644 --- a/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.FoMPlugin/WindowTranslator.Plugin.FoMPlugin.csproj @@ -1,7 +1,11 @@  net10.0-windows10.0.20348.0 + Fields of Mistria Filter Plugin + Context-aware translation filtering for Fields of Mistria. true + true + false diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md new file mode 100644 index 00000000..77713bfd --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/README.md @@ -0,0 +1,199 @@ +# WindowTranslator GitHub Copilot Translator Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でGitHub Copilotを利用する翻訳プラグインです。 + +## 機能 + +- 選択したCopilotモデルによる文脈を考慮した翻訳 +- 翻訳方針を追加できるカスタムコンテキスト +- CSV用語集による表記の統一 +- WindowTranslatorが提供する会話やゲーム固有のコンテキストを反映 + +## 必要条件と設定 + +- GitHub Copilotを利用できるGitHubアカウントと認証 +- 利用するモデル名 +- 必要に応じて翻訳コンテキストと、ヘッダーなしの`原文,訳文`形式のCSV用語集 + +利用可能なモデル、料金、上限はGitHub Copilotの契約内容に従います。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that uses GitHub Copilot. + +## Features + +- Context-aware translation with the selected Copilot model +- Custom context for adding translation instructions +- Consistent terminology through a CSV glossary +- Uses conversation and game-specific context supplied by WindowTranslator + +## Requirements and configuration + +- A GitHub account with access to GitHub Copilot and completed authentication +- The model name to use +- Optionally, translation context and a headerless CSV glossary in `source,target` format + +Available models, pricing, and usage limits depend on your GitHub Copilot plan. + +## ar + +ترجمة GitHub Copilot + +- معرف النموذج المتاح على GitHub Copilot (مثال: gpt-4o، claude-sonnet-4.5) +- معلومات السياق المستخدمة أثناء الترجمة +- مسار المسرد + +## cs + +Překlad GitHub Copilot + +- ID modelu dostupného na GitHub Copilot (např. gpt-4o, claude-sonnet-4.5) +- Kontextové informace použité při překladu +- Cesta ke glosáři + +## de + +GitHub Copilot-Übersetzung + +- ID des auf GitHub Copilot verfügbaren Modells (z. B. gpt-4o, claude-sonnet-4.5) +- Kontextinformationen für die Übersetzung +- Glossarpfad + +## es + +Traducción GitHub Copilot + +- ID del modelo disponible en GitHub Copilot (ej: gpt-4o, claude-sonnet-4.5) +- Información contextual utilizada durante la traducción +- Ruta del glosario + +## fa + +ترجمه GitHub Copilot + +- شناسه مدل موجود در GitHub Copilot (مثال: gpt-4o، claude-sonnet-4.5) +- اطلاعات زمینه‌ای که هنگام ترجمه استفاده می‌شود +- مسیر واژه‌نامه + +## fil + +Pagsasalin ng GitHub Copilot + +- ID ng modelong available sa GitHub Copilot (hal: gpt-4o, claude-sonnet-4.5) +- Impormasyon ng konteksto na ginagamit sa panahon ng pagsasalin +- Landas ng glossaryo + +## fr + +Traduction GitHub Copilot + +- ID du modèle disponible sur GitHub Copilot (ex: gpt-4o, claude-sonnet-4.5) +- Informations contextuelles utilisées lors de la traduction +- Chemin du glossaire + +## hi + +GitHub Copilot अनुवाद + +- GitHub Copilot पर उपलब्ध मॉडल की ID (उदा., gpt-4o, claude-sonnet-4.5) +- अनुवाद के दौरान उपयोग की जाने वाली संदर्भ जानकारी +- शब्दावली पथ + +## hu + +GitHub Copilot fordítás + +- A GitHub Copiloton elérhető modell azonosítója (pl. gpt-4o, claude-sonnet-4.5) +- Fordítás során használandó kontextuális információk +- Szószedet elérési útja + +## id + +Terjemahan GitHub Copilot + +- ID model yang tersedia di GitHub Copilot (contoh: gpt-4o, claude-sonnet-4.5) +- Informasi konteks yang digunakan saat penerjemahan +- Jalur glosarium + +## ko + +GitHub Copilot 번역 + +- GitHub Copilot에서 사용 가능한 모델 ID (예: gpt-4o, claude-sonnet-4.5) +- 번역 시 사용할 문맥 정보 +- 용어집 경로 + +## ms + +Terjemahan GitHub Copilot + +- ID model yang tersedia pada GitHub Copilot (contoh: gpt-4o, claude-sonnet-4.5) +- Maklumat konteks yang digunakan semasa terjemahan +- Laluan glosari + +## pl + +Tłumaczenie GitHub Copilot + +- ID modelu dostępnego w GitHub Copilot (np: gpt-4o, claude-sonnet-4.5) +- Informacje kontekstowe używane podczas tłumaczenia +- Ścieżka do słownika + +## pt-BR + +Tradução GitHub Copilot + +- ID do modelo disponível no GitHub Copilot (ex: gpt-4o, claude-sonnet-4.5) +- Informações de contexto usadas durante a tradução +- Caminho do glossário + +## ru + +Перевод GitHub Copilot + +- ID модели, доступной в GitHub Copilot (например: gpt-4o, claude-sonnet-4.5) +- Контекстная информация, используемая при переводе +- Путь к глоссарию + +## th + +การแปล GitHub Copilot + +- ID ของโมเดลที่มีใน GitHub Copilot (เช่น: gpt-4o, claude-sonnet-4.5) +- ข้อมูลบริบทที่ใช้ระหว่างการแปล +- เส้นทางอภิธานศัพท์ + +## tr + +GitHub Copilot Çevirisi + +- GitHub Copilot'ta mevcut modelin kimliği (örn: gpt-4o, claude-sonnet-4.5) +- Çeviri sırasında kullanılan bağlam bilgisi +- Sözlük yolu + +## vi + +Dịch thuật GitHub Copilot + +- ID mô hình có sẵn trên GitHub Copilot (ví dụ: gpt-4o, claude-sonnet-4.5) +- Thông tin ngữ cảnh sử dụng khi dịch thuật +- Đường dẫn thuật ngữ + +## zh-CN + +GitHub Copilot 翻译 + +- GitHub Copilot 上可用的模型 ID(例如:gpt-4o、claude-sonnet-4.5) +- 翻译时使用的上下文信息 +- 术语表路径 + +## zh-TW + +GitHub Copilot 翻譯 + +- GitHub Copilot 上可用的模型 ID(例如:gpt-4o、claude-sonnet-4.5) +- 翻譯時使用的上下文資訊 +- 術語表路徑 diff --git a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj index 90dd527f..c27723be 100644 --- a/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GitHubCopilotPlugin/WindowTranslator.Plugin.GitHubCopilotPlugin.csproj @@ -1,6 +1,11 @@  net10.0-windows10.0.20348.0 + GitHub Copilot Translator Plugin + Translation for WindowTranslator using GitHub Copilot. + true + false + true diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md new file mode 100644 index 00000000..e229b111 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/README.md @@ -0,0 +1,222 @@ +# WindowTranslator Google AI Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でGoogle AI(Gemini)を利用する多機能プラグインです。 + +## 機能 + +- Geminiによる文脈を考慮した高品質な翻訳 +- 画像を直接送信するAI OCR +- OCRテキストまたは元画像を使った認識結果の補正 +- カスタム翻訳コンテキスト、補正サンプル、CSV用語集 + +AI OCRとOCR補正は実験的な機能です。 + +## 必要条件と設定 + +- Google AI APIキー +- 利用するGeminiモデル。必要に応じてプレビュー版モデル名も指定できます。 +- OCR補正を使う場合は、補正方法と待機動作を選択します。 +- 用語集はヘッダーなしの`原文,訳文`形式のCSVファイルです。 + +画像やテキストは設定したGoogle AIサービスへ送信されます。利用可能なモデル、料金、上限はサービスの契約内容に従います。 + +## en + +A multi-purpose [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin that uses Google AI (Gemini). + +## Features + +- High-quality, context-aware translation with Gemini +- AI OCR that sends the source image directly to the model +- Correction of recognition results using OCR text or the original image +- Custom translation context, correction examples, and CSV glossaries + +AI OCR and OCR correction are experimental features. + +## Requirements and configuration + +- A Google AI API key +- The Gemini model to use; a preview model name can also be specified when necessary +- When using OCR correction, select the correction method and waiting behavior +- Glossaries use a headerless CSV file in `source,target` format + +Images and text are sent to the configured Google AI service. Available models, pricing, and usage limits depend on the service plan. + +## ar + +وحدة ترجمة تستخدم أحدث تقنيات الذكاء الاصطناعي من Google. + +### المزايا +- **أعلى دقة**: ترجمات عالية الجودة جداً تفهم السياق +- **المرونة**: تخصيص المطالبات لتعديل أسلوب الترجمة +- **دعم المسرد**: حافظ على اتساق الترجمة مع المسارد + +## cs + +Překladový modul využívající Gemini AI od Googlu. + +### Výhody +- **Vysoká přesnost překladu**: Vysoká kvalita překladů díky AI +- **Kontextový překlad**: Chápe kontext a překládá přirozeně + +## de + +Ein Übersetzungsmodul, das Googles neueste KI-Technologie nutzt. + +### Vorteile +- **Höchste Genauigkeit**: Fähig zu sehr hochwertiger Übersetzung mit Kontextverständnis +- **Flexibilität**: Kann Prompts anpassen, um Übersetzungsstil zu justieren +- **Glossar-Unterstützung**: Kann Übersetzungskonsistenz durch Glossare aufrechterhalten + +## es + +Un módulo de traducción que utiliza la última tecnología de IA de Google. + +### Ventajas +- **Mayor precisión**: Traducciones de muy alta calidad que entienden el contexto +- **Flexibilidad**: Personalice prompts para ajustar el estilo de traducción +- **Soporte de glosario**: Mantenga la consistencia de traducción con glosarios + +## fa + +ماژول ترجمه‌ای که از جدیدترین فناوری هوش مصنوعی Google استفاده می‌کند. + +### مزایا +- **بالاترین دقت**: ترجمه‌های با کیفیت بسیار بالا که زمینه را درک می‌کنند +- **انعطاف‌پذیری**: سفارشی‌سازی دستورالعمل برای تنظیم سبک ترجمه +- **پشتیبانی از واژه‌نامه**: حفظ ثبات ترجمه با واژه‌نامه‌ها + +## fil + +Isang modyul ng pagsasalin na gumagamit ng pinakabagong teknolohiya ng AI ng Google. + +### Mga Bentahe +- **Pinakamataas na Katumpakan**: Kayang mag-translate ng napakataas na kalidad na may pag-unawa sa konteksto +- **Kakayahang umangkop**: Maaaring i-customize ang mga prompt upang ayusin ang estilo ng pagsasalin +- **Suporta sa Glossary**: Maaaring mapanatili ang consistency ng pagsasalin gamit ang mga glossary + +## fr + +Un module de traduction utilisant la dernière technologie IA de Google. + +### Avantages +- **Plus haute précision**: Traductions de très haute qualité qui comprennent le contexte +- **Flexibilité**: Personnalisez les prompts pour ajuster le style de traduction +- **Support de glossaire**: Maintenez la cohérence de traduction avec des glossaires + +## hi + +Google की नवीनतम AI तकनीक का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **सर्वोच्च सटीकता**: संदर्भ को समझकर बहुत उच्च गुणवत्ता अनुवाद की क्षमता +- **लचीलापन**: अनुवाद शैली को समायोजित करने के लिए प्रॉम्प्ट को कस्टमाइज़ कर सकते हैं +- **शब्दावली समर्थन**: शब्दावली का उपयोग करके अनुवाद की स्थिरता बनाए रख सकते हैं + +## hu + +A Google Gemini AI-t használó fordítási modul. + +### Előnyök +- **Magas fordítási pontosság**: Magas minőségű fordítások AI segítségével +- **Kontextuális fordítás**: Érti a kontextust és természetesen fordít + +## id + +Modul terjemahan yang memanfaatkan teknologi AI terbaru Google. + +### Keuntungan +- **Akurasi Tertinggi**: Mampu melakukan terjemahan berkualitas sangat tinggi dengan pemahaman kontekstual +- **Fleksibilitas**: Dapat menyesuaikan prompt untuk menyesuaikan gaya terjemahan +- **Dukungan Glosarium**: Dapat mempertahankan konsistensi terjemahan menggunakan glosarium + +## ko + +Google의 최신 AI 기술을 활용한 번역 모듈입니다. + +### 장점 +- **최고 정확도**: 문맥을 이해한 매우 고품질의 번역이 가능합니다 +- **유연성**: 프롬프트를 커스터마이즈하여 번역 스타일을 조정할 수 있습니다 +- **용어집 지원**: 용어집을 이용하여 번역의 일관성을 유지할 수 있습니다 + +## ms + +Modul terjemahan yang memanfaatkan teknologi AI terkini Google. + +### Kelebihan +- **Ketepatan Tertinggi**: Mampu melakukan terjemahan berkualiti sangat tinggi dengan pemahaman kontekstual +- **Fleksibiliti**: Boleh menyesuaikan prompt untuk melaraskan gaya terjemahan +- **Sokongan Glosari**: Boleh mengekalkan konsistensi terjemahan menggunakan glosari + +## pl + +Moduł tłumaczeniowy wykorzystujący Gemini AI od Google. + +### Zalety +- **Wysoka dokładność tłumaczenia**: Wysoka jakość tłumaczeń dzięki AI +- **Kontekstowe tłumaczenie**: Rozumie kontekst i tłumaczy naturalnie + +## pt-BR + +Módulo de tradução que utiliza a mais recente tecnologia de IA do Google. + +### Vantagens +- **Máxima precisão**: Permite traduções de altíssima qualidade com compreensão de contexto +- **Flexibilidade**: Customize prompts para ajustar o estilo de tradução +- **Suporte a glossário**: Use glossários para manter consistência na tradução + +## ru + +Модуль перевода, использующий новейшие технологии искусственного интеллекта Google. + +### Преимущества +- **Наивысшая точность**: Способен на очень высококачественный перевод с пониманием контекста +- **Гибкость**: Можно настраивать подсказки для настройки стиля перевода +- **Поддержка глоссария**: Может поддерживать согласованность перевода с использованием глоссариев + +## th + +โมดูลการแปลที่ใช้เทคโนโลยี AI ล่าสุดของ Google + +### ข้อดี +- **ความแม่นยำสูงสุด**: สามารถแปลคุณภาพสูงมากด้วยความเข้าใจบริบท +- **ความยืดหยุ่น**: สามารถปรับแต่ง prompt เพื่อปรับสไตล์การแปล +- **รองรับอภิธานศัพท์**: สามารถรักษาความสอดคล้องของการแปลโดยใช้อภิธานศัพท์ + +## tr + +Google'ın en son yapay zeka teknolojisinden yararlanan bir çeviri modülü. + +### Avantajlar +- **En Yüksek Doğruluk**: Bağlamsal anlayışla çok yüksek kaliteli çeviri yapabilir +- **Esneklik**: Çeviri stilini ayarlamak için istemler özelleştirebilir +- **Sözlük Desteği**: Sözlükler kullanarak çeviri tutarlılığını koruyabilir + +## vi + +Mô-đun dịch thuật tận dụng công nghệ AI mới nhất của Google. + +### Ưu điểm +- **Độ chính xác cao nhất**: Có khả năng dịch chất lượng rất cao với hiểu biết ngữ cảnh +- **Linh hoạt**: Có thể tùy chỉnh prompt để điều chỉnh phong cách dịch +- **Hỗ trợ thuật ngữ**: Có thể duy trì tính nhất quán trong dịch thuật bằng cách sử dụng thuật ngữ + +## zh-CN + +利用 Google 最新 AI 技术的翻译模块。 + +### 优点 +- **最高准确度**:能够理解上下文进行极高质量的翻译 +- **灵活性**:可以自定义提示来调整翻译风格 +- **术语表支持**:可以使用术语表保持翻译一致性 + +## zh-TW + +利用 Google 最新 AI 技術的翻譯模組。 + +### 優點 +- **最高準確度**:能夠理解上下文進行極高品質的翻譯 +- **靈活性**:可以自訂提示來調整翻譯風格 +- **術語表支援**:可以使用術語表保持翻譯一致性 diff --git a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj index ecc7110a..4a7f97ba 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAIPlugin/WindowTranslator.Plugin.GoogleAIPlugin.csproj @@ -2,6 +2,10 @@ net10.0-windows10.0.20348.0 + Google AI Plugin + Translation, OCR, and text correction for WindowTranslator using Google AI. + true + false diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md new file mode 100644 index 00000000..ff005259 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/README.md @@ -0,0 +1,213 @@ +# WindowTranslator Google Apps Script Translator Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)からGoogle Apps Scriptを呼び出して翻訳するプラグインです。 + +## 機能 + +- 複数のテキストをGoogle Apps Script経由で翻訳 +- 組み込みの公開スクリプト、または任意のApps Scriptデプロイを利用可能 +- WindowTranslatorの翻訳元言語と翻訳先言語をスクリプトへ自動送信 + +## 必要条件と設定 + +- 組み込みスクリプトを利用する場合はGoogleアカウントによる認証が必要です。 +- 独自スクリプトを利用する場合は、Google Apps Script APIから実行可能なデプロイIDを設定します。 +- インターネット接続が必要です。利用量や実行上限はGoogle側の制限に従います。 + +## en + +A translation plugin that invokes Google Apps Script from [WindowTranslator](https://github.com/Freeesia/WindowTranslator). + +## Features + +- Translates multiple text entries through Google Apps Script +- Supports the built-in published script or a custom Apps Script deployment +- Automatically sends WindowTranslator's source and target languages to the script + +## Requirements and configuration + +- Using the built-in script requires authentication with a Google account. +- To use a custom script, specify a deployment ID that can be executed through the Google Apps Script API. +- An internet connection is required. Usage and execution limits are subject to Google's service limits. + +## ar + +وحدة ترجمة تستخدم خدمة ترجمة Google. + +### المزايا +- **مجاني تماماً**: يمكن استخدامه بدون مفتاح API +- **دعم متعدد اللغات**: يدعم العديد من أزواج اللغات +- **سهل**: لا حاجة لإعداد خاص + +## cs + +Překladový modul využívající překladatelskou službu Google. + +### Výhody +- **Zcela zdarma**: Lze používat bez klíče API +- **Vícejazyčná podpora**: Podporuje mnoho jazykových párů +- **Snadné**: Nevyžaduje speciální nastavení + +## de + +Ein Übersetzungsmodul, das Googles Übersetzungsdienst verwendet. + +### Vorteile +- **Völlig kostenlos**: Kann ohne API-Schlüssel verwendet werden +- **Mehrsprachige Unterstützung**: Unterstützt viele Sprachpaare +- **Einfach**: Keine spezielle Konfiguration erforderlich + +## es + +Un módulo de traducción que utiliza el servicio de traducción de Google. + +### Ventajas +- **Completamente gratis**: Se puede usar sin clave API +- **Soporte multilingüe**: Admite muchos pares de idiomas +- **Fácil**: No se requiere configuración especial + +## fa + +ماژول ترجمه‌ای که از سرویس ترجمه Google استفاده می‌کند. + +### مزایا +- **کاملاً رایگان**: می‌توان بدون کلید API استفاده کرد +- **پشتیبانی از چند زبان**: از جفت‌های زبانی زیادی پشتیبانی می‌کند +- **آسان**: نیاز به تنظیم خاصی ندارد + +## fil + +Isang modyul ng pagsasalin na gumagamit ng serbisyo ng pagsasalin ng Google. + +### Mga Bentahe +- **Lubos na Libre**: Maaaring gamitin nang walang API key +- **Suporta sa Maraming Wika**: Sumusuporta sa maraming language pair +- **Madali**: Walang kinakailangang special na configuration + +## fr + +Un module de traduction utilisant le service de traduction de Google. + +### Avantages +- **Complètement gratuit**: Peut être utilisé sans clé API +- **Support multilingue**: Prend en charge de nombreuses paires de langues +- **Facile**: Aucune configuration spéciale requise + +## hi + +Google की अनुवाद सेवा का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **पूर्ण रूप से निःशुल्क**: API कुंजी के बिना उपयोग कर सकते हैं +- **बहुभाषी सहायता**: कई भाषा जोड़ों का समर्थन करता है +- **सरल**: विशेष सेटअप की आवश्यकता नहीं + +## hu + +A Google fordítási szolgáltatását használó fordítási modul. + +### Előnyök +- **Teljesen ingyenes**: API kulcs nélkül is használható +- **Többnyelvű támogatás**: Sok nyelvpárt támogat +- **Egyszerű**: Nem igényel speciális beállítást + +## id + +Modul terjemahan menggunakan layanan terjemahan Google. + +### Keuntungan +- **Sepenuhnya Gratis**: Dapat digunakan tanpa kunci API +- **Dukungan Multibahasa**: Mendukung banyak pasangan bahasa +- **Mudah**: Tidak ada konfigurasi khusus yang diperlukan + +## ko + +Google의 번역 서비스를 사용하는 번역 모듈입니다. + +### 장점 +- **완전 무료**: API 키 없이 사용할 수 있습니다 +- **다국어 지원**: 많은 언어 쌍을 지원합니다 +- **간단함**: 특별한 구성이 필요 없습니다 + +## ms + +Modul terjemahan menggunakan perkhidmatan terjemahan Google. + +### Kelebihan +- **Percuma Sepenuhnya**: Boleh digunakan tanpa kunci API +- **Sokongan Berbilang Bahasa**: Menyokong banyak pasangan bahasa +- **Mudah**: Tiada konfigurasi khas diperlukan + +## pl + +Moduł tłumaczeniowy wykorzystujący usługę tłumaczeniową Google. + +### Zalety +- **Całkowicie darmowe**: Można używać bez klucza API +- **Obsługa wielojęzyczna**: Obsługuje wiele par językowych +- **Łatwość**: Nie wymaga specjalnej konfiguracji + +## pt-BR + +Módulo de tradução que utiliza o serviço de tradução do Google. + +### Vantagens +- **Totalmente gratuito**: Pode ser usado sem chave de API +- **Suporte multilíngue**: Suporta muitos pares de idiomas +- **Fácil**: Não requer configuração especial + +## ru + +Модуль перевода, использующий службу перевода Google. + +### Преимущества +- **Полностью бесплатно**: Можно использовать без API-ключа +- **Многоязычная поддержка**: Поддерживает множество языковых пар +- **Легко**: Не требуется специальная настройка + +## th + +โมดูลการแปลที่ใช้บริการแปลของ Google + +### ข้อดี +- **ฟรีทั้งหมด**: สามารถใช้ได้โดยไม่ต้องมี API key +- **รองรับหลายภาษา**: รองรับคู่ภาษามากมาย +- **ง่าย**: ไม่ต้องการการตั้งค่าพิเศษ + +## tr + +Google'ın çeviri hizmetini kullanan bir çeviri modülü. + +### Avantajlar +- **Tamamen Ücretsiz**: API anahtarı olmadan kullanılabilir +- **Çok Dilli Destek**: Birçok dil çiftini destekler +- **Kolay**: Özel yapılandırma gerektirmez + +## vi + +Mô-đun dịch thuật sử dụng dịch vụ dịch của Google. + +### Ưu điểm +- **Hoàn toàn miễn phí**: Có thể sử dụng mà không cần API key +- **Hỗ trợ đa ngôn ngữ**: Hỗ trợ nhiều cặp ngôn ngữ +- **Đơn giản**: Không cần cấu hình đặc biệt + +## zh-CN + +使用 Google 翻译服务的翻译模块。 + +### 优点 +- **完全免费**:无需 API 密钥即可使用 +- **多语言支持**:支持多种语言对 +- **简单**:无需特殊配置 + +## zh-TW + +使用 Google 翻譯服務的翻譯模組。 + +### 優點 +- **完全免費**:無需 API 金鑰即可使用 +- **多語言支援**:支援多種語言對 +- **簡單**:無需特殊設定 diff --git a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj index e09e0417..c317da57 100644 --- a/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.GoogleAppsSctiptPlugin/WindowTranslator.Plugin.GoogleAppsSctiptPlugin.csproj @@ -1,4 +1,10 @@  + + Google Apps Script Translator Plugin + Translation for WindowTranslator through Google Apps Script. + true + false + diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md b/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md new file mode 100644 index 00000000..24a94a0e --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/README.md @@ -0,0 +1,241 @@ +# WindowTranslator LLM Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)でOpenAI互換APIを利用する多機能プラグインです。 + +## 機能 + +- 大規模言語モデルによる文脈を考慮した高品質な翻訳 +- 画像対応モデルを利用したAI OCR +- OCRテキストまたは元画像を使った認識結果の補正 +- OpenAI APIと互換エンドポイントの両方に対応 +- カスタム翻訳コンテキスト、補正サンプル、CSV用語集 + +AI OCRとOCR補正は実験的な機能です。 + +## 必要条件と設定 + +- 利用するモデル名 +- サービスが要求するAPIキー +- OpenAI以外を利用する場合はOpenAI互換APIのエンドポイント +- 用語集を利用する場合は、ヘッダーなしの`原文,訳文`形式のCSVファイル + +画像やテキストは設定したAPIへ送信されます。料金、上限、データの扱いは利用するサービスの契約内容に従います。 + +## en + +A multi-purpose [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin for OpenAI-compatible APIs. + +## Features + +- High-quality, context-aware translation with large language models +- AI OCR using vision-capable models +- Correction of recognition results using OCR text or the original image +- Supports both the OpenAI API and compatible endpoints +- Custom translation context, correction examples, and CSV glossaries + +AI OCR and OCR correction are experimental features. + +## Requirements and configuration + +- The model name to use +- An API key when required by the service +- An OpenAI-compatible endpoint when using another provider +- A headerless CSV glossary in `source,target` format when terminology control is needed + +Images and text are sent to the configured API. Pricing, usage limits, and data handling depend on the selected service. + +## ar + +وحدة ترجمة تستخدم ChatGPT API أو LLM محلي. + +### المزايا +- **أعلى دقة**: ترجمات عالية الجودة بواسطة نماذج اللغة الكبيرة +- **المرونة**: تخصيص المطالبات لتعديل أسلوب الترجمة +- **دعم المسرد**: حافظ على اتساق الترجمة مع المسارد +- **دعم LLM محلي**: إمكانية استخدام خادم LLM الخاص بك + +## cs + +Překladový modul využívající ChatGPT od OpenAI. + +### Výhody +- **Vysoká přesnost překladu**: Vysoká kvalita překladů díky AI +- **Přirozený překlad**: Překlady znějí přirozeně + +## de + +Ein Übersetzungsmodul, das ChatGPT API oder lokales LLM verwendet. + +### Vorteile +- **Höchste Genauigkeit**: Hochwertige Übersetzung durch große Sprachmodelle +- **Flexibilität**: Kann Prompts anpassen, um Übersetzungsstil zu justieren +- **Glossar-Unterstützung**: Kann Übersetzungskonsistenz durch Glossare aufrechterhalten +- **Lokale LLM-Unterstützung**: Kann auch eigenen LLM-Server verwenden + +## es + +Un módulo de traducción que utiliza la API de ChatGPT o un LLM local. + +### Ventajas +- **Mayor precisión**: Traducciones de alta calidad por grandes modelos de lenguaje +- **Flexibilidad**: Personalice prompts para ajustar el estilo de traducción +- **Soporte de glosario**: Mantenga la consistencia de traducción con glosarios +- **Soporte LLM local**: Posibilidad de usar su propio servidor LLM + +## fa + +ماژول ترجمه‌ای که از ChatGPT API یا LLM محلی استفاده می‌کند. + +### مزایا +- **بالاترین دقت**: ترجمه‌های با کیفیت بالا توسط مدل‌های زبانی بزرگ +- **انعطاف‌پذیری**: سفارشی‌سازی دستورالعمل برای تنظیم سبک ترجمه +- **پشتیبانی از واژه‌نامه**: حفظ ثبات ترجمه با واژه‌نامه‌ها +- **پشتیبانی از LLM محلی**: امکان استفاده از سرور LLM خودتان + +## fil + +Isang modyul ng pagsasalin na gumagamit ng ChatGPT API o local LLM. + +### Mga Bentahe +- **Pinakamataas na Katumpakan**: Mataas na kalidad ng pagsasalin ng malalaking modelo ng wika +- **Kakayahang umangkop**: Maaaring i-customize ang mga prompt upang ayusin ang estilo ng pagsasalin +- **Suporta sa Glossary**: Maaaring mapanatili ang consistency ng pagsasalin gamit ang mga glossary +- **Suporta sa Local LLM**: Maaari ring gamitin ang sarili mong LLM server + +## fr + +Un module de traduction utilisant l'API ChatGPT ou un LLM local. + +### Avantages +- **Plus haute précision**: Traductions de haute qualité par grands modèles de langage +- **Flexibilité**: Personnalisez les prompts pour ajuster le style de traduction +- **Support de glossaire**: Maintenez la cohérence de traduction avec des glossaires +- **Support LLM local**: Possibilité d'utiliser votre propre serveur LLM + +## hi + +ChatGPT API या स्थानीय LLM का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **सर्वोच्च सटीकता**: बड़े भाषा मॉडल द्वारा उच्च गुणवत्ता अनुवाद +- **लचीलापन**: अनुवाद शैली को समायोजित करने के लिए प्रॉम्प्ट को कस्टमाइज़ कर सकते हैं +- **शब्दावली समर्थन**: शब्दावली का उपयोग करके अनुवाद की स्थिरता बनाए रख सकते हैं +- **स्थानीय LLM समर्थन**: अपना LLM सर्वर भी उपयोग कर सकते हैं + +## hu + +Az OpenAI ChatGPT-jét használó fordítási modul. + +### Előnyök +- **Magas fordítási pontosság**: Magas minőségű fordítások AI segítségével +- **Természetes fordítás**: A fordítások természetesen hangzanak + +## id + +Modul terjemahan menggunakan API ChatGPT atau LLM lokal. + +### Keuntungan +- **Akurasi Tertinggi**: Terjemahan berkualitas tinggi oleh model bahasa besar +- **Fleksibilitas**: Dapat menyesuaikan prompt untuk menyesuaikan gaya terjemahan +- **Dukungan Glosarium**: Dapat mempertahankan konsistensi terjemahan menggunakan glosarium +- **Dukungan LLM Lokal**: Juga dapat menggunakan server LLM Anda sendiri + +## ko + +ChatGPT API 또는 로컬 LLM을 사용하는 번역 모듈입니다. + +### 장점 +- **최고 정확도**: 대규모 언어 모델에 의한 고품질 번역 +- **유연성**: 프롬프트를 커스터마이즈하여 번역 스타일을 조정할 수 있습니다 +- **용어집 지원**: 용어집을 이용하여 번역의 일관성을 유지할 수 있습니다 +- **로컬 LLM 지원**: 자체 LLM 서버도 사용 가능 + +## ms + +Modul terjemahan menggunakan API ChatGPT atau LLM tempatan. + +### Kelebihan +- **Ketepatan Tertinggi**: Terjemahan berkualiti tinggi oleh model bahasa besar +- **Fleksibiliti**: Boleh menyesuaikan prompt untuk melaraskan gaya terjemahan +- **Sokongan Glosari**: Boleh mengekalkan konsistensi terjemahan menggunakan glosari +- **Sokongan LLM Tempatan**: Juga boleh menggunakan pelayan LLM anda sendiri + +## pl + +Moduł tłumaczeniowy wykorzystujący ChatGPT od OpenAI. + +### Zalety +- **Wysoka dokładność tłumaczenia**: Wysoka jakość tłumaczeń dzięki AI +- **Naturalne tłumaczenie**: Tłumaczenia brzmią naturalnie + +## pt-BR + +Módulo de tradução que utiliza ChatGPT API ou LLM local. + +### Vantagens +- **Máxima precisão**: Traduções de alta qualidade por modelos de linguagem em grande escala +- **Flexibilidade**: Customize prompts para ajustar o estilo de tradução +- **Suporte a glossário**: Use glossários para manter consistência na tradução +- **Suporte a LLM local**: Também pode usar seu próprio servidor LLM + +## ru + +Модуль перевода, использующий ChatGPT API или локальный LLM. + +### Преимущества +- **Наивысшая точность**: Высококачественный перевод большими языковыми моделями +- **Гибкость**: Можно настраивать подсказки для настройки стиля перевода +- **Поддержка глоссария**: Может поддерживать согласованность перевода с использованием глоссариев +- **Поддержка локального LLM**: Также можно использовать собственный сервер LLM + +## th + +โมดูลการแปลที่ใช้ ChatGPT API หรือ LLM ในเครื่อง + +### ข้อดี +- **ความแม่นยำสูงสุด**: การแปลคุณภาพสูงโดยโมเดลภาษาขนาดใหญ่ +- **ความยืดหยุ่น**: สามารถปรับแต่ง prompt เพื่อปรับสไตล์การแปล +- **รองรับอภิธานศัพท์**: สามารถรักษาความสอดคล้องของการแปลโดยใช้อภิธานศัพท์ +- **รองรับ LLM ในเครื่อง**: สามารถใช้เซิร์ฟเวอร์ LLM ของคุณเองได้ + +## tr + +ChatGPT API veya yerel LLM kullanan bir çeviri modülü. + +### Avantajlar +- **En Yüksek Doğruluk**: Büyük dil modelleri tarafından yüksek kaliteli çeviri +- **Esneklik**: Çeviri stilini ayarlamak için istemler özelleştirebilir +- **Sözlük Desteği**: Sözlükler kullanarak çeviri tutarlılığını koruyabilir +- **Yerel LLM Desteği**: Kendi LLM sunucunuzu da kullanabilirsiniz + +## vi + +Mô-đun dịch thuật sử dụng ChatGPT API hoặc LLM cục bộ. + +### Ưu điểm +- **Độ chính xác cao nhất**: Bản dịch chất lượng cao bằng các mô hình ngôn ngữ lớn +- **Linh hoạt**: Có thể tùy chỉnh prompt để điều chỉnh phong cách dịch +- **Hỗ trợ thuật ngữ**: Có thể duy trì tính nhất quán trong dịch thuật bằng cách sử dụng thuật ngữ +- **Hỗ trợ LLM cục bộ**: Cũng có thể sử dụng máy chủ LLM của riêng bạn + +## zh-CN + +使用 ChatGPT API 或本地 LLM 的翻译模块。 + +### 优点 +- **最高准确度**:通过大型语言模型实现高质量翻译 +- **灵活性**:可以自定义提示来调整翻译风格 +- **术语表支持**:可以使用术语表保持翻译一致性 +- **本地 LLM 支持**:也可以使用自己的 LLM 服务器 + +## zh-TW + +使用 ChatGPT API 或本地 LLM 的翻譯模組。 + +### 優點 +- **最高準確度**:透過大型語言模型實現高品質翻譯 +- **靈活性**:可以自訂提示來調整翻譯風格 +- **術語表支援**:可以使用術語表保持翻譯一致性 +- **本地 LLM 支援**:也可以使用自己的 LLM 伺服器 diff --git a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj index 33feedb9..a426d83c 100644 --- a/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.LLMPlugin/WindowTranslator.Plugin.LLMPlugin.csproj @@ -1,6 +1,10 @@  net10.0-windows10.0.20348.0 + LLM Plugin + Translation, OCR, and text correction through OpenAI-compatible language models. + true + false diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md index f7676e21..23af9fc9 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/README.md @@ -1,5 +1,203 @@ -# OneOcr +# WindowTranslator OneOCR Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、WindowsのSnipping Toolに含まれるOneOCRエンジンを利用するOCRプラグインです。 + +## 機能 + +- WindowsのローカルOCRエンジンによる高速で高精度な文字認識 +- OCR領域の結合、傾き、拡大率、明るさ、コントラストを考慮した後処理 +- 認識結果から翻訳先言語のテキストを除外 + +## 必要条件 + +- OneOCRを含む対応バージョンのWindows Snipping Tool +- 初回設定時に、Snipping Toolから必要なOneOCRコンポーネントをWindowTranslatorの共有データ領域へコピーできること + +対応するSnipping Toolが見つからない場合は、WindowTranslatorからMicrosoft Storeを開いて更新できます。OneOCR本体とモデルは、このNuGetパッケージには含まれません。 ## 参考 -https://github.com/ksasao/SnippingToolOcrSharp \ No newline at end of file +- [SnippingToolOcrSharp](https://github.com/ksasao/SnippingToolOcrSharp) + +## en + +An OCR plugin for [WindowTranslator](https://github.com/Freeesia/WindowTranslator) that uses the OneOCR engine included with Windows Snipping Tool. + +## Features + +- Fast, high-accuracy text recognition with a local Windows OCR engine +- Post-processing that accounts for merged OCR regions, rotation, scale, brightness, and contrast +- Excludes text written in the translation target language from recognition results + +## Requirements + +- A supported version of Windows Snipping Tool that includes OneOCR +- Permission during initial setup to copy the required OneOCR components from Snipping Tool to WindowTranslator's shared data directory + +If a supported Snipping Tool cannot be found, WindowTranslator can open Microsoft Store so it can be updated. The OneOCR engine and models are not included in this NuGet package. + +## Reference + +- [SnippingToolOcrSharp](https://github.com/ksasao/SnippingToolOcrSharp) + +## ar + +وحدة OCR محلية مقدمة من Microsoft. + +### المزايا +- **دقة التعرف**: أعلى دقة في التعرف +- **سريع**: سرعة معالجة عالية جداً + +## cs + +Místní modul OCR dodaný společností Microsoft. + +### Výhody +- **Přesnost rozpoznávání**: Může se pochlubit nejvyšší přesností rozpoznávání +- **Rychlost**: Velmi vysoká rychlost zpracování + +## de + +Ein lokales OCR-Modul von Microsoft. + +### Vorteile +- **Erkennungsgenauigkeit**: Verfügt über die höchste Erkennungsgenauigkeit +- **Schnell**: Sehr hohe Verarbeitungsgeschwindigkeit + +## es + +Un módulo OCR local proporcionado por Microsoft. + +### Ventajas +- **Precisión de reconocimiento**: La más alta precisión de reconocimiento +- **Rápido**: Velocidad de procesamiento muy rápida + +## fa + +ماژول OCR محلی ارائه‌شده توسط Microsoft. + +### مزایا +- **دقت تشخیص**: بالاترین دقت تشخیص +- **سریع**: سرعت پردازش بسیار بالا + +## fil + +Isang lokal na modyul ng OCR na ibinigay ng Microsoft. + +### Mga Bentahe +- **Katumpakan ng Pagkilala**: Nag-aanyaya ng pinakamataas na katumpakan ng pagkilala +- **Mabilis**: Napakabilis ng bilis ng pagproseso + +## fr + +Un module OCR local fourni par Microsoft. + +### Avantages +- **Précision de reconnaissance**: La plus haute précision de reconnaissance +- **Rapide**: Vitesse de traitement très rapide + +## hi + +Microsoft द्वारा प्रदान किया गया एक स्थानीय OCR मॉड्यूल। + +### लाभ +- **पहचान सटीकता**: उच्चतम पहचान सटीकता का दावा करता है +- **तेज़**: बहुत तेज़ प्रोसेसिंग गति + +## hu + +A Microsoft által biztosított helyi OCR modul. + +### Előnyök +- **Felismerési pontosság**: A legjobb felismerési pontossággal rendelkezik +- **Sebesség**: Nagyon gyors feldolgozási sebesség + +## id + +Modul OCR lokal yang disediakan oleh Microsoft. + +### Keuntungan +- **Akurasi Pengenalan**: Memiliki akurasi pengenalan tertinggi +- **Cepat**: Kecepatan pemrosesan sangat cepat + +## ko + +Microsoft가 제공하는 로컬 OCR 모듈입니다. + +### 장점 +- **인식 정확도**: 가장 높은 인식 정확도를 자랑합니다 +- **빠름**: 처리 속도가 매우 빠릅니다 + +## ms + +Modul OCR tempatan yang disediakan oleh Microsoft. + +### Kelebihan +- **Ketepatan Pengecaman**: Mempunyai ketepatan pengecaman tertinggi +- **Pantas**: Kelajuan pemprosesan sangat pantas + +## pl + +Lokalny moduł OCR dostarczony przez Microsoft. + +### Zalety +- **Dokładność rozpoznawania**: Może pochwalić się najwyższą dokładnością rozpoznawania +- **Szybkość**: Bardzo szybka prędkość przetwarzania + +## pt-BR + +Módulo OCR local fornecido pela Microsoft. + +### Vantagens +- **Precisão de reconhecimento**: Possui a maior precisão de reconhecimento +- **Rápido**: Velocidade de processamento muito rápida + +## ru + +Локальный OCR-модуль, предоставляемый Microsoft. + +### Преимущества +- **Точность распознавания**: Обладает наивысшей точностью распознавания +- **Скорость**: Очень высокая скорость обработки + +## th + +โมดูล OCR ในเครื่องที่จัดทำโดย Microsoft + +### ข้อดี +- **ความแม่นยำในการรู้จำ**: มีความแม่นยำในการรู้จำสูงสุด +- **ความเร็ว**: ความเร็วในการประมวลผลสูงมาก + +## tr + +Microsoft tarafından sağlanan yerel bir OCR modülü. + +### Avantajlar +- **Tanıma Doğruluğu**: En yüksek tanıma doğruluğuna sahiptir +- **Hızlı**: Çok hızlı işleme hızı + +## vi + +Mô-đun OCR cục bộ do Microsoft cung cấp. + +### Ưu điểm +- **Độ chính xác nhận dạng**: Có độ chính xác nhận dạng cao nhất +- **Nhanh**: Tốc độ xử lý rất nhanh + +## zh-CN + +Microsoft 提供的本地 OCR 模块。 + +### 优点 +- **识别准确度**:拥有最高的识别准确度 +- **快速**:处理速度非常快 + +## zh-TW + +Microsoft 提供的本地 OCR 模組。 + +### 優點 +- **識別準確度**:擁有最高的識別準確度 +- **快速**:處理速度非常快 diff --git a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj index e6bb20f4..010991cf 100644 --- a/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.OneOcrPlugin/WindowTranslator.Plugin.OneOcrPlugin.csproj @@ -1,7 +1,11 @@  net10.0-windows10.0.20348.0 + OneOCR Plugin + OCR for WindowTranslator using the Windows OneOCR engine. true + true + true diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md new file mode 100644 index 00000000..f1dd595c --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/README.md @@ -0,0 +1,232 @@ +# WindowTranslator PLaMo Translator Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、日本語に強いPLaMo 2 Translateモデルをローカル実行する翻訳プラグインです。 + +## 機能 + +- LLamaSharpを使用した日本語に強いローカル翻訳 +- 翻訳テキストを外部サービスへ送信せずに処理 +- 初回利用時に量子化済みPLaMo翻訳モデルを自動取得 +- コンテキスト長と使用するVRAM量を設定可能 + +## 必要条件 + +- 64ビット版Windows +- 十分な空きストレージとメモリ +- CUDAに対応するNVIDIA GPUとドライバーを推奨 +- モデルを初めて取得するときのインターネット接続 + +モデル取得後の翻訳はローカルで実行されます。用語集と追加コンテキストには対応していません。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) translation plugin that runs the Japanese-focused PLaMo 2 Translate model locally. + +## Features + +- Japanese-focused local translation through LLamaSharp +- Processes translated text without sending it to an external service +- Downloads a quantized PLaMo translation model when it is first used +- Configurable context length and VRAM usage + +## Requirements + +- 64-bit Windows +- Sufficient free storage and memory +- An NVIDIA GPU and driver with CUDA support are recommended +- An internet connection when downloading the model for the first time + +After the model has been downloaded, translation runs locally. Glossaries and additional translation context are not supported. + +## ar + +وحدة ترجمة تستخدم LLM محلي متخصص في اليابانية. + +### المزايا +- **متخصص في اليابانية**: محسن للترجمات اليابانية +- **مجاني تماماً**: نموذج مفتوح المصدر بدون رسوم +- **الخصوصية**: يعمل محلياً، البيانات لا تُرسل للخارج +- **غير متصل**: لا حاجة لاتصال بالإنترنت + +## cs + +Překlad PLaMo + +- Délka kontextu: Větší hodnoty umožňují překládat delší texty. +- Využití VRAM: Množství paměti GPU k použití. -1: pouze GPU. 0: pouze CPU. (Jednotka: GB) + +## de + +Ein Übersetzungsmodul, das lokales LLM spezialisiert für Japanisch verwendet. + +### Vorteile +- **Japanisch-spezialisiert**: Optimiert für japanische Übersetzung +- **Völlig kostenlos**: Open-Source-Modell ohne Gebühren +- **Datenschutz**: Läuft lokal, Daten werden nicht extern gesendet +- **Offline**: Keine Internetverbindung erforderlich + +## es + +Un módulo de traducción que utiliza un LLM local especializado para japonés. + +### Ventajas +- **Especializado en japonés**: Optimizado para traducciones al japonés +- **Completamente gratis**: Modelo de código abierto sin cargos +- **Privacidad**: Funciona localmente, los datos no se envían al exterior +- **Sin conexión**: No se necesita conexión a Internet + +## fa + +ماژول ترجمه‌ای که از LLM محلی تخصصی در زبان ژاپنی استفاده می‌کند. + +### مزایا +- **تخصصی در ژاپنی**: بهینه‌شده برای ترجمه‌های ژاپنی +- **کاملاً رایگان**: مدل متن‌باز بدون هزینه +- **حریم خصوصی**: به صورت محلی اجرا می‌شود، داده‌ها به خارج ارسال نمی‌شوند +- **آفلاین**: نیاز به اتصال به اینترنت ندارد + +## fil + +Isang modyul ng pagsasalin na gumagamit ng local LLM na dalubhasa para sa wikang Hapon. + +### Mga Bentahe +- **Dalubhasa sa Hapon**: Naka-optimize para sa pagsasalin ng Hapon +- **Lubos na Libre**: Open source model na walang bayad +- **Privacy**: Tumatakbo nang lokal, ang data ay hindi ipinapadala sa labas +- **Offline**: Walang kailangang koneksyon sa internet + +## fr + +Un module de traduction utilisant un LLM local spécialisé pour le japonais. + +### Avantages +- **Spécialisé japonais**: Optimisé pour les traductions japonaises +- **Complètement gratuit**: Modèle open source sans frais +- **Confidentialité**: Fonctionne localement, les données ne sont pas envoyées à l'extérieur +- **Hors ligne**: Pas de connexion Internet nécessaire + +## hi + +जापानी भाषा के लिए विशेष स्थानीय LLM का उपयोग करने वाला अनुवाद मॉड्यूल। + +### फायदे +- **जापानी विशेषज्ञता**: जापानी अनुवाद के लिए अनुकूलित +- **पूर्ण रूप से निःशुल्क**: ओपन सोर्स मॉडल के साथ कोई शुल्क नहीं +- **गोपनीयता**: स्थानीय रूप से चलता है, डेटा बाहर नहीं भेजा जाता +- **ऑफ़लाइन**: इंटरनेट कनेक्शन की आवश्यकता नहीं + +## hu + +PLaMo fordítás + +- Kontextus hossza: A nagyobb értékek lehetővé teszik hosszabb szövegek fordítását. +- VRAM használat: A használandó GPU memória mennyisége. -1: Csak GPU. 0: Csak CPU. (Egység: GB) + +## id + +Modul terjemahan menggunakan LLM lokal khusus untuk Bahasa Jepang. + +### Keuntungan +- **Khusus Jepang**: Dioptimalkan untuk terjemahan Jepang +- **Sepenuhnya Gratis**: Model sumber terbuka tanpa biaya +- **Privasi**: Berjalan secara lokal, data tidak dikirim ke luar +- **Offline**: Tidak ada koneksi internet yang diperlukan + +## ko + +일본어에 특화된 로컬 LLM을 사용하는 번역 모듈입니다. + +### 장점 +- **일본어 특화**: 일본어 번역에 최적화되어 있습니다 +- **완전 무료**: 오픈 소스 모델로 비용이 발생하지 않습니다 +- **프라이버시**: 로컬에서 작동하므로 데이터가 외부로 전송되지 않습니다 +- **오프라인**: 인터넷 연결이 필요 없습니다 + +## ms + +Modul terjemahan menggunakan LLM tempatan khusus untuk Bahasa Jepun. + +### Kelebihan +- **Khusus Jepun**: Dioptimumkan untuk terjemahan Jepun +- **Percuma Sepenuhnya**: Model sumber terbuka tanpa caj +- **Privasi**: Berjalan secara tempatan, data tidak dihantar ke luar +- **Luar Talian**: Tiada sambungan internet diperlukan + +## pl + +Tłumaczenie PLaMo + +- Długość kontekstu: Większe wartości umożliwiają tłumaczenie dłuższych tekstów. +- Użycie VRAM: Ilość pamięci GPU do użycia. -1: tylko GPU. 0: tylko CPU. (Jednostka: GB) + +## pt-BR + +Módulo de tradução que utiliza LLM local especializado em japonês. + +### Vantagens +- **Especializado em japonês**: Otimizado para tradução de japonês +- **Totalmente gratuito**: Modelo de código aberto sem custos +- **Privacidade**: Como opera localmente, dados não são enviados externamente +- **Offline**: Não requer conexão à internet + +## ru + +Модуль перевода, использующий локальный LLM, специализированный для японского языка. + +### Преимущества +- **Специализация на японском**: Оптимизирован для перевода на японский язык +- **Полностью бесплатно**: Модель с открытым исходным кодом без платежей +- **Конфиденциальность**: Работает локально, данные не передаются наружу +- **Автономный**: Не требуется подключение к интернету + +## th + +โมดูลการแปลที่ใช้ LLM ในเครื่องที่เชี่ยวชาญสำหรับภาษาญี่ปุ่น + +### ข้อดี +- **เชี่ยวชาญภาษาญี่ปุ่น**: ปรับให้เหมาะสมสำหรับการแปลภาษาญี่ปุ่น +- **ฟรีทั้งหมด**: โมเดลโอเพนซอร์สไม่มีค่าใช้จ่าย +- **ความเป็นส่วนตัว**: ทำงานในเครื่อง ข้อมูลไม่ถูกส่งออกภายนอก +- **ออฟไลน์**: ไม่ต้องการการเชื่อมต่ออินเทอร์เน็ต + +## tr + +Japonca için özelleştirilmiş yerel LLM kullanan bir çeviri modülü. + +### Avantajlar +- **Japonca Uzmanlaşması**: Japonca çeviri için optimize edilmiş +- **Tamamen Ücretsiz**: Açık kaynak modeli, ücret yok +- **Gizlilik**: Yerel olarak çalışır, veriler dışarıya gönderilmez +- **Çevrimdışı**: İnternet bağlantısı gerekmez + +## vi + +Mô-đun dịch thuật sử dụng LLM cục bộ chuyên về tiếng Nhật. + +### Ưu điểm +- **Chuyên về tiếng Nhật**: Được tối ưu hóa cho dịch tiếng Nhật +- **Hoàn toàn miễn phí**: Mô hình nguồn mở không tốn phí +- **Quyền riêng tư**: Chạy cục bộ, dữ liệu không được gửi ra bên ngoài +- **Ngoại tuyến**: Không cần kết nối internet + +## zh-CN + +使用专门针对日语的本地 LLM 的翻译模块。 + +### 优点 +- **日语专用**:针对日语翻译进行了优化 +- **完全免费**:开源模型不产生费用 +- **隐私保护**:在本地运行,数据不会发送到外部 +- **离线**:无需互联网连接 + +## zh-TW + +使用專門針對日語的本地 LLM 的翻譯模組。 + +### 優點 +- **日語專用**:針對日語翻譯進行了最佳化 +- **完全免費**:開源模型不產生費用 +- **隱私保護**:在本地執行,資料不會傳送到外部 +- **離線**:無需網際網路連線 diff --git a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj index 9076b1c1..e95baa06 100644 --- a/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.PLaMoPlugin/WindowTranslator.Plugin.PLaMoPlugin.csproj @@ -2,11 +2,16 @@ net10.0 + PLaMo Translator Plugin + Local PLaMo translation for WindowTranslator using LLamaSharp and CUDA. + true + false + true x64 - + diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md new file mode 100644 index 00000000..0aa2e338 --- /dev/null +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/README.md @@ -0,0 +1,195 @@ +# WindowTranslator Tesseract OCR Plugin + +## ja + +[WindowTranslator](https://github.com/Freeesia/WindowTranslator)で、オープンソースのTesseract OCRエンジンを利用するプラグインです。 + +## 機能 + +- Tesseractによる100以上の言語に対応したOCR +- 翻訳元言語に対応する`traineddata`を初回利用時に自動取得 +- OCR領域の結合、拡大率、明るさ、コントラストを考慮した後処理 + +## 必要条件 + +- Microsoft Visual C++ 2015以降のx64ランタイム +- 言語データを初めて取得するときのインターネット接続 + +必要なVisual C++ランタイムがない場合は、WindowTranslatorから取得できます。言語データは`tesseract-ocr/tessdata_best`から取得されます。 + +## en + +A [WindowTranslator](https://github.com/Freeesia/WindowTranslator) plugin that uses the open-source Tesseract OCR engine. + +## Features + +- Tesseract OCR with support for more than 100 languages +- Downloads the `traineddata` for the translation source language when it is first used +- Post-processing that accounts for merged OCR regions, scale, brightness, and contrast + +## Requirements + +- The x64 Microsoft Visual C++ 2015 or later runtime +- An internet connection when downloading language data for the first time + +WindowTranslator can obtain the required Visual C++ runtime if it is missing. Language data is downloaded from `tesseract-ocr/tessdata_best`. + +## ar + +محرك OCR مفتوح المصدر. + +### المزايا +- **دعم متعدد اللغات**: يدعم أكثر من 100 لغة +- **الاستقرار**: محرك موثوق به مع تاريخ طويل + +## cs + +OCR engine s otevřeným zdrojovým kódem. + +### Výhody +- **Vícejazyčná podpora**: Podporuje více než 100 jazyků +- **Stabilita**: Spolehlivý engine s dlouhou historií + +## de + +Eine Open-Source-OCR-Engine. + +### Vorteile +- **Mehrsprachige Unterstützung**: Unterstützt über 100 Sprachen +- **Stabilität**: Zuverlässige Engine mit langer Geschichte + +## es + +Un motor OCR de código abierto. + +### Ventajas +- **Soporte multilingüe**: Admite más de 100 idiomas +- **Estabilidad**: Un motor confiable con una larga historia + +## fa + +موتور OCR متن‌باز. + +### مزایا +- **پشتیبانی از چند زبان**: از بیش از 100 زبان پشتیبانی می‌کند +- **پایداری**: موتور قابل اعتماد با سابقه طولانی + +## fil + +Isang open-source na OCR engine. + +### Mga Bentahe +- **Suporta sa Maraming Wika**: Sumusuporta sa higit sa 100 wika +- **Katatagan**: Maaasahang engine na may mahabang kasaysayan + +## fr + +Un moteur OCR open source. + +### Avantages +- **Support multilingue**: Prend en charge plus de 100 langues +- **Stabilité**: Un moteur fiable avec une longue histoire + +## hi + +एक ओपन-सोर्स OCR इंजन। + +### लाभ +- **बहुभाषी समर्थन**: 100 से अधिक भाषाओं का समर्थन करता है +- **स्थिरता**: लंबे इतिहास वाला विश्वसनीय इंजन + +## hu + +Nyílt forráskódú OCR motor. + +### Előnyök +- **Többnyelvű támogatás**: Több mint 100 nyelvet támogat +- **Stabilitás**: Hosszú múltra visszatekintő megbízható motor + +## id + +Mesin OCR sumber terbuka. + +### Keuntungan +- **Dukungan Multibahasa**: Mendukung lebih dari 100 bahasa +- **Stabilitas**: Mesin yang dapat diandalkan dengan sejarah panjang + +## ko + +오픈 소스 OCR 엔진입니다. + +### 장점 +- **다국어 지원**: 100개 이상의 언어를 지원합니다 +- **안정성**: 오랜 역사를 가진 신뢰할 수 있는 엔진 + +## ms + +Enjin OCR sumber terbuka. + +### Kelebihan +- **Sokongan Berbilang Bahasa**: Menyokong lebih 100 bahasa +- **Kestabilan**: Enjin yang boleh dipercayai dengan sejarah panjang + +## pl + +Silnik OCR o otwartym kodzie źródłowym. + +### Zalety +- **Wsparcie wielojęzyczne**: Obsługuje ponad 100 języków +- **Stabilność**: Niezawodny silnik z długą historią + +## pt-BR + +Motor OCR de código aberto. + +### Vantagens +- **Suporte multilíngue**: Suporta mais de 100 idiomas +- **Estabilidade**: Motor confiável com longa história + +## ru + +OCR-движок с открытым исходным кодом. + +### Преимущества +- **Многоязычная поддержка**: Поддерживает более 100 языков +- **Стабильность**: Надежный движок с долгой историей + +## th + +เครื่องมือ OCR แบบโอเพนซอร์ส + +### ข้อดี +- **รองรับหลายภาษา**: รองรับมากกว่า 100 ภาษา +- **ความเสถียร**: เครื่องมือที่เชื่อถือได้พร้อมประวัติที่ยาวนาน + +## tr + +Açık kaynaklı bir OCR motoru. + +### Avantajlar +- **Çok Dilli Destek**: 100'den fazla dili destekler +- **Kararlılık**: Uzun geçmişe sahip güvenilir motor + +## vi + +Công cụ OCR nguồn mở. + +### Ưu điểm +- **Hỗ trợ đa ngôn ngữ**: Hỗ trợ hơn 100 ngôn ngữ +- **Ổn định**: Công cụ đáng tin cậy với lịch sử lâu dài + +## zh-CN + +开源 OCR 引擎。 + +### 优点 +- **多语言支持**:支持100多种语言 +- **稳定性**:历史悠久的可靠引擎 + +## zh-TW + +開源 OCR 引擎。 + +### 優點 +- **多語言支援**:支援100多種語言 +- **穩定性**:歷史悠久的可靠引擎 diff --git a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj index bcae45af..89d62cfd 100644 --- a/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj +++ b/Plugins/WindowTranslator.Plugin.TesseractOCRPlugin/WindowTranslator.Plugin.TesseractOCRPlugin.csproj @@ -2,6 +2,10 @@ net10.0-windows10.0.20348.0 + Tesseract OCR Plugin + OCR for WindowTranslator using the Tesseract engine. + true + false diff --git a/Plugins/WindowTranslator.Plugins.proj b/Plugins/WindowTranslator.Plugins.proj new file mode 100644 index 00000000..62e6d85a --- /dev/null +++ b/Plugins/WindowTranslator.Plugins.proj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Plugins/plugin-icon.png b/Plugins/plugin-icon.png new file mode 100644 index 00000000..27a93452 Binary files /dev/null and b/Plugins/plugin-icon.png differ diff --git a/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj b/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj index db4086ad..d168a920 100644 --- a/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj +++ b/WindowTranslator.Abstractions/WindowTranslator.Abstractions.csproj @@ -4,6 +4,7 @@ net10.0;net10.0-windows10.0.20348.0 WindowTranslator true + false wt.png README.md true diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/ColorThiefModuleTests.cs diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj similarity index 65% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj index f4f0a345..33cb0650 100644 --- a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj +++ b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/WindowTranslator.Plugin.ColorThiefPlugin.Tests.csproj @@ -1,6 +1,9 @@  net10.0-windows10.0.20348.0 + true + false + false @@ -15,8 +18,8 @@ - - + + diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_000.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_001.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_002.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_003.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_004.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_005.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_006.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_007.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_008.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_009.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_010.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_011.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_012.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_013.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_014.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_015.jpg diff --git a/Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg b/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg similarity index 100% rename from Plugins/WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg rename to WindowTranslator.Plugin.ColorThiefPlugin.Tests/images/text_016.jpg diff --git a/WindowTranslator.Tests/CatalogProbeResources.ar.resx b/WindowTranslator.Tests/CatalogProbeResources.ar.resx new file mode 100644 index 00000000..cec4452a --- /dev/null +++ b/WindowTranslator.Tests/CatalogProbeResources.ar.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + العربية + + diff --git a/WindowTranslator.Tests/CatalogProbeResources.fr.resx b/WindowTranslator.Tests/CatalogProbeResources.fr.resx new file mode 100644 index 00000000..d96e511b --- /dev/null +++ b/WindowTranslator.Tests/CatalogProbeResources.fr.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + français + + diff --git a/WindowTranslator.Tests/CatalogProbeResources.resx b/WindowTranslator.Tests/CatalogProbeResources.resx new file mode 100644 index 00000000..6bf0819a --- /dev/null +++ b/WindowTranslator.Tests/CatalogProbeResources.resx @@ -0,0 +1,18 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 日本語 + + diff --git a/WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs b/WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs new file mode 100644 index 00000000..7cf8c5a4 --- /dev/null +++ b/WindowTranslator.Tests/LocalizedReadmeSelectorTests.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using WindowTranslator.Modules.PluginStore; + +namespace WindowTranslator.Tests; + +public class LocalizedReadmeSelectorTests +{ + [Theory] + [InlineData("pt-BR", "Português do Brasil")] + [InlineData("fr-CA", "Français")] + [InlineData("it-IT", "English")] + public void SelectUsesExactParentAndEnglishFallback( + string cultureName, + string expected) + { + const string markdown = """ + ## pt-BR + + Português do Brasil + + ## fr + + Français + + ## en + + English + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo(cultureName)); + + Assert.Equal(expected, result); + } + + [Fact] + public void SelectKeepsTheSharedPreambleAndOrdinaryHeadings() + { + const string markdown = """ + # Shared title + + Shared introduction. + + ## ja + + 日本語 + + ## 機能 + + - 機能A + + ## en + + English + + ## Features + + - Feature A + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("ja-JP")); + + Assert.Contains("# Shared title", result); + Assert.Contains("## 機能", result); + Assert.Contains("- 機能A", result); + Assert.DoesNotContain("## ja", result); + Assert.DoesNotContain("English", result); + } + + [Fact] + public void SelectFallsBackToTheFirstNonEmptyLanguageSection() + { + const string markdown = """ + ## ja + + 日本語 + + ## de + + Deutsch + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("fr-FR")); + + Assert.Equal("日本語", result); + } + + [Fact] + public void SelectReturnsTheOriginalMarkdownWithoutLanguageSections() + { + const string markdown = """ + # README + + ## Features + + - Feature A + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("en-US")); + + Assert.Same(markdown, result); + } + + [Fact] + public void SelectKeepsAnUppercaseAcronymHeadingInsideTheLanguageSection() + { + const string markdown = """ + ## ja + + 日本語 + + ## API + + APIの説明 + + ## en + + English + """; + + var result = LocalizedReadmeSelector.Select( + markdown, + CultureInfo.GetCultureInfo("ja-JP")); + + Assert.Contains("## API", result); + Assert.Contains("APIの説明", result); + Assert.DoesNotContain("English", result); + } + +} diff --git a/WindowTranslator.Tests/NuGetPluginServiceTests.cs b/WindowTranslator.Tests/NuGetPluginServiceTests.cs new file mode 100644 index 00000000..c6a0374d --- /dev/null +++ b/WindowTranslator.Tests/NuGetPluginServiceTests.cs @@ -0,0 +1,2412 @@ +using System.ComponentModel; +using System.Globalization; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Reflection.Emit; +using System.Resources; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using NuGet.Configuration; +using NuGet.Frameworks; +using NuGet.Packaging; +using NuGet.Packaging.Core; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; +using Weikio.PluginFramework.Abstractions; +using Weikio.PluginFramework.Catalogs; +using Weikio.PluginFramework.Context; +using WindowTranslator.Modules; +using WindowTranslator.Modules.PluginStore; + +namespace WindowTranslator.Tests; + +public sealed class NuGetPluginServiceTests +{ + private static readonly string RuntimeIdentifier = RuntimeInformation.RuntimeIdentifier; + + [Fact] + public async Task InstallResolvesDependenciesUsingNuGetRuntimeAssetLayout() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [ + new("Dependency.Package", "[1.0.0, 2.0.0)"), + new("Host.Provided", "[1.0.0]", Exclude: "Runtime"), + ], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + ["lib/net10.0/fr/Root.Plugin.resources.dll"] = "fr"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/native/subdirectory/root-native.dll"] = "native"u8.ToArray(), + [$"lib/net10.0/runtimes/{RuntimeIdentifier}/native/custom-native.dll"] = "custom"u8.ToArray(), + })); + handler.AddPackage( + "Dependency.Package", + "1.0.0", + CreatePackage( + "Dependency.Package", + "1.0.0", + [new("Transitive.Package", "[2.0.0]")], + new Dictionary + { + ["lib/net8.0/Dependency.Package.dll"] = "fallback"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/lib/net8.0/Dependency.Package.dll"] = + "dependency"u8.ToArray(), + })); + handler.AddPackage( + "Transitive.Package", + "2.0.0", + CreatePackage( + "Transitive.Package", + "2.0.0", + [], + new Dictionary + { + ["lib/netstandard2.0/Transitive.Package.dll"] = "transitive"u8.ToArray(), + })); + + using var service = CreateService(handler, testDirectory, hostMajorVersion: 7); + var progress = new RecordingProgress(); + + await service.InstallPackageAsync("Root.Plugin", "1.0.0", progress); + + var pluginDirectory = Path.Combine(testDirectory, "Root.Plugin"); + Assert.Equal("root", await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Root.Plugin.dll"))); + Assert.Equal( + "fr", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "fr", "Root.Plugin.resources.dll"))); + Assert.Equal( + "dependency", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Dependency.Package.dll"))); + Assert.False(File.Exists(Path.Combine( + pluginDirectory, + "runtimes", + RuntimeIdentifier, + "lib", + "net8.0", + "Dependency.Package.dll"))); + Assert.Equal( + "transitive", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "Transitive.Package.dll"))); + Assert.Equal( + "native", + await File.ReadAllTextAsync(Path.Combine(pluginDirectory, "root-native.dll"))); + Assert.False(File.Exists(Path.Combine( + pluginDirectory, + "runtimes", + RuntimeIdentifier, + "native", + "subdirectory", + "root-native.dll"))); + Assert.Equal( + "custom", + await File.ReadAllTextAsync(Path.Combine( + pluginDirectory, + "runtimes", + RuntimeIdentifier, + "native", + "custom-native.dll"))); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.Contains("host.provided", StringComparison.OrdinalIgnoreCase)); + + var installed = service.PackageSnapshot.InstalledPackages; + var package = Assert.Single(installed); + Assert.Equal("Root.Plugin", package.Id); + Assert.Equal("1.0.0", package.Version); + Assert.Equal(7, package.HostMajorVersion); + Assert.True(package.IsCompatible); + Assert.Equal(0, progress.Values.First()); + Assert.Equal(100, progress.Values.Last()); + Assert.All(progress.Values, value => Assert.InRange(value, 0, 100)); + Assert.True(progress.Values.SequenceEqual(progress.Values.OrderBy(value => value))); + Assert.Contains(progress.Values, value => value is > 0 and < 100); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task ManifestWriteFailureRestoresThePreviousPluginDirectory() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), + })); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-two"u8.ToArray(), + })); + + using var service = CreateService(handler, testDirectory); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + var manifestPath = Path.Combine(testDirectory, "nuget-manifest.json"); + await using (var manifestLock = new FileStream( + manifestPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + var exception = await Record.ExceptionAsync( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + Assert.True( + exception is IOException or UnauthorizedAccessException, + $"Unexpected exception: {exception}"); + } + + Assert.Equal( + "version-one", + await File.ReadAllTextAsync( + Path.Combine(testDirectory, "Root.Plugin", "Root.Plugin.dll"))); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); + Assert.Equal("1.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task UninstallDeletesManagedFilesAtNextStartupAndAllowsManualReinstall() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), + })); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-two"u8.ToArray(), + })); + + using var service = CreateService(handler, testDirectory); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + await service.UninstallPackageAsync("Root.Plugin"); + + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(service.PackageSnapshot.InstalledPackages); + + NuGetPluginCatalog.DeleteUninstalledPackageDirectories(testDirectory); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + + await service.InstallPackageAsync("Root.Plugin", "2.0.0"); + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); + Assert.Equal("2.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task UninstallKeepsManagedFilesWhenManifestUpdateFails() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "version-one"u8.ToArray(), + })); + + using var service = CreateService(handler, testDirectory); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + var manifestPath = Path.Combine(testDirectory, "nuget-manifest.json"); + using (var manifestLock = new FileStream( + manifestPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + var exception = await Record.ExceptionAsync( + () => service.UninstallPackageAsync("Root.Plugin")); + Assert.True( + exception is IOException or UnauthorizedAccessException, + $"Unexpected exception: {exception}"); + } + + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); + Assert.Equal("1.0.0", installed.Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task DependencyWithIncompatibleLibStillInstallsCompatibleNativeAssets() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [new("Native.Dependency", "[1.0.0]")], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + handler.AddPackage( + "Native.Dependency", + "1.0.0", + CreatePackage( + "Native.Dependency", + "1.0.0", + [], + new Dictionary + { + ["lib/net48/LegacyOnly.dll"] = "legacy"u8.ToArray(), + [$"runtimes/{RuntimeIdentifier}/native/compatible.dll"] = "native"u8.ToArray(), + })); + + using var service = CreateService(handler, testDirectory); + + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + Assert.False(File.Exists( + Path.Combine(testDirectory, "Root.Plugin", "LegacyOnly.dll"))); + Assert.Equal( + "native", + await File.ReadAllTextAsync(Path.Combine( + testDirectory, + "Root.Plugin", + "runtimes", + RuntimeIdentifier, + "native", + "compatible.dll"))); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InvalidManifestDoesNotReplaceExistingPluginDirectory() + { + var testDirectory = CreateTestDirectory(); + try + { + var pluginDirectory = Path.Combine(testDirectory, "Root.Plugin"); + Directory.CreateDirectory(pluginDirectory); + await File.WriteAllTextAsync( + Path.Combine(pluginDirectory, "Root.Plugin.dll"), + "existing"); + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + "{ invalid"); + + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "replacement"u8.ToArray(), + })); + + using var service = CreateService(handler, testDirectory); + + await Assert.ThrowsAsync( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + + Assert.Equal( + "existing", + await File.ReadAllTextAsync( + Path.Combine(pluginDirectory, "Root.Plugin.dll"))); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task ManifestLoadsInstalledPackages() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo( + "Root.Plugin", + "1.0.0", + HostMajorVersion: 7, + AbstractionsVersionRange: "[1.0.0, 2.0.0)")]))); + + using var handler = new InMemoryNuGetHandler(); + using var service = CreateService( + handler, + testDirectory, + hostMajorVersion: 7); + + await service.RefreshPackageInformationAsync(); + var installed = Assert.Single(service.PackageSnapshot.InstalledPackages); + + Assert.Equal("Root.Plugin", installed.Id); + Assert.Equal("1.0.0", installed.Version); + Assert.Equal(7, installed.HostMajorVersion); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task ManifestWithoutHostMajorVersionIsRejected() + { + var testDirectory = CreateTestDirectory(); + try + { + Directory.CreateDirectory(Path.Combine(testDirectory, "Legacy.Plugin")); + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new + { + Packages = new[] + { + new + { + Id = "Legacy.Plugin", + Version = "1.0.0", + }, + }, + })); + using var handler = new InMemoryNuGetHandler(); + using var service = CreateService( + handler, + testDirectory, + hostMajorVersion: 7); + + await service.RefreshPackageInformationAsync(); + Assert.IsType(service.PackageSnapshot.Error); + Assert.Empty(service.PackageSnapshot.InstalledPackages); + Assert.Empty(NuGetPluginCatalog.GetLoadablePackageIds( + testDirectory, + hostMajorVersion: 7, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"))); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstalledPackageCompatibilityFollowsValidationSetting() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo( + "Old.Plugin", + "1.0.0", + HostMajorVersion: 6, + AbstractionsVersionRange: "[1.0.0, 2.0.0)")]))); + using var handler = new InMemoryNuGetHandler(); + using var service = CreateService( + handler, + testDirectory, + hostMajorVersion: 7); + + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.InstalledPackages); + + Assert.Equal(PluginCompatibility.ValidationDisabled, package.IsCompatible); + Assert.Equal(6, package.HostMajorVersion); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstalledPackageCompatibilityChecksAbstractionsVersionRange() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [ + new InstalledPackageInfo( + "Range.Plugin", + "1.0.0", + HostMajorVersion: 7, + AbstractionsVersionRange: "[2.0.0, 3.0.0)"), + ]))); + using var handler = new InMemoryNuGetHandler(); + var hostAbstractionsVersion = NuGetVersion.Parse("1.5.0"); + using var service = CreateService( + handler, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [NuGetPluginService.AbstractionsPackageId] = hostAbstractionsVersion, + }, + hostMajorVersion: 7); + + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.InstalledPackages); + var loadablePackages = NuGetPluginCatalog.GetLoadablePackageIds( + testDirectory, + hostMajorVersion: 7, + hostAbstractionsVersion); + + Assert.Equal(PluginCompatibility.ValidationDisabled, package.IsCompatible); + Assert.Equal( + PluginCompatibility.ValidationDisabled, + loadablePackages.Contains("Range.Plugin")); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task BackgroundServiceRefreshesPluginInformationWithoutOpeningSettings() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Background.Plugin", + "Background Plugin", + null, + null, + null, + null), + ]; + handler.AddMetadataVersions( + "Background.Plugin", + CreatePluginVersionMetadata("1.0.0")); + using var service = CreateService( + handler, + testDirectory); + var updated = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + service.PackageInformationUpdated += (_, _) => updated.TrySetResult(); + + Assert.IsAssignableFrom(service); + await service.StartAsync(CancellationToken.None); + try + { + await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + Assert.Null(service.PackageSnapshot.Error); + Assert.Equal( + "Background.Plugin", + Assert.Single(service.PackageSnapshot.Packages).Id); + Assert.Equal(["tags:windowtranslator-plugin"], handler.RequestedSearchTerms); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task PluginStoreKeepsInstalledPackagesVisibleWhenNuGetSearchFails() + { + var testDirectory = CreateTestDirectory(); + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [new InstalledPackageInfo( + "Installed.Plugin", + "1.2.3", + HostMajorVersion: 7, + AbstractionsVersionRange: "(, )")])), + Encoding.UTF8); + using var handler = new InMemoryNuGetHandler(); + handler.SearchException = new HttpRequestException("NuGet search failed."); + using var service = CreateService( + handler, + testDirectory, + hostMajorVersion: 7); + var viewModel = new PluginStoreViewModel( + service, + NullLogger.Instance, + dialogService: null!); + + await service.RefreshPackageInformationAsync(); + + var package = Assert.Single(viewModel.Packages); + Assert.Equal("Installed.Plugin", package.Id); + Assert.Equal("1.2.3", package.InstalledVersion); + Assert.True(package.IsInstalled); + Assert.NotNull(viewModel.ErrorMessage); + Assert.Equal(["tags:windowtranslator-plugin"], handler.RequestedSearchTerms); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task RefreshReportsMetadataFailuresAndKeepsSuccessfulPackages() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Available.Plugin", + null, + null, + null, + null, + null), + CreatePackageSearchMetadata( + "Unavailable.Plugin", + null, + null, + null, + null, + null), + ]; + handler.AddMetadataVersions( + "Available.Plugin", + CreatePluginVersionMetadata("1.0.0")); + handler.AddMetadataException( + "Unavailable.Plugin", + new HttpRequestException("Metadata request failed.")); + using var service = CreateService(handler, testDirectory); + + await service.RefreshPackageInformationAsync(); + + Assert.Equal( + "Available.Plugin", + Assert.Single(service.PackageSnapshot.Packages).Id); + var error = Assert.IsType(service.PackageSnapshot.Error); + Assert.Contains( + error.InnerExceptions, + exception => exception.Message.Contains( + "Unavailable.Plugin", + StringComparison.Ordinal)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task RefreshReportsAnErrorWhenEveryMetadataRequestFails() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Unavailable.Plugin", + null, + null, + null, + null, + null), + ]; + handler.AddMetadataException( + "Unavailable.Plugin", + new HttpRequestException("Metadata request failed.")); + using var service = CreateService(handler, testDirectory); + + await service.RefreshPackageInformationAsync(); + + Assert.Empty(service.PackageSnapshot.Packages); + Assert.NotNull(service.PackageSnapshot.Error); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task SearchReturnsReleaseAndPrereleaseVersions() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Test.Plugin", + "Test Plugin", + "Test description", + "WindowTranslator.Tests", + null, + null, + iconUrl: "https://nuget.test/icons/test-plugin.png"), + ]; + handler.AddMetadataVersions( + "Test.Plugin", + CreatePluginVersionMetadata("1.0.0"), + CreatePluginVersionMetadata("1.1.0-beta.1"), + CreatePluginVersionMetadata("1.1.0-beta.2")); + using var service = CreateService( + handler, + testDirectory); + + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.Packages); + + Assert.Equal("Test.Plugin", package.Id); + Assert.Equal( + ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"], + package.Versions); + Assert.Equal("https://nuget.test/icons/test-plugin.png", package.IconUrl); + Assert.Equal([true], handler.RequestedPrereleaseOptions); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task DisclaimerPreferenceIsStoredInThePluginManifestAndPreservedByPluginOperations() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + + using (var service = CreateService(handler, testDirectory)) + { + await service.SetHideDisclaimerAsync(true); + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + await service.UninstallPackageAsync("Root.Plugin"); + } + + using (var service = CreateService(handler, testDirectory)) + { + Assert.True(service.HideDisclaimer); + await service.SetHideDisclaimerAsync(false); + } + + using var reloadedService = CreateService(handler, testDirectory); + Assert.False(reloadedService.HideDisclaimer); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task SearchUsesNuGetOwnersForOfficialPackageStatus() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Official.Plugin", + title: null, + description: null, + authors: "Other", + projectUrl: null, + licenseUrl: null, + owners: [NuGetPluginService.OfficialPackageOwner]), + CreatePackageSearchMetadata( + "Spoofed.Plugin", + title: null, + description: null, + authors: NuGetPluginService.OfficialPackageOwner, + projectUrl: null, + licenseUrl: null, + owners: ["Other"]), + ]; + handler.AddMetadataVersions( + "Official.Plugin", + CreatePluginVersionMetadata("1.0.0")); + handler.AddMetadataVersions( + "Spoofed.Plugin", + CreatePluginVersionMetadata("1.0.0")); + using var service = CreateService(handler, testDirectory); + + await service.RefreshPackageInformationAsync(); + + Assert.True(service.PackageSnapshot.Packages + .Single(package => package.Id == "Official.Plugin") + .IsOfficial); + Assert.False(service.PackageSnapshot.Packages + .Single(package => package.Id == "Spoofed.Plugin") + .IsOfficial); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task SearchKeepsOnlyVersionsWithCompatibleDirectAbstractionsDependency() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.SearchResults = + [ + CreatePackageSearchMetadata( + "Compatible.Plugin", + null, + null, + null, + null, + null), + CreatePackageSearchMetadata( + "Missing.Dependency.Plugin", + null, + null, + null, + null, + null), + ]; + handler.AddMetadataVersions( + "Compatible.Plugin", + CreatePluginVersionMetadata("1.0.0", "[1.0.0, 2.0.0)"), + CreatePluginVersionMetadata("2.0.0", "[2.0.0, 3.0.0)")); + handler.AddMetadataVersions( + "Missing.Dependency.Plugin", + CreatePluginVersionMetadata( + "1.0.0", + includeAbstractionsDependency: false)); + + using var service = CreateService( + handler, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), + }); + + await service.RefreshPackageInformationAsync(); + var package = Assert.Single(service.PackageSnapshot.Packages); + + Assert.Equal("Compatible.Plugin", package.Id); + Assert.Equal( + PluginCompatibility.ValidationDisabled ? ["1.0.0", "2.0.0"] : ["1.0.0"], + package.Versions); + Assert.Equal(["tags:windowtranslator-plugin"], handler.RequestedSearchTerms); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public void PackageVersionSelectionRequiresOptInForPrerelease() + { + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Test.Plugin", + "Test Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.0.0", "1.1.0-beta.1", "1.1.0-beta.2"]), + isInstalled: true, + installedVersion: "1.0.0"); + + Assert.Equal("1.0.0", package.LatestVersion); + Assert.False(package.UsePrerelease); + Assert.False(package.IsUpdateAvailable); + + package.UsePrerelease = true; + + Assert.Equal("1.1.0-beta.2", package.LatestVersion); + Assert.True(package.IsUpdateAvailable); + Assert.True(package.CanInstall); + + var prereleaseOnlyPackage = new PluginPackageViewModel( + new NuGetPackageInfo( + "Preview.Plugin", + "Preview Plugin", + string.Empty, + string.Empty, + null, + null, + ["2.0.0-preview.1"]), + isInstalled: false, + installedVersion: null); + + Assert.Null(prereleaseOnlyPackage.LatestVersion); + Assert.False(prereleaseOnlyPackage.CanInstall); + + prereleaseOnlyPackage.UsePrerelease = true; + + Assert.Equal("2.0.0-preview.1", prereleaseOnlyPackage.LatestVersion); + Assert.True(prereleaseOnlyPackage.CanInstall); + + var releaseNewerThanPrerelease = new PluginPackageViewModel( + new NuGetPackageInfo( + "Released.Plugin", + "Released Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.9.0-preview.1", "2.0.0"]), + isInstalled: false, + installedVersion: null) + { + UsePrerelease = true, + }; + + Assert.Equal("2.0.0", releaseNewerThanPrerelease.LatestVersion); + } + + [Fact] + public void PackagePresentationUsesOfficialFlagFromMetadata() + { + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Test.Plugin", + "Test Plugin", + "Description", + "Other", + null, + null, + ["1.0.0"], + "https://nuget.test/icons/test-plugin.png", + IsOfficial: true), + isInstalled: false, + installedVersion: null); + + Assert.Equal("Test Plugin", package.Title); + Assert.True(package.IsOfficial); + Assert.Equal("https://nuget.test/icons/test-plugin.png", package.IconUrl); + } + + [Fact] + public void IncompatibleInstalledPackageCanReinstallACompatibleVersion() + { + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Test.Plugin", + "Test Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.0.0"]), + isInstalled: true, + installedVersion: "1.0.0", + isCompatible: false, + hasCompatiblePackageVersion: true); + + Assert.False(package.IsUpdateAvailable); + Assert.True(package.RequiresReinstall); + Assert.True(package.CanUpdate); + + package.IsCompatible = true; + + Assert.False(package.RequiresReinstall); + Assert.False(package.CanUpdate); + } + + [Fact] + public void RestartArgumentsAreNotForwardedToTheRestartedApplication() + { + var arguments = ApplicationRestart.RemoveRestartArguments( + [ + "--IgnoreUpdate", + ApplicationRestart.RestartProcessIdArgument, + "1234", + "--SuppressMode", + ]); + + Assert.Equal(["--IgnoreUpdate", "--SuppressMode"], arguments); + } + + [Fact] + public async Task InstallCompatibilityFollowsValidationSetting() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "2.0.0", + CreatePackage( + "Root.Plugin", + "2.0.0", + [ + new( + "WindowTranslator.Abstractions", + "[2.0.0, 3.0.0)", + Exclude: "Runtime"), + ], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + + using var service = CreateService( + handler, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), + }); + + if (PluginCompatibility.ValidationDisabled) + { + await service.InstallPackageAsync("Root.Plugin", "2.0.0"); + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + } + else + { + var exception = await Assert.ThrowsAsync( + () => service.InstallPackageAsync("Root.Plugin", "2.0.0")); + Assert.Contains("WindowTranslator.Abstractions", exception.Message); + Assert.Contains("[2.0.0, 3.0.0)", exception.Message); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(service.PackageSnapshot.InstalledPackages); + } + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstallAcceptsCompatibleHostAbstractionsWithoutDownloadingIt() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [ + new( + "WindowTranslator.Abstractions", + "[1.0.0, 2.0.0)"), + ], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + })); + + using var service = CreateService( + handler, + testDirectory, + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["WindowTranslator.Abstractions"] = NuGetVersion.Parse("1.5.0"), + }); + + await service.InstallPackageAsync("Root.Plugin", "1.0.0"); + + Assert.True(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Equal( + "[1.0.0, 2.0.0)", + Assert.Single(service.PackageSnapshot.InstalledPackages) + .AbstractionsVersionRange); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.Contains( + "windowtranslator.abstractions", + StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstallRejectsPackageWithoutDirectAbstractionsDependency() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + }, + includeAbstractionsDependency: false)); + + using var service = CreateService(handler, testDirectory); + + var exception = await Assert.ThrowsAsync( + () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); + + Assert.Contains("WindowTranslator.Abstractions", exception.Message); + Assert.False(Directory.Exists(Path.Combine(testDirectory, "Root.Plugin"))); + Assert.Empty(service.PackageSnapshot.InstalledPackages); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task InstallRejectsPackageWithoutPluginTag() + { + var testDirectory = CreateTestDirectory(); + try + { + var targetDirectory = Path.Combine(testDirectory, "Root.Plugin"); + Directory.CreateDirectory(targetDirectory); + File.WriteAllText(Path.Combine(targetDirectory, "plugin.txt"), "old"); + await NuGetPluginService.SaveManifestAsync( + Path.Combine(testDirectory, "nuget-manifest.json"), + new([new( + "Root.Plugin", + "0.9.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]), + CancellationToken.None); + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Root.Plugin", + "1.0.0", + CreatePackage( + "Root.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Root.Plugin.dll"] = "root"u8.ToArray(), + }, + includePluginTag: false)); + + using var service = CreateService(handler, testDirectory); + await service.RefreshPackageInformationAsync(); + + var exception = await Assert.ThrowsAsync( + () => service.InstallPackageAsync("Root.Plugin", "1.0.0")); + + Assert.Contains("プラグインタグ", exception.Message); + Assert.Equal("old", File.ReadAllText(Path.Combine(targetDirectory, "plugin.txt"))); + Assert.Equal("0.9.0", Assert.Single(service.PackageSnapshot.InstalledPackages).Version); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task SelectedPackageLoadsReadmeForTheSelectedReleaseChannel() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Readme.Plugin", + "1.0.0", + CreatePackage( + "Readme.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Readme.Plugin.dll"] = "release"u8.ToArray(), + ["README.md"] = "# Release README"u8.ToArray(), + })); + handler.AddPackage( + "Readme.Plugin", + "2.0.0-preview.1", + CreatePackage( + "Readme.Plugin", + "2.0.0-preview.1", + [], + new Dictionary + { + ["lib/net10.0/Readme.Plugin.dll"] = "preview"u8.ToArray(), + ["README.md"] = "# Preview README"u8.ToArray(), + })); + + handler.AddReadmeUrl( + "Readme.Plugin", + "1.0.0", + "https://nuget.test/readme/readme.plugin/1.0.0"); + handler.AddReadmeUrl( + "Readme.Plugin", + "2.0.0-preview.1", + "https://nuget.test/readme/readme.plugin/2.0.0-preview.1"); + using var service = CreateService( + handler, + testDirectory); + var viewModel = new PluginStoreViewModel( + service, + NullLogger.Instance, + dialogService: null!); + var package = new PluginPackageViewModel( + new NuGetPackageInfo( + "Readme.Plugin", + "README Plugin", + string.Empty, + string.Empty, + null, + null, + ["1.0.0", "2.0.0-preview.1"]), + isInstalled: false, + installedVersion: null); + + viewModel.SelectedPackage = package; + await WaitForReadmeAsync(package, "# Release README"); + + package.UsePrerelease = true; + await WaitForReadmeAsync(package, "# Preview README"); + + Assert.Contains( + handler.RequestedPaths, + path => path.Equals("/readme/readme.plugin/1.0.0", StringComparison.OrdinalIgnoreCase)); + Assert.Contains( + handler.RequestedPaths, + path => path.Equals( + "/readme/readme.plugin/2.0.0-preview.1", + StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public async Task PackageReadmeUsesTheRequestedUiCulture() + { + var testDirectory = CreateTestDirectory(); + try + { + using var handler = new InMemoryNuGetHandler(); + handler.AddPackage( + "Localized.Plugin", + "1.0.0", + CreatePackage( + "Localized.Plugin", + "1.0.0", + [], + new Dictionary + { + ["lib/net10.0/Localized.Plugin.dll"] = "plugin"u8.ToArray(), + ["README.md"] = """ + ## ja + + # 日本語 + + ## en + + # English + """u8.ToArray(), + })); + handler.AddReadmeUrl( + "Localized.Plugin", + "1.0.0", + "https://nuget.test/readme/localized.plugin/1.0.0"); + using var service = CreateService(handler, testDirectory); + + var readme = await service.GetPackageReadmeAsync( + "Localized.Plugin", + "1.0.0", + CultureInfo.GetCultureInfo("en-US")); + + Assert.Equal("# English", readme); + Assert.DoesNotContain( + handler.RequestedPaths, + path => path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(testDirectory); + } + } + + [Fact] + public void StartupCleanupDeletesDirectoriesMissingFromAReadableManifest() + { + var sourceDirectory = CreateTestDirectory(); + try + { + var installedDirectory = Path.Combine(sourceDirectory, "Installed.Plugin"); + var removedDirectory = Path.Combine(sourceDirectory, "Removed.Plugin"); + var interruptedInstallDirectory = Path.Combine( + sourceDirectory, + "Installed.Plugin.installing-test"); + Directory.CreateDirectory(installedDirectory); + Directory.CreateDirectory(removedDirectory); + Directory.CreateDirectory(interruptedInstallDirectory); + File.WriteAllText( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize( + new InstalledManifest( + [ + new( + "Installed.Plugin", + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )"), + ]), + NuGetPluginService.ManifestJsonOptions)); + + NuGetPluginCatalog.DeleteUninstalledPackageDirectories(sourceDirectory); + + Assert.True(Directory.Exists(installedDirectory)); + Assert.False(Directory.Exists(removedDirectory)); + Assert.False(Directory.Exists(interruptedInstallDirectory)); + + var directoryKeptForInvalidManifest = Path.Combine( + sourceDirectory, + "Kept.For.Invalid.Manifest"); + Directory.CreateDirectory(directoryKeptForInvalidManifest); + File.WriteAllText( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + "{ invalid json"); + + NuGetPluginCatalog.DeleteUninstalledPackageDirectories(sourceDirectory); + + Assert.True(Directory.Exists(directoryKeptForInvalidManifest)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + } + } + + [Fact] + public void CatalogSynchronizationCopiesOnlyChangesAndRemovesStaleFiles() + { + var sourceDirectory = CreateTestDirectory(); + var destinationDirectory = CreateTestDirectory(); + try + { + File.WriteAllText(Path.Combine(sourceDirectory, "Legacy.Plugin.dll"), "legacy"); + File.WriteAllText(Path.Combine(sourceDirectory, "nuget-manifest.json"), "{}"); + File.WriteAllText(Path.Combine(sourceDirectory, "nuget-manifest.json.tmp-test"), "{}"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin")); + var sourcePluginPath = + Path.Combine(sourceDirectory, "Root.Plugin", "Root.Plugin.dll"); + File.WriteAllText(sourcePluginPath, "plugin-new"); + var unchangedSourcePath = + Path.Combine(sourceDirectory, "Root.Plugin", "Unchanged.dll"); + File.WriteAllText(unchangedSourcePath, "unchanged"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Empty.Plugin")); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.backup-test")); + File.WriteAllText( + Path.Combine(sourceDirectory, "Root.Plugin.backup-test", "old.dll"), + "old"); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.installing-test")); + Directory.CreateDirectory(Path.Combine(sourceDirectory, "Root.Plugin.uninstalling-test")); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Root.Plugin")); + var destinationPluginPath = + Path.Combine(destinationDirectory, "Root.Plugin", "Root.Plugin.dll"); + File.WriteAllText(destinationPluginPath, "plugin-old"); + var unchangedDestinationPath = + Path.Combine(destinationDirectory, "Root.Plugin", "Unchanged.dll"); + File.WriteAllText(unchangedDestinationPath, "unchanged"); + var unchangedTimestamp = DateTime.UtcNow.AddMinutes(-5); + File.SetLastWriteTimeUtc(unchangedSourcePath, unchangedTimestamp); + File.SetLastWriteTimeUtc(unchangedDestinationPath, unchangedTimestamp); + File.SetCreationTimeUtc(unchangedSourcePath, unchangedTimestamp); + File.SetCreationTimeUtc(unchangedDestinationPath, unchangedTimestamp); + File.SetLastWriteTimeUtc(sourcePluginPath, unchangedTimestamp); + File.SetLastWriteTimeUtc(destinationPluginPath, unchangedTimestamp); + File.SetCreationTimeUtc( + sourcePluginPath, + unchangedTimestamp.AddMinutes(2)); + File.SetCreationTimeUtc( + destinationPluginPath, + unchangedTimestamp.AddMinutes(-2)); + File.WriteAllText( + Path.Combine(destinationDirectory, "Root.Plugin", "Removed.dll"), + "stale"); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Removed.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Removed.Plugin", "Removed.Plugin.dll"), + "stale"); + File.WriteAllText( + Path.Combine(destinationDirectory, "nuget-manifest.json"), + "{}"); + + using var unchangedFileLock = new FileStream( + unchangedDestinationPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + var includedPackages = new HashSet( + ["Root.Plugin", "Empty.Plugin"], + StringComparer.OrdinalIgnoreCase); + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory, + includedPackages); + using var synchronizedFileLock = new FileStream( + destinationPluginPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory, + includedPackages); + + Assert.False(File.Exists(Path.Combine(destinationDirectory, "Legacy.Plugin.dll"))); + Assert.Equal( + "plugin-new", + File.ReadAllText(destinationPluginPath)); + Assert.Equal("unchanged", File.ReadAllText(unchangedDestinationPath)); + Assert.False(File.Exists( + Path.Combine(destinationDirectory, "Root.Plugin", "Removed.dll"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Removed.Plugin"))); + Assert.True(Directory.Exists( + Path.Combine(destinationDirectory, "Empty.Plugin"))); + Assert.False(File.Exists(Path.Combine(destinationDirectory, "nuget-manifest.json"))); + Assert.False(File.Exists( + Path.Combine(destinationDirectory, "nuget-manifest.json.tmp-test"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.backup-test"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.installing-test"))); + Assert.False(Directory.Exists( + Path.Combine(destinationDirectory, "Root.Plugin.uninstalling-test"))); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(destinationDirectory); + } + } + + [Fact] + public void CatalogSynchronizationClearsStaleFilesWhenSourceIsMissing() + { + var sourceDirectory = CreateTestDirectory(); + var destinationDirectory = CreateTestDirectory(); + try + { + Directory.Delete(sourceDirectory); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Removed.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Removed.Plugin", "Removed.Plugin.dll"), + "stale"); + + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory, + new HashSet(StringComparer.OrdinalIgnoreCase)); + + Assert.Empty(Directory.EnumerateFileSystemEntries(destinationDirectory)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(destinationDirectory); + } + } + + [Fact] + public void CatalogSynchronizationFollowsCompatibilityValidationSetting() + { + var sourceDirectory = CreateTestDirectory(); + var destinationDirectory = CreateTestDirectory(); + try + { + var compatibleDirectory = Path.Combine(sourceDirectory, "Compatible.Plugin"); + var incompatibleDirectory = Path.Combine(sourceDirectory, "Incompatible.Plugin"); + var orphanDirectory = Path.Combine(sourceDirectory, "Orphan.Plugin"); + Directory.CreateDirectory(compatibleDirectory); + Directory.CreateDirectory(incompatibleDirectory); + Directory.CreateDirectory(orphanDirectory); + File.WriteAllText(Path.Combine(compatibleDirectory, "Compatible.Plugin.dll"), "compatible"); + File.WriteAllText(Path.Combine(incompatibleDirectory, "Incompatible.Plugin.dll"), "incompatible"); + File.WriteAllText(Path.Combine(orphanDirectory, "Orphan.Plugin.dll"), "orphan"); + Directory.CreateDirectory(Path.Combine(destinationDirectory, "Incompatible.Plugin")); + File.WriteAllText( + Path.Combine(destinationDirectory, "Incompatible.Plugin", "Incompatible.Plugin.dll"), + "stale"); + File.WriteAllText( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + JsonSerializer.Serialize(new InstalledManifest( + [ + new InstalledPackageInfo( + "Compatible.Plugin", + "1.0.0", + HostMajorVersion: 7, + AbstractionsVersionRange: "(, )"), + new InstalledPackageInfo( + "Incompatible.Plugin", + "1.0.0", + HostMajorVersion: 6, + AbstractionsVersionRange: "(, )"), + ]))); + + var loadablePackages = NuGetPluginCatalog.GetLoadablePackageIds( + sourceDirectory, + hostMajorVersion: 7, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0")); + NuGetPluginCatalog.SynchronizePluginFiles( + sourceDirectory, + destinationDirectory, + loadablePackages); + + Assert.True(File.Exists(Path.Combine( + destinationDirectory, + "Compatible.Plugin", + "Compatible.Plugin.dll"))); + Assert.Equal( + PluginCompatibility.ValidationDisabled, + Directory.Exists(Path.Combine(destinationDirectory, "Incompatible.Plugin"))); + Assert.False(Directory.Exists(Path.Combine(destinationDirectory, "Orphan.Plugin"))); + Assert.Equal( + PluginCompatibility.ValidationDisabled + ? ["Compatible.Plugin", "Incompatible.Plugin"] + : ["Compatible.Plugin"], + loadablePackages.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(destinationDirectory); + } + } + + [Fact] + public async Task PrioritizedCatalogReplacesFallbackAssemblyAndKeepsSameTypeNameFromOtherAssembly() + { + var replacedAssemblyName = $"Replaced.Plugin.{Guid.NewGuid():N}"; + var nugetTypes = CreatePluginTypes( + replacedAssemblyName, + "NuGet.ReplacedPlugin", + "NuGet.NuGetOnlyPlugin"); + var replacedBundledTypes = CreatePluginTypes( + replacedAssemblyName, + "Bundled.ReplacedPlugin", + "Bundled.AlsoReplacedPlugin"); + var duplicateNugetTypes = CreatePluginTypes( + replacedAssemblyName, + "DuplicateNuGet.ReplacedPlugin", + "DuplicateNuGet.AlsoReplacedPlugin"); + var bundledOnlyType = CreatePluginTypes( + $"Bundled.Plugin.{Guid.NewGuid():N}", + "Bundled.BundledOnlyPlugin")[0]; + var sameTypeNameFromOtherAssembly = CreatePluginTypes( + $"Other.Plugin.{Guid.NewGuid():N}", + "Other.ReplacedPlugin")[0]; + var nugetCatalog = new TestPluginCatalog( + nugetTypes[0], + nugetTypes[1], + duplicateNugetTypes[0], + duplicateNugetTypes[1]); + var bundledCatalog = new TestPluginCatalog( + replacedBundledTypes[0], + replacedBundledTypes[1], + bundledOnlyType, + sameTypeNameFromOtherAssembly); + var catalog = new PrioritizedPluginCatalog(nugetCatalog, bundledCatalog); + + await catalog.Initialize(); + + Assert.True(catalog.IsInitialized); + Assert.Equal( + [ + nugetTypes[0], + nugetTypes[1], + bundledOnlyType, + sameTypeNameFromOtherAssembly, + ], + catalog.GetPlugins().Select(plugin => plugin.Type)); + Assert.Equal( + nugetTypes[0], + catalog.Get("ReplacedPlugin", new Version(1, 0)).Type); + } + + [Fact] + public async Task CatalogLoadsARealAssemblyFromAPackageSubdirectory() + { + var sourceDirectory = CreateTestDirectory(); + var tempDirectory = CreateTestDirectory(); + try + { + var packageDirectory = Path.Combine(sourceDirectory, "Catalog.Probe"); + Directory.CreateDirectory(packageDirectory); + var testAssemblyPath = typeof(NuGetPluginServiceTests).Assembly.Location; + File.Copy( + testAssemblyPath, + Path.Combine(packageDirectory, Path.GetFileName(testAssemblyPath))); + var runtimeDirectory = Path.Combine( + packageDirectory, + "runtimes", + "win", + "lib", + "net10.0"); + Directory.CreateDirectory(runtimeDirectory); + File.Copy( + typeof(NuGetVersion).Assembly.Location, + Path.Combine(runtimeDirectory, "NuGet.Versioning.dll")); + Assert.Empty(Directory.EnumerateFiles( + packageDirectory, + "*.deps.json", + SearchOption.AllDirectories)); + await NuGetPluginService.SaveManifestAsync( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + new([new( + "Catalog.Probe", + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]), + CancellationToken.None); + + var options = new FolderPluginCatalogOptions(); + options.TypeFinderOptions.TypeFinderCriterias.Clear(); + options.TypeFinderOptions.TypeFinderCriterias.Add(new() + { + Query = static (_, type) => + type.Name == nameof(CatalogProbeTranslateModule), + }); + options.PluginLoadContextOptions.UseHostApplicationAssemblies = + UseHostApplicationAssembliesEnum.Selected; + options.PluginLoadContextOptions.HostApplicationAssemblies = + AssemblyLoadContext.Default.Assemblies + .Where(assembly => !assembly.IsDynamic + && assembly != typeof(NuGetPluginServiceTests).Assembly + && !string.Equals( + assembly.GetName().Name, + typeof(NuGetVersion).Assembly.GetName().Name, + StringComparison.OrdinalIgnoreCase)) + .Select(assembly => assembly.GetName()) + .ToList(); + options.PluginLoadContextOptions.AdditionalRuntimePaths = []; + var catalog = new NuGetPluginCatalog( + sourceDirectory, + tempDirectory, + hostMajorVersion: 1, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"), + options); + + await catalog.Initialize(); + + Assert.True(catalog.IsInitialized); + var plugin = Assert.Single( + catalog.GetPlugins(), + plugin => plugin.Type.Name == nameof(CatalogProbeTranslateModule)); + var module = Assert.IsAssignableFrom( + Activator.CreateInstance(plugin.Type)); + var translated = await module.TranslateAsync([new("source", null)]); + Assert.Equal("1.2.3", Assert.Single(translated)); + } + finally + { + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(tempDirectory); + } + } + + [Fact] + public async Task CatalogLoadsTheSatelliteAssemblyForTheRequestedCulture() + { + var sourceDirectory = CreateTestDirectory(); + var tempDirectory = CreateTestDirectory(); + var originalCulture = CultureInfo.CurrentUICulture; + try + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("fr-FR"); + var packageDirectory = Path.Combine(sourceDirectory, "Catalog.Probe"); + Directory.CreateDirectory(packageDirectory); + var testAssemblyPath = typeof(NuGetPluginServiceTests).Assembly.Location; + File.Copy( + testAssemblyPath, + Path.Combine(packageDirectory, Path.GetFileName(testAssemblyPath))); + foreach (var cultureName in new[] { "ar", "fr" }) + { + var cultureDirectory = Path.Combine(packageDirectory, cultureName); + Directory.CreateDirectory(cultureDirectory); + File.Copy( + Path.Combine( + Path.GetDirectoryName(testAssemblyPath)!, + cultureName, + "WindowTranslator.Tests.resources.dll"), + Path.Combine(cultureDirectory, "WindowTranslator.Tests.resources.dll")); + } + await NuGetPluginService.SaveManifestAsync( + Path.Combine(sourceDirectory, "nuget-manifest.json"), + new([new( + "Catalog.Probe", + "1.0.0", + HostMajorVersion: 1, + AbstractionsVersionRange: "(, )")]), + CancellationToken.None); + + var options = new FolderPluginCatalogOptions(); + options.TypeFinderOptions.TypeFinderCriterias.Clear(); + options.TypeFinderOptions.TypeFinderCriterias.Add(new() + { + Query = static (_, type) => + type.Name == nameof(CatalogProbeLocalizedTranslateModule), + }); + options.PluginNameOptions.PluginNameGenerator = static (_, type) => + new ResourceManager( + "WindowTranslator.Tests.CatalogProbeResources", + type.Assembly).GetString("Greeting", CultureInfo.CurrentUICulture) + ?? type.Name; + options.PluginLoadContextOptions.UseHostApplicationAssemblies = + UseHostApplicationAssembliesEnum.Selected; + options.PluginLoadContextOptions.HostApplicationAssemblies = + AssemblyLoadContext.Default.Assemblies + .Where(assembly => !assembly.IsDynamic + && assembly != typeof(NuGetPluginServiceTests).Assembly) + .Select(assembly => assembly.GetName()) + .ToList(); + options.PluginLoadContextOptions.AdditionalRuntimePaths = []; + var catalog = new NuGetPluginCatalog( + sourceDirectory, + tempDirectory, + hostMajorVersion: 1, + hostAbstractionsVersion: NuGetVersion.Parse("1.0.0"), + options); + + await catalog.Initialize(); + + var plugin = Assert.Single( + catalog.GetPlugins(), + plugin => plugin.Type.Name == nameof(CatalogProbeLocalizedTranslateModule)); + Assert.Equal("français", plugin.Name); + Assert.NotSame( + AssemblyLoadContext.Default, + AssemblyLoadContext.GetLoadContext(plugin.Type.Assembly)); + var module = Assert.IsAssignableFrom( + Activator.CreateInstance(plugin.Type)); + var translated = await module.TranslateAsync([new("source", null)]); + Assert.Equal("français", Assert.Single(translated)); + } + finally + { + CultureInfo.CurrentUICulture = originalCulture; + DeleteTestDirectory(sourceDirectory); + DeleteTestDirectory(tempDirectory); + } + } + + [Fact] + public void FrameworkSelectionPrefersTheCompatibleWindowsTarget() + { + Assert.Equal( + "net10.0-windows10.0.20348.0", + NuGetPackageInstaller.SelectBestTfm( + ["net10.0", "net10.0-windows10.0.20348.0", "netstandard2.0"])); + Assert.Equal( + "net10.0-windows10.0.19041.0", + NuGetPackageInstaller.SelectBestTfm( + [ + "net10.0", + "net10.0-windows10.0.19041.0", + "net10.0-windows10.0.22621.0", + ])); + Assert.Equal( + ".NETCoreApp,Version=v10.0", + NuGetPackageInstaller.SelectBestTfm([".NETCoreApp,Version=v10.0"])); + Assert.Null(NuGetPackageInstaller.SelectBestTfm( + ["net10.0-windows10.0.22621.0"])); + Assert.Null(NuGetPackageInstaller.SelectBestTfm(["net11.0-windows"])); + Assert.Null(NuGetPackageInstaller.SelectBestTfm(["net48"])); + } + + private static NuGetPluginService CreateService( + InMemoryNuGetHandler handler, + string pluginDirectory, + IReadOnlyDictionary? hostPackageVersions = null, + int? hostMajorVersion = null) + => new( + NullLogger.Instance, + new InMemoryHttpClientFactory(handler), + handler.CreateRepository(), + pluginDirectory, + hostPackageVersions ?? NuGetPluginService.CreateHostPackageVersions(), + hostMajorVersion ?? AppInfo.Instance.Version.Major); + + private sealed class RecordingProgress : IProgress + { + public List Values { get; } = []; + + public void Report(double value) => this.Values.Add(value); + } + + private static TestPackageVersion CreatePluginVersionMetadata( + string version, + string? abstractionsRange = null, + bool includeAbstractionsDependency = true) + => new( + NuGetVersion.Parse(version), + IsListed: true, + [ + new PackageDependencyGroup( + NuGetFramework.ParseFolder("net10.0"), + includeAbstractionsDependency + ? [ + new PackageDependency( + NuGetPluginService.AbstractionsPackageId, + abstractionsRange is null + ? VersionRange.All + : VersionRange.Parse(abstractionsRange)), + ] + : []), + ]); + + private static IPackageSearchMetadata CreatePackageSearchMetadata( + string packageId, + string? title, + string? description, + string? authors, + string? projectUrl, + string? licenseUrl, + NuGetVersion? version = null, + IEnumerable? dependencySets = null, + bool isListed = true, + string? readmeFileUrl = null, + string? iconUrl = null, + IReadOnlyList? owners = null) + => new TestPackageSearchMetadata + { + Identity = new PackageIdentity( + packageId, + version ?? NuGetVersion.Parse("0.0.0")), + Title = title!, + Description = description!, + Authors = authors!, + ProjectUrl = projectUrl is null ? null! : new Uri(projectUrl), + LicenseUrl = licenseUrl is null ? null! : new Uri(licenseUrl), + DependencySets = dependencySets ?? [], + IsListed = isListed, + ReadmeFileUrl = readmeFileUrl!, + IconUrl = iconUrl is null ? null! : new Uri(iconUrl), + OwnersList = owners ?? [], + }; + + private static async Task WaitForReadmeAsync( + PluginPackageViewModel package, + string expectedReadme) + { + if (package.ReadmeMarkdown == expectedReadme) + { + return; + } + + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + PropertyChangedEventHandler? handler = null; + handler = (_, e) => + { + if (e.PropertyName == nameof(PluginPackageViewModel.ReadmeMarkdown) + && package.ReadmeMarkdown == expectedReadme) + { + completion.TrySetResult(); + } + }; + package.PropertyChanged += handler; + try + { + if (package.ReadmeMarkdown == expectedReadme) + { + return; + } + await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + package.PropertyChanged -= handler; + } + } + + private static byte[] CreatePackage( + string id, + string version, + IReadOnlyCollection dependencies, + IReadOnlyDictionary entries, + bool includePluginTag = true, + bool includeAbstractionsDependency = true) + { + var packageDependencies = dependencies.ToList(); + if (includeAbstractionsDependency + && !packageDependencies.Any(dependency => dependency.Id.Equals( + "WindowTranslator.Abstractions", + StringComparison.OrdinalIgnoreCase))) + { + packageDependencies.Add(new( + "WindowTranslator.Abstractions", + "(, )", + Exclude: "Runtime")); + } + + using var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var dependencyElements = packageDependencies.Select(dependency => + { + var element = new XElement( + "dependency", + new XAttribute("id", dependency.Id), + new XAttribute("version", dependency.Version)); + if (dependency.Exclude is not null) + { + element.Add(new XAttribute("exclude", dependency.Exclude)); + } + return element; + }); + var metadata = new XElement( + "metadata", + new XElement("id", id), + new XElement("version", version), + new XElement("authors", "WindowTranslator.Tests"), + new XElement("description", "Test package")); + if (includePluginTag) + { + metadata.Add(new XElement("tags", "windowtranslator-plugin")); + } + if (entries.ContainsKey("README.md")) + { + metadata.Add(new XElement("readme", "README.md")); + } + metadata.Add(new XElement( + "dependencies", + new XElement( + "group", + new XAttribute("targetFramework", "net10.0"), + dependencyElements))); + var nuspec = new XDocument(new XElement("package", metadata)); + var nuspecEntry = archive.CreateEntry($"{id}.nuspec"); + using (var nuspecStream = nuspecEntry.Open()) + { + nuspec.Save(nuspecStream); + } + + foreach (var (path, content) in entries) + { + var entry = archive.CreateEntry(path); + using var entryStream = entry.Open(); + entryStream.Write(content); + } + } + + return stream.ToArray(); + } + + private static string CreateTestDirectory() + { + var path = Path.Combine( + Path.GetTempPath(), + "WindowTranslator.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private static void DeleteTestDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + // テスト用ディレクトリの後始末はテスト結果へ影響させない + } + } + + private sealed record TestDependency(string Id, string Version, string? Exclude = null); + + private sealed record TestPackageVersion( + NuGetVersion Version, + bool IsListed, + IReadOnlyList DependencyGroups); + + private sealed class TestPackageSearchMetadata : IPackageSearchMetadata + { + public string Authors { get; init; } = null!; + public IEnumerable DependencySets { get; init; } = []; + public string Description { get; init; } = null!; + public long? DownloadCount { get; init; } + public Uri IconUrl { get; init; } = null!; + public PackageIdentity Identity { get; init; } = null!; + public Uri LicenseUrl { get; init; } = null!; + public Uri ProjectUrl { get; init; } = null!; + public Uri ReadmeUrl { get; init; } = null!; + public string ReadmeFileUrl { get; init; } = null!; + public Uri ReportAbuseUrl { get; init; } = null!; + public Uri PackageDetailsUrl { get; init; } = null!; + public DateTimeOffset? Published { get; init; } + public IReadOnlyList OwnersList { get; init; } = []; + public string Owners { get; init; } = null!; + public bool RequireLicenseAcceptance { get; init; } + public string Summary { get; init; } = null!; + public string Tags { get; init; } = null!; + public string Title { get; init; } = null!; + public bool IsListed { get; init; } + public bool PrefixReserved { get; init; } + public LicenseMetadata LicenseMetadata { get; init; } = null!; + public IEnumerable Vulnerabilities { get; init; } = []; + + public Task GetDeprecationMetadataAsync() + => Task.FromResult(null); + + public Task> GetVersionsAsync() + => Task.FromResult>([]); + } + + private sealed class TestPluginCatalog : IPluginCatalog + { + private readonly List plugins; + + public TestPluginCatalog(params Type[] pluginTypes) + { + this.plugins = pluginTypes + .Select(type => new Plugin( + type.Assembly, + type, + type.Name, + new Version(1, 0), + this, + string.Empty, + string.Empty, + string.Empty, + [])) + .ToList(); + } + + public bool IsInitialized { get; private set; } + + public Task Initialize() + { + this.IsInitialized = true; + return Task.CompletedTask; + } + + public List GetPlugins() => [.. this.plugins]; + + public Plugin Get(string name, Version version) + => this.plugins.FirstOrDefault(plugin => + plugin.Name == name && plugin.Version == version)!; + } + + private static Type[] CreatePluginTypes(string assemblyName, params string[] typeNames) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName(assemblyName), + AssemblyBuilderAccess.Run); + var module = assembly.DefineDynamicModule(assemblyName); + return typeNames + .Select(typeName => module + .DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class) + .CreateType()!) + .ToArray(); + } + + private sealed class InMemoryHttpClientFactory(InMemoryNuGetHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } + + private sealed class InMemoryNuGetHandler : HttpMessageHandler + { + private readonly Dictionary<(string Id, string Version), byte[]> packages = new(); + private readonly Dictionary> metadataVersions = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary metadataExceptions = + new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary readmeUrls = + new(StringComparer.OrdinalIgnoreCase); + + public IReadOnlyList SearchResults { get; set; } = []; + + public Exception? SearchException { get; set; } + + public List RequestedSearchTerms { get; } = []; + + public List RequestedPrereleaseOptions { get; } = []; + + public List RequestedPaths { get; } = []; + + public void AddPackage(string id, string version, byte[] package) + => this.packages[(NormalizeId(id), NormalizeVersion(version))] = package; + + public void AddMetadataVersions(string packageId, params TestPackageVersion[] packageVersions) + => this.metadataVersions[packageId] = packageVersions; + + public void AddMetadataException(string packageId, Exception exception) + => this.metadataExceptions[packageId] = exception; + + public void AddReadmeUrl(string packageId, string version, string url) + => this.readmeUrls[GetReadmeKey(packageId, NuGetVersion.Parse(version))] = url; + + public SourceRepository CreateRepository() + => new( + new PackageSource("https://nuget.test/v3/index.json"), + [ + new InMemoryResourceProvider( + new InMemoryPackageSearchResource(this)), + new InMemoryResourceProvider( + new InMemoryPackageMetadataResource(this)), + new InMemoryResourceProvider( + new InMemoryFindPackageByIdResource(this)), + ]); + + private static string GetReadmeKey(string packageId, NuGetVersion version) + => $"{packageId}\n{version.ToNormalizedString()}"; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + this.RequestedPaths.Add(path); + + var segments = path.Trim('/').Split('/'); + if (segments.Length == 3 + && segments[0].Equals("readme", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(CreateReadmeResponse(segments[1], segments[2])); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + + private HttpResponseMessage CreateReadmeResponse(string packageId, string version) + { + if (!this.packages.TryGetValue( + (NormalizeId(packageId), NormalizeVersion(version)), + out var package)) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + using var stream = new MemoryStream(package); + using var archive = new ZipArchive(stream, ZipArchiveMode.Read); + var readmeEntry = archive.Entries.FirstOrDefault(entry => + entry.FullName.Equals("README.md", StringComparison.OrdinalIgnoreCase)); + if (readmeEntry is null) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + using var reader = new StreamReader(readmeEntry.Open(), Encoding.UTF8); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(reader.ReadToEnd(), Encoding.UTF8, "text/markdown"), + }; + } + + private static string NormalizeId(string packageId) => packageId.ToLowerInvariant(); + + private static string NormalizeVersion(string version) + => NuGetVersion.Parse(version).ToNormalizedString().ToLowerInvariant(); + + private sealed class InMemoryPackageSearchResource(InMemoryNuGetHandler source) + : PackageSearchResource + { + public override Task> SearchAsync( + string searchTerm, + SearchFilter filters, + int skip, + int take, + NuGet.Common.ILogger log, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + source.RequestedSearchTerms.Add(searchTerm); + source.RequestedPrereleaseOptions.Add(filters.IncludePrerelease); + return source.SearchException is null + ? Task.FromResult(source.SearchResults.Skip(skip).Take(take).AsEnumerable()) + : Task.FromException>(source.SearchException); + } + } + + private sealed class InMemoryPackageMetadataResource(InMemoryNuGetHandler source) + : PackageMetadataResource + { + public override Task> GetMetadataAsync( + string packageId, + bool includePrerelease, + bool includeUnlisted, + SourceCacheContext sourceCacheContext, + NuGet.Common.ILogger log, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (source.metadataExceptions.TryGetValue(packageId, out var exception)) + { + return Task.FromException>(exception); + } + var versions = source.metadataVersions.TryGetValue(packageId, out var packageVersions) + ? packageVersions + : []; + var metadata = versions + .Where(version => includePrerelease || !version.Version.IsPrerelease) + .Where(version => includeUnlisted || version.IsListed) + .Select(version => CreatePackageSearchMetadata( + packageId, + title: null, + description: null, + authors: null, + projectUrl: null, + licenseUrl: null, + version.Version, + version.DependencyGroups, + version.IsListed)); + return Task.FromResult(metadata); + } + + public override Task GetMetadataAsync( + PackageIdentity identity, + SourceCacheContext sourceCacheContext, + NuGet.Common.ILogger log, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + source.readmeUrls.TryGetValue(GetReadmeKey(identity.Id, identity.Version), out var readmeUrl); + return Task.FromResult(CreatePackageSearchMetadata( + identity.Id, + title: null, + description: null, + authors: null, + projectUrl: null, + licenseUrl: null, + identity.Version, + readmeFileUrl: readmeUrl)); + } + } + + private sealed class InMemoryFindPackageByIdResource(InMemoryNuGetHandler source) + : FindPackageByIdResource + { + public override Task> GetAllVersionsAsync( + string id, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var normalizedId = NormalizeId(id); + source.RequestedPaths.Add($"/v3-flatcontainer/{normalizedId}/index.json"); + return Task.FromResult(source.packages.Keys + .Where(key => key.Id == normalizedId) + .Select(key => NuGetVersion.Parse(key.Version)) + .OrderBy(version => version) + .AsEnumerable()); + } + + public override async Task CopyNupkgToStreamAsync( + string id, + NuGetVersion version, + Stream destination, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + { + token.ThrowIfCancellationRequested(); + var normalizedId = NormalizeId(id); + var normalizedVersion = NormalizeVersion(version.ToNormalizedString()); + source.RequestedPaths.Add( + $"/v3-flatcontainer/{normalizedId}/{normalizedVersion}/{normalizedId}.{normalizedVersion}.nupkg"); + if (!source.packages.TryGetValue((normalizedId, normalizedVersion), out var package)) + { + return false; + } + + await destination.WriteAsync(package, token); + return true; + } + + public override Task GetDependencyInfoAsync( + string id, + NuGetVersion version, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + => throw new NotSupportedException(); + + public override Task GetPackageDownloaderAsync( + PackageIdentity packageIdentity, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + => throw new NotSupportedException(); + + public override Task DoesPackageExistAsync( + string id, + NuGetVersion version, + SourceCacheContext cacheContext, + NuGet.Common.ILogger logger, + CancellationToken token) + => Task.FromResult(source.packages.ContainsKey( + (NormalizeId(id), NormalizeVersion(version.ToNormalizedString())))); + } + } + + private sealed class InMemoryResourceProvider(TResource resource) + : ResourceProvider(typeof(TResource)) + where TResource : class, INuGetResource + { + public override Task> TryCreate( + SourceRepository source, + CancellationToken token) + => Task.FromResult(Tuple.Create(true, resource)); + } +} + +public sealed class CatalogProbeTranslateModule : ITranslateModule +{ + public ValueTask TranslateAsync(TextInfo[] srcTexts) + => ValueTask.FromResult( + Enumerable.Repeat( + new NuGetVersion(1, 2, 3).ToNormalizedString(), + srcTexts.Length).ToArray()); +} + +public sealed class CatalogProbeLocalizedTranslateModule : ITranslateModule +{ + private static readonly ResourceManager Resources = new( + "WindowTranslator.Tests.CatalogProbeResources", + typeof(CatalogProbeLocalizedTranslateModule).Assembly); + + public ValueTask TranslateAsync(TextInfo[] srcTexts) + => ValueTask.FromResult( + Enumerable.Repeat( + Resources.GetString("Greeting", CultureInfo.CurrentUICulture) ?? string.Empty, + srcTexts.Length).ToArray()); +} diff --git a/WindowTranslator.slnx b/WindowTranslator.slnx index 3447197f..2f4bab73 100644 --- a/WindowTranslator.slnx +++ b/WindowTranslator.slnx @@ -25,8 +25,8 @@ + - @@ -41,6 +41,7 @@ + diff --git a/WindowTranslator/ApplicationRestart.cs b/WindowTranslator/ApplicationRestart.cs new file mode 100644 index 00000000..1dcaf38f --- /dev/null +++ b/WindowTranslator/ApplicationRestart.cs @@ -0,0 +1,81 @@ +using System.Diagnostics; +using System.Globalization; +using System.Windows; + +namespace WindowTranslator; + +internal static class ApplicationRestart +{ + internal const string RestartProcessIdArgument = "--windowtranslator-restart-pid"; + + public static void Restart() + { + var executablePath = Environment.ProcessPath + ?? throw new InvalidOperationException("実行ファイルのパスを取得できませんでした。"); + var startInfo = new ProcessStartInfo(executablePath) + { + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(RestartProcessIdArgument); + startInfo.ArgumentList.Add(Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + foreach (var argument in RemoveRestartArguments(Environment.GetCommandLineArgs().Skip(1))) + { + startInfo.ArgumentList.Add(argument); + } + + _ = Process.Start(startInfo) + ?? throw new InvalidOperationException("WindowTranslatorを再起動できませんでした。"); + Application.Current.Shutdown(); + } + + public static string[] WaitForPreviousProcess(IEnumerable arguments) + { + var (remainingArguments, processId) = ParseRestartArguments(arguments); + + if (processId is not null && processId != Environment.ProcessId) + { + try + { + using var process = Process.GetProcessById(processId.Value); + process.WaitForExit(); + } + catch (ArgumentException) + { + // 再起動元のプロセスはすでに終了している + } + } + + return remainingArguments; + } + + internal static string[] RemoveRestartArguments(IEnumerable arguments) + => ParseRestartArguments(arguments).Arguments; + + private static (string[] Arguments, int? ProcessId) ParseRestartArguments( + IEnumerable arguments) + { + var argumentArray = arguments.ToArray(); + var remainingArguments = new List(); + int? processId = null; + for (var index = 0; index < argumentArray.Length; index++) + { + var argument = argumentArray[index]; + if (argument.Equals(RestartProcessIdArgument, StringComparison.OrdinalIgnoreCase) + && index + 1 < argumentArray.Length + && int.TryParse( + argumentArray[index + 1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var parsedProcessId)) + { + processId = parsedProcessId; + index++; + continue; + } + + remainingArguments.Add(argument); + } + + return ([.. remainingArguments], processId); + } +} diff --git a/WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs b/WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs new file mode 100644 index 00000000..0f565f6a --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/LocalizedReadmeSelector.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace WindowTranslator.Modules.PluginStore; + +internal static partial class LocalizedReadmeSelector +{ + public static string Select(string markdown, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(markdown); + ArgumentNullException.ThrowIfNull(culture); + + var headings = LanguageHeadingRegex.Matches(markdown) + .Select(TryCreateHeading) + .OfType() + .ToArray(); + if (headings.Length == 0) + { + return markdown; + } + + var preamble = markdown[..headings[0].Start]; + var sections = headings + .Select((heading, index) => new ReadmeSection( + heading.CultureName, + markdown[heading.ContentStart..(index + 1 < headings.Length + ? headings[index + 1].Start + : markdown.Length)])) + .Where(section => !string.IsNullOrWhiteSpace(section.Content)) + .ToArray(); + if (sections.Length == 0) + { + return markdown; + } + + var selected = GetPreferredCultureNames(culture) + .Select(name => sections.FirstOrDefault(section => + section.CultureName.Equals(name, StringComparison.OrdinalIgnoreCase))) + .FirstOrDefault(section => section is not null) + ?? sections[0]; + return Combine(preamble, selected.Content, markdown); + } + + private static IEnumerable GetPreferredCultureNames(CultureInfo culture) + { + for (var candidate = culture; !string.IsNullOrEmpty(candidate.Name); candidate = candidate.Parent) + { + yield return candidate.Name; + } + + yield return "en"; + } + + private static LanguageHeading? TryCreateHeading(Match match) + { + var value = match.Groups["culture"].Value; + try + { + var cultureName = CultureInfo.GetCultureInfo(value).Name; + return value.Equals(cultureName, StringComparison.Ordinal) + ? new(match.Index, match.Index + match.Length, cultureName) + : null; + } + catch (CultureNotFoundException) + { + return null; + } + } + + private static string Combine(string preamble, string content, string markdown) + { + var shared = preamble.TrimEnd('\r', '\n'); + var localized = content.Trim('\r', '\n'); + if (shared.Length == 0) + { + return localized; + } + if (localized.Length == 0) + { + return shared; + } + + var newLine = markdown.Contains("\r\n", StringComparison.Ordinal) + ? "\r\n" + : "\n"; + return $"{shared}{newLine}{newLine}{localized}"; + } + + [GeneratedRegex( + @"^##[ \t]+(?[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*)(?:[ \t]+#+)?[ \t]*(?:\r?\n|$)", + RegexOptions.CultureInvariant | RegexOptions.Multiline)] + private static partial Regex LanguageHeadingRegex { get; } + + private sealed record LanguageHeading(int Start, int ContentStart, string CultureName); + + private sealed record ReadmeSection(string CultureName, string Content); +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs new file mode 100644 index 00000000..6d83d90b --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPackageInstaller.cs @@ -0,0 +1,613 @@ +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using NuGet.Frameworks; +using NuGet.Packaging; +using NuGet.Packaging.Core; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// NuGetパッケージとそのランタイム依存関係を、プラグインフォルダへ展開します。 +/// +internal sealed class NuGetPackageInstaller( + FindPackageByIdResource packageResource, + ILogger logger, + IReadOnlyDictionary hostPackageVersions) +{ + private static readonly NuGetFramework HostFramework = GetHostFramework(); + private static readonly FrameworkReducer FrameworkReducer = new(); + + private static readonly string[] CompatibleRuntimeIdentifiers = + [RuntimeInformation.RuntimeIdentifier, "win", "any"]; + + private readonly FindPackageByIdResource packageResource = packageResource; + private readonly ILogger logger = logger; + private readonly IReadOnlyDictionary hostPackageVersions = hostPackageVersions; + + public async Task InstallAsync( + string packageId, + string version, + string destinationDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + PackageIdValidator.ValidatePackageId(packageId); + var requestedVersion = NuGetVersion.Parse(version); + var workDirectory = Path.Combine( + Path.GetTempPath(), + "WindowTranslatorPlugins", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workDirectory); + progress?.Report(0); + + try + { + var artifacts = await ResolvePackageGraphAsync( + packageId, + requestedVersion, + workDirectory, + progress, + cancellationToken).ConfigureAwait(false); + progress?.Report(60); + + Directory.CreateDirectory(destinationDirectory); + var orderedArtifacts = artifacts.OrderByDescending(a => + a.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)).ToArray(); + for (var index = 0; index < orderedArtifacts.Length; index++) + { + var artifact = orderedArtifacts[index]; + ExtractPackageAssets( + artifact.PackagePath, + destinationDirectory, + requirePluginAssembly: artifact.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + progress?.Report(60 + (30d * (index + 1) / orderedArtifacts.Length)); + } + + var rootPackage = artifacts.First(artifact => + artifact.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + return rootPackage.Metadata.Dependencies.First(dependency => + dependency.Id.Equals( + NuGetPluginService.AbstractionsPackageId, + StringComparison.OrdinalIgnoreCase)).VersionRange; + } + finally + { + TryDeleteDirectory(workDirectory); + } + } + + internal static string? SelectBestTfm(IEnumerable frameworks) + { + var candidates = frameworks + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(original => (Original: original, Framework: ParseFramework(original))) + .Where(candidate => candidate.Framework is not null) + .ToArray(); + + var nearest = FrameworkReducer.GetNearest( + HostFramework, + candidates.Select(candidate => candidate.Framework!)); + if (nearest is null) + { + return null; + } + + return candidates.First(candidate => NuGetFrameworkFullComparer.Instance.Equals( + candidate.Framework, + nearest)).Original; + } + + internal static PackageDependencyGroup? SelectBestDependencyGroup( + IEnumerable dependencyGroups) + { + var groups = dependencyGroups.ToArray(); + var frameworkGroups = groups + .Where(group => !group.TargetFramework.IsAny && !group.TargetFramework.IsUnsupported) + .ToArray(); + var nearest = FrameworkReducer.GetNearest( + HostFramework, + frameworkGroups.Select(group => group.TargetFramework)); + if (nearest is not null) + { + return frameworkGroups.First(group => NuGetFrameworkFullComparer.Instance.Equals( + group.TargetFramework, + nearest)); + } + + return groups.FirstOrDefault(group => group.TargetFramework.IsAny); + } + + private static NuGetFramework GetHostFramework() + { + var assembly = typeof(NuGetPackageInstaller).Assembly; + var frameworkName = assembly.GetCustomAttribute()?.FrameworkName + ?? throw new InvalidOperationException("WindowTranslator のターゲットフレームワークを取得できませんでした。"); + var framework = NuGetFramework.Parse(frameworkName); + var platformName = assembly.GetCustomAttribute()?.PlatformName; + return string.IsNullOrWhiteSpace(platformName) + ? framework + : NuGetFramework.ParseFolder($"{framework.GetShortFolderName()}-{platformName}"); + } + + private static NuGetFramework? ParseFramework(string framework) + { + if (string.IsNullOrWhiteSpace(framework)) + { + return null; + } + + var parsed = NuGetFramework.Parse(framework); + return parsed.IsUnsupported ? null : parsed; + } + + private async Task> ResolvePackageGraphAsync( + string rootPackageId, + NuGetVersion rootVersion, + string workDirectory, + IProgress? progress, + CancellationToken cancellationToken) + { + var constraints = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var selectedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + var artifacts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var queue = new Queue(); + var queued = new HashSet(StringComparer.OrdinalIgnoreCase); + using var cacheContext = new SourceCacheContext(); + + AddConstraint( + rootPackageId, + "$root", + new VersionRange( + rootVersion, + includeMinVersion: true, + maxVersion: rootVersion, + includeMaxVersion: true)); + + while (queue.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var currentId = queue.Dequeue(); + queued.Remove(currentId); + + if (!constraints.TryGetValue(currentId, out var currentConstraints) + || currentConstraints.Count == 0) + { + if (selectedVersions.Remove(currentId)) + { + artifacts.Remove(currentId); + RemoveConstraintsFrom(currentId); + } + continue; + } + + var ranges = currentConstraints.Select(c => c.Range).ToArray(); + var resolvedVersion = currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) + ? rootVersion + : await ResolveDependencyVersionAsync( + currentId, + ranges, + cacheContext, + cancellationToken).ConfigureAwait(false); + + if (!ranges.All(r => r.Satisfies(resolvedVersion))) + { + throw new InvalidOperationException( + $"パッケージ {currentId} の依存バージョンを解決できませんでした: {string.Join(", ", ranges)}"); + } + + if (selectedVersions.TryGetValue(currentId, out var existingVersion) + && existingVersion == resolvedVersion) + { + continue; + } + + if (selectedVersions.ContainsKey(currentId)) + { + RemoveConstraintsFrom(currentId); + } + selectedVersions[currentId] = resolvedVersion; + var packagePath = Path.Combine( + workDirectory, + $"{currentId.ToLowerInvariant()}.{resolvedVersion.ToNormalizedString().ToLowerInvariant()}.nupkg"); + await DownloadPackageAsync( + currentId, + resolvedVersion, + packagePath, + currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase) ? progress : null, + cacheContext, + cancellationToken).ConfigureAwait(false); + var metadata = ReadPackageMetadata(packagePath); + ValidateHostPackageDependencies( + metadata, + currentId, + resolvedVersion, + this.hostPackageVersions, + requirePluginPackage: currentId.Equals(rootPackageId, StringComparison.OrdinalIgnoreCase)); + + artifacts[currentId] = new PackageArtifact(currentId, packagePath, metadata); + foreach (var dependency in metadata.Dependencies.Where(IncludesRuntimeAssets)) + { + if (this.hostPackageVersions.ContainsKey(dependency.Id)) + { + continue; + } + + AddConstraint(dependency.Id, currentId, dependency.VersionRange); + } + } + + return artifacts.Values.ToArray(); + + void AddConstraint(string id, string source, VersionRange range) + { + PackageIdValidator.ValidatePackageId(id); + if (!constraints.TryGetValue(id, out var packageConstraints)) + { + packageConstraints = []; + constraints[id] = packageConstraints; + } + + if (packageConstraints.Any(c => + c.Source.Equals(source, StringComparison.OrdinalIgnoreCase) + && c.Range.ToString().Equals(range.ToString(), StringComparison.OrdinalIgnoreCase))) + { + return; + } + + packageConstraints.Add(new DependencyConstraint(source, range)); + Enqueue(id); + } + + void RemoveConstraintsFrom(string source) + { + foreach (var (id, packageConstraints) in constraints) + { + if (packageConstraints.RemoveAll(c => + c.Source.Equals(source, StringComparison.OrdinalIgnoreCase)) > 0) + { + Enqueue(id); + } + } + } + + void Enqueue(string id) + { + if (queued.Add(id)) + { + queue.Enqueue(id); + } + } + } + + private async Task ResolveDependencyVersionAsync( + string packageId, + IReadOnlyCollection ranges, + SourceCacheContext cacheContext, + CancellationToken cancellationToken) + { + var versions = await this.packageResource.GetAllVersionsAsync( + packageId, + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + var compatibleVersions = versions + .Where(v => ranges.All(r => r.Satisfies(v))) + .OrderBy(v => v) + .ToArray(); + return compatibleVersions.FirstOrDefault(v => !v.IsPrerelease) + ?? compatibleVersions.FirstOrDefault() + ?? throw new InvalidOperationException( + $"パッケージ {packageId} の依存条件を満たすバージョンがありません: {string.Join(", ", ranges)}"); + } + + private async Task DownloadPackageAsync( + string packageId, + NuGetVersion version, + string destinationPath, + IProgress? progress, + SourceCacheContext cacheContext, + CancellationToken cancellationToken) + { + this.logger.LogInformation("NuGetパッケージをダウンロード中: {PackageId} {Version}", packageId, version); + progress?.Report(10); + await using var destination = File.Create(destinationPath); + var copied = await this.packageResource.CopyNupkgToStreamAsync( + packageId, + version, + destination, + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + if (!copied) + { + throw new InvalidOperationException($"NuGetパッケージを取得できませんでした: {packageId} {version}"); + } + progress?.Report(50); + } + + private static PackageMetadata ReadPackageMetadata(string packagePath) + { + using var packageStream = File.OpenRead(packagePath); + using var packageReader = new PackageArchiveReader(packageStream); + var groups = packageReader.NuspecReader.GetDependencyGroups().ToArray(); + var selectedGroup = SelectBestDependencyGroup(groups); + if (selectedGroup is null && groups.Length > 0) + { + throw new InvalidOperationException("互換性のある依存関係グループが見つかりませんでした。"); + } + + var dependencies = new List(); + var anyGroup = groups.FirstOrDefault(group => group.TargetFramework.IsAny); + if (anyGroup is not null && !ReferenceEquals(anyGroup, selectedGroup)) + { + dependencies.AddRange(anyGroup.Packages); + } + if (selectedGroup is not null) + { + dependencies.AddRange(selectedGroup.Packages); + } + + var tags = packageReader.NuspecReader.GetTags(); + var hasPluginTag = tags?.Split( + [' ', '\t', '\r', '\n', ';', ','], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Contains(NuGetPluginService.PluginTag, StringComparer.OrdinalIgnoreCase) == true; + return new(dependencies, hasPluginTag); + } + + private static void ValidateHostPackageDependencies( + PackageMetadata metadata, + string packageId, + NuGetVersion packageVersion, + IReadOnlyDictionary hostPackageVersions, + bool requirePluginPackage) + { + if (requirePluginPackage) + { + if (!metadata.HasPluginTag) + { + throw new InvalidOperationException( + $"パッケージ {packageId} {packageVersion} はWindowTranslatorプラグインタグを持っていません。"); + } + + if (!PluginCompatibility.ValidationDisabled + && !hostPackageVersions.ContainsKey(NuGetPluginService.AbstractionsPackageId)) + { + throw new InvalidOperationException( + $"実行中の{NuGetPluginService.AbstractionsPackageId}のバージョンを確認できません。"); + } + + if (!metadata.Dependencies.Any(dependency => dependency.Id.Equals( + NuGetPluginService.AbstractionsPackageId, + StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + $"パッケージ {packageId} {packageVersion} は" + + $"{NuGetPluginService.AbstractionsPackageId}へ直接依存していません。"); + } + } + + foreach (var dependency in metadata.Dependencies) + { + if (!hostPackageVersions.TryGetValue(dependency.Id, out var hostVersion) + || PluginCompatibility.IsVersionCompatible(dependency.VersionRange, hostVersion)) + { + continue; + } + + throw new InvalidOperationException( + $"プラグイン {packageId} {packageVersion} は " + + $"{dependency.Id} {dependency.VersionRange} を必要としますが、" + + $"実行中のWindowTranslatorが提供するバージョンは {hostVersion} です。"); + } + } + + private static bool IncludesRuntimeAssets(PackageDependency dependency) + { + if (dependency.Exclude.Contains("all", StringComparer.OrdinalIgnoreCase) + || dependency.Exclude.Contains("runtime", StringComparer.OrdinalIgnoreCase)) + { + return false; + } + + return dependency.Include.Count == 0 + || dependency.Include.Contains("all", StringComparer.OrdinalIgnoreCase) + || dependency.Include.Contains("runtime", StringComparer.OrdinalIgnoreCase); + } + + private static void ExtractPackageAssets( + string packagePath, + string destinationDirectory, + bool requirePluginAssembly) + { + using var archive = ZipFile.OpenRead(packagePath); + var libEntries = archive.Entries + .Where(e => e.FullName.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name) + && e.Name != "_._") + .ToArray(); + var libGroups = libEntries + .Where(e => e.FullName.Split('/').Length >= 3) + .GroupBy(e => e.FullName.Split('/')[1]) + .ToArray(); + var hasPluginAssembly = ExtractRuntimeAssets(archive, destinationDirectory); + + if (!hasPluginAssembly && libGroups.Length > 0) + { + var selectedFramework = SelectBestTfm(libGroups.Select(g => g.Key)); + if (selectedFramework is not null) + { + var selectedEntries = libGroups.First(g => g.Key.Equals( + selectedFramework, + StringComparison.OrdinalIgnoreCase)).ToArray(); + hasPluginAssembly = selectedEntries.Any(e => + e.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); + + ExtractEntries( + selectedEntries, + $"lib/{selectedFramework}/", + destinationDirectory); + } + } + + if (requirePluginAssembly && !hasPluginAssembly) + { + throw new InvalidOperationException("プラグインパッケージに互換性のあるアセンブリが見つかりませんでした。"); + } + } + + private static bool ExtractRuntimeAssets(ZipArchive archive, string destinationDirectory) + { + var hasManagedAssembly = false; + var runtimeLibIdentifier = CompatibleRuntimeIdentifiers.FirstOrDefault(rid => + archive.Entries.Any(e => e.FullName.StartsWith( + $"runtimes/{rid}/lib/", + StringComparison.OrdinalIgnoreCase))); + var runtimeLibPrefix = $"runtimes/{runtimeLibIdentifier}/lib/"; + var runtimeLibGroups = archive.Entries + .Where(e => runtimeLibIdentifier is not null + && e.FullName.StartsWith(runtimeLibPrefix, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name)) + .Where(e => e.FullName.Split('/').Length >= 5) + .GroupBy(e => e.FullName.Split('/')[3]) + .ToArray(); + if (runtimeLibGroups.Length > 0) + { + var selectedFramework = SelectBestTfm(runtimeLibGroups.Select(g => g.Key)); + if (selectedFramework is not null) + { + var selectedEntries = runtimeLibGroups.First(g => g.Key.Equals( + selectedFramework, + StringComparison.OrdinalIgnoreCase)).ToArray(); + hasManagedAssembly = selectedEntries.Any(e => + e.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); + ExtractEntries( + selectedEntries, + $"{runtimeLibPrefix}{selectedFramework}/", + destinationDirectory); + } + } + + var nativeRuntimeIdentifier = CompatibleRuntimeIdentifiers.FirstOrDefault(rid => + archive.Entries.Any(e => e.FullName.StartsWith( + $"runtimes/{rid}/native/", + StringComparison.OrdinalIgnoreCase))); + if (nativeRuntimeIdentifier is null) + { + return hasManagedAssembly; + } + + var nativePrefix = $"runtimes/{nativeRuntimeIdentifier}/native/"; + ExtractEntries( + archive.Entries.Where(e => e.FullName.StartsWith(nativePrefix, StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(e.Name)), + nativePrefix, + destinationDirectory, + flatten: true); + return hasManagedAssembly; + } + + private static void ExtractEntries( + IEnumerable entries, + string prefix, + string destinationDirectory, + bool flatten = false) + { + foreach (var entry in entries) + { + var relativePath = flatten ? entry.Name : entry.FullName[prefix.Length..]; + if (string.IsNullOrWhiteSpace(relativePath)) + { + continue; + } + + var destinationPath = GetSafeDestinationPath(destinationDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + if (File.Exists(destinationPath)) + { + using var existing = File.OpenRead(destinationPath); + using var incoming = entry.Open(); + if (!StreamsEqual(existing, incoming)) + { + throw new InvalidOperationException( + $"依存パッケージ間でファイルが競合しています: {relativePath}"); + } + continue; + } + + entry.ExtractToFile(destinationPath); + } + } + + private static string GetSafeDestinationPath(string destinationDirectory, string relativePath) + { + var root = Path.GetFullPath(destinationDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var destination = Path.GetFullPath(Path.Combine( + root, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!destination.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"不正なパッケージエントリです: {relativePath}"); + } + + return destination; + } + + private static bool StreamsEqual(Stream left, Stream right) + { + if (left.Length != right.Length) + { + return false; + } + + var leftBuffer = new byte[81920]; + var rightBuffer = new byte[81920]; + int leftRead; + while ((leftRead = left.Read(leftBuffer, 0, leftBuffer.Length)) > 0) + { + var rightRead = right.Read(rightBuffer, 0, rightBuffer.Length); + if (leftRead != rightRead + || !leftBuffer.AsSpan(0, leftRead).SequenceEqual(rightBuffer.AsSpan(0, rightRead))) + { + return false; + } + } + + return right.ReadByte() == -1; + } + + private static void TryDeleteDirectory(string directory) + { + try + { + if (Directory.Exists(directory)) + { + Directory.Delete(directory, recursive: true); + } + } + catch + { + // 一時ディレクトリの後始末失敗はインストール結果へ影響させない + } + } + + private sealed record PackageArtifact( + string Id, + string PackagePath, + PackageMetadata Metadata); + + private sealed record DependencyConstraint(string Source, VersionRange Range); + + private sealed record PackageMetadata( + IReadOnlyList Dependencies, + bool HasPluginTag); + +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs new file mode 100644 index 00000000..ef31aa1e --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginCatalog.cs @@ -0,0 +1,507 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; +using NuGet.Versioning; +using Weikio.PluginFramework.Abstractions; +using Weikio.PluginFramework.Catalogs; +using Weikio.PluginFramework.Context; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// NuGet経由でインストールされたプラグインを一時フォルダからロードするカタログです。 +/// ファイルロックを回避するため、読み込み前にプラグインフォルダを一時フォルダにコピーします。 +/// +public sealed class NuGetPluginCatalog : IPluginCatalog +{ + private static readonly string DefaultTempDir = + Path.Combine(Path.GetTempPath(), "WindowTranslator", "nuget-plugins"); + + static NuGetPluginCatalog() + { +#if DISABLE_PLUGIN_COMPATIBILITY_VALIDATION + var hostAbstractions = typeof(UserSettings).Assembly; + AssemblyLoadContext.Default.Resolving += (_, assemblyName) => + string.Equals( + assemblyName.Name, + hostAbstractions.GetName().Name, + StringComparison.OrdinalIgnoreCase) + ? hostAbstractions + : null; +#endif + } + + private readonly string sourceDir; + private readonly string tempDir; + private readonly int hostMajorVersion; + private readonly NuGetVersion hostAbstractionsVersion; + private readonly FolderPluginCatalogOptions options; + private CompositePluginCatalog innerCatalog = new(); + + public NuGetPluginCatalog( + string sourceDir, + int hostMajorVersion, + NuGetVersion hostAbstractionsVersion, + FolderPluginCatalogOptions options) + : this( + sourceDir, + DefaultTempDir, + hostMajorVersion, + hostAbstractionsVersion, + options) + { + } + + internal NuGetPluginCatalog( + string sourceDir, + string tempDir, + int hostMajorVersion, + NuGetVersion hostAbstractionsVersion, + FolderPluginCatalogOptions options) + { + this.sourceDir = sourceDir; + this.tempDir = tempDir; + this.hostMajorVersion = hostMajorVersion; + this.hostAbstractionsVersion = hostAbstractionsVersion; + this.options = options; + } + + /// + public bool IsInitialized => this.innerCatalog.IsInitialized; + + /// + public async Task Initialize() + { + DeleteUninstalledPackageDirectories(this.sourceDir); + var loadablePackages = GetLoadablePackageIds( + this.sourceDir, + this.hostMajorVersion, + this.hostAbstractionsVersion); + SynchronizePluginFiles(this.sourceDir, this.tempDir, loadablePackages); + + this.innerCatalog = CreateCatalog(this.tempDir, this.options); + await this.innerCatalog.Initialize().ConfigureAwait(false); + } + + /// + public List GetPlugins() => this.innerCatalog.GetPlugins(); + + /// + public Plugin Get(string name, Version version) => this.innerCatalog.Get(name, version); + + private static CompositePluginCatalog CreateCatalog( + string directory, + FolderPluginCatalogOptions baseOptions) + { + return new CompositePluginCatalog([.. Directory + .EnumerateDirectories(directory) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Select(packageDirectory => new FolderPluginCatalog( + packageDirectory, + CreateCatalogOptions(baseOptions, packageDirectory))) ]); + } + + private static FolderPluginCatalogOptions CreateCatalogOptions( + FolderPluginCatalogOptions baseOptions, + string pluginDirectory) + { + var baseLoadOptions = baseOptions.PluginLoadContextOptions; + var files = Directory + .EnumerateFiles(pluginDirectory, "*", SearchOption.AllDirectories) + .Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + .Select(path => CreatePluginFile(pluginDirectory, path)) + .OrderBy(file => GetRuntimeAssetPriority(file.RelativePath)) + .ThenBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var satelliteAssemblies = files + .Where(file => file.IsSatelliteAssembly) + .GroupBy(file => GetSatelliteKey( + file.AssemblyName!.Name!, + file.AssemblyName.CultureName!)) + .ToDictionary( + group => group.Key, + group => group.First(), + StringComparer.OrdinalIgnoreCase); + + var runtimeHints = new List( + baseLoadOptions.RuntimeAssemblyHints ?? []); + var hintKeys = runtimeHints + .Select(hint => GetHintKey(hint.FileName, hint.IsNative)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var file in files + .Where(file => !file.IsSatelliteAssembly) + .OrderByDescending(file => file.IsManaged)) + { + var isNative = !file.IsManaged; + if (hintKeys.Add(GetHintKey(Path.GetFileName(file.Path), isNative))) + { + runtimeHints.Add(new RuntimeAssemblyHint( + Path.GetFileName(file.Path), + file.Path, + isNative)); + } + } + + var additionalRuntimePaths = new List( + baseLoadOptions.AdditionalRuntimePaths ?? []); + foreach (var path in files + .Where(file => file.IsManaged && !file.IsSatelliteAssembly) + .Select(file => Path.GetDirectoryName(file.Path)!) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!additionalRuntimePaths.Contains(path, StringComparer.OrdinalIgnoreCase)) + { + additionalRuntimePaths.Add(path); + } + } + + return new FolderPluginCatalogOptions + { + IncludeSubfolders = true, + SearchPatterns = [.. baseOptions.SearchPatterns], + TypeFinderOptions = baseOptions.TypeFinderOptions, + PluginNameOptions = CreatePluginNameOptions( + baseOptions.PluginNameOptions, + satelliteAssemblies), + PluginLoadContextOptions = new PluginLoadContextOptions + { + UseHostApplicationAssemblies = baseLoadOptions.UseHostApplicationAssemblies, + HostApplicationAssemblies = [.. baseLoadOptions.HostApplicationAssemblies], + LoggerFactory = baseLoadOptions.LoggerFactory, + AdditionalRuntimePaths = additionalRuntimePaths, + RuntimeAssemblyHints = runtimeHints, + }, + }; + } + + private static PluginFile CreatePluginFile(string pluginDirectory, string path) + { + var assemblyName = TryGetAssemblyName(path); + return new PluginFile( + path, + Path.GetRelativePath(pluginDirectory, path), + assemblyName is not null, + assemblyName); + } + + private static PluginNameOptions CreatePluginNameOptions( + PluginNameOptions baseOptions, + IReadOnlyDictionary satelliteAssemblies) + { + if (satelliteAssemblies.Count == 0) + { + return baseOptions; + } + + var configuredContexts = new HashSet(); + var contextLock = new object(); + + void EnsureSatelliteResolver(Type type) + { + var context = AssemblyLoadContext.GetLoadContext(type.Assembly); + if (context is null || context == AssemblyLoadContext.Default) + { + return; + } + + lock (contextLock) + { + if (configuredContexts.Add(context)) + { + context.Resolving += ResolveSatelliteAssembly; + } + } + } + + Assembly? ResolveSatelliteAssembly( + AssemblyLoadContext context, + AssemblyName requestedAssembly) + { + if (string.IsNullOrWhiteSpace(requestedAssembly.Name) + || string.IsNullOrWhiteSpace(requestedAssembly.CultureName) + || !satelliteAssemblies.TryGetValue( + GetSatelliteKey(requestedAssembly.Name, requestedAssembly.CultureName), + out var satelliteAssembly) + || requestedAssembly.Version is not null + && satelliteAssembly.AssemblyName!.Version != requestedAssembly.Version) + { + return null; + } + + return context.LoadFromAssemblyPath(satelliteAssembly.Path); + } + + return new PluginNameOptions + { + PluginNameGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginNameGenerator(baseOptions, type); + }, + PluginVersionGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginVersionGenerator(baseOptions, type); + }, + PluginDescriptionGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginDescriptionGenerator(baseOptions, type); + }, + PluginProductVersionGenerator = (_, type) => + { + EnsureSatelliteResolver(type); + return baseOptions.PluginProductVersionGenerator(baseOptions, type); + }, + }; + } + + private static AssemblyName? TryGetAssemblyName(string path) + { + try + { + return AssemblyName.GetAssemblyName(path); + } + catch (BadImageFormatException) + { + return null; + } + catch (FileLoadException) + { + return null; + } + } + + private static int GetRuntimeAssetPriority(string relativePath) + => relativePath.StartsWith( + $"runtimes{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase) + ? 0 + : 1; + + private static string GetHintKey(string fileName, bool isNative) + => $"{(isNative ? 'N' : 'M')}:{fileName}"; + + private static string GetSatelliteKey(string assemblyName, string cultureName) + => $"{cultureName}:{assemblyName}"; + + internal static void SynchronizePluginFiles( + string source, + string destination, + IReadOnlySet includedRootDirectories) + { + Directory.CreateDirectory(destination); + + var sourceFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + var sourceDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Directory.Exists(source)) + { + foreach (var packageDirectory in Directory.EnumerateDirectories(source) + .Where(path => includedRootDirectories.Contains(Path.GetFileName(path)))) + { + sourceDirectories.Add(Path.GetRelativePath(source, packageDirectory)); + CollectSourceEntries( + source, + packageDirectory, + sourceFiles, + sourceDirectories); + } + } + + foreach (var relativeDirectory in sourceDirectories.OrderBy(GetPathDepth)) + { + var destinationDirectory = Path.Combine(destination, relativeDirectory); + if (File.Exists(destinationDirectory)) + { + File.Delete(destinationDirectory); + } + + Directory.CreateDirectory(destinationDirectory); + } + + foreach (var (relativePath, sourceFile) in sourceFiles) + { + var destinationFile = Path.Combine(destination, relativePath); + if (FilesMatch(sourceFile, destinationFile)) + { + continue; + } + + if (Directory.Exists(destinationFile)) + { + Directory.Delete(destinationFile, recursive: true); + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + CopyFile(sourceFile, destinationFile); + } + + foreach (var destinationFile in Directory.EnumerateFiles( + destination, + "*", + SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(destination, destinationFile); + if (!sourceFiles.ContainsKey(relativePath)) + { + File.Delete(destinationFile); + } + } + + foreach (var destinationDirectory in Directory + .EnumerateDirectories(destination, "*", SearchOption.AllDirectories) + .OrderByDescending(GetPathDepth)) + { + var relativePath = Path.GetRelativePath(destination, destinationDirectory); + if (!sourceDirectories.Contains(relativePath)) + { + Directory.Delete(destinationDirectory, recursive: true); + } + } + } + + internal static HashSet GetLoadablePackageIds( + string sourceDirectory, + int hostMajorVersion, + NuGetVersion hostAbstractionsVersion) + => (TryLoadManifest(sourceDirectory)?.Packages ?? []) + .Where(package => PluginCompatibility.IsInstalledPackageCompatible( + package.HostMajorVersion, + hostMajorVersion, + package.AbstractionsVersionRange, + hostAbstractionsVersion)) + .Select(package => package.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + internal static void DeleteUninstalledPackageDirectories(string sourceDirectory) + { + var manifest = TryLoadManifest(sourceDirectory); + if (manifest is null || !Directory.Exists(sourceDirectory)) + { + return; + } + + var installedPackageIds = manifest.Packages + .Select(package => package.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var packageDirectory in Directory.EnumerateDirectories(sourceDirectory)) + { + var directoryName = Path.GetFileName(packageDirectory); + if (installedPackageIds.Contains(directoryName)) + { + continue; + } + + try + { + Directory.Delete(packageDirectory, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Trace.TraceWarning( + "アンインストール済みNuGetプラグインの削除に失敗しました: {0} ({1})", + packageDirectory, + ex); + } + } + } + + private static InstalledManifest? TryLoadManifest(string sourceDirectory) + { + try + { + using var stream = File.OpenRead(Path.Combine( + sourceDirectory, + "nuget-manifest.json")); + var manifest = JsonSerializer.Deserialize( + stream, + NuGetPluginService.ManifestJsonOptions) + ?? throw new InvalidDataException("プラグインmanifestが空です。"); + return manifest.Packages is null + ? throw new InvalidDataException("プラグインmanifestにパッケージ一覧がありません。") + : manifest; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or InvalidDataException + or JsonException) + { + return null; + } + } + + private static void CollectSourceEntries( + string sourceRoot, + string currentDirectory, + Dictionary sourceFiles, + HashSet sourceDirectories) + { + foreach (var file in Directory.EnumerateFiles(currentDirectory)) + { + sourceFiles[Path.GetRelativePath(sourceRoot, file)] = file; + } + + foreach (var subDirectory in Directory.EnumerateDirectories(currentDirectory)) + { + var relativePath = Path.GetRelativePath(sourceRoot, subDirectory); + sourceDirectories.Add(relativePath); + CollectSourceEntries( + sourceRoot, + subDirectory, + sourceFiles, + sourceDirectories); + } + } + + private static bool FilesMatch(string source, string destination) + { + if (!File.Exists(destination)) + { + return false; + } + + var sourceInfo = new FileInfo(source); + var destinationInfo = new FileInfo(destination); + return sourceInfo.Length == destinationInfo.Length + && sourceInfo.LastWriteTimeUtc == destinationInfo.LastWriteTimeUtc + && sourceInfo.CreationTimeUtc == destinationInfo.CreationTimeUtc; + } + + private static void CopyFile(string source, string destination) + { + var temporaryPath = $"{destination}.sync-{Guid.NewGuid():N}"; + try + { + File.Copy(source, temporaryPath); + File.SetCreationTimeUtc(temporaryPath, File.GetCreationTimeUtc(source)); + File.SetLastWriteTimeUtc(temporaryPath, File.GetLastWriteTimeUtc(source)); + File.Move(temporaryPath, destination, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static int GetPathDepth(string path) + => path.Count(character => + character == Path.DirectorySeparatorChar + || character == Path.AltDirectorySeparatorChar); + + private sealed record PluginFile( + string Path, + string RelativePath, + bool IsManaged, + AssemblyName? AssemblyName) + { + public bool IsSatelliteAssembly + => !string.IsNullOrWhiteSpace(this.AssemblyName?.CultureName); + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs new file mode 100644 index 00000000..2ab0936b --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginOperation.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using System.IO; +using NuGet.Packaging; + +namespace WindowTranslator.Modules.PluginStore; + +internal sealed class NuGetPluginOperation : IDisposable +{ + private bool committed; + + internal NuGetPluginOperation(string rootDirectory, string packageId) + { + PackageIdValidator.ValidatePackageId(packageId); + var rootPath = Path.GetFullPath(rootDirectory); + var operationId = Guid.NewGuid().ToString("N"); + this.TargetPath = Path.Combine(rootPath, packageId); + this.WorkingPath = Path.Combine(rootPath, $"{packageId}.installing-{operationId}"); + this.BackupPath = Path.Combine(rootPath, $"{packageId}.backup-{operationId}"); + Directory.CreateDirectory(this.WorkingPath); + } + + internal string TargetPath { get; } + + internal string WorkingPath { get; } + + internal string BackupPath { get; } + + internal void Commit() => this.committed = true; + + public void Dispose() + { + try + { + if (!this.committed) + { + if (!Directory.Exists(this.WorkingPath) && Directory.Exists(this.TargetPath)) + { + Directory.Move(this.TargetPath, this.WorkingPath); + } + if (Directory.Exists(this.BackupPath)) + { + Directory.Move(this.BackupPath, this.TargetPath); + } + } + + DeleteDirectoryIfExists(this.WorkingPath); + DeleteDirectoryIfExists(this.BackupPath); + } + catch (Exception ex) + { + Trace.TraceWarning("NuGet plugin operation cleanup failed: {0} ({1})", this.TargetPath, ex); + } + } + + private static void DeleteDirectoryIfExists(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs new file mode 100644 index 00000000..ac252097 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/NuGetPluginService.cs @@ -0,0 +1,613 @@ +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.Threading; +using NuGet.Packaging; +using NuGet.Packaging.Core; +using NuGet.Protocol.Core.Types; +using NuGet.Versioning; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// NuGetクライアントSDKを使用してプラグインパッケージの検索・インストール・管理を行うサービスです。 +/// +public sealed class NuGetPluginService : BackgroundService +{ + internal const string NuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json"; + internal const string HttpClientName = "NuGetPluginReadme"; + internal const string PluginTag = "windowtranslator-plugin"; + internal const string AbstractionsPackageId = "WindowTranslator.Abstractions"; + internal const string OfficialPackageOwner = "Freesia"; + private const int SearchResultLimit = 100; + private const int MaxConcurrentMetadataRequests = 8; + private static readonly TimeSpan PackageInformationRefreshInterval = TimeSpan.FromHours(1); + + internal static readonly JsonSerializerOptions ManifestJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly IHttpClientFactory httpClientFactory; + private readonly SourceRepository repository; + private readonly ILogger logger; + private readonly string nugetPluginsDir; + private readonly string manifestPath; + private readonly IReadOnlyDictionary hostPackageVersions; + private readonly int hostMajorVersion; + private readonly AsyncSemaphore operationLock = new(1); + private readonly AsyncSemaphore refreshLock = new(1); + private readonly object snapshotLock = new(); + private PluginStoreSnapshot packageSnapshot = PluginStoreSnapshot.Empty; + private bool hideDisclaimer; + private int restartRequired; + private int disposed; + + internal NuGetPluginService( + ILogger logger, + IHttpClientFactory httpClientFactory, + SourceRepository repository, + string nugetPluginsDir, + IReadOnlyDictionary hostPackageVersions, + int hostMajorVersion) + { + this.logger = logger; + this.httpClientFactory = httpClientFactory; + this.repository = repository; + this.nugetPluginsDir = Path.GetFullPath(nugetPluginsDir); + this.manifestPath = Path.Combine(this.nugetPluginsDir, "nuget-manifest.json"); + this.hostPackageVersions = hostPackageVersions; + this.hostMajorVersion = hostMajorVersion; + this.hideDisclaimer = TryLoadHideDisclaimer(this.manifestPath, this.logger); + } + + internal event EventHandler? PackageInformationUpdated; + + internal bool HideDisclaimer => Volatile.Read(ref this.hideDisclaimer); + + internal bool IsRestartRequired => Volatile.Read(ref this.restartRequired) != 0; + + internal PluginStoreSnapshot PackageSnapshot + { + get + { + lock (this.snapshotLock) + { + return this.packageSnapshot; + } + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await RefreshPackageInformationAsync(stoppingToken).ConfigureAwait(false); + await Task.Delay(PackageInformationRefreshInterval, stoppingToken).ConfigureAwait(false); + } + } + + internal async Task RefreshPackageInformationAsync(CancellationToken cancellationToken = default) + { + using var refresh = await this.refreshLock.EnterAsync(cancellationToken); + var previousSnapshot = this.PackageSnapshot; + var packages = previousSnapshot.Packages; + Exception? error = null; + try + { + var searchResult = await SearchPackagesCoreAsync(cancellationToken).ConfigureAwait(false); + packages = searchResult.Packages; + error = searchResult.Error; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "NuGetからプラグイン情報を更新できませんでした。"); + error = ex; + } + + using var operation = await this.operationLock.EnterAsync(cancellationToken); + IReadOnlyList installedPackages; + try + { + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + installedPackages = GetCompatibilityAwarePackages(manifest.Packages); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "インストール済みプラグイン情報を更新できませんでした。"); + installedPackages = this.PackageSnapshot.InstalledPackages; + error = ex; + } + + SetPackageSnapshot(new( + InstalledPackages: installedPackages, + Packages: packages, + Error: error)); + } + + private async Task<(IReadOnlyList Packages, Exception? Error)> SearchPackagesCoreAsync( + CancellationToken cancellationToken) + { + var searchResource = await this.repository + .GetResourceAsync(cancellationToken) + .ConfigureAwait(false); + var metadataResource = await this.repository + .GetResourceAsync(cancellationToken) + .ConfigureAwait(false); + var searchResults = (await searchResource.SearchAsync( + $"tags:{PluginTag}", + new(includePrerelease: true) { IncludeDelisted = false }, + skip: 0, + take: SearchResultLimit, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false)) + .Where(metadata => !string.IsNullOrWhiteSpace(metadata.Identity?.Id)) + .ToArray(); + this.logger.LogInformation("NuGetタグ検索完了: {Count}件の候補が見つかりました。", searchResults.Length); + + using var requestGate = new AsyncSemaphore(MaxConcurrentMetadataRequests); + var packageTasks = searchResults.Select(async data => + { + using var request = await requestGate.EnterAsync(cancellationToken); + try + { + var package = await CreateCompatiblePackageInfoAsync( + data, + metadataResource, + cancellationToken).ConfigureAwait(false); + return (Package: package, Error: (Exception?)null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + var packageError = new InvalidOperationException( + $"NuGetパッケージ {data.Identity.Id} のメタデータを取得できませんでした。", + ex); + this.logger.LogWarning( + packageError, + "NuGetパッケージのプラグイン互換性を確認できなかったため除外します: {PackageId}", + data.Identity.Id); + return (Package: (NuGetPackageInfo?)null, Error: packageError); + } + }); + var results = await Task.WhenAll(packageTasks).ConfigureAwait(false); + var compatiblePackages = results + .Where(result => result.Package is not null) + .Select(result => result.Package!) + .ToArray(); + var errors = results + .Where(result => result.Error is not null) + .Select(result => result.Error!) + .ToArray(); + var error = errors.Length == 0 + ? null + : new AggregateException( + "一部のNuGetパッケージ情報を取得できませんでした。", + errors); + + this.logger.LogInformation( + "NuGet互換性確認完了: {Count}件のWindowTranslatorプラグインが見つかりました。", + compatiblePackages.Length); + return new(compatiblePackages, error); + } + + /// + /// 指定したパッケージバージョンのREADMEを取得します。 + /// + public async Task GetPackageReadmeAsync(string packageId, string version, CultureInfo culture, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(packageId)) + { + throw new ArgumentException("NuGetパッケージIDが空です。", nameof(packageId)); + } + if (!NuGetVersion.TryParse(version, out var packageVersion)) + { + throw new ArgumentException($"不正なNuGetパッケージバージョンです: {version}", nameof(version)); + } + ArgumentNullException.ThrowIfNull(culture); + + var metadataResource = await this.repository + .GetResourceAsync(cancellationToken) + .ConfigureAwait(false); + using var cacheContext = new SourceCacheContext(); + var metadata = await metadataResource.GetMetadataAsync( + new PackageIdentity(packageId, packageVersion), + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + var readmeUrl = metadata?.ReadmeFileUrl; + if (string.IsNullOrWhiteSpace(readmeUrl)) + { + return null; + } + + using var httpClient = this.httpClientFactory.CreateClient(HttpClientName); + using var response = await httpClient.GetAsync(readmeUrl, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + var markdown = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + return LocalizedReadmeSelector.Select(markdown, culture); + } + + /// + /// 指定したNuGetパッケージをインストールします。 + /// + public async Task InstallPackageAsync(string packageId, string version, IProgress? progress = null, CancellationToken cancellationToken = default) + { + using var operation = await this.operationLock.EnterAsync(cancellationToken); + var currentManifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + using var pluginOperation = new NuGetPluginOperation(this.nugetPluginsDir, packageId); + + var packageResource = await this.repository + .GetResourceAsync(cancellationToken) + .ConfigureAwait(false); + var installer = new NuGetPackageInstaller( + packageResource, + this.logger, + this.hostPackageVersions); + var abstractionsVersionRange = await installer.InstallAsync( + packageId, + version, + pluginOperation.WorkingPath, + progress, + cancellationToken).ConfigureAwait(false); + + if (Directory.Exists(pluginOperation.TargetPath)) + { + Directory.Move(pluginOperation.TargetPath, pluginOperation.BackupPath); + } + Directory.Move(pluginOperation.WorkingPath, pluginOperation.TargetPath); + progress?.Report(95); + + var updatedManifest = currentManifest with + { + Packages = + [ + .. currentManifest.Packages.Where(package => + !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)), + new( + packageId, + version, + this.hostMajorVersion, + abstractionsVersionRange.ToString()), + ], + HideDisclaimer = this.HideDisclaimer, + }; + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + pluginOperation.Commit(); + UpdateInstalledPackages(updatedManifest.Packages); + Volatile.Write(ref this.restartRequired, 1); + progress?.Report(100); + + this.logger.LogInformation( + "パッケージのインストール完了: {PackageId} {Version} -> {TargetDir}", + packageId, + version, + pluginOperation.TargetPath); + } + + /// + /// 指定したパッケージをアンインストール対象としてmanifestから削除します。 + /// 管理フォルダの実体は次回起動時に削除されます。 + /// + public async Task UninstallPackageAsync(string packageId, CancellationToken cancellationToken = default) + { + using var operation = await this.operationLock.EnterAsync(cancellationToken); + this.logger.LogInformation("パッケージをアンインストール: {PackageId}", packageId); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var updatedManifest = manifest with + { + Packages = [.. manifest.Packages.Where(package => + !package.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase))], + HideDisclaimer = this.HideDisclaimer, + }; + await SaveManifestAsync(updatedManifest, cancellationToken).ConfigureAwait(false); + UpdateInstalledPackages(updatedManifest.Packages); + Volatile.Write(ref this.restartRequired, 1); + + this.logger.LogInformation( + "パッケージ {PackageId} をアンインストール対象として記録しました。管理フォルダは次回起動時に削除されます。", + packageId); + } + + internal async Task SetHideDisclaimerAsync( + bool value, + CancellationToken cancellationToken = default) + { + Volatile.Write(ref this.hideDisclaimer, value); + using var operation = await this.operationLock.EnterAsync(cancellationToken); + var manifest = await LoadManifestAsync(cancellationToken).ConfigureAwait(false); + var currentValue = this.HideDisclaimer; + if (manifest.HideDisclaimer == currentValue) + { + return; + } + + await SaveManifestAsync( + manifest with { HideDisclaimer = currentValue }, + cancellationToken).ConfigureAwait(false); + } + + private async Task CreateCompatiblePackageInfoAsync( + IPackageSearchMetadata data, + PackageMetadataResource metadataResource, + CancellationToken cancellationToken) + { + var packageId = data.Identity.Id; + using var cacheContext = new SourceCacheContext(); + var versions = await metadataResource.GetMetadataAsync( + packageId, + includePrerelease: true, + includeUnlisted: false, + cacheContext, + NuGet.Common.NullLogger.Instance, + cancellationToken).ConfigureAwait(false); + var compatibleVersions = versions + .Where(version => version.Identity?.Version is not null + && version.IsListed + && HasCompatibleAbstractionsDependency(version.DependencySets)) + .OrderBy(version => version.Identity.Version) + .ToArray(); + if (compatibleVersions.Length == 0) + { + this.logger.LogDebug( + "WindowTranslator.Abstractionsへの互換依存がないため除外します: {PackageId}", + packageId); + return null; + } + + return new NuGetPackageInfo( + Id: packageId, + Title: data.Title ?? packageId, + Description: data.Description ?? string.Empty, + Authors: data.Authors ?? string.Empty, + ProjectUrl: data.ProjectUrl?.AbsoluteUri, + LicenseUrl: data.LicenseUrl?.AbsoluteUri, + Versions: compatibleVersions + .Select(version => version.Identity.Version.ToNormalizedString()) + .ToArray(), + IconUrl: data.IconUrl?.AbsoluteUri, + IsOfficial: data.OwnersList.Contains( + OfficialPackageOwner, + StringComparer.OrdinalIgnoreCase)); + } + + private bool HasCompatibleAbstractionsDependency( + IEnumerable? dependencyGroups) + { + var dependencyGroup = NuGetPackageInstaller.SelectBestDependencyGroup(dependencyGroups ?? []); + var dependency = dependencyGroup?.Packages.FirstOrDefault(item => + item.Id.Equals(AbstractionsPackageId, StringComparison.OrdinalIgnoreCase)); + if (dependency is null) + { + return false; + } + + this.hostPackageVersions.TryGetValue(AbstractionsPackageId, out var hostVersion); + return PluginCompatibility.IsVersionCompatible(dependency.VersionRange, hostVersion); + } + + internal static IReadOnlyDictionary CreateHostPackageVersions() + { + var abstractionsAssembly = typeof(UserSettings).Assembly; + var informationalVersion = abstractionsAssembly + .GetCustomAttribute()? + .InformationalVersion; + if (!string.IsNullOrWhiteSpace(informationalVersion) + && NuGetVersion.TryParse(informationalVersion, out var packageVersion)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AbstractionsPackageId] = packageVersion, + }; + } + + var assemblyVersion = abstractionsAssembly.GetName().Version + ?? throw new InvalidOperationException( + "WindowTranslator.Abstractions のバージョンを取得できませんでした。"); + var fallbackVersion = new NuGetVersion( + assemblyVersion.Major, + assemblyVersion.Minor, + Math.Max(assemblyVersion.Build, 0)); + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AbstractionsPackageId] = fallbackVersion, + }; + } + + private InstalledPackageInfo[] GetCompatibilityAwarePackages( + IEnumerable packages) + => packages + .Select(package => package with + { + IsCompatible = PluginCompatibility.IsInstalledPackageCompatible( + package.HostMajorVersion, + this.hostMajorVersion, + package.AbstractionsVersionRange, + this.hostPackageVersions.GetValueOrDefault(AbstractionsPackageId)), + }) + .ToArray(); + + private void UpdateInstalledPackages(IEnumerable packages) + { + lock (this.snapshotLock) + { + this.packageSnapshot = this.packageSnapshot with + { + InstalledPackages = GetCompatibilityAwarePackages(packages), + }; + } + NotifyPackageInformationUpdated(); + } + + private void SetPackageSnapshot(PluginStoreSnapshot snapshot) + { + lock (this.snapshotLock) + { + this.packageSnapshot = snapshot; + } + + NotifyPackageInformationUpdated(); + } + + private void NotifyPackageInformationUpdated() + { + foreach (EventHandler handler in this.PackageInformationUpdated?.GetInvocationList() + .Cast() ?? []) + { + try + { + handler(this, EventArgs.Empty); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "プラグイン情報更新イベントの通知に失敗しました。"); + } + } + } + + private async Task LoadManifestAsync(CancellationToken cancellationToken) + { + if (!File.Exists(this.manifestPath)) + { + return new InstalledManifest([]); + } + + try + { + await using var fs = File.OpenRead(this.manifestPath); + var manifest = await JsonSerializer.DeserializeAsync( + fs, + ManifestJsonOptions, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidDataException("プラグインマニフェストが空です。"); + if (manifest.Packages is null) + { + throw new InvalidDataException( + "プラグインマニフェストにインストール済みパッケージ一覧がありません。"); + } + + return manifest; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this.logger.LogWarning(ex, "プラグインマニフェストの読み込みに失敗しました。"); + throw new InvalidOperationException("プラグインマニフェストを読み込めませんでした。", ex); + } + } + + private Task SaveManifestAsync(InstalledManifest manifest, CancellationToken cancellationToken) + => SaveManifestAsync(this.manifestPath, manifest, cancellationToken); + + private static bool TryLoadHideDisclaimer( + string manifestPath, + ILogger logger) + { + if (!File.Exists(manifestPath)) + { + return false; + } + + try + { + using var stream = File.OpenRead(manifestPath); + return JsonSerializer.Deserialize(stream, ManifestJsonOptions) + ?.HideDisclaimer ?? false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + logger.LogWarning(ex, "プラグインマニフェストから免責事項の表示設定を読み込めませんでした。"); + return false; + } + } + + internal static async Task SaveManifestAsync( + string manifestPath, + InstalledManifest manifest, + CancellationToken cancellationToken) + { + Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!); + var temporaryPath = $"{manifestPath}.tmp-{Guid.NewGuid():N}"; + try + { + var json = JsonSerializer.Serialize(manifest, ManifestJsonOptions); + await File.WriteAllTextAsync( + temporaryPath, + json, + cancellationToken).ConfigureAwait(false); + File.Move(temporaryPath, manifestPath, overwrite: true); + } + finally + { + File.Delete(temporaryPath); + } + } + + public override void Dispose() + { + if (Interlocked.Exchange(ref this.disposed, 1) != 0) + { + return; + } + + base.Dispose(); + this.operationLock.Dispose(); + this.refreshLock.Dispose(); + } + +} + +/// NuGetパッケージ情報 +public record NuGetPackageInfo( + string Id, + string Title, + string Description, + string Authors, + string? ProjectUrl, + string? LicenseUrl, + IReadOnlyList Versions, + string? IconUrl = null, + bool IsOfficial = false +); + +/// インストール済みパッケージ情報 +public record InstalledPackageInfo( + string Id, + string Version, + [property: JsonRequired] int HostMajorVersion, + [property: JsonRequired] string AbstractionsVersionRange) +{ + [JsonIgnore] + public bool IsCompatible { get; init; } = true; +} + +/// NuGetプラグインの管理マニフェスト +public record InstalledManifest( + List Packages, + bool HideDisclaimer = false); + +internal sealed record PluginStoreSnapshot( + IReadOnlyList InstalledPackages, + IReadOnlyList Packages, + Exception? Error) +{ + public static PluginStoreSnapshot Empty { get; } = new([], [], null); +} diff --git a/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs new file mode 100644 index 00000000..084f75c6 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginCompatibility.cs @@ -0,0 +1,37 @@ +using NuGet.Versioning; + +namespace WindowTranslator.Modules.PluginStore; + +internal static class PluginCompatibility +{ +#if DISABLE_PLUGIN_COMPATIBILITY_VALIDATION + internal static bool ValidationDisabled => true; +#else + internal static bool ValidationDisabled => false; +#endif + + internal static bool IsVersionCompatible(VersionRange? requiredVersion, NuGetVersion? hostVersion) + => ValidationDisabled + || (hostVersion is not null && requiredVersion?.Satisfies(hostVersion) is not false); + + internal static bool IsHostMajorCompatible(int installedHostMajorVersion, int hostMajorVersion) + => ValidationDisabled + || installedHostMajorVersion == hostMajorVersion; + + internal static bool IsInstalledPackageCompatible( + int installedHostMajorVersion, + int hostMajorVersion, + string? abstractionsVersionRange, + NuGetVersion? hostAbstractionsVersion) + { + if (ValidationDisabled) + { + return true; + } + + return IsHostMajorCompatible(installedHostMajorVersion, hostMajorVersion) + && !string.IsNullOrWhiteSpace(abstractionsVersionRange) + && VersionRange.TryParse(abstractionsVersionRange, out var requiredVersion) + && IsVersionCompatible(requiredVersion, hostAbstractionsVersion); + } +} diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml new file mode 100644 index 00000000..60a93c38 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml @@ -0,0 +1,428 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs new file mode 100644 index 00000000..78bce56d --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginStoreView.xaml.cs @@ -0,0 +1,12 @@ +namespace WindowTranslator.Modules.PluginStore; + +/// +/// PluginStoreView.xaml の相互作用ロジック +/// +public partial class PluginStoreView +{ + public PluginStoreView() + { + InitializeComponent(); + } +} diff --git a/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs new file mode 100644 index 00000000..50513d89 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PluginStoreViewModel.cs @@ -0,0 +1,580 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using NuGet.Versioning; +using System.Windows; +using WindowTranslator.Properties; +using Wpf.Ui; +using Wpf.Ui.Extensions; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// プラグインストアのViewModel +/// +public partial class PluginStoreViewModel : ObservableObject, IDisposable +{ + private readonly NuGetPluginService nugetService; + private readonly ILogger logger; + private readonly IContentDialogService dialogService; + private CancellationTokenSource? readmeLoadCancellation; + private PluginPackageViewModel? selectedPackage; + private PluginStoreSnapshot? pendingSnapshot; + private PluginStoreSnapshot? appliedSnapshot; + private bool disposed; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasError))] + private string? errorMessage; + + [ObservableProperty] + private bool hideDisclaimer; + + [ObservableProperty] + private bool requiresRestart; + + public bool HasError => this.ErrorMessage is not null; + + public PluginPackageViewModel? SelectedPackage + { + get => this.selectedPackage; + set + { + var previous = this.selectedPackage; + if (!SetProperty(ref this.selectedPackage, value)) + { + return; + } + + if (previous is not null) + { + previous.PropertyChanged -= OnSelectedPackagePropertyChanged; + previous.IsReadmeLoading = false; + } + if (value is not null) + { + value.PropertyChanged += OnSelectedPackagePropertyChanged; + StartReadmeLoad(value); + } + else + { + this.readmeLoadCancellation?.Cancel(); + this.readmeLoadCancellation = null; + } + } + } + + public ObservableCollection Packages { get; } = []; + + public PluginStoreViewModel( + NuGetPluginService nugetService, + ILogger logger, + IContentDialogService dialogService) + { + this.nugetService = nugetService; + this.logger = logger; + this.dialogService = dialogService; + this.hideDisclaimer = this.nugetService.HideDisclaimer; + this.requiresRestart = this.nugetService.IsRestartRequired; + this.nugetService.PackageInformationUpdated += OnPackageInformationUpdated; + ApplyPackageSnapshot(this.nugetService.PackageSnapshot); + } + + partial void OnHideDisclaimerChanged(bool value) + => _ = SaveHideDisclaimerAsync(value); + + private async Task SaveHideDisclaimerAsync(bool value) + { + try + { + await this.nugetService.SetHideDisclaimerAsync(value).ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogError(ex, "プラグインストアの免責事項の表示設定を保存できませんでした。"); + } + } + + private void OnPackageInformationUpdated(object? sender, EventArgs e) + { + if (this.disposed) + { + return; + } + + var snapshot = this.nugetService.PackageSnapshot; + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher is null || dispatcher.CheckAccess()) + { + ApplyPackageSnapshot(snapshot); + } + else + { + _ = dispatcher.BeginInvoke(() => ApplyPackageSnapshot(snapshot)); + } + } + + private void ApplyPackageSnapshot(PluginStoreSnapshot snapshot) + { + if (this.disposed) + { + return; + } + if (ReferenceEquals(this.appliedSnapshot, snapshot)) + { + return; + } + if (this.Packages.Any(package => package.IsInstalling)) + { + this.pendingSnapshot = snapshot; + return; + } + + this.pendingSnapshot = null; + this.appliedSnapshot = snapshot; + var selectedPackageId = this.SelectedPackage?.Id; + var prereleaseSelections = this.Packages.ToDictionary( + package => package.Id, + package => package.UsePrerelease, + StringComparer.OrdinalIgnoreCase); + var installedPackages = snapshot.InstalledPackages.ToDictionary( + package => package.Id, + StringComparer.OrdinalIgnoreCase); + + this.Packages.Clear(); + foreach (var packageInfo in snapshot.Packages) + { + installedPackages.TryGetValue(packageInfo.Id, out var installedPackage); + var package = new PluginPackageViewModel( + packageInfo, + isInstalled: installedPackage is not null, + installedVersion: installedPackage?.Version, + isCompatible: installedPackage?.IsCompatible ?? true, + hasCompatiblePackageVersion: true); + if (package.HasPrereleaseVersion + && prereleaseSelections.TryGetValue(package.Id, out var usePrerelease)) + { + package.UsePrerelease = usePrerelease; + } + this.Packages.Add(package); + } + + foreach (var installedPackage in snapshot.InstalledPackages) + { + if (this.Packages.Any(package => package.Id.Equals( + installedPackage.Id, + StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + this.Packages.Add(new PluginPackageViewModel( + new NuGetPackageInfo( + Id: installedPackage.Id, + Title: installedPackage.Id, + Description: string.Empty, + Authors: string.Empty, + ProjectUrl: null, + LicenseUrl: null, + Versions: []), + isInstalled: true, + installedVersion: installedPackage.Version, + isCompatible: installedPackage.IsCompatible, + hasCompatiblePackageVersion: false)); + } + + this.SelectedPackage = selectedPackageId is null + ? null + : this.Packages.FirstOrDefault(package => package.Id.Equals( + selectedPackageId, + StringComparison.OrdinalIgnoreCase)); + this.ErrorMessage = snapshot.Error is null ? null : Resources.NuGetSearchFailed; + this.logger.LogInformation( + "バックグラウンド更新から{Count}件のプラグインパッケージを反映しました。", + snapshot.Packages.Count); + } + + private void ApplyPendingSnapshot() + { + if (this.pendingSnapshot is { } snapshot + && !this.Packages.Any(package => package.IsInstalling)) + { + ApplyPackageSnapshot(snapshot); + } + } + + /// + /// プラグインをインストールまたは更新します。 + /// + [RelayCommand] + public async Task InstallAsync( + PluginPackageViewModel package, + CancellationToken cancellationToken = default) + { + var version = package.LatestVersion; + if (string.IsNullOrWhiteSpace(version)) + { + return; + } + + package.InstallProgress = 0; + package.IsInstalling = true; + try + { + this.logger.LogInformation("プラグインのインストール開始: {PackageId} {Version}", package.Id, version); + var progress = new Progress(v => package.InstallProgress = v); + await this.nugetService.InstallPackageAsync( + package.Id, + version, + progress, + cancellationToken).ConfigureAwait(true); + + package.IsInstalled = true; + package.InstalledVersion = version; + package.IsCompatible = true; + this.RequiresRestart = this.nugetService.IsRestartRequired; + + this.logger.LogInformation("プラグインのインストール完了: {PackageId}", package.Id); + + await ShowRestartDialogAsync( + Resources.PluginInstallSuccess, + cancellationToken).ConfigureAwait(true); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // キャンセルは正常 + } + catch (Exception ex) + { + this.logger.LogError(ex, "プラグインのインストールに失敗しました: {PackageId}", package.Id); + await this.dialogService.ShowAlertAsync( + Resources.PluginInstallFailed, + ex.Message, + Resources.Close, + cancellationToken).ConfigureAwait(true); + } + finally + { + package.IsInstalling = false; + ApplyPendingSnapshot(); + } + } + + /// + /// プラグインをアンインストールします。 + /// + [RelayCommand] + public async Task UninstallAsync(PluginPackageViewModel package) + { + var result = await this.dialogService.ShowSimpleDialogAsync(new() + { + Title = Resources.Uninstall, + Content = string.Format(Resources.UninstallConfirm, package.Title), + PrimaryButtonText = Resources.Uninstall, + CloseButtonText = Resources.Cancel, + }).ConfigureAwait(true); + + if (result != Wpf.Ui.Controls.ContentDialogResult.Primary) + return; + + package.IsInstalling = true; + try + { + await this.nugetService.UninstallPackageAsync(package.Id).ConfigureAwait(true); + package.IsInstalled = false; + package.InstalledVersion = null; + package.IsCompatible = true; + this.RequiresRestart = this.nugetService.IsRestartRequired; + + await ShowRestartDialogAsync(Resources.Uninstall).ConfigureAwait(true); + } + catch (Exception ex) + { + this.logger.LogError(ex, "プラグインのアンインストールに失敗しました: {PackageId}", package.Id); + await this.dialogService.ShowAlertAsync( + Resources.Uninstall, + ex.Message, + Resources.Close).ConfigureAwait(true); + } + finally + { + package.IsInstalling = false; + ApplyPendingSnapshot(); + } + } + + private async Task ShowRestartDialogAsync( + string title, + CancellationToken cancellationToken = default) + { + var result = await this.dialogService.ShowSimpleDialogAsync(new() + { + Title = title, + Content = Resources.RestartRequired, + PrimaryButtonText = Resources.RestartNow, + CloseButtonText = Resources.ReviewLater, + }, cancellationToken).ConfigureAwait(true); + if (result == Wpf.Ui.Controls.ContentDialogResult.Primary) + { + await SaveHideDisclaimerAsync(this.HideDisclaimer).ConfigureAwait(true); + ApplicationRestart.Restart(); + } + } + + [RelayCommand] + private async Task RestartAsync() + { + await SaveHideDisclaimerAsync(this.HideDisclaimer).ConfigureAwait(true); + ApplicationRestart.Restart(); + } + + private void OnSelectedPackagePropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is PluginPackageViewModel package + && ReferenceEquals(package, this.SelectedPackage) + && e.PropertyName == nameof(PluginPackageViewModel.LatestVersion)) + { + StartReadmeLoad(package); + } + } + + private void StartReadmeLoad(PluginPackageViewModel package) + { + this.readmeLoadCancellation?.Cancel(); + this.readmeLoadCancellation = null; + package.ReadmeMarkdown = null; + + var version = package.LatestVersion; + if (string.IsNullOrWhiteSpace(version)) + { + package.IsReadmeLoading = false; + return; + } + + var cancellationSource = new CancellationTokenSource(); + this.readmeLoadCancellation = cancellationSource; + package.IsReadmeLoading = true; + _ = LoadPackageReadmeAsync(package, version, cancellationSource); + } + + private async Task LoadPackageReadmeAsync( + PluginPackageViewModel package, + string version, + CancellationTokenSource cancellationSource) + { + try + { + var readme = await this.nugetService.GetPackageReadmeAsync( + package.Id, + version, + CultureInfo.CurrentUICulture, + cancellationSource.Token).ConfigureAwait(true); + if (!cancellationSource.IsCancellationRequested + && ReferenceEquals(package, this.SelectedPackage) + && string.Equals(version, package.LatestVersion, StringComparison.OrdinalIgnoreCase)) + { + package.ReadmeMarkdown = readme; + } + } + catch (OperationCanceledException) when (cancellationSource.IsCancellationRequested) + { + // 選択変更によるキャンセルは正常 + } + catch (Exception ex) + { + this.logger.LogWarning( + ex, + "プラグインREADMEの取得に失敗しました: {PackageId} {Version}", + package.Id, + version); + } + finally + { + if (ReferenceEquals(this.readmeLoadCancellation, cancellationSource)) + { + package.IsReadmeLoading = false; + this.readmeLoadCancellation = null; + } + cancellationSource.Dispose(); + } + } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.disposed = true; + this.nugetService.PackageInformationUpdated -= OnPackageInformationUpdated; + this.readmeLoadCancellation?.Cancel(); + this.readmeLoadCancellation = null; + GC.SuppressFinalize(this); + } + +} + +/// +/// プラグインパッケージの表示モデル +/// +public partial class PluginPackageViewModel : ObservableObject +{ + public string Id { get; } + public string Title { get; } + public string Description { get; } + public string Authors { get; } + public string? IconUrl { get; } + public bool IsOfficial { get; } + public string? ReleaseVersion { get; } + public string? PrereleaseVersion { get; } + public string? LatestVersion => this.UsePrerelease + ? GetLatestVersion(this.ReleaseVersion, this.PrereleaseVersion) + : this.ReleaseVersion; + public bool HasPrereleaseVersion => this.PrereleaseVersion is not null; + public bool CanInstall => !this.IsInstalling && this.LatestVersion is not null; + public bool RequiresReinstall => this.IsInstalled + && !this.IsCompatible + && this.hasCompatiblePackageVersion + && this.LatestVersion is not null; + public bool CanUpdate => this.IsUpdateAvailable || this.RequiresReinstall; + public string? ProjectUrl { get; } + public string? LicenseUrl { get; } + public bool HasProjectUrl => this.ProjectUrl is not null; + public bool HasLicenseUrl => this.LicenseUrl is not null; + private readonly bool hasCompatiblePackageVersion; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsNotInstalled))] + [NotifyPropertyChangedFor(nameof(RequiresReinstall))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] + private bool isInstalled; + + public bool IsNotInstalled => !this.IsInstalled; + + [ObservableProperty] + private string? installedVersion; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanUpdate))] + private bool isUpdateAvailable; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(RequiresReinstall))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] + private bool isCompatible; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanInstall))] + private bool isInstalling; + + [ObservableProperty] + private double installProgress; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasReadme))] + private string? readmeMarkdown; + + [ObservableProperty] + private bool isReadmeLoading; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(LatestVersion))] + [NotifyPropertyChangedFor(nameof(CanInstall))] + [NotifyPropertyChangedFor(nameof(RequiresReinstall))] + [NotifyPropertyChangedFor(nameof(CanUpdate))] + private bool usePrerelease; + + public bool HasReadme => !string.IsNullOrWhiteSpace(this.ReadmeMarkdown); + + public PluginPackageViewModel( + NuGetPackageInfo info, + bool isInstalled, + string? installedVersion, + bool isCompatible = true, + bool hasCompatiblePackageVersion = true) + { + var versions = info.Versions + .Where(version => !string.IsNullOrWhiteSpace(version)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(version => (Text: version, Parsed: ParseVersion(version))) + .Where(version => version.Parsed is not null) + .ToArray(); + + this.Id = info.Id; + this.Title = info.Title; + this.Description = info.Description; + this.Authors = info.Authors; + this.IconUrl = info.IconUrl; + this.IsOfficial = info.IsOfficial; + this.ReleaseVersion = versions + .Where(version => !version.Parsed!.IsPrerelease) + .OrderByDescending(version => version.Parsed) + .Select(version => version.Text) + .FirstOrDefault(); + this.PrereleaseVersion = versions + .Where(version => version.Parsed!.IsPrerelease) + .OrderByDescending(version => version.Parsed) + .Select(version => version.Text) + .FirstOrDefault(); + this.ProjectUrl = info.ProjectUrl; + this.LicenseUrl = info.LicenseUrl; + this.hasCompatiblePackageVersion = hasCompatiblePackageVersion; + this.isInstalled = isInstalled; + this.installedVersion = installedVersion; + this.isCompatible = isCompatible; + this.usePrerelease = this.PrereleaseVersion is not null + && NuGetVersion.TryParse(installedVersion, out var installed) + && installed.IsPrerelease; + RefreshUpdateAvailable(); + } + + partial void OnIsInstalledChanged(bool value) => RefreshUpdateAvailable(); + + partial void OnInstalledVersionChanged(string? value) => RefreshUpdateAvailable(); + + partial void OnUsePrereleaseChanged(bool value) => RefreshUpdateAvailable(); + + partial void OnIsCompatibleChanged(bool value) => RefreshUpdateAvailable(); + + private void RefreshUpdateAvailable() + { + this.IsUpdateAvailable = this.IsInstalled + && this.InstalledVersion is not null + && this.LatestVersion is not null + && IsNewerVersion(this.LatestVersion, this.InstalledVersion); + } + + private static NuGetVersion? ParseVersion(string version) + => NuGetVersion.TryParse(version, out var parsed) ? parsed : null; + + private static string? GetLatestVersion(string? releaseVersion, string? prereleaseVersion) + { + if (releaseVersion is null) + { + return prereleaseVersion; + } + if (prereleaseVersion is null) + { + return releaseVersion; + } + + return IsNewerVersion(prereleaseVersion, releaseVersion) + ? prereleaseVersion + : releaseVersion; + } + + private static bool IsNewerVersion(string latestVersion, string installedVersion) + { + if (NuGetVersion.TryParse(latestVersion, out var latest) + && NuGetVersion.TryParse(installedVersion, out var installed)) + { + return latest > installed; + } + + return string.Compare(latestVersion, installedVersion, StringComparison.OrdinalIgnoreCase) > 0; + } +} diff --git a/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs new file mode 100644 index 00000000..3fc20909 --- /dev/null +++ b/WindowTranslator/Modules/PluginStore/PrioritizedPluginCatalog.cs @@ -0,0 +1,55 @@ +using System.Reflection; +using Weikio.PluginFramework.Abstractions; + +namespace WindowTranslator.Modules.PluginStore; + +/// +/// 優先カタログのプラグインを先に返し、同じアセンブリ名のフォールバックプラグインを除外します。 +/// +internal sealed class PrioritizedPluginCatalog( + IPluginCatalog preferredCatalog, + IPluginCatalog fallbackCatalog) : IPluginCatalog +{ + private readonly IPluginCatalog preferredCatalog = preferredCatalog; + private readonly IPluginCatalog fallbackCatalog = fallbackCatalog; + + /// + public bool IsInitialized + => this.preferredCatalog.IsInitialized && this.fallbackCatalog.IsInitialized; + + /// + public async Task Initialize() + { + await this.preferredCatalog.Initialize().ConfigureAwait(false); + await this.fallbackCatalog.Initialize().ConfigureAwait(false); + } + + /// + public List GetPlugins() + { + var plugins = this.preferredCatalog.GetPlugins() + .Concat(this.fallbackCatalog.GetPlugins()) + .ToList(); + var selectedAssemblyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var selectedAssemblies = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var assembly in plugins + .Select(plugin => plugin.Type.Assembly) + .Distinct(ReferenceEqualityComparer.Instance)) + { + var assemblyName = assembly.GetName().Name; + if (assemblyName is null || selectedAssemblyNames.Add(assemblyName)) + { + selectedAssemblies.Add(assembly); + } + } + + return plugins + .Where(plugin => selectedAssemblies.Contains(plugin.Type.Assembly)) + .ToList(); + } + + /// + public Plugin Get(string name, Version version) + => this.GetPlugins().FirstOrDefault(plugin => + plugin.Name == name && plugin.Version == version)!; +} diff --git a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml index 87815a2b..70a9b153 100644 --- a/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml +++ b/WindowTranslator/Modules/Settings/AllSettingsDialog.xaml @@ -6,6 +6,7 @@ xmlns:data="clr-namespace:WindowTranslator.Data" xmlns:local="clr-namespace:WindowTranslator.Modules.Settings" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:pluginStore="clr-namespace:WindowTranslator.Modules.PluginStore" xmlns:properties="clr-namespace:WindowTranslator.Properties" xmlns:pt="http://propertytools.org/wpf" xmlns:root="clr-namespace:WindowTranslator" @@ -203,6 +204,11 @@ + + + + this.reviewRequestService.CanOpenReview; + public PluginStoreViewModel PluginStore { get; } + public AllSettingsViewModel( [Inject] PluginProvider provider, [Inject] IOptionsSnapshot options, @@ -108,6 +111,7 @@ public AllSettingsViewModel( [Inject] IEnumerable validators, [Inject] IMainWindowModule mainWindowModule, [Inject] ILogger logger, + [Inject] PluginStoreViewModel pluginStoreViewModel, string target, bool? applyMode = null) { @@ -146,6 +150,7 @@ public AllSettingsViewModel( this.logger = logger; this.target = target; this.rootConfig = config as IConfigurationRoot; + this.PluginStore = pluginStoreViewModel; this.updateChecker.UpdateAvailable += UpdateChecker_UpdateAvailable; SetUpUpdateInfo(); this.isStartup = GetIsStartup(); @@ -313,6 +318,7 @@ private DisposeAction EnterBusy() public void Dispose() { this.updateChecker.UpdateAvailable -= UpdateChecker_UpdateAvailable; + this.PluginStore.Dispose(); } } diff --git a/WindowTranslator/Program.cs b/WindowTranslator/Program.cs index dae1cf60..12925c88 100644 --- a/WindowTranslator/Program.cs +++ b/WindowTranslator/Program.cs @@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; +using System.Net; +using System.Net.Http; using System.Reflection; using System.Windows; using System.Windows.Markup; @@ -12,6 +14,8 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NuGet.Protocol; +using NuGet.Protocol.Core.Types; using Octokit; using Sentry.Extensions.Logging; using Weikio.PluginFramework.Abstractions; @@ -27,6 +31,7 @@ using WindowTranslator.Modules.LogView; using WindowTranslator.Modules.Main; using WindowTranslator.Modules.Ocr; +using WindowTranslator.Modules.PluginStore; using WindowTranslator.Modules.Settings; using WindowTranslator.Modules.Startup; using WindowTranslator.Modules.Validate; @@ -37,6 +42,8 @@ //Thread.CurrentThread.CurrentUICulture = System.Globalization.CultureInfo.GetCultureInfo("it"); //Thread.CurrentThread.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo("it"); +args = ApplicationRestart.WaitForPreviousProcess(args); + #if NO_MUTEX var createdNew = true; #else @@ -108,17 +115,31 @@ .AddPluginType() .AddPluginType(); -var pluginFolderCatalog = new CompositePluginCatalog(); +var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); +var nugetPluginsDir = Path.Combine(PathUtility.UserDir, "nuget-plugins"); +var hostPackageVersions = NuGetPluginService.CreateHostPackageVersions(); +IPluginCatalog pluginFolderCatalog = new NuGetPluginCatalog( + nugetPluginsDir, + AppInfo.Instance.Version.Major, + hostPackageVersions[NuGetPluginService.AbstractionsPackageId], + new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } }); +var fallbackPluginCatalogs = new List(); var appPluginDir = @".\plugins"; if (Directory.Exists(appPluginDir)) { - pluginFolderCatalog.AddCatalog(new FolderPluginCatalog(appPluginDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); + fallbackPluginCatalogs.Add( + new FolderPluginCatalog(appPluginDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); } - -var userPluginsDir = Path.Combine(PathUtility.UserDir, "plugins"); if (Directory.Exists(userPluginsDir)) { - pluginFolderCatalog.AddCatalog(new FolderPluginCatalog(userPluginsDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); + fallbackPluginCatalogs.Add( + new FolderPluginCatalog(userPluginsDir, options: new() { PluginNameOptions = { PluginNameGenerator = GetPluginName } })); +} +if (fallbackPluginCatalogs.Count > 0) +{ + pluginFolderCatalog = new PrioritizedPluginCatalog( + pluginFolderCatalog, + new CompositePluginCatalog([.. fallbackPluginCatalogs])); } builder.Services.AddPluginCatalog(pluginFolderCatalog); @@ -157,6 +178,23 @@ builder.Services.AddPresentation(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHttpClient(NuGetPluginService.HttpClientName, client => + client.Timeout = TimeSpan.FromSeconds(30)) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + }); +builder.Services.AddSingleton(_ => + NuGet.Protocol.Core.Types.Repository.Factory.GetCoreV3(NuGetPluginService.NuGetServiceIndexUrl)); +builder.Services.AddSingleton(sp => new NuGetPluginService( + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + nugetPluginsDir, + hostPackageVersions, + AppInfo.Instance.Version.Major)) + .AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddTransient(); builder.Services.Configure(builder.Configuration, op => op.ErrorOnUnknownConfiguration = false); builder.Services.Configure(builder.Configuration.GetSection(nameof(UserSettings.Common))); builder.Services.AddTransient(typeof(IConfigureNamedOptions<>), typeof(ConfigurePluginParam<>)); diff --git a/WindowTranslator/Properties/AssemblyInfo.cs b/WindowTranslator/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..cbbe402c --- /dev/null +++ b/WindowTranslator/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WindowTranslator.Tests")] diff --git a/WindowTranslator/Properties/Resources.Designer.cs b/WindowTranslator/Properties/Resources.Designer.cs index 5baefe6e..d44c65d5 100644 --- a/WindowTranslator/Properties/Resources.Designer.cs +++ b/WindowTranslator/Properties/Resources.Designer.cs @@ -277,6 +277,21 @@ internal Resources() { /// public static string InMemoryCache => ResourceManager.GetString("InMemoryCache", resourceCulture) ?? string.Empty; + /// + /// "インストール" に類似しているローカライズされた文字列を検索します。 + /// + public static string Install => ResourceManager.GetString("Install", resourceCulture) ?? string.Empty; + + /// + /// "インストール済み" に類似しているローカライズされた文字列を検索します。 + /// + public static string Installed => ResourceManager.GetString("Installed", resourceCulture) ?? string.Empty; + + /// + /// "インストール済みバージョン" に類似しているローカライズされた文字列を検索します。 + /// + public static string InstalledVersionLabel => ResourceManager.GetString("InstalledVersionLabel", resourceCulture) ?? string.Empty; + /// /// "新しいバージョン: {0} のインストール" に類似しているローカライズされた文字列を検索します。 /// @@ -317,11 +332,21 @@ internal Resources() { /// public static string Language => ResourceManager.GetString("Language", resourceCulture) ?? string.Empty; + /// + /// "最新バージョン" に類似しているローカライズされた文字列を検索します。 + /// + public static string LatestVersion => ResourceManager.GetString("LatestVersion", resourceCulture) ?? string.Empty; + /// /// "ライセンス" に類似しているローカライズされた文字列を検索します。 /// public static string License => ResourceManager.GetString("License", resourceCulture) ?? string.Empty; + /// + /// "ライセンス情報" に類似しているローカライズされた文字列を検索します。 + /// + public static string LicenseUrl => ResourceManager.GetString("LicenseUrl", resourceCulture) ?? string.Empty; + /// /// "ローカルファイルキャッシュ" に類似しているローカライズされた文字列を検索します。 /// @@ -367,6 +392,11 @@ internal Resources() { /// public static string NoTranslateModule => ResourceManager.GetString("NoTranslateModule", resourceCulture) ?? string.Empty; + /// + /// "NuGetからのプラグイン一覧の取得に失敗しました。ネットワーク接続を確認してください。" に類似しているローカライズされた文字列を検索します。 + /// + public static string NuGetSearchFailed => ResourceManager.GetString("NuGetSearchFailed", resourceCulture) ?? string.Empty; + /// /// "{0}のOCR機能が使えません。対象の言語機能をインストールしてください" に類似しているローカライズされた文字列を検索します。 /// @@ -417,6 +447,41 @@ internal Resources() { /// public static string Plugin => ResourceManager.GetString("Plugin", resourceCulture) ?? string.Empty; + /// + /// "インストール失敗" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginInstallFailed => ResourceManager.GetString("PluginInstallFailed", resourceCulture) ?? string.Empty; + + /// + /// "インストール完了" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginInstallSuccess => ResourceManager.GetString("PluginInstallSuccess", resourceCulture) ?? string.Empty; + + /// + /// "公式プラグイン" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginOfficial => ResourceManager.GetString("PluginOfficial", resourceCulture) ?? string.Empty; + + /// + /// "プラグイン" に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginStore => ResourceManager.GetString("PluginStore", resourceCulture) ?? string.Empty; + + /// + /// "プラグインはNuGet上の第三者パッケージです。各パッケージのライセンスは所有者から付与され、Win..." に類似しているローカライズされた文字列を検索します。 + /// + public static string PluginStoreDisclaimer => ResourceManager.GetString("PluginStoreDisclaimer", resourceCulture) ?? string.Empty; + + /// + /// "プレリリース" に類似しているローカライズされた文字列を検索します。 + /// + public static string Prerelease => ResourceManager.GetString("Prerelease", resourceCulture) ?? string.Empty; + + /// + /// "プロジェクトページ" に類似しているローカライズされた文字列を検索します。 + /// + public static string ProjectUrl => ResourceManager.GetString("ProjectUrl", resourceCulture) ?? string.Empty; + /// /// "公開ページ" に類似しているローカライズされた文字列を検索します。 /// @@ -427,6 +492,21 @@ internal Resources() { /// public static string RegisterAutoStart => ResourceManager.GetString("RegisterAutoStart", resourceCulture) ?? string.Empty; + /// + /// "今すぐ再起動" に類似しているローカライズされた文字列を検索します。 + /// + public static string RestartNow => ResourceManager.GetString("RestartNow", resourceCulture) ?? string.Empty; + + /// + /// "プラグインの変更を適用するには、WindowTranslatorを再起動してください。" に類似しているローカライズされた文字列を検索します。 + /// + public static string RestartRequired => ResourceManager.GetString("RestartRequired", resourceCulture) ?? string.Empty; + + /// + /// "再起動" に類似しているローカライズされた文字列を検索します。 + /// + public static string Restart => ResourceManager.GetString("Restart", resourceCulture) ?? string.Empty; + /// /// "後で" に類似しているローカライズされた文字列を検索します。 /// @@ -562,6 +642,16 @@ internal Resources() { /// public static string UnhundledErrorMessage => ResourceManager.GetString("UnhundledErrorMessage", resourceCulture) ?? string.Empty; + /// + /// "アンインストール" に類似しているローカライズされた文字列を検索します。 + /// + public static string Uninstall => ResourceManager.GetString("Uninstall", resourceCulture) ?? string.Empty; + + /// + /// "{0} をアンインストールしますか?" に類似しているローカライズされた文字列を検索します。 + /// + public static string UninstallConfirm => ResourceManager.GetString("UninstallConfirm", resourceCulture) ?? string.Empty; + /// /// "選択したウィンドウ「{0}」はプロセスを特定できないため、キャプチャー出来ません。&#13;..." に類似しているローカライズされた文字列を検索します。 /// @@ -572,6 +662,16 @@ internal Resources() { /// public static string UnregisterAutoStart => ResourceManager.GetString("UnregisterAutoStart", resourceCulture) ?? string.Empty; + /// + /// "更新" に類似しているローカライズされた文字列を検索します。 + /// + public static string Update => ResourceManager.GetString("Update", resourceCulture) ?? string.Empty; + + /// + /// "更新あり" に類似しているローカライズされた文字列を検索します。 + /// + public static string UpdateAvailable => ResourceManager.GetString("UpdateAvailable", resourceCulture) ?? string.Empty; + /// /// "最新バージョンに更新" に類似しているローカライズされた文字列を検索します。 /// diff --git a/WindowTranslator/Properties/Resources.ar.resx b/WindowTranslator/Properties/Resources.ar.resx index 840afd77..b042929a 100644 --- a/WindowTranslator/Properties/Resources.ar.resx +++ b/WindowTranslator/Properties/Resources.ar.resx @@ -447,4 +447,64 @@ + + المكونات الإضافية + + + إصدار أولي + + + تثبيت + + + مثبت + + + تحديث + + + إلغاء التثبيت + + + هل أنت متأكد من رغبتك في إلغاء تثبيت {0}؟ + + + يتوفر تحديث + + + الإصدار المثبت + + + أحدث إصدار + + + فشل استرداد قائمة المكونات الإضافية من NuGet. يرجى التحقق من اتصالك بالشبكة. + + + اكتمل التثبيت + + + فشل التثبيت + + + إعادة التشغيل الآن + + + يرجى إعادة تشغيل WindowTranslator لتطبيق تغييرات المكون الإضافي. + + + إعادة التشغيل + + + صفحة المشروع + + + معلومات الترخيص + + + المكونات الإضافية حزم تابعة لجهات خارجية موزعة عبر NuGet. يمنح مالك كل حزمة ترخيصها، ولا يضمن WindowTranslator محتواها أو سلوكها أو أمانها. ثبّت فقط الحزم التي تثق بها. + + + مكوّن إضافي رسمي + diff --git a/WindowTranslator/Properties/Resources.cs.resx b/WindowTranslator/Properties/Resources.cs.resx index 31d903ef..9886e4b4 100644 --- a/WindowTranslator/Properties/Resources.cs.resx +++ b/WindowTranslator/Properties/Resources.cs.resx @@ -337,4 +337,64 @@ Monitory nejsou podporovány. + + Pluginy + + + Předběžná verze + + + Nainstalovat + + + Nainstalováno + + + Aktualizovat + + + Odinstalovat + + + Opravdu chcete odinstalovat {0}? + + + Dostupná aktualizace + + + Nainstalovaná verze + + + Nejnovější verze + + + Nepodařilo se načíst seznam pluginů z NuGet. Zkontrolujte připojení k síti. + + + Instalace dokončena + + + Instalace se nezdařila + + + Restartovat nyní + + + Restartujte WindowTranslator, aby se změny pluginu projevily. + + + Restartovat + + + Stránka projektu + + + Informace o licenci + + + Pluginy jsou balíčky třetích stran distribuované prostřednictvím NuGet. Licenci ke každému balíčku uděluje jeho vlastník; WindowTranslator nezaručuje jeho obsah, chování ani bezpečnost. Instalujte pouze balíčky, kterým důvěřujete. + + + Oficiální plugin + diff --git a/WindowTranslator/Properties/Resources.de.resx b/WindowTranslator/Properties/Resources.de.resx index 1b51a657..6e270c9c 100644 --- a/WindowTranslator/Properties/Resources.de.resx +++ b/WindowTranslator/Properties/Resources.de.resx @@ -456,4 +456,64 @@ Monitore werden nicht unterstützt. + + Plugins + + + Vorabversion + + + Installieren + + + Installiert + + + Aktualisieren + + + Deinstallieren + + + Möchten Sie {0} wirklich deinstallieren? + + + Update verfügbar + + + Installierte Version + + + Neueste Version + + + Fehler beim Abrufen der Plugin-Liste von NuGet. Bitte überprüfen Sie Ihre Netzwerkverbindung. + + + Installation abgeschlossen + + + Installation fehlgeschlagen + + + Jetzt neu starten + + + Bitte starten Sie WindowTranslator neu, um Plugin-Änderungen anzuwenden. + + + Neu starten + + + Projektseite + + + Lizenzinformationen + + + Plugins sind Drittanbieterpakete, die über NuGet verteilt werden. Die Lizenz jedes Pakets wird vom jeweiligen Eigentümer gewährt; WindowTranslator übernimmt keine Gewähr für Inhalt, Verhalten oder Sicherheit. Installieren Sie nur vertrauenswürdige Pakete. + + + Offizielles Plugin + diff --git a/WindowTranslator/Properties/Resources.en.resx b/WindowTranslator/Properties/Resources.en.resx index c8c80457..d3e76d5e 100644 --- a/WindowTranslator/Properties/Resources.en.resx +++ b/WindowTranslator/Properties/Resources.en.resx @@ -456,4 +456,64 @@ Monitors are not supported. + + Plugins + + + Prerelease + + + Install + + + Installed + + + Update + + + Uninstall + + + Are you sure you want to uninstall {0}? + + + Update available + + + Installed version + + + Latest version + + + Failed to retrieve plugin list from NuGet. Please check your network connection. + + + Installation complete + + + Installation failed + + + Restart now + + + Please restart WindowTranslator to apply plugin changes. + + + Restart + + + Project page + + + License information + + + Plugins are third-party packages distributed through NuGet. Each package is licensed by its owner; WindowTranslator does not guarantee its content, behavior, or safety. Install only packages you trust. + + + Official plugin + diff --git a/WindowTranslator/Properties/Resources.es.resx b/WindowTranslator/Properties/Resources.es.resx index f3388633..b85f99a1 100644 --- a/WindowTranslator/Properties/Resources.es.resx +++ b/WindowTranslator/Properties/Resources.es.resx @@ -447,4 +447,64 @@ + + Plugins + + + Versión preliminar + + + Instalar + + + Instalado + + + Actualizar + + + Desinstalar + + + ¿Está seguro de que desea desinstalar {0}? + + + Actualización disponible + + + Versión instalada + + + Última versión + + + Error al recuperar la lista de plugins de NuGet. Compruebe su conexión de red. + + + Instalación completada + + + Error de instalación + + + Reiniciar ahora + + + Reinicie WindowTranslator para aplicar los cambios del plugin. + + + Reiniciar + + + Página del proyecto + + + Información de licencia + + + Los complementos son paquetes de terceros distribuidos mediante NuGet. La licencia de cada paquete la concede su propietario; WindowTranslator no garantiza su contenido, funcionamiento ni seguridad. Instala solo paquetes de confianza. + + + Complemento oficial + diff --git a/WindowTranslator/Properties/Resources.fa.resx b/WindowTranslator/Properties/Resources.fa.resx index a961b79f..13f059d9 100644 --- a/WindowTranslator/Properties/Resources.fa.resx +++ b/WindowTranslator/Properties/Resources.fa.resx @@ -441,4 +441,64 @@ + + افزونه‌ها + + + پیش‌انتشار + + + نصب + + + نصب شده + + + به‌روزرسانی + + + حذف + + + آیا مطمئن هستید که می‌خواهید {0} را حذف کنید؟ + + + به‌روزرسانی موجود است + + + نسخه نصب شده + + + جدیدترین نسخه + + + دریافت لیست افزونه از NuGet ناموفق بود. لطفاً اتصال شبکه خود را بررسی کنید. + + + نصب کامل شد + + + نصب ناموفق بود + + + اکنون راه‌اندازی مجدد شود + + + لطفاً WindowTranslator را مجدداً راه‌اندازی کنید تا تغییرات افزونه اعمال شود. + + + راه‌اندازی مجدد + + + صفحه پروژه + + + اطلاعات مجوز + + + افزونه‌ها بسته‌های شخص ثالثی هستند که از طریق NuGet توزیع می‌شوند. مجوز هر بسته را مالک آن ارائه می‌کند و WindowTranslator محتوا، عملکرد یا امنیت آن را تضمین نمی‌کند. فقط بسته‌های مورد اعتماد را نصب کنید. + + + افزونه رسمی + diff --git a/WindowTranslator/Properties/Resources.fil.resx b/WindowTranslator/Properties/Resources.fil.resx index d5f18cee..8abb2184 100644 --- a/WindowTranslator/Properties/Resources.fil.resx +++ b/WindowTranslator/Properties/Resources.fil.resx @@ -456,4 +456,64 @@ Ang monitor ay hindi suportado. + + Mga Plugin + + + Prerelease + + + I-install + + + Naka-install + + + I-update + + + I-uninstall + + + Sigurado ka bang gusto mong i-uninstall ang {0}? + + + Available ang update + + + Naka-install na bersyon + + + Pinakabagong bersyon + + + Nabigo sa pagkuha ng listahan ng plugin mula sa NuGet. Pakisuri ang iyong koneksyon sa network. + + + Natapos ang pag-install + + + Nabigo ang pag-install + + + I-restart ngayon + + + Mangyaring i-restart ang WindowTranslator upang mailapat ang mga pagbabago sa plugin. + + + I-restart + + + Pahina ng proyekto + + + Impormasyon ng lisensya + + + Ang mga plugin ay mga third-party package na ipinapamahagi sa NuGet. Ang lisensya ng bawat package ay ibinibigay ng may-ari nito; hindi ginagarantiya ng WindowTranslator ang nilalaman, paggana, o kaligtasan nito. Mag-install lamang ng mga package na pinagkakatiwalaan mo. + + + Opisyal na plugin + diff --git a/WindowTranslator/Properties/Resources.fr.resx b/WindowTranslator/Properties/Resources.fr.resx index 028dda7c..d6b28f98 100644 --- a/WindowTranslator/Properties/Resources.fr.resx +++ b/WindowTranslator/Properties/Resources.fr.resx @@ -447,4 +447,64 @@ + + Plugins + + + Préversion + + + Installer + + + Installé + + + Mettre à jour + + + Désinstaller + + + Voulez-vous vraiment désinstaller {0} ? + + + Mise à jour disponible + + + Version installée + + + Dernière version + + + Échec de la récupération de la liste des plugins depuis NuGet. Vérifiez votre connexion réseau. + + + Installation terminée + + + Échec de l'installation + + + Redémarrer maintenant + + + Veuillez redémarrer WindowTranslator pour appliquer les modifications de plugin. + + + Redémarrer + + + Page du projet + + + Informations de licence + + + Les plugins sont des paquets tiers distribués via NuGet. La licence de chaque paquet est accordée par son propriétaire ; WindowTranslator ne garantit ni son contenu, ni son fonctionnement, ni sa sécurité. N’installez que des paquets de confiance. + + + Plugin officiel + diff --git a/WindowTranslator/Properties/Resources.hi.resx b/WindowTranslator/Properties/Resources.hi.resx index b38b8fe1..c69e2f92 100644 --- a/WindowTranslator/Properties/Resources.hi.resx +++ b/WindowTranslator/Properties/Resources.hi.resx @@ -449,4 +449,64 @@ - \ No newline at end of file + + प्लगइन + + + प्रीरिलीज़ + + + इंस्टॉल करें + + + इंस्टॉल है + + + अपडेट करें + + + अनइंस्टॉल करें + + + क्या आप {0} को अनइंस्टॉल करना चाहते हैं? + + + अपडेट उपलब्ध + + + इंस्टॉल किया गया संस्करण + + + नवीनतम संस्करण + + + NuGet से प्लगइन सूची प्राप्त करने में विफल। कृपया अपना नेटवर्क कनेक्शन जांचें। + + + इंस्टॉलेशन पूर्ण + + + इंस्टॉलेशन विफल + + + अभी पुनः आरंभ करें + + + प्लगइन परिवर्तन लागू करने के लिए कृपया WindowTranslator को पुनः आरंभ करें। + + + पुनः आरंभ करें + + + प्रोजेक्ट पेज + + + लाइसेंस जानकारी + + + प्लगइन NuGet के माध्यम से वितरित तृतीय-पक्ष पैकेज हैं। प्रत्येक पैकेज का लाइसेंस उसके स्वामी द्वारा दिया जाता है; WindowTranslator उसकी सामग्री, व्यवहार या सुरक्षा की गारंटी नहीं देता। केवल विश्वसनीय पैकेज इंस्टॉल करें। + + + आधिकारिक प्लगइन + + diff --git a/WindowTranslator/Properties/Resources.hu.resx b/WindowTranslator/Properties/Resources.hu.resx index 79ab42f1..f8d7d132 100644 --- a/WindowTranslator/Properties/Resources.hu.resx +++ b/WindowTranslator/Properties/Resources.hu.resx @@ -337,4 +337,64 @@ A monitorok nem támogatottak. + + Bővítmények + + + Előzetes kiadás + + + Telepítés + + + Telepítve + + + Frissítés + + + Eltávolítás + + + Biztosan eltávolítja a(z) {0} bővítményt? + + + Frissítés érhető el + + + Telepített verzió + + + Legújabb verzió + + + Nem sikerült lekérni a bővítmények listáját a NuGet szolgáltatásból. Ellenőrizze a hálózati kapcsolatot. + + + A telepítés befejeződött + + + A telepítés sikertelen + + + Újraindítás most + + + A bővítménymódosítások alkalmazásához indítsa újra a WindowTranslator alkalmazást. + + + Újraindítás + + + Projektoldal + + + Licencinformációk + + + A beépülő modulok a NuGeten keresztül terjesztett, harmadik féltől származó csomagok. Az egyes csomagok licencét a tulajdonosuk biztosítja; a WindowTranslator nem garantálja azok tartalmát, működését vagy biztonságát. Csak megbízható csomagokat telepítsen. + + + Hivatalos beépülő modul + diff --git a/WindowTranslator/Properties/Resources.id.resx b/WindowTranslator/Properties/Resources.id.resx index 626bdbaa..8189ca16 100644 --- a/WindowTranslator/Properties/Resources.id.resx +++ b/WindowTranslator/Properties/Resources.id.resx @@ -455,4 +455,64 @@ Monitor tidak didukung. + + Plugin + + + Prarilis + + + Pasang + + + Terpasang + + + Perbarui + + + Hapus + + + Apakah Anda yakin ingin menghapus {0}? + + + Pembaruan tersedia + + + Versi terpasang + + + Versi terbaru + + + Gagal mengambil daftar plugin dari NuGet. Silakan periksa koneksi jaringan Anda. + + + Instalasi selesai + + + Instalasi gagal + + + Mulai ulang sekarang + + + Silakan restart WindowTranslator untuk menerapkan perubahan plugin. + + + Mulai ulang + + + Halaman proyek + + + Informasi lisensi + + + Plugin adalah paket pihak ketiga yang didistribusikan melalui NuGet. Lisensi setiap paket diberikan oleh pemiliknya; WindowTranslator tidak menjamin konten, perilaku, atau keamanannya. Instal hanya paket yang Anda percayai. + + + Plugin resmi + diff --git a/WindowTranslator/Properties/Resources.ko.resx b/WindowTranslator/Properties/Resources.ko.resx index 72483a3f..6bc74f4f 100644 --- a/WindowTranslator/Properties/Resources.ko.resx +++ b/WindowTranslator/Properties/Resources.ko.resx @@ -456,4 +456,64 @@ + + 플러그인 + + + 시험판 + + + 설치 + + + 설치됨 + + + 업데이트 + + + 제거 + + + {0}을(를) 제거하시겠습니까? + + + 업데이트 있음 + + + 설치된 버전 + + + 최신 버전 + + + NuGet에서 플러그인 목록을 가져오지 못했습니다. 네트워크 연결을 확인하세요. + + + 설치 완료 + + + 설치 실패 + + + 지금 다시 시작 + + + 플러그인 변경 사항을 적용하려면 WindowTranslator를 다시 시작하세요. + + + 다시 시작 + + + 프로젝트 페이지 + + + 라이선스 정보 + + + 플러그인은 NuGet을 통해 배포되는 타사 패키지입니다. 각 패키지의 라이선스는 해당 소유자가 제공하며, WindowTranslator는 콘텐츠, 동작 또는 안전성을 보장하지 않습니다. 신뢰할 수 있는 패키지만 설치하세요. + + + 공식 플러그인 + diff --git a/WindowTranslator/Properties/Resources.ms.resx b/WindowTranslator/Properties/Resources.ms.resx index 481b193e..5bd75f0b 100644 --- a/WindowTranslator/Properties/Resources.ms.resx +++ b/WindowTranslator/Properties/Resources.ms.resx @@ -455,4 +455,64 @@ Monitor tidak disokong. + + Plugin + + + Prakeluaran + + + Pasang + + + Dipasang + + + Kemaskini + + + Nyahpasang + + + Adakah anda pasti mahu menyahpasang {0}? + + + Kemaskini tersedia + + + Versi yang dipasang + + + Versi terkini + + + Gagal mendapatkan senarai plugin dari NuGet. Sila semak sambungan rangkaian anda. + + + Pemasangan selesai + + + Pemasangan gagal + + + Mulakan semula sekarang + + + Sila mulakan semula WindowTranslator untuk menerapkan perubahan plugin. + + + Mulakan semula + + + Halaman projek + + + Maklumat lesen + + + Pemalam ialah pakej pihak ketiga yang diedarkan melalui NuGet. Lesen setiap pakej diberikan oleh pemiliknya; WindowTranslator tidak menjamin kandungan, tingkah laku atau keselamatannya. Pasang hanya pakej yang anda percayai. + + + Pemalam rasmi + diff --git a/WindowTranslator/Properties/Resources.pl.resx b/WindowTranslator/Properties/Resources.pl.resx index 576d7a95..aa01d8e1 100644 --- a/WindowTranslator/Properties/Resources.pl.resx +++ b/WindowTranslator/Properties/Resources.pl.resx @@ -456,4 +456,64 @@ Monitory nie są obsługiwane. + + Wtyczki + + + Wersja wstępna + + + Zainstaluj + + + Zainstalowano + + + Aktualizuj + + + Odinstaluj + + + Czy na pewno chcesz odinstalować {0}? + + + Dostępna aktualizacja + + + Zainstalowana wersja + + + Najnowsza wersja + + + Nie udało się pobrać listy wtyczek z NuGet. Sprawdź połączenie sieciowe. + + + Instalacja zakończona + + + Instalacja nie powiodła się + + + Uruchom ponownie teraz + + + Uruchom ponownie WindowTranslator, aby zastosować zmiany wtyczki. + + + Uruchom ponownie + + + Strona projektu + + + Informacje o licencji + + + Wtyczki są pakietami innych firm rozpowszechnianymi przez NuGet. Licencję każdego pakietu zapewnia jego właściciel; WindowTranslator nie gwarantuje jego zawartości, działania ani bezpieczeństwa. Instaluj tylko zaufane pakiety. + + + Oficjalna wtyczka + diff --git a/WindowTranslator/Properties/Resources.pt-BR.resx b/WindowTranslator/Properties/Resources.pt-BR.resx index 0a3b952d..a97ba41c 100644 --- a/WindowTranslator/Properties/Resources.pt-BR.resx +++ b/WindowTranslator/Properties/Resources.pt-BR.resx @@ -455,4 +455,64 @@ Monitor tidak didukung. + + Plugins + + + Pré-lançamento + + + Instalar + + + Instalado + + + Atualizar + + + Desinstalar + + + Tem certeza que deseja desinstalar {0}? + + + Atualização disponível + + + Versão instalada + + + Versão mais recente + + + Falha ao recuperar lista de plugins do NuGet. Verifique sua conexão de rede. + + + Instalação concluída + + + Falha na instalação + + + Reiniciar agora + + + Reinicie o WindowTranslator para aplicar as alterações de plugin. + + + Reiniciar + + + Página do projeto + + + Informações de licença + + + Os plugins são pacotes de terceiros distribuídos pelo NuGet. A licença de cada pacote é concedida pelo proprietário; o WindowTranslator não garante seu conteúdo, funcionamento ou segurança. Instale apenas pacotes confiáveis. + + + Plugin oficial + diff --git a/WindowTranslator/Properties/Resources.resx b/WindowTranslator/Properties/Resources.resx index 29f224b1..7d3e346f 100644 --- a/WindowTranslator/Properties/Resources.resx +++ b/WindowTranslator/Properties/Resources.resx @@ -456,4 +456,64 @@ + + プラグイン + + + プレリリース + + + インストール + + + インストール済み + + + 更新 + + + アンインストール + + + {0} をアンインストールしますか? + + + 更新あり + + + インストール済みバージョン + + + 最新バージョン + + + NuGetからのプラグイン一覧の取得に失敗しました。ネットワーク接続を確認してください。 + + + インストール完了 + + + インストール失敗 + + + 今すぐ再起動 + + + プラグインの変更を適用するには、WindowTranslatorを再起動してください。 + + + 再起動 + + + プロジェクトページ + + + ライセンス情報 + + + プラグインはNuGet上の第三者パッケージです。各パッケージのライセンスは所有者から付与され、WindowTranslatorは内容・動作・安全性を保証しません。信頼できるパッケージだけをインストールしてください。 + + + 公式プラグイン + diff --git a/WindowTranslator/Properties/Resources.ru.resx b/WindowTranslator/Properties/Resources.ru.resx index 701b47b9..01b5b10c 100644 --- a/WindowTranslator/Properties/Resources.ru.resx +++ b/WindowTranslator/Properties/Resources.ru.resx @@ -447,4 +447,64 @@ + + Плагины + + + Предварительная версия + + + Установить + + + Установлен + + + Обновить + + + Удалить + + + Вы уверены, что хотите удалить {0}? + + + Доступно обновление + + + Установленная версия + + + Последняя версия + + + Не удалось получить список плагинов из NuGet. Проверьте подключение к сети. + + + Установка завершена + + + Ошибка установки + + + Перезапустить сейчас + + + Перезапустите WindowTranslator для применения изменений плагина. + + + Перезапустить + + + Страница проекта + + + Информация о лицензии + + + Плагины — это сторонние пакеты, распространяемые через NuGet. Лицензию на каждый пакет предоставляет его владелец; WindowTranslator не гарантирует его содержимое, работу или безопасность. Устанавливайте только пакеты, которым доверяете. + + + Официальный плагин + diff --git a/WindowTranslator/Properties/Resources.th.resx b/WindowTranslator/Properties/Resources.th.resx index 78d60f9a..09655236 100644 --- a/WindowTranslator/Properties/Resources.th.resx +++ b/WindowTranslator/Properties/Resources.th.resx @@ -456,4 +456,64 @@ + + ปลั๊กอิน + + + รุ่นก่อนเผยแพร่ + + + ติดตั้ง + + + ติดตั้งแล้ว + + + อัปเดต + + + ถอนการติดตั้ง + + + คุณแน่ใจหรือไม่ว่าต้องการถอนการติดตั้ง {0}? + + + มีการอัปเดต + + + เวอร์ชันที่ติดตั้ง + + + เวอร์ชันล่าสุด + + + ไม่สามารถดึงรายการปลั๊กอินจาก NuGet ได้ โปรดตรวจสอบการเชื่อมต่อเครือข่าย + + + การติดตั้งเสร็จสมบูรณ์ + + + การติดตั้งล้มเหลว + + + เริ่มใหม่ตอนนี้ + + + กรุณาเริ่ม WindowTranslator ใหม่เพื่อนำการเปลี่ยนแปลงปลั๊กอินไปใช้ + + + เริ่มใหม่ + + + หน้าโครงการ + + + ข้อมูลใบอนุญาต + + + ปลั๊กอินเป็นแพ็กเกจของบุคคลที่สามที่เผยแพร่ผ่าน NuGet เจ้าของแพ็กเกจเป็นผู้ให้สิทธิ์การใช้งาน และ WindowTranslator ไม่รับประกันเนื้อหา การทำงาน หรือความปลอดภัย โปรดติดตั้งเฉพาะแพ็กเกจที่คุณเชื่อถือ + + + ปลั๊กอินอย่างเป็นทางการ + diff --git a/WindowTranslator/Properties/Resources.tr.resx b/WindowTranslator/Properties/Resources.tr.resx index e8cfdbd1..68f78557 100644 --- a/WindowTranslator/Properties/Resources.tr.resx +++ b/WindowTranslator/Properties/Resources.tr.resx @@ -456,4 +456,64 @@ Monitör desteklenmiyor. + + Eklentiler + + + Ön sürüm + + + Yükle + + + Yüklü + + + Güncelle + + + Kaldır + + + {0} öğesini kaldırmak istediğinizden emin misiniz? + + + Güncelleme mevcut + + + Yüklü sürüm + + + En son sürüm + + + NuGet'ten eklenti listesi alınamadı. Lütfen ağ bağlantınızı kontrol edin. + + + Kurulum tamamlandı + + + Kurulum başarısız + + + Şimdi yeniden başlat + + + Eklenti değişikliklerini uygulamak için lütfen WindowTranslator'ı yeniden başlatın. + + + Yeniden başlat + + + Proje sayfası + + + Lisans bilgileri + + + Eklentiler, NuGet üzerinden dağıtılan üçüncü taraf paketlerdir. Her paketin lisansı sahibi tarafından sağlanır; WindowTranslator içeriğini, davranışını veya güvenliğini garanti etmez. Yalnızca güvendiğiniz paketleri yükleyin. + + + Resmî eklenti + diff --git a/WindowTranslator/Properties/Resources.vi.resx b/WindowTranslator/Properties/Resources.vi.resx index 469d378c..773f02c0 100644 --- a/WindowTranslator/Properties/Resources.vi.resx +++ b/WindowTranslator/Properties/Resources.vi.resx @@ -456,4 +456,64 @@ Màn hình không được hỗ trợ. + + Plugin + + + Bản phát hành trước + + + Cài đặt + + + Đã cài đặt + + + Cập nhật + + + Gỡ cài đặt + + + Bạn có chắc muốn gỡ cài đặt {0}? + + + Có cập nhật + + + Phiên bản đã cài + + + Phiên bản mới nhất + + + Không thể lấy danh sách plugin từ NuGet. Vui lòng kiểm tra kết nối mạng. + + + Cài đặt hoàn tất + + + Cài đặt thất bại + + + Khởi động lại ngay + + + Vui lòng khởi động lại WindowTranslator để áp dụng thay đổi plugin. + + + Khởi động lại + + + Trang dự án + + + Thông tin giấy phép + + + Plugin là các gói của bên thứ ba được phân phối qua NuGet. Giấy phép của mỗi gói do chủ sở hữu cung cấp; WindowTranslator không đảm bảo nội dung, hoạt động hoặc độ an toàn của gói. Chỉ cài đặt các gói bạn tin cậy. + + + Plugin chính thức + diff --git a/WindowTranslator/Properties/Resources.zh-CN.resx b/WindowTranslator/Properties/Resources.zh-CN.resx index 2af7736c..f2d61645 100644 --- a/WindowTranslator/Properties/Resources.zh-CN.resx +++ b/WindowTranslator/Properties/Resources.zh-CN.resx @@ -456,4 +456,64 @@ + + 插件 + + + 预发行版 + + + 安装 + + + 已安装 + + + 更新 + + + 卸载 + + + 确定要卸载 {0} 吗? + + + 有更新 + + + 已安装版本 + + + 最新版本 + + + 无法从 NuGet 获取插件列表。请检查您的网络连接。 + + + 安装完成 + + + 安装失败 + + + 立即重启 + + + 请重启 WindowTranslator 以应用插件更改。 + + + 重启 + + + 项目页面 + + + 许可证信息 + + + 插件是通过 NuGet 分发的第三方包。每个包的许可证由其所有者授予;WindowTranslator 不保证其内容、行为或安全性。请仅安装你信任的包。 + + + 官方插件 + diff --git a/WindowTranslator/Properties/Resources.zh-TW.resx b/WindowTranslator/Properties/Resources.zh-TW.resx index 25b11251..cf7700ec 100644 --- a/WindowTranslator/Properties/Resources.zh-TW.resx +++ b/WindowTranslator/Properties/Resources.zh-TW.resx @@ -456,4 +456,64 @@ + + 外掛程式 + + + 預發行版本 + + + 安裝 + + + 已安裝 + + + 更新 + + + 解除安裝 + + + 確定要解除安裝 {0} 嗎? + + + 有更新 + + + 已安裝版本 + + + 最新版本 + + + 無法從 NuGet 取得外掛程式清單。請檢查您的網路連線。 + + + 安裝完成 + + + 安裝失敗 + + + 立即重新啟動 + + + 請重新啟動 WindowTranslator 以套用外掛程式變更。 + + + 重新啟動 + + + 專案頁面 + + + 授權資訊 + + + 外掛程式是透過 NuGet 發佈的第三方套件。每個套件的授權由其擁有者授予;WindowTranslator 不保證其內容、行為或安全性。請只安裝您信任的套件。 + + + 官方外掛程式 + diff --git a/WindowTranslator/WindowTranslator.csproj b/WindowTranslator/WindowTranslator.csproj index a248f527..22d40b62 100644 --- a/WindowTranslator/WindowTranslator.csproj +++ b/WindowTranslator/WindowTranslator.csproj @@ -21,7 +21,7 @@ false - $(DefineConstants);NO_MUTEX + $(DefineConstants);NO_MUTEX;DISABLE_PLUGIN_COMPATIBILITY_VALIDATION @@ -33,6 +33,7 @@ + @@ -44,6 +45,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -51,6 +53,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/docs/plugin.md b/docs/plugin.md new file mode 100644 index 00000000..18c0c6e3 --- /dev/null +++ b/docs/plugin.md @@ -0,0 +1,207 @@ +# WindowTranslator 外部プラグイン開発ガイド + +## 概要 + +WindowTranslator は外部プラグインによる機能拡張をサポートしています。 +NuGet パッケージとしてプラグインを公開することで、他のユーザーがアプリ内から簡単にインストールできます。 + +## クイックスタート + +### 1. プロジェクト作成 + +```bash +dotnet new classlib -n WindowTranslator.Plugin.YourPlugin +cd WindowTranslator.Plugin.YourPlugin +``` + +### 2. .csproj を設定 + +最小構成の `.csproj` 例: + +```xml + + + net10.0 + true + + + WindowTranslator.Plugin.YourPlugin + Your Plugin + 1.0.0 + YourName + プラグインストアに表示する具体的な説明文 + https://github.com/YourName/YourPlugin + README.md + + $(PackageTags);windowtranslator-plugin + MIT + + + + + + + + + + + + + +``` + +> **重要**: `` に `windowtranslator-plugin` を含めることで、 +> WindowTranslator アプリ内のプラグインストアに表示されます。 +> +> ``、`<Description>`、`<Authors>`、プロジェクトURL、ライセンス情報は +> プラグインストアの一覧・詳細に表示されます。利用者が機能と提供元を判断できる +> 内容を設定してください。 +> +> `WindowTranslator.Abstractions` の依存バージョン範囲は、インストール先の +> WindowTranslator との互換性判定に使用されます。サポートする最も古い +> `WindowTranslator.Abstractions` のバージョンを指定してください。 + +### README の多言語化 + +プラグインストアは、README 内のカルチャー名だけで構成された第2レベル見出しを +言語セクションとして認識します。README は1ファイルのまま、必要な言語だけを +次のように記載してください。 + +```markdown +# Your Plugin + +## ja + +日本語の説明です。 + +## 機能 + +- 機能A + +## en + +English description. + +## Features + +- Feature A +``` + +アプリのUIカルチャーとの完全一致、親言語、英語、先頭セクションの順で表示する +言語を選択します。`## 機能`や`## Features`など、カルチャー名ではない見出しは +言語セクション内の通常の見出しとして扱われます。カルチャー名は`ja`、`pt-BR`、 +`zh-Hans`のように、.NETの正規表記と大文字・小文字まで一致させてください。 +正規形のカルチャー名を持つ第2レベル見出しはREADME全体で言語境界として扱うため、 +コード例など別の用途には使用しないでください。 + +リポジトリ内の公式プラグインは、既存のUI翻訳に合わせて次の22言語を1つの +READMEに収録しています。 + +`ja`、`en`、`ar`、`cs`、`de`、`es`、`fa`、`fil`、`fr`、`hi`、`hu`、`id`、 +`ko`、`ms`、`pl`、`pt-BR`、`ru`、`th`、`tr`、`vi`、`zh-CN`、`zh-TW` + +外部プラグインで全言語を用意する必要はありません。収録していないUI言語には +英語セクションが表示されます。 + +### 3. プラグインを実装 + +対象のインターフェースを実装します: + +| インターフェース | 用途 | +|---|---| +| `ITranslateModule` | テキスト翻訳 | +| `IOcrModule` | 画像からテキスト認識 | +| `ICaptureModule` | ウィンドウキャプチャ | +| `IFilterModule` | 翻訳前後のテキスト加工 | +| `IColorModule` | 色変換 | +| `ICacheModule` | 翻訳結果キャッシュ | + +実際の実装例は +[DeepLTranslator.cs](../Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/DeepLTranslator.cs) +と +[WindowTranslator.Plugin.DeepLTranslatePlugin.csproj](../Plugins/WindowTranslator.Plugin.DeepLTranslatePlugin/WindowTranslator.Plugin.DeepLTranslatePlugin.csproj) +を参照してください。 + +### 4. パッケージをビルドして NuGet に公開 + +```bash +dotnet pack -c Release -o ./nupkg +dotnet nuget push ./nupkg/WindowTranslator.Plugin.YourPlugin.1.0.0.nupkg \ + --api-key YOUR_API_KEY \ + --source https://api.nuget.org/v3/index.json +``` + +## プラグイン設定パラメータ + +プラグインに設定画面を追加するには `IPluginParam` を実装します: + +```csharp +using PropertyTools.DataAnnotations; +using WindowTranslator; + +public class MyPluginParam : IPluginParam +{ + [Category("API設定")] + [DisplayName("APIキー")] + public string ApiKey { get; set; } = string.Empty; + + [Category("翻訳設定")] + [DisplayName("翻訳元言語")] + public string SourceLanguage { get; set; } = "ja"; +} +``` + +## デフォルトモジュールの指定 + +プラグインをデフォルトとして使用させるには `[DefaultModule]` 属性を付与します: + +```csharp +[DefaultModule] +[DisplayName("My 翻訳")] +public class MyTranslateModule : ITranslateModule { ... } +``` + +## プラグインインストール先 + +インストールされたプラグインは以下のフォルダに配置されます: + +- NuGetからインストール: `%USERPROFILE%\.wt\nuget-plugins\{PackageId}\` +- ユーザーが手動で配置: `%USERPROFILE%\.wt\plugins\` + +NuGetのインストール・更新・アンインストールでは、手動配置用フォルダの内容を変更しません。 + +## アプリからインストールする + +1. WindowTranslator の設定を開きます。 +2. 「プラグイン」タブを選択します。 +3. 利用するプラグインの「インストール」を選択します。 + プレリリース版を利用する場合は、そのプラグインの「プレリリース」にチェックを入れます。 +4. インストール完了後のダイアログから WindowTranslator を再起動します。 + +NuGetパッケージで宣言されたランタイム依存関係も再帰的に取得されます。 +同じ依存パッケージに両立しないバージョン条件がある場合は、既存の +プラグイン配置を変更せずにインストールを中止します。 + +保存済みのモジュール選択やプラグイン設定パラメータだけを根拠に、パッケージが +自動インストールされることはありません。インストールはプラグインストアで +利用者が明示的に実行した場合だけ行われます。 + +プラグイン情報は WindowTranslator の起動後にバックグラウンドで更新されます。 +プラグインをインストールした WindowTranslator と現在のメジャーバージョンが異なる場合、 +そのプラグインは互換性がないものとして起動時のロード対象から除外されます。 +また、インストール時に記録された `WindowTranslator.Abstractions` の依存バージョン範囲を +現在のホストが満たさない場合も、起動時のロード対象から除外されます。 +互換バージョンを再インストールすると、次回起動から再び利用できます。 + +アンインストールするとパッケージは読み込み対象から除外され、管理フォルダの実体は +次回の WindowTranslator 起動時に削除されます。実行中に読み込まれたプラグインを +停止するにも、WindowTranslator の再起動が必要です。 + +## 注意事項 + +- プラグインは WindowTranslator と同じ `net10.0` をターゲットにしてください。`net11.0` など、ホストより新しいTFMは読み込めません +- `<EnableDynamicLoading>true</EnableDynamicLoading>` を必ず設定してください +- ホスト側で既に提供されているパッケージは `ExcludeAssets="runtime"` を設定し、DLL を重複させないようにしてください +- 通常のランタイム依存は `PackageReference` として宣言してください +- `ProjectReference` は通常、参照先プロジェクトへの NuGet 依存としてパッケージ化されます。参照先をNuGetへ公開しない場合は、`PrivateAssets="all"` を設定したうえで必要なDLLをプラグインパッケージへ同梱し、参照先が必要とする `PackageReference` もプラグイン側で宣言してください +- パッケージ固有の追加ファイルは、実行時に必要な相対ディレクトリを保って `lib/net10.0/` に含めてください