Compare commits

...

22 Commits

Author SHA1 Message Date
Gabe
864e0651b1 fix: set pnpm version 2025-11-15 22:37:26 +08:00
Gabe
65d328eb38 Update version number: 2.0.10 2025-11-15 22:30:17 +08:00
Gabe
731d360323 fix: unavailable action log 2025-11-15 21:09:24 +08:00
Gabe
c4ccdba268 fix: sync bug 2025-11-15 21:05:06 +08:00
Gabe
4f00492e49 fix: Adjust popup UI 2025-11-15 14:39:03 +08:00
Gabe
abcf2baad6 feat: supports plain text translation 2025-11-15 14:25:05 +08:00
Gabe
49a7698993 fix: update ignore selectors 2025-11-15 01:08:17 +08:00
Gabe
8d2548acaf doc: i18n 2025-11-15 00:41:58 +08:00
Gabe
251deb5886 fix: notice text 2025-11-13 01:49:51 +08:00
Gabe
7a15bdeadc fix: from lang bug 2025-11-13 01:45:11 +08:00
Gabe
1e59d57764 fix: from lang bug 2025-11-13 01:38:16 +08:00
Gabe
12b3768598 doc: readme 2025-11-13 00:14:00 +08:00
Gabe
3abe5b98d0 doc: readme 2025-11-12 23:54:27 +08:00
Gabe
ad004105c3 fix: Solidified build environment 2025-11-12 23:38:29 +08:00
Gabe
f70266197e fix: Solidified build environment 2025-11-12 23:37:40 +08:00
Gabe
cc31a8004a fix: try fix workflows error 2025-11-12 23:14:31 +08:00
Gabe
fa14851596 fix: try fix workflows error 2025-11-12 23:08:08 +08:00
Gabe
d56c46e944 Update version number: 2.0.9 2025-11-12 22:25:59 +08:00
Gabe
9f8bcf1fe1 feat: Added Japanese and Korean language support 2025-11-12 21:41:29 +08:00
Gabe
e50387a796 fix: custom apis 2025-11-12 00:56:27 +08:00
Gabe
3d2eac8772 fix: save rule bug 2025-11-12 00:13:41 +08:00
Gabe
343f529cac fix: Optimize subtitle translation 2025-11-11 23:36:06 +08:00
36 changed files with 1501 additions and 138 deletions

2
.env
View File

@@ -2,7 +2,7 @@ GENERATE_SOURCEMAP=false
REACT_APP_NAME=KISS Translator
REACT_APP_NAME_CN=简约翻译
REACT_APP_VERSION=2.0.8
REACT_APP_VERSION=2.0.10
REACT_APP_HOMEPAGE=https://github.com/fishjar/kiss-translator

View File

@@ -7,15 +7,15 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: latest
version: 9.14.4
- uses: actions/setup-node@v4
with:
node-version: latest
node-version: 24
cache: "pnpm"
- run: pnpm install
- run: pnpm build+zip
@@ -25,7 +25,7 @@ jobs:
path: build
deploy-web:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
@@ -38,7 +38,7 @@ jobs:
folder: build/web
create-release:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
outputs:
upload_url: ${{ steps.create-release.outputs.upload_url }}
steps:
@@ -56,7 +56,7 @@ jobs:
strategy:
matrix:
client: ["chrome", "edge", "firefox", "userscript", "thunderbird"]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4

1
.pnpm-version Normal file
View File

@@ -0,0 +1 @@
9.14.4

View File

@@ -1,6 +1,6 @@
# KISS Translator
English | [简体中文](README.md)
[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
A simple, open source [bilingual translation extension & Greasemonkey script](https://github.com/fishjar/kiss-translator).

177
README.ja.md Normal file
View File

@@ -0,0 +1,177 @@
# KISS Translator シンプル翻訳
[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
シンプルでオープンソースの [バイリンガル対照翻訳拡張機能&ユーザースクリプト](https://github.com/fishjar/kiss-translator)です。
[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)
## 特徴
- [x] シンプルさを維持
- [x] オープンソース
- [x] 主要なブラウザに対応
- [x] Chrome/Edge
- [x] Firefox
- [x] Kiwi (Android)
- [x] Orion (iOS)
- [x] Safari
- [x] Thunderbird
- [x] 複数の翻訳サービスをサポート
- [x] Google/Microsoft
- [x] Tencent/Volcengine
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
- [x] DeepL/DeepLX/NiuTrans
- [x] AzureAI/CloudflareAI
- [x] Chromeブラウザ内蔵AI翻訳(BuiltinAI)
- [x] 一般的な翻訳シナリオをカバー
- [x] Webページのバイリンガル対照翻訳
- [x] 入力ボックス翻訳
- ショートカットキーで入力ボックス内のテキストを即座に他言語に翻訳
- [x] テキスト選択翻訳
- [x] 任意のページで翻訳ボックスを開き、複数の翻訳サービスで比較翻訳が可能
- [x] 英語辞書翻訳
- [x] 単語のブックマーク
- [x] マウスオーバー翻訳
- [x] YouTube 字幕翻訳
- 任意の翻訳サービスを使用してビデオ字幕を翻訳し、バイリンガル表示をサポート
- 基本的な字幕結合・改行アルゴリズムを内蔵し、翻訳品質を向上
- AIによる改行機能をサポートし、翻訳品質をさらに向上
- 字幕スタイルのカスタマイズ
- [x] 多様な翻訳効果をサポート
- [x] テキスト自動認識と手動ルールの2つのモードをサポート
- テキスト自動認識モードにより、ほとんどのWebサイトでルールを記述しなくても完全な翻訳が可能
- 手動ルールモードで、特定のWebサイトに合わせた最適な最適化が可能
- [x] 翻訳テキストスタイルのカスタマイズ
- [x] リッチテキストの翻訳と表示をサポートし、原文のリンクやその他のテキストスタイルを可能な限り保持
- [x] 翻訳文のみの表示(原文を非表示)をサポート
- [x] 翻訳APIの高度な機能
- [x] カスタムAPIにより、理論上あらゆる翻訳インターフェースをサポート
- [x] 翻訳テキストの統合バッチ送信
- [x] AIコンテキスト会話メモリ機能をサポートし、翻訳品質を向上
- [x] カスタムAI用語集
- [x] すべてのインターフェースがフックやカスタムパラメータなどの高度な機能をサポート
- [x] クライアント間のデータ同期
- [x] KISS-Workercloudflare/docker
- [x] WebDAV
- [x] カスタム翻訳ルール
- [x] ルールの購読/ルール共有
- [x] カスタム専門用語
- [x] カスタムショートカットキー
- `Alt+Q` 翻訳をオン
- `Alt+C` スタイル切り替え
- `Alt+K` 設定ポップアップを開く
- `Alt+S` 翻訳ポップアップを開く/選択テキストを翻訳
- `Alt+O` 設定ページを開く
- `Alt+I` 入力ボックス翻訳
## インストール
> 注:以下の理由により、ブラウザ拡張機能の使用を優先することをお勧めします
>
> - ブラウザ拡張機能の方が機能が完全です(ローカル言語認識、右クリックメニューなど)
> - ユーザースクリプトはより多くの問題(クロスドメイン問題、スクリプトの競合など)に遭遇する可能性があります
- [x] ブラウザ拡張機能
- [x] Chrome [インストール](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
- [x] Kiwi (Android)
- [x] Orion (iOS)
- [x] Edge [インストール](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)
- [x] Firefox [インストール](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
- [ ] Safari
- [ ] Safari (Mac)
- [ ] Safari (iOS)
- [x] Thunderbird [ダウンロード](https://github.com/fishjar/kiss-translator/releases)
- [x] ユーザースクリプト
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [インストールリンク](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
- [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [インストールリンク](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
## 関連プロジェクト
- データ同期サービス: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
- 本プロジェクトのデータ同期サービスとして使用できます。
- 個人のプライベートなルールリストの共有にも使用できます。
- セルフホスト、セルフマネジメント、データはプライベート。
- コミュニティ購読ルール: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)
- コミュニティによってメンテナンスされた、最新かつ最も完全な購読ルールリストを提供します。
- ルール関連の問題についての助けを求める。
## よくある質問FAQ
### ショートカットキーの設定方法
拡張機能の管理ページで設定します。例:
- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)
- firefox [about:addons](about:addons)
### ルール設定の優先順位は?
個人ルール > 購読ルール > グローバルルール
グローバルルールの優先順位は最も低いですが、フォールバックルールとして非常に重要です。
### APIOllamaなどのテストに失敗する
APIテストの失敗には、一般的に以下の原因が考えられます
- アドレスが間違っている:
- 例えば `Ollama` にはネイティブAPIアドレスと `Openai` 互換のアドレスがありますが、本プラグインは現在、`Openai` 互換アドレスをサポートしており、`Ollama` ネイティブAPIアドレスはサポートしていません
- 一部のAIモデルが統合翻訳をサポートしていない
- この場合、統合翻訳を無効にするか、カスタムAPIを使用して対応できます。
- または、カスタムAPIを使用して対応します。詳細は[カスタムAPIサンプルドキュメント](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)を参照してください
- 一部のAIモデルでパラメータが一致しない
- 例えば `Gemini` のネイティブAPIはパラメータの不一致が大きく、一部のバージョンのモデルが特定のパラメータをサポートしていないためエラーが返されることがあります。
- この場合、`Hook` を使用してリクエスト `body` を変更するか、`Gemini2` (`Openai` 互換アドレス) に切り替えることができます
- サーバーのクロスドメイン制限によりアクセスが拒否され、403エラーが返される
- 例えば `Ollama` を起動する際に、環境変数 `OLLAMA_ORIGINS=*` を追加する必要があります。参考https://github.com/fishjar/kiss-translator/issues/174
### 入力したAPIがユーザースクリプトで使用できない
ユーザースクリプトは、リクエストを送信するためにドメインのホワイトリストを追加する必要があります。
### カスタムAPIのhook関数の設定方法
カスタムAPI機能は非常に強力で柔軟性があり、理論的にはどんな翻訳APIにも接続できます。
サンプル参照: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
### ユーザースクリプトの設定ページに直接アクセスする方法
設定ページアドレス: https://fishjar.github.io/kiss-translator/options.html
### 字幕翻訳のヒント
KTボタンがオンの状態青地に白文字であれば、何度もクリックする必要はありません。Youtubeプレーヤーの字幕ボタンをクリックしてオンにするだけで、バイリンガル字幕が自動的に表示されるのを待つだけです。
## 今後の計画
本プロジェクトは余暇に開発しており、厳密なタイムスケジュールはありません。コミュニティの共同構築を歓迎します。以下は初期段階の機能の方向性です:
- [x] **テキストの統合送信**リクエスト戦略を最適化し、翻訳APIの呼び出し回数を減らし、パフォーマンスを向上させます。
- [x] **リッチテキスト翻訳の強化**:より複雑なページ構造やリッチテキストコンテンツの正確な翻訳をサポートします。
- [x] **カスタム/AI APIの強化**コンテキストメモリ、複数ラウンドの対話など、高度なAI機能をサポートします。
- [x] **英語辞書のフォールバックメカニズム**:翻訳サービスが利用できない場合、他の辞書に切り替えるか、ローカル辞書での検索にフォールバックします。
- [x] **YouTube字幕サポートの最適化**:ストリーミング字幕の結合と翻訳体験を改善し、途切れを減らします。
- [ ] **ルール共同構築メカニズムのアップグレード**:より柔軟なルールの共有、バージョン管理、コミュニティレビュープロセスを導入します。
特定の方向に興味がある場合は、[Issues](https://github.com/fishjar/kiss-translator/issues) で議論したり、PRを送信したりすることを歓迎します
## 開発ガイド
```sh
git clone [https://github.com/fishjar/kiss-translator.git](https://github.com/fishjar/kiss-translator.git)
cd kiss-translator
git checkout dev # PRを送信する場合はdevブランチにプッシュすることをお勧めします
pnpm install
pnpm build
```
## コミュニケーション
- [Telegram グループ](https://t.me/+RRCu_4oNwrM2NmFl)に参加
## 寄付
![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)

178
README.ko.md Normal file
View File

@@ -0,0 +1,178 @@
# KISS Translator 심플 번역
[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
심플하고 오픈 소스인 [이중 언어 대조 번역 확장 프로그램 & 유저 스크립트](https://github.com/fishjar/kiss-translator)입니다.
[kiss-translator.webm](https://github.com/fishjar/kiss-translator/assets/1157624/f7ba8a5c-e4a8-4d5a-823a-5c5c67a0a47f)
## 특징
- [x] 심플함 유지
- [x] 오픈 소스
- [x] 주요 브라우저 지원
- [x] Chrome/Edge
- [x] Firefox
- [x] Kiwi (Android)
- [x] Orion (iOS)
- [x] Safari
- [x] Thunderbird
- [x] 다양한 번역 서비스 지원
- [x] Google/Microsoft
- [x] Tencent/Volcengine
- [x] OpenAI/Gemini/Claude/Ollama/DeepSeek/OpenRouter
- [x] DeepL/DeepLX/NiuTrans
- [x] AzureAI/CloudflareAI
- [x] Chrome 브라우저 내장 AI 번역(BuiltinAI)
- [x] 일반적인 번역 시나리오 지원
- [x] 웹페이지 이중 언어 대조 번역
- [x] 입력창 번역
- 단축키를 통해 입력창 내 텍스트를 즉시 다른 언어로 번역
- [x] 텍스트 선택 번역
- [x] 모든 페이지에서 번역창을 열어 여러 번역 서비스로 비교 번역 가능
- [x] 영어 사전 번역
- [x] 단어 즐겨찾기
- [x] 마우스오버 번역
- [x] YouTube 자막 번역
- 모든 번역 서비스를 사용하여 비디오 자막을 번역하고 이중 언어로 표시 지원
- 기본적인 자막 병합 및 줄 바꿈 알고리즘 내장으로 번역 품질 향상
- AI 줄 바꿈 기능 지원으로 번역 품질 추가 향상
- 사용자 정의 자막 스타일
- [x] 다양한 번역 효과 지원
- [x] 자동 텍스트 인식 및 수동 규칙 두 가지 모드 지원
- 자동 텍스트 인식 모드는 대부분의 웹사이트에서 규칙 작성 없이도 완벽한 번역 가능
- 수동 규칙 모드로 특정 웹사이트에 대한 최적의 최적화 가능
- [x] 번역문 스타일 사용자 정의
- [x] 리치 텍스트 번역 및 표시 지원, 원문의 링크 및 기타 텍스트 스타일 최대한 보존
- [x] 번역문만 표시 (원문 숨기기) 지원
- [x] 번역 인터페이스 고급 기능
- [x] 사용자 정의 인터페이스를 통해 이론상 모든 번역 인터페이스 지원
- [x] 번역 텍스트 일괄 통합 전송
- [x] AI 컨텍스트 (대화 기억) 기능 지원으로 번역 품질 향상
- [x] 사용자 정의 AI 용어 사전
- [x] 모든 인터페이스는 후크 및 사용자 정의 파라미터 등 고급 기능 지원
- [x] 클라이언트 간 데이터 동기화
- [x] KISS-Worker (cloudflare/docker)
- [x] WebDAV
- [x] 사용자 정의 번역 규칙
- [x] 규칙 구독 / 규칙 공유
- [x] 사용자 정의 전문 용어
- [x] 사용자 정의 단축키
- `Alt+Q` 번역 켜기
- `Alt+C` 스타일 전환
- `Alt+K` 설정 팝업 열기
- `Alt+S` 번역 팝업 열기 / 선택한 텍스트 번역
- `Alt+O` 설정 페이지 열기
- `Alt+I` 입력창 번역
## 설치
> 참고: 다음과 같은 이유로 브라우저 확장 프로그램 사용을 우선적으로 권장합니다.
>
> - 브라우저 확장 프로그램의 기능이 더 완전합니다 (로컬 언어 인식, 우클릭 메뉴 등).
> - 유저 스크립트는 사용상 더 많은 문제 (크로스 도메인 문제, 스크립트 충돌 등)를 겪을 수 있습니다.
- [x] 브라우저 확장 프로그램
- [x] Chrome [설치 주소](https://chrome.google.com/webstore/detail/kiss-translator/bdiifdefkgmcblbcghdlonllpjhhjgof?hl=zh-CN)
- [x] Kiwi (Android)
- [x] Orion (iOS)
- [x] Edge [설치 주소](https://microsoftedge.microsoft.com/addons/detail/%E7%AE%80%E7%BA%A6%E7%BF%BB%E8%AF%91/jemckldkclkinpjighnoilpbldbdmmlh?hl=zh-CN)
- [x] Firefox [설치 주소](https://addons.mozilla.org/zh-CN/firefox/addon/kiss-translator/)
- [ ] Safari
- [ ] Safari (Mac)
- [ ] Safari (iOS)
- [x] Thunderbird [다운로드 주소](https://github.com/fishjar/kiss-translator/releases)
- [x] 유저 스크립트
- [x] Chrome/Edge/Firefox ([Tampermonkey](https://www.tampermonkey.net/)/[Violentmonkey](https://violentmonkey.github.io/)) [설치 링크](https://fishjar.github.io/kiss-translator/kiss-translator.user.js)
- [Greasy Fork](https://greasyfork.org/zh-CN/scripts/472840-kiss-translator)
- [x] iOS Safari ([Userscripts Safari](https://github.com/quoid/userscripts)) [설치 링크](https://fishjar.github.io/kiss-translator/kiss-translator-ios-safari.user.js)
## 관련 프로젝트
- 데이터 동기화 서비스: [https://github.com/fishjar/kiss-worker](https://github.com/fishjar/kiss-worker)
- 본 프로젝트의 데이터 동기화 서비스로 사용할 수 있습니다.
- 개인의 비공개 규칙 목록을 공유하는 데에도 사용할 수 있습니다.
- 직접 배포, 직접 관리, 데이터 비공개.
- 커뮤니티 구독 규칙: [https://github.com/fishjar/kiss-rules](https://github.com/fishjar/kiss-rules)
- 커뮤니티에서 유지 관리하는 최신의 가장 완벽한 구독 규칙 목록을 제공합니다.
- 규칙 관련 문제에 대한 도움 요청.
## 자주 묻는 질문 (FAQ)
### 단축키는 어떻게 설정하나요?
플러그인 관리 페이지에서 설정합니다. 예:
- chrome [chrome://extensions/shortcuts](chrome://extensions/shortcuts)
- firefox [about:addons](about:addons)
### 규칙 설정의 우선순위는 어떻게 되나요?
개인 규칙 > 구독 규칙 > 전역 규칙
그중 전역 규칙은 우선순위가 가장 낮지만, 예비 규칙으로서 매우 중요합니다.
### 인터페이스 (Ollama 등) 테스트 실패
일반적으로 인터페이스 테스트 실패는 다음과 같은 몇 가지 원인이 있습니다:
- 주소를 잘못 입력한 경우:
- 예를 들어 `Ollama`는 네이티브 인터페이스 주소와 `Openai` 호환 주소가 있습니다. 본 플러그인은 현재 `Openai` 호환 주소를 통일되게 지원하며, `Ollama` 네이티브 인터페이스 주소는 지원하지 않습니다.
- 일부 AI 모델이 통합 번역을 지원하지 않는 경우:
- 이 경우 통합 번역을 비활성화하거나 사용자 정의 인터페이스 방식을 통해 사용할 수 있습니다.
- 또는 사용자 정의 인터페이스 방식을 통해 사용합니다. 자세한 내용은 [사용자 정의 인터페이스 예시 문서](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)를 참조하세요.
- 일부 AI 모델의 파라미터가 일치하지 않는 경우:
- 예를 들어 `Gemini` 네이티브 인터페이스 파라미터는 매우 불일치하며, 일부 버전의 모델은 특정 파라미터를 지원하지 않아 오류를 반환할 수 있습니다.
- 이 경우 `Hook`을 사용하여 요청 `body`를 수정하거나, `Gemini2` (`Openai` 호환 주소)로 변경할 수 있습니다.
- 서버의 크로스 도메인 접근 제한으로 403 오류가 반환되는 경우:
- 예를 들어 `Ollama` 시작 시 환경 변수 `OLLAMA_ORIGINS=*`를 추가해야 합니다. 참고: https://github.com/fishjar/kiss-translator/issues/174
### 입력한 인터페이스를 유저 스크립트에서 사용할 수 없습니다
유저 스크립트는 도메인 화이트리스트를 추가해야 요청을 보낼 수 있습니다.
### 사용자 정의 인터페이스의 hook 함수는 어떻게 설정하나요?
사용자 정의 인터페이스 기능은 매우 강력하고 유연하며, 이론적으로 어떤 번역 인터페이스든 연결할 수 있습니다.
예시 참고: [custom-api_v2.md](https://github.com/fishjar/kiss-translator/blob/master/custom-api_v2.md)
### 유저 스크립트 설정 페이지로 바로 이동하는 방법
설정 페이지 주소: https://fishjar.github.io/kiss-translator/options.html
### 자막 번역 팁
KT 버튼이 켜진 상태(파란 바탕에 흰 글씨)이기만 하면, 여러 번 클릭할 필요 없이 Youtube 플레이어의 원래 자막 버튼을 클릭하여 켜기만 하면 이중 언어 자막이 자동으로 나타날 때까지 기다리면 됩니다.
## 향후 계획
본 프로젝트는 여가 시간에 개발되며, 엄격한 시간표는 없습니다. 커뮤니티의 공동 구축을 환영합니다. 다음은 초기 구상 중인 기능 방향입니다:
- [x] **텍스트 통합 전송**: 요청 전략을 최적화하여 번역 인터페이스 호출 횟수를 줄이고 성능을 향상시킵니다.
- [x] **리치 텍스트 번역 강화**: 더 복잡한 페이지 구조와 리치 텍스트 콘텐츠의 정확한 번역을 지원합니다.
- [x] **사용자 정의/AI 인터페이스 강화**: 컨텍스트 기억, 다중 턴 대화 등 고급 AI 기능을 지원합니다.
- [x] **영어 사전 예비 메커니즘**: 번역 서비스가 실패할 경우 다른 사전으로 전환하거나 로컬 사전 조회로 대체합니다.
- [x] **YouTube 자막 지원 최적화**: 스트리밍 자막의 병합 및 번역 경험을 개선하고, 끊김을 줄입니다.
- [ ] **규칙 공동 구축 메커니즘 업그레이드**: 더 유연한 규칙 공유, 버전 관리 및 커뮤니티 검토 프로세스를 도입합니다.
특정 방향에 관심이 있다면, [Issues](https://github.com/fishjar/kiss-translator/issues)에서 토론하거나 PR을 제출해 주세요!
## 개발 가이드
```sh
git clone [https://github.com/fishjar/kiss-translator.git](https://github.com/fishjar/kiss-translator.git)
cd kiss-translator
git checkout dev # PR 제출 시 dev 브랜치로 푸시하는 것을 권장합니다
pnpm install
pnpm build
```
## 커뮤니티
- [Telegram 그룹](https://t.me/+RRCu_4oNwrM2NmFl) 가입
## 후원
![appreciate](https://github.com/fishjar/kiss-translator/assets/1157624/ebaecabe-2934-4172-8085-af236f5ee399)

View File

@@ -1,6 +1,6 @@
# 简约翻译
# KISS Translator 简约翻译
[English](README.en.md) | 简体中文
[English](README.en.md) | [中文](README.md) | [日本語](README.ja.md) | [한국어](README.ko.md)
一个简约、开源的 [双语对照翻译扩展 & 油猴脚本](https://github.com/fishjar/kiss-translator)。

View File

@@ -5,6 +5,37 @@
如果接口的请求数据和返回数据符合以下规范,
则无需填写 `Request Hook``Response Hook`
### 非聚合翻译 (v2.0.9)
Request body
```json
{
"text": "hello", // 需要翻译的文本列表
"from":"auto", // 原文语言
"to": "zh-CN" // 目标语言
}
```
Response
```json
{
"text": "你好", // 译文
"src": "en" // 原文语言
}
// 或者
{
"text": "你好", // 译文
"from": "en" // 原文语言
}
```
### 聚合翻译
Request body
```json

View File

@@ -1,7 +1,7 @@
{
"name": "kiss-translator",
"description": "A minimalist bilingual translation Extension & Greasemonkey Script",
"version": "2.0.8",
"version": "2.0.10",
"author": "Gabe<yugang2002@gmail.com>",
"private": true,
"dependencies": {

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "KISS Übersetzer"
},
"app_description": {
"message": "Eine einfache zweisprachige Übersetzungs-Erweiterung und Greasemonkey-Skript"
},
"toggle_translate": {
"message": "Übersetzung umschalten"
},
"toggle_style": {
"message": "Stile umschalten"
},
"open_options": {
"message": "Einstellungen öffnen"
},
"open_tranbox": {
"message": "Popup-Fenster öffnen"
}
}

View File

@@ -6,15 +6,15 @@
"message": "A simple bilingual translation extension & Greasemonkey script"
},
"toggle_translate": {
"message": "Toggle Translate"
"message": "Toggle Translation"
},
"toggle_style": {
"message": "Toggle Style"
"message": "Toggle Styles"
},
"open_options": {
"message": "Open Options"
"message": "Open Setting"
},
"open_tranbox": {
"message": "Translate Popup/Selected"
"message": "Open Popup Box"
}
}

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "KISS Traductor"
},
"app_description": {
"message": "Una sencilla extensión y script de Greasemonkey para traducción bilingüe"
},
"toggle_translate": {
"message": "Alternar traducción"
},
"toggle_style": {
"message": "Cambiar estilo"
},
"open_options": {
"message": "Abrir configuración"
},
"open_tranbox": {
"message": "Abrir ventana emergente"
}
}

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "KISS Traducteur"
},
"app_description": {
"message": "Une extension et un script Greasemonkey de traduction bilingue simple"
},
"toggle_translate": {
"message": "Activer/désactiver la traduction"
},
"toggle_style": {
"message": "Changer de style"
},
"open_options": {
"message": "Ouvrir les paramètres"
},
"open_tranbox": {
"message": "Ouvrir la fenêtre contextuelle"
}
}

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "シンプル翻訳"
},
"app_description": {
"message": "シンプルなバイリンガル対訳翻訳拡張機能Tampermonkeyスクリプト"
},
"toggle_translate": {
"message": "翻訳の切り替え"
},
"toggle_style": {
"message": "スタイル切り替え"
},
"open_options": {
"message": "設定を開く"
},
"open_tranbox": {
"message": "ポップアップを開く"
}
}

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "심플 번역"
},
"app_description": {
"message": "심플한 이중 언어 대조 번역 확장 프로그램 & Tampermonkey 스크립트"
},
"toggle_translate": {
"message": "번역 켜기"
},
"toggle_style": {
"message": "스타일 전환"
},
"open_options": {
"message": "설정 열기"
},
"open_tranbox": {
"message": "팝업 열기"
}
}

View File

@@ -15,6 +15,6 @@
"message": "打开设置"
},
"open_tranbox": {
"message": "翻译弹窗/选中文字"
"message": "打开弹窗"
}
}

View File

@@ -0,0 +1,20 @@
{
"app_name": {
"message": "簡約翻譯"
},
"app_description": {
"message": "一個簡約的雙語對照翻譯擴充功能與 Tampermonkey 腳本"
},
"toggle_translate": {
"message": "開啟翻譯"
},
"toggle_style": {
"message": "切換樣式"
},
"open_options": {
"message": "開啟設定"
},
"open_tranbox": {
"message": "開啟彈出視窗"
}
}

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.8",
"version": "2.0.10",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.8",
"version": "2.0.10",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_app_name__",
"description": "__MSG_app_description__",
"version": "2.0.8",
"version": "2.0.10",
"default_locale": "en",
"author": "Gabe<yugang2002@gmail.com>",
"homepage_url": "https://github.com/fishjar/kiss-translator",

View File

@@ -589,8 +589,10 @@ const genCloudflareAI = ({ texts, from, to, url, key }) => {
return { url, body, headers };
};
const genCustom = ({ texts, fromLang, toLang, url, key }) => {
const body = { texts, from: fromLang, to: toLang };
const genCustom = ({ texts, fromLang, toLang, url, key, useBatchFetch }) => {
const body = useBatchFetch
? { texts, from: fromLang, to: toLang }
: { text: texts[0], from: fromLang, to: toLang };
const headers = {
"Content-type": "application/json",
Authorization: `Bearer ${key}`,
@@ -810,6 +812,8 @@ export const parseTransRes = async (
history.add(userMsg, hookResult.modelMsg);
}
return hookResult.translations;
} else if (Array.isArray(hookResult)) {
return hookResult;
}
} catch (err) {
kissLog("run res hook", err);
@@ -912,7 +916,10 @@ export const parseTransRes = async (
}
return parseAIRes(modelMsg?.content, useBatchFetch);
case OPT_TRANS_CUSTOMIZE:
if (useBatchFetch) {
return (res?.translations ?? res)?.map((item) => [item.text, item.src]);
}
return [[res.text, res.src || res.from]];
default:
}

View File

@@ -121,7 +121,7 @@ export async function run(isUserscript = false) {
// if (document?.documentElement?.tagName?.toUpperCase() !== "HTML") {
// return;
// }
if (!document?.contentType?.includes("html")) {
if (!document?.contentType?.includes("text")) {
return;
}

View File

@@ -559,7 +559,6 @@ const defaultApiOpts = {
},
[OPT_TRANS_CUSTOMIZE]: {
...defaultApi,
url: "https://translate.googleapis.com/translate_a/single?client=gtx&dj=1&dt=t&ie=UTF-8&q={{text}}&sl=en&tl=zh-CN",
reqHook: defaultRequestHook,
resHook: defaultResponseHook,
},

File diff suppressed because it is too large Load Diff

View File

@@ -13,5 +13,11 @@ export function useDebouncedCallback(callback, delay) {
[delay]
);
useEffect(() => {
return () => {
debouncedCallback.cancel();
};
}, [debouncedCallback]);
return debouncedCallback;
}

View File

@@ -25,13 +25,17 @@ const SettingContext = createContext({
reloadSetting: () => {},
});
export function SettingProvider({ children }) {
export function SettingProvider({ children, isSettingPage }) {
const {
data: setting,
isLoading,
update,
reload,
} = useStorage(STOKEY_SETTING, DEFAULT_SETTING, KV_SETTING_KEY);
} = useStorage(
STOKEY_SETTING,
DEFAULT_SETTING,
isSettingPage ? KV_SETTING_KEY : ""
);
useEffect(() => {
if (typeof setting?.darkMode === "boolean") {
@@ -43,6 +47,8 @@ export function SettingProvider({ children }) {
}, [setting?.darkMode, update]);
useEffect(() => {
if (!isSettingPage) return;
(async () => {
try {
logger.setLevel(setting?.logLevel);
@@ -53,7 +59,7 @@ export function SettingProvider({ children }) {
logger.error("Failed to fetch log level, using default.", error);
}
})();
}, [setting]);
}, [isSettingPage, setting?.logLevel]);
const updateSetting = useCallback(
(objOrFn) => {

View File

@@ -62,11 +62,13 @@ class Logger {
return;
}
if (this.config.level.value !== newLevelObject.value) {
this.config.level = newLevelObject;
console.log(
`[${this.config.prefix}] Log level dynamically set to ${this.config.level.name}`
);
}
}
/**
* 核心日志记录方法

View File

@@ -226,9 +226,15 @@ export const saveRule = async (curRule) => {
}
const newRule = {};
Object.entries(GLOBLA_RULE).forEach(([key, val]) => {
const globalRule = {
...GLOBLA_RULE,
...(rules.find((r) => r.pattern === GLOBAL_KEY) || {}),
};
Object.keys(GLOBLA_RULE).forEach((key) => {
newRule[key] =
!curRule[key] || curRule[key] === val ? DEFAULT_RULE[key] : curRule[key];
!curRule[key] || curRule[key] === globalRule[key]
? DEFAULT_RULE[key]
: curRule[key];
});
rules.unshift(newRule);

View File

@@ -104,7 +104,7 @@ export default class ShadowDomManager {
this.#hostElement = host;
const shadowContainer = host.attachShadow({ mode: "open" });
const appRoot = document.createElement("div");
appRoot.className = `${this._id}_wrapper`;
appRoot.className = `${this._id}_wrapper notranslate`;
shadowContainer.appendChild(appRoot);
const cache = createCache({

View File

@@ -274,8 +274,7 @@ export class Translator {
data, datalist, embed, head, iframe, input, noscript, map,
object, option, param, picture, progress,
select, script, style, track, textarea, template,
video, wbr, .notranslate, [contenteditable], [translate='no'],
${Translator.KISS_IGNORE_SELECTOR}`;
video, wbr, .notranslate, [contenteditable='true'], [translate='no']`;
#setting; // 设置选项
#rule; // 规则
@@ -322,11 +321,15 @@ export class Translator {
// 忽略元素
get #ignoreSelector() {
if (this.#rule.isPlainText) {
return Translator.KISS_IGNORE_SELECTOR;
}
if (this.#rule.autoScan === "false") {
return `${Translator.KISS_IGNORE_SELECTOR}, ${this.#rule.ignoreSelector}`;
}
return `${Translator.BUILTIN_IGNORE_SELECTOR}, ${this.#rule.ignoreSelector}`;
return `${Translator.KISS_IGNORE_SELECTOR}, ${Translator.BUILTIN_IGNORE_SELECTOR}, ${this.#rule.ignoreSelector}`;
}
// 接口参数
@@ -353,7 +356,7 @@ export class Translator {
constructor({ rule = {}, setting = {}, favWords = [] }) {
this.#setting = { ...Translator.DEFAULT_OPTIONS, ...setting };
this.#rule = { ...Translator.DEFAULT_RULE, ...rule };
this.#rule = { ...Translator.DEFAULT_RULE, ...rule, isPlainText: false };
this.#favWords = favWords;
this.#apisMap = new Map(
this.#setting.transApis.map((api) => [api.apiSlug, api])
@@ -413,6 +416,19 @@ export class Translator {
// 注入JS/CSS
this.#initInjector();
// 纯文本预处理
if (this.#rule.isPlainText) {
document
.querySelectorAll("pre")
.forEach(
(pre) =>
(pre.innerHTML = pre.innerHTML?.replace(
/(?:\r\n|\r|\n)/g,
"<br />"
))
);
}
// 查找根节点并扫描
document
.querySelectorAll(this.#rule.rootsSelector || "body")
@@ -1786,7 +1802,11 @@ export class Translator {
this.#rule[key] !== newRule[key]
) {
this.#rule[key] = newRule[key];
if (key === "autoScan" || key === "hasShadowroot") {
if (
key === "autoScan" ||
key === "hasShadowroot" ||
key === "isPlainText"
) {
needsRescan = true;
} else {
hasChanged = true;

View File

@@ -246,6 +246,8 @@ export default class TranslatorManager {
}
#processActions({ action, args } = {}, fromExt = false) {
if (!action) return;
if (!fromExt) {
sendIframeMsg(action, args);
}

View File

@@ -59,14 +59,21 @@ export const sleep = (delay) =>
*/
export const debounce = (func, delay = 200) => {
let timer = null;
return (...args) => {
const debouncedFunc = (...args) => {
timer && clearTimeout(timer);
timer = setTimeout(() => {
func(...args);
clearTimeout(timer);
timer = null;
}, delay);
};
debouncedFunc.cancel = () => {
clearTimeout(timer);
timer = null;
};
return debouncedFunc;
};
/**

View File

@@ -128,7 +128,7 @@ export function Menus({
return i18n("processing_subtitles");
}, [progressed, i18n]);
const { isAISegment, skipAd, isBilingual } = formData;
const { isAISegment, skipAd, isBilingual, showOrigin } = formData;
return (
<div
@@ -157,6 +157,12 @@ export function Menus({
value={isBilingual}
label={i18n("is_bilingual_view")}
/>
<Switch
onChange={handleChange}
name="showOrigin"
value={showOrigin}
label={i18n("show_origin_subtitle")}
/>
<Switch
onChange={handleChange}
name="skipAd"

View File

@@ -8,6 +8,7 @@ import {
OPT_TRANS_MICROSOFT,
MSG_MENUS_PROGRESSED,
MSG_MENUS_UPDATEFORM,
OPT_LANGS_SPEC_DEFAULT,
} from "../config";
import { sleep, genEventName, downloadBlobFile } from "../libs/utils.js";
import { createLogoSVG } from "../libs/svg.js";
@@ -42,7 +43,7 @@ class YouTubeCaptionProvider {
#menuEventName = "kiss-event";
constructor(setting = {}) {
this.#setting = { ...setting, isAISegment: false };
this.#setting = { ...setting, isAISegment: false, showOrigin: false };
this.#i18n = newI18n(setting.uiLang || "zh");
this.#menuEventName = genEventName();
}
@@ -151,6 +152,10 @@ class YouTubeCaptionProvider {
if (node.matches(adLayoutSelector)) {
logger.debug("Youtube Provider: Ad ends!");
if (!this.#setting.showOrigin) {
this.#hideYtCaption();
}
if (videoEl && skipAd) {
videoEl.playbackRate = 1;
}
@@ -200,6 +205,16 @@ class YouTubeCaptionProvider {
this.#managerInstance?.updateSetting({ [name]: value });
} else if (name === "isAISegment") {
this.#reProcessEvents();
} else if (name === "showOrigin") {
this.#toggleShowOrigin();
}
}
#toggleShowOrigin() {
if (this.#setting.showOrigin) {
this.#destroyManager();
} else {
this.#startManager();
}
}
@@ -241,7 +256,8 @@ class YouTubeCaptionProvider {
toggleButton.appendChild(createLogoSVG());
kissControls.appendChild(toggleButton);
const { segApiSetting, isAISegment, skipAd, isBilingual } = this.#setting;
const { segApiSetting, isAISegment, skipAd, isBilingual, showOrigin } =
this.#setting;
const menu = new ShadowDomManager({
id: "kiss-subtitle-menus",
className: "notranslate",
@@ -254,9 +270,10 @@ class YouTubeCaptionProvider {
hasSegApi: !!segApiSetting,
eventName: this.#menuEventName,
initData: {
isAISegment,
skipAd,
isBilingual,
isAISegment, // AI智能断句
skipAd, // 快进广告
isBilingual, // 双语显示
showOrigin, // 显示原字幕
},
},
});
@@ -268,6 +285,10 @@ class YouTubeCaptionProvider {
createLogoSVG({ isSelected: true })
);
menu.show();
this.#sendMenusMsg({
action: MSG_MENUS_PROGRESSED,
data: this.#progressed,
});
} else {
this.#isMenuShow = false;
this.#toggleButton?.replaceChildren(createLogoSVG());
@@ -394,6 +415,20 @@ class YouTubeCaptionProvider {
return [];
}
#getFromLang(lang) {
if (lang === "zh") {
return "zh-CN";
}
return (
OPT_LANGS_SPEC_DEFAULT.get(lang) ||
OPT_LANGS_SPEC_DEFAULT.get(lang.slice(0, 2)) ||
OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang) ||
OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang.slice(0, 2)) ||
"auto"
);
}
async #handleInterceptedRequest(url, responseText) {
const videoId = this.#videoId;
if (!videoId) {
@@ -442,16 +477,14 @@ class YouTubeCaptionProvider {
}
const lang = potUrl.searchParams.get("lang");
const fromLang =
OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang) ||
OPT_LANGS_TO_CODE[OPT_TRANS_MICROSOFT].get(lang.slice(0, 2)) ||
"auto";
const fromLang = this.#getFromLang(lang);
logger.debug(
`Youtube Provider: fromLang: ${fromLang}, toLang: ${toLang}`
`Youtube Provider: lang: ${lang}, fromLang: ${fromLang}, toLang: ${toLang}`
);
if (this.#isSameLang(fromLang, toLang)) {
logger.debug("Youtube Provider: skip same lang", fromLang, toLang);
this.#showNotification(this.#i18n("subtitle_same_lang"));
return;
}
@@ -512,6 +545,9 @@ class YouTubeCaptionProvider {
}
#reProcessEvents() {
this.#progressed = 0;
this.#subtitles = [];
const videoId = this.#videoId;
const flatEvents = this.#flatEvents;
const fromLang = this.#fromLang;
@@ -557,19 +593,19 @@ class YouTubeCaptionProvider {
return subtitlesFallback();
}
const chunkCount = eventChunks.length;
if (chunkCount > 1) {
if (eventChunks.length > 1) {
const remainingChunks = eventChunks.slice(1);
this.#processRemainingChunksAsync({
chunks: remainingChunks,
chunkCount,
videoId,
fromLang,
toLang,
segApiSetting,
});
return [firstBatchSubtitles, 100 / eventChunks.length];
const processed = Math.floor(100 / eventChunks.length);
return [firstBatchSubtitles, processed];
} else {
return [firstBatchSubtitles, 100];
}
@@ -583,6 +619,10 @@ class YouTubeCaptionProvider {
return;
}
if (this.#setting.showOrigin) {
return;
}
if (!this.#subtitles.length) {
this.#showNotification(this.#i18n("waitting_for_subtitle"));
return;
@@ -605,8 +645,7 @@ class YouTubeCaptionProvider {
this.#showNotification(this.#i18n("subtitle_load_succeed"));
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "none");
this.#hideYtCaption();
}
#destroyManager() {
@@ -619,6 +658,15 @@ class YouTubeCaptionProvider {
this.#managerInstance.destroy();
this.#managerInstance = null;
this.#showYtCaption();
}
#hideYtCaption() {
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "none");
}
#showYtCaption() {
const ytCaption = document.querySelector(YT_CAPTION_SELECT);
ytCaption && (ytCaption.style.display = "block");
}
@@ -638,8 +686,13 @@ class YouTubeCaptionProvider {
if (noSpaceLanguages.some((l) => lang?.startsWith(l))) {
const subtitles = [];
if (this.#isQualityPoor(flatEvents, 5, 0.5)) {
return flatEvents;
}
let currentLine = null;
const MAX_LENGTH = 100;
const MAX_LENGTH = 30;
for (const segment of flatEvents) {
if (segment.text) {
@@ -920,7 +973,6 @@ class YouTubeCaptionProvider {
async #processRemainingChunksAsync({
chunks,
chunkCount,
videoId,
fromLang,
toLang,
@@ -968,7 +1020,7 @@ class YouTubeCaptionProvider {
}
if (subtitlesForThisChunk.length > 0) {
const progressed = (chunkNum * 100) / chunkCount;
const progressed = Math.floor((chunkNum * 100) / (chunks.length + 1));
this.#subtitles.push(...subtitlesForThisChunk);
this.#progressed = progressed;

View File

@@ -99,7 +99,7 @@ export default function Options() {
}
return (
<SettingProvider>
<SettingProvider isSettingPage={true}>
<ThemeProvider>
<AlertProvider>
<ConfirmProvider>

View File

@@ -112,7 +112,10 @@ export default function PopupCont({
const handleChange = async (e) => {
try {
const { name, value } = e.target;
let { name, value, checked } = e.target;
if (name === "isPlainText") {
value = checked;
}
setRule((pre) => ({ ...pre, [name]: value }));
if (!processActions) {
@@ -204,6 +207,7 @@ export default function PopupCont({
transOnly,
hasRichText,
hasShadowroot,
isPlainText = false,
} = rule;
return (
@@ -322,24 +326,23 @@ export default function PopupCont({
label={i18n("input_translate")}
/>
</Grid>
<Grid item xs={6}>
<FormControlLabel
control={
<Switch
size="small"
name="isPlainText"
value={!isPlainText}
checked={isPlainText}
onChange={handleChange}
/>
}
label={i18n("plain_text_translate")}
/>
</Grid>
</Grid>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={apiSlug}
name="apiSlug"
label={i18n("translate_service")}
onChange={handleChange}
>
{optApis.map(({ key, name }) => (
<MenuItem key={key} value={key}>
{name}
</MenuItem>
))}
</TextField>
<Stack direction="row" spacing={2}>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
@@ -348,6 +351,7 @@ export default function PopupCont({
name="fromLang"
label={i18n("from_lang")}
onChange={handleChange}
fullWidth
>
{OPT_LANGS_FROM.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
@@ -364,6 +368,7 @@ export default function PopupCont({
name="toLang"
label={i18n("to_lang")}
onChange={handleChange}
fullWidth
>
{OPT_LANGS_TO.map(([lang, name]) => (
<MenuItem key={lang} value={lang}>
@@ -371,6 +376,25 @@ export default function PopupCont({
</MenuItem>
))}
</TextField>
</Stack>
<Stack direction="row" spacing={2}>
<TextField
select
SelectProps={{ MenuProps: { disablePortal: true } }}
size="small"
value={apiSlug}
name="apiSlug"
label={i18n("translate_service")}
onChange={handleChange}
fullWidth
>
{optApis.map(({ key, name }) => (
<MenuItem key={key} value={key}>
{name}
</MenuItem>
))}
</TextField>
<TextField
select
@@ -384,6 +408,7 @@ export default function PopupCont({
: i18n("text_style_alt")
}
onChange={handleChange}
fullWidth
>
{allTextStyles.map((item) => (
<MenuItem key={item.styleSlug} value={item.styleSlug}>
@@ -391,6 +416,7 @@ export default function PopupCont({
</MenuItem>
))}
</TextField>
</Stack>
<Stack
direction="row"