From d29ba5987bf3a6540b5f4fb3f852a64e5cd16bea Mon Sep 17 00:00:00 2001 From: release-verification Date: Sun, 16 Aug 2026 12:14:34 +0300 Subject: [PATCH] Fix provider duplicate lifecycle --- src/chrome/src/providers/manager.js | 22 ++++++++++++++- src/chrome/src/ui/locales/ar.js | 1 + src/chrome/src/ui/locales/bn.js | 1 + src/chrome/src/ui/locales/de.js | 1 + src/chrome/src/ui/locales/en.js | 1 + src/chrome/src/ui/locales/es.js | 1 + src/chrome/src/ui/locales/fa.js | 1 + src/chrome/src/ui/locales/fr.js | 1 + src/chrome/src/ui/locales/he.js | 1 + src/chrome/src/ui/locales/hi.js | 1 + src/chrome/src/ui/locales/id.js | 1 + src/chrome/src/ui/locales/ja.js | 1 + src/chrome/src/ui/locales/ko.js | 1 + src/chrome/src/ui/locales/ms.js | 1 + src/chrome/src/ui/locales/nl.js | 1 + src/chrome/src/ui/locales/pl.js | 1 + src/chrome/src/ui/locales/pt.js | 1 + src/chrome/src/ui/locales/ru.js | 1 + src/chrome/src/ui/locales/th.js | 1 + src/chrome/src/ui/locales/tl.js | 1 + src/chrome/src/ui/locales/tr.js | 1 + src/chrome/src/ui/locales/uk.js | 1 + src/chrome/src/ui/locales/vi.js | 1 + src/chrome/src/ui/locales/zh.js | 1 + src/chrome/src/ui/settings.html | 2 +- src/chrome/src/ui/settings.js | 37 +++++++++++++++++++----- src/firefox/src/providers/manager.js | 22 ++++++++++++++- src/firefox/src/ui/locales/ar.js | 1 + src/firefox/src/ui/locales/bn.js | 1 + src/firefox/src/ui/locales/de.js | 1 + src/firefox/src/ui/locales/en.js | 1 + src/firefox/src/ui/locales/es.js | 1 + src/firefox/src/ui/locales/fa.js | 1 + src/firefox/src/ui/locales/fr.js | 1 + src/firefox/src/ui/locales/he.js | 1 + src/firefox/src/ui/locales/hi.js | 1 + src/firefox/src/ui/locales/id.js | 1 + src/firefox/src/ui/locales/ja.js | 1 + src/firefox/src/ui/locales/ko.js | 1 + src/firefox/src/ui/locales/ms.js | 1 + src/firefox/src/ui/locales/nl.js | 1 + src/firefox/src/ui/locales/pl.js | 1 + src/firefox/src/ui/locales/pt.js | 1 + src/firefox/src/ui/locales/ru.js | 1 + src/firefox/src/ui/locales/th.js | 1 + src/firefox/src/ui/locales/tl.js | 1 + src/firefox/src/ui/locales/tr.js | 1 + src/firefox/src/ui/locales/uk.js | 1 + src/firefox/src/ui/locales/vi.js | 1 + src/firefox/src/ui/locales/zh.js | 1 + src/firefox/src/ui/settings.html | 2 +- src/firefox/src/ui/settings.js | 37 +++++++++++++++++++----- test/run.js | 42 ++++++++++++++++++++++++++-- 53 files changed, 190 insertions(+), 20 deletions(-) diff --git a/src/chrome/src/providers/manager.js b/src/chrome/src/providers/manager.js index a6e44bf78..e59a5f48f 100644 --- a/src/chrome/src/providers/manager.js +++ b/src/chrome/src/providers/manager.js @@ -785,6 +785,7 @@ export class ProviderManager { id === this._duplicateProviderId(sourceId) && !!sourceConfig && this._canDuplicateProvider(sourceId, sourceConfig) && + sourceConfig.configured === true && sourceConfig.type === config.type; } @@ -1304,6 +1305,9 @@ export class ProviderManager { if (!this._canDuplicateProvider(id, source.config)) { throw new Error(`Provider cannot be duplicated: ${id}`); } + if (source.config.configured !== true) { + throw new Error(`Save provider before duplicating it: ${id}`); + } const duplicateId = this._duplicateProviderId(id); if (this.providers.has(duplicateId) || [...this.providers.values()].some(provider => provider.config?.duplicateOf === id)) { throw new Error(`${source.config.label || id} already has a duplicate.`); @@ -1361,7 +1365,23 @@ export class ProviderManager { const duplicatedSourceIds = new Set( [...this.providers.values()].map(provider => provider.config?.duplicateOf).filter(Boolean), ); - for (const [id, provider] of this.providers) { + const duplicateEntriesBySourceId = new Map(); + for (const entry of this.providers) { + const sourceId = entry[1].config?.duplicateOf; + if (sourceId) duplicateEntriesBySourceId.set(sourceId, entry); + } + const orderedEntries = []; + for (const entry of this.providers) { + const [id, provider] = entry; + if (provider.config?.duplicateOf) continue; + orderedEntries.push(entry); + const duplicateEntry = duplicateEntriesBySourceId.get(id); + if (duplicateEntry) orderedEntries.push(duplicateEntry); + } + for (const entry of duplicateEntriesBySourceId.values()) { + if (!this.providers.has(entry[1].config?.duplicateOf)) orderedEntries.push(entry); + } + for (const [id, provider] of orderedEntries) { const config = provider.config; const isDuplicate = !!config.duplicateOf; const hasDuplicate = !isDuplicate && duplicatedSourceIds.has(id); diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index 40bdfa2cc..02d39168f 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'نسخ', 'st.providers.duplicate_limit': 'يُسمح بنسخة واحدة فقط', 'st.providers.duplicate_unavailable': 'لا يمكن نسخ مزود الخدمة هذا', + 'st.providers.duplicate_inactive': 'احفظ مزود الخدمة هذا قبل نسخه', 'st.providers.remove_duplicate': 'حذف النسخة', 'st.providers.remove_duplicate_confirm': 'هل تريد حذف نسخة مزود الخدمة هذه وإعداداتها المحفوظة؟', 'st.providers.set_active': 'تعيينه نشطًا', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 3dc5f38db..148572e97 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -630,6 +630,7 @@ export default { 'st.providers.duplicate': 'ডুপ্লিকেট', 'st.providers.duplicate_limit': 'শুধু একটি ডুপ্লিকেট অনুমোদিত', 'st.providers.duplicate_unavailable': 'এই প্রদানকারীকে ডুপ্লিকেট করা যাবে না', + 'st.providers.duplicate_inactive': 'ডুপ্লিকেট করার আগে এই প্রদানকারীটি সংরক্ষণ করুন', 'st.providers.remove_duplicate': 'ডুপ্লিকেট মুছুন', 'st.providers.remove_duplicate_confirm': 'এই ডুপ্লিকেট প্রদানকারী ও এর সংরক্ষিত সেটিংস মুছবেন?', 'st.providers.set_active': "সক্রিয় সেট করুন", diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index 8a1ffb860..2f478d318 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -625,6 +625,7 @@ export default { 'st.providers.duplicate': 'Duplizieren', 'st.providers.duplicate_limit': 'Es ist nur ein Duplikat zulässig', 'st.providers.duplicate_unavailable': 'Dieser Anbieter kann nicht dupliziert werden', + 'st.providers.duplicate_inactive': 'Speichern Sie diesen Anbieter, bevor Sie ihn duplizieren', 'st.providers.remove_duplicate': 'Duplikat entfernen', 'st.providers.remove_duplicate_confirm': 'Diesen duplizierten Anbieter und seine gespeicherte Konfiguration entfernen?', 'st.providers.set_active': 'Als aktiv setzen', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 56c8e2863..43193efe7 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -631,6 +631,7 @@ export default { 'st.providers.duplicate': 'Duplicate', 'st.providers.duplicate_limit': 'Only one duplicate is allowed', 'st.providers.duplicate_unavailable': 'This provider cannot be duplicated', + 'st.providers.duplicate_inactive': 'Save this provider before duplicating it', 'st.providers.remove_duplicate': 'Remove duplicate', 'st.providers.remove_duplicate_confirm': 'Remove this duplicate provider and its saved configuration?', 'st.providers.set_active': 'Set Active', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index 097f94923..efa1554df 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'Duplicar', 'st.providers.duplicate_limit': 'Solo se permite un duplicado', 'st.providers.duplicate_unavailable': 'Este proveedor no se puede duplicar', + 'st.providers.duplicate_inactive': 'Guarda este proveedor antes de duplicarlo', 'st.providers.remove_duplicate': 'Eliminar duplicado', 'st.providers.remove_duplicate_confirm': '¿Eliminar este proveedor duplicado y su configuración guardada?', 'st.providers.set_active': 'Establecer activo', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index 9831fe275..711fd4e7f 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -630,6 +630,7 @@ export default { 'st.providers.duplicate': 'تکثیر', 'st.providers.duplicate_limit': 'فقط یک نسخهٔ تکراری مجاز است', 'st.providers.duplicate_unavailable': 'امکان تکثیر این ارائه‌دهنده وجود ندارد', + 'st.providers.duplicate_inactive': 'پیش از تکثیر، این ارائه‌دهنده را ذخیره کنید', 'st.providers.remove_duplicate': 'حذف نسخهٔ تکراری', 'st.providers.remove_duplicate_confirm': 'این ارائه‌دهندهٔ تکراری و تنظیمات ذخیره‌شدهٔ آن حذف شود؟', 'st.providers.set_active': "فعال را تنظیم کنید", diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index c21cbaccc..531254d35 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'Dupliquer', 'st.providers.duplicate_limit': 'Un seul doublon est autorisé', 'st.providers.duplicate_unavailable': 'Ce fournisseur ne peut pas être dupliqué', + 'st.providers.duplicate_inactive': 'Enregistrez ce fournisseur avant de le dupliquer', 'st.providers.remove_duplicate': 'Supprimer le doublon', 'st.providers.remove_duplicate_confirm': 'Supprimer ce fournisseur dupliqué et sa configuration enregistrée ?', 'st.providers.set_active': 'Définir comme actif', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index 007383198..f52e6e93a 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -599,6 +599,7 @@ export default { 'st.providers.duplicate': 'שכפול', 'st.providers.duplicate_limit': 'מותר שכפול אחד בלבד', 'st.providers.duplicate_unavailable': 'לא ניתן לשכפל ספק זה', + 'st.providers.duplicate_inactive': 'יש לשמור את הספק הזה לפני שכפולו', 'st.providers.remove_duplicate': 'הסרת השכפול', 'st.providers.remove_duplicate_confirm': 'להסיר את הספק המשוכפל ואת ההגדרות השמורות שלו?', "st.providers.set_active": "הגדר פעיל", diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 4877e5ecb..730d21162 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -630,6 +630,7 @@ export default { 'st.providers.duplicate': 'डुप्लिकेट करें', 'st.providers.duplicate_limit': 'केवल एक डुप्लिकेट की अनुमति है', 'st.providers.duplicate_unavailable': 'इस प्रदाता को डुप्लिकेट नहीं किया जा सकता', + 'st.providers.duplicate_inactive': 'इस प्रदाता को डुप्लिकेट करने से पहले सहेजें', 'st.providers.remove_duplicate': 'डुप्लिकेट हटाएँ', 'st.providers.remove_duplicate_confirm': 'क्या इस डुप्लिकेट प्रदाता और इसकी सहेजी गई सेटिंग हटानी हैं?', 'st.providers.set_active': "सक्रिय सेट करें", diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index ad79a0d4f..c63161792 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'Duplikat', 'st.providers.duplicate_limit': 'Hanya satu duplikat yang diizinkan', 'st.providers.duplicate_unavailable': 'Penyedia ini tidak dapat diduplikasi', + 'st.providers.duplicate_inactive': 'Simpan penyedia ini sebelum menduplikasinya', 'st.providers.remove_duplicate': 'Hapus duplikat', 'st.providers.remove_duplicate_confirm': 'Hapus penyedia duplikat ini beserta konfigurasi tersimpannya?', 'st.providers.set_active': 'Jadikan aktif', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index 6f0574016..ea368e7ab 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': '複製', 'st.providers.duplicate_limit': '複製は1つだけ作成できます', 'st.providers.duplicate_unavailable': 'このプロバイダーは複製できません', + 'st.providers.duplicate_inactive': '複製する前にこのプロバイダーを保存してください', 'st.providers.remove_duplicate': '複製を削除', 'st.providers.remove_duplicate_confirm': 'この複製プロバイダーと保存済み設定を削除しますか?', 'st.providers.set_active': '有効化', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index 889cbb853..0046fac92 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': '복제', 'st.providers.duplicate_limit': '복제본은 하나만 만들 수 있습니다', 'st.providers.duplicate_unavailable': '이 공급자는 복제할 수 없습니다', + 'st.providers.duplicate_inactive': '이 공급자를 복제하기 전에 저장하세요', 'st.providers.remove_duplicate': '복제본 삭제', 'st.providers.remove_duplicate_confirm': '이 복제 공급자와 저장된 설정을 삭제할까요?', 'st.providers.set_active': '활성으로 설정', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index 8ba1d6279..718f054ab 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'Duplikasi', 'st.providers.duplicate_limit': 'Hanya satu duplikasi dibenarkan', 'st.providers.duplicate_unavailable': 'Penyedia ini tidak boleh diduplikasi', + 'st.providers.duplicate_inactive': 'Simpan penyedia ini sebelum menduplikasikannya', 'st.providers.remove_duplicate': 'Alih keluar duplikasi', 'st.providers.remove_duplicate_confirm': 'Alih keluar penyedia duplikasi ini dan konfigurasi tersimpannya?', 'st.providers.set_active': 'Jadikan aktif', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index 196450427..a24b80151 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -606,6 +606,7 @@ export default { 'st.providers.duplicate': 'Dupliceren', 'st.providers.duplicate_limit': 'Er is slechts één duplicaat toegestaan', 'st.providers.duplicate_unavailable': 'Deze provider kan niet worden gedupliceerd', + 'st.providers.duplicate_inactive': 'Sla deze provider op voordat u deze dupliceert', 'st.providers.remove_duplicate': 'Duplicaat verwijderen', 'st.providers.remove_duplicate_confirm': 'Deze gedupliceerde provider en de opgeslagen configuratie verwijderen?', 'st.providers.set_active': 'Actief instellen', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index 87ae2a615..f6cf5f7c4 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -443,6 +443,7 @@ export default { 'st.providers.duplicate': 'Duplikuj', 'st.providers.duplicate_limit': 'Dozwolony jest tylko jeden duplikat', 'st.providers.duplicate_unavailable': 'Tego dostawcy nie można zduplikować', + 'st.providers.duplicate_inactive': 'Zapisz tego dostawcę przed jego zduplikowaniem', 'st.providers.remove_duplicate': 'Usuń duplikat', 'st.providers.remove_duplicate_confirm': 'Usunąć tego zduplikowanego dostawcę i jego zapisaną konfigurację?', 'st.providers.set_active': 'Ustaw jako aktywny', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 737dfb91a..f3ff05518 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -630,6 +630,7 @@ export default { 'st.providers.duplicate': 'Duplicar', 'st.providers.duplicate_limit': 'Só é permitida uma duplicata', 'st.providers.duplicate_unavailable': 'Este provedor não pode ser duplicado', + 'st.providers.duplicate_inactive': 'Salve este provedor antes de duplicá-lo', 'st.providers.remove_duplicate': 'Remover duplicata', 'st.providers.remove_duplicate_confirm': 'Remover este provedor duplicado e a configuração salva?', 'st.providers.set_active': "Definir ativo", diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 4723f56f1..4994d04e0 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'Дублировать', 'st.providers.duplicate_limit': 'Разрешён только один дубликат', 'st.providers.duplicate_unavailable': 'Этого провайдера нельзя дублировать', + 'st.providers.duplicate_inactive': 'Сохраните этого провайдера перед дублированием', 'st.providers.remove_duplicate': 'Удалить дубликат', 'st.providers.remove_duplicate_confirm': 'Удалить этого дублированного провайдера и его сохранённые настройки?', 'st.providers.set_active': 'Сделать активным', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index 3a924244f..8f06457f0 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'ทำสำเนา', 'st.providers.duplicate_limit': 'อนุญาตให้มีสำเนาได้เพียงหนึ่งรายการ', 'st.providers.duplicate_unavailable': 'ไม่สามารถทำสำเนาผู้ให้บริการนี้ได้', + 'st.providers.duplicate_inactive': 'บันทึกผู้ให้บริการนี้ก่อนทำสำเนา', 'st.providers.remove_duplicate': 'ลบสำเนา', 'st.providers.remove_duplicate_confirm': 'ลบผู้ให้บริการสำเนานี้และการตั้งค่าที่บันทึกไว้หรือไม่', 'st.providers.set_active': 'ตั้งเป็นใช้งาน', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index d54530d00..33b2f1b8f 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'I-duplicate', 'st.providers.duplicate_limit': 'Isang duplicate lang ang pinapayagan', 'st.providers.duplicate_unavailable': 'Hindi maaaring i-duplicate ang provider na ito', + 'st.providers.duplicate_inactive': 'I-save ang provider na ito bago ito i-duplicate', 'st.providers.remove_duplicate': 'Alisin ang duplicate', 'st.providers.remove_duplicate_confirm': 'Alisin ang duplicate na provider na ito at ang naka-save nitong configuration?', 'st.providers.set_active': 'Itakda Bilang Aktibo', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index f36cc9160..28af3dac8 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -314,6 +314,7 @@ export default { 'st.providers.duplicate': 'Çoğalt', 'st.providers.duplicate_limit': 'Yalnızca bir kopyaya izin verilir', 'st.providers.duplicate_unavailable': 'Bu sağlayıcı çoğaltılamaz', + 'st.providers.duplicate_inactive': 'Çoğaltmadan önce bu sağlayıcıyı kaydedin', 'st.providers.remove_duplicate': 'Kopyayı kaldır', 'st.providers.remove_duplicate_confirm': 'Bu çoğaltılmış sağlayıcı ve kayıtlı yapılandırması kaldırılsın mı?', 'st.providers.set_active': 'Etkinleştir', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index 8bc9f4edc..7e91ce209 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': 'Дублювати', 'st.providers.duplicate_limit': 'Дозволено лише один дублікат', 'st.providers.duplicate_unavailable': 'Цього постачальника не можна дублювати', + 'st.providers.duplicate_inactive': 'Збережіть цього постачальника перед дублюванням', 'st.providers.remove_duplicate': 'Видалити дублікат', 'st.providers.remove_duplicate_confirm': 'Видалити цього дубльованого постачальника та його збережені налаштування?', 'st.providers.set_active': 'Зробити активним', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index daaef48ad..2af46298b 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -630,6 +630,7 @@ export default { 'st.providers.duplicate': 'Nhân bản', 'st.providers.duplicate_limit': 'Chỉ được phép có một bản sao', 'st.providers.duplicate_unavailable': 'Không thể nhân bản nhà cung cấp này', + 'st.providers.duplicate_inactive': 'Lưu nhà cung cấp này trước khi nhân bản', 'st.providers.remove_duplicate': 'Xóa bản sao', 'st.providers.remove_duplicate_confirm': 'Xóa nhà cung cấp nhân bản này và cấu hình đã lưu?', 'st.providers.set_active': "Đặt hoạt động", diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index fe9b6e4ba..7f88a3a6a 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -275,6 +275,7 @@ export default { 'st.providers.duplicate': '复制', 'st.providers.duplicate_limit': '最多只能创建一个副本', 'st.providers.duplicate_unavailable': '此服务商不可复制', + 'st.providers.duplicate_inactive': '请先保存此服务商,然后再复制', 'st.providers.remove_duplicate': '删除副本', 'st.providers.remove_duplicate_confirm': '要删除此服务商副本及其已保存的配置吗?', 'st.providers.set_active': '设为启用', diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index e3d394575..e029b4841 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -294,7 +294,7 @@ .provider-card.collapsed { padding: 12px 16px; } .provider-card.collapsed .provider-header { margin-bottom: 0; } - .provider-card .btn-duplicate[aria-disabled="true"] { cursor: not-allowed; opacity: 0.48; transform: none; } + .provider-card .btn-duplicate:disabled { cursor: not-allowed; opacity: 0.48; transform: none; } .provider-header { display: flex; align-items: center; diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index e9597eef9..99eba6730 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -325,6 +325,7 @@ let providersData = {}; // including temporarily invalid JSON while the user is still editing it. // Keep the raw UI draft separate from the last valid provider config. const providerCompatibilityJsonDrafts = new Map(); +const dirtyProviderIds = new Set(); let activeProviderId = ''; let providerActivationRequestId = 0; let requestedActiveProviderId = ''; @@ -2981,6 +2982,11 @@ function renderProviders() { ` : ''; const compatibilitySettings = renderProviderCompatibilitySettings(id, config); + const duplicateDisabledKey = config.hasDuplicate + ? 'st.providers.duplicate_limit' + : (!config.canDuplicate + ? 'st.providers.duplicate_unavailable' + : ((!isConfigured || dirtyProviderIds.has(id)) ? 'st.providers.duplicate_inactive' : '')); const body = ` ${fieldsHTML} @@ -2994,11 +3000,7 @@ function renderProviders() { ${!isSelected ? `` : ''} ${config.isDuplicate ? `` - : (config.canDuplicate - ? `` - : (config.hasDuplicate - ? `` - : ``))} + : ``}
`; @@ -3029,9 +3031,13 @@ function renderProviders() { document.querySelectorAll('.btn-activate').forEach(btn => { btn.addEventListener('click', () => activateProvider(btn.dataset.provider)); }); - document.querySelectorAll('.btn-duplicate:not([aria-disabled="true"])').forEach(btn => { + document.querySelectorAll('.btn-duplicate').forEach(btn => { btn.addEventListener('click', () => duplicateProvider(btn.dataset.provider)); }); + document.querySelectorAll('input[data-provider], select[data-provider], textarea[data-provider]').forEach(input => { + const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; + input.addEventListener(eventName, () => markProviderDirty(input.dataset.provider)); + }); document.querySelectorAll('.btn-remove-duplicate').forEach(btn => { btn.addEventListener('click', () => removeDuplicateProvider(btn.dataset.provider)); }); @@ -3058,6 +3064,7 @@ function renderProviders() { input.style.display = 'none'; input.value = sel.value; } + markProviderDirty(providerId); refreshProviderCompatibilitySummary(providerId); refreshVisionStatus(providerId); }); @@ -3092,6 +3099,7 @@ function renderProviders() { textarea.value = ''; providerCompatibilityJsonDrafts.set(id, ''); } + markProviderDirty(id); refreshProviderCompatibilitySummary(id); }); }); @@ -3274,6 +3282,12 @@ function providerIsActive(id, config) { return id !== 'webbrain_cloud' && config?.configured === true; } +function markProviderDirty(id) { + if (!id || !providersData[id]) return; + dirtyProviderIds.add(id); + refreshProviderCardStatus(id); +} + function refreshActiveProviderFilterCount() { const count = Object.entries(providersData) .filter(([id, config]) => id !== 'webgpu' && providerIsActive(id, config)) @@ -3435,6 +3449,7 @@ async function saveProvider(id, { showFlash = true, markConfigured = true } = {} } if (markConfigured) providersData[id].configured = id !== 'webbrain_cloud'; } + if (markConfigured) dirtyProviderIds.delete(id); refreshProviderCardStatus(id); refreshVisionStatus(id); @@ -3458,6 +3473,13 @@ function refreshProviderCardStatus(id) { const isSelected = id === activeProviderId; card.classList.toggle('configured', isConfigured); card.classList.toggle('selected', isSelected); + const duplicateButton = card.querySelector('.btn-duplicate'); + if (duplicateButton && providersData[id]?.canDuplicate && !providersData[id]?.hasDuplicate) { + const requiresSave = !isConfigured || dirtyProviderIds.has(id); + duplicateButton.disabled = requiresSave; + if (!requiresSave) duplicateButton.removeAttribute('title'); + else duplicateButton.title = t('st.providers.duplicate_inactive'); + } const badges = card.querySelector('.provider-status-badges'); if (!badges) return; badges.innerHTML = ` @@ -3543,10 +3565,10 @@ function restoreProviderDrafts(drafts) { } async function duplicateProvider(id) { + if (!providerIsActive(id, providersData[id]) || dirtyProviderIds.has(id)) return; try { syncInputsIntoProvidersData(); const providerDrafts = providersData; - await saveProvider(id, { showFlash: false, markConfigured: false }); const created = await sendToBackground('duplicate_provider', { providerId: id }); const refreshed = await sendToBackground('get_providers'); providersData = refreshed.providers; @@ -3571,6 +3593,7 @@ async function removeDuplicateProvider(id) { restoreProviderDrafts(providerDrafts); expandedProviders.delete(id); providerCompatibilityJsonDrafts.delete(id); + dirtyProviderIds.delete(id); renderProviders(); } catch (error) { setProviderTestResult(id, 'fail', t('st.providers.failed', { error: error.message })); diff --git a/src/firefox/src/providers/manager.js b/src/firefox/src/providers/manager.js index f15ec62ff..dc2b03685 100644 --- a/src/firefox/src/providers/manager.js +++ b/src/firefox/src/providers/manager.js @@ -732,6 +732,7 @@ export class ProviderManager { id === this._duplicateProviderId(sourceId) && !!sourceConfig && this._canDuplicateProvider(sourceId, sourceConfig) && + sourceConfig.configured === true && sourceConfig.type === config.type; } @@ -1137,6 +1138,9 @@ export class ProviderManager { if (!this._canDuplicateProvider(id, source.config)) { throw new Error(`Provider cannot be duplicated: ${id}`); } + if (source.config.configured !== true) { + throw new Error(`Save provider before duplicating it: ${id}`); + } const duplicateId = this._duplicateProviderId(id); if (this.providers.has(duplicateId) || [...this.providers.values()].some(provider => provider.config?.duplicateOf === id)) { throw new Error(`${source.config.label || id} already has a duplicate.`); @@ -1194,7 +1198,23 @@ export class ProviderManager { const duplicatedSourceIds = new Set( [...this.providers.values()].map(provider => provider.config?.duplicateOf).filter(Boolean), ); - for (const [id, provider] of this.providers) { + const duplicateEntriesBySourceId = new Map(); + for (const entry of this.providers) { + const sourceId = entry[1].config?.duplicateOf; + if (sourceId) duplicateEntriesBySourceId.set(sourceId, entry); + } + const orderedEntries = []; + for (const entry of this.providers) { + const [id, provider] = entry; + if (provider.config?.duplicateOf) continue; + orderedEntries.push(entry); + const duplicateEntry = duplicateEntriesBySourceId.get(id); + if (duplicateEntry) orderedEntries.push(duplicateEntry); + } + for (const entry of duplicateEntriesBySourceId.values()) { + if (!this.providers.has(entry[1].config?.duplicateOf)) orderedEntries.push(entry); + } + for (const [id, provider] of orderedEntries) { const config = provider.config; const isDuplicate = !!config.duplicateOf; const hasDuplicate = !isDuplicate && duplicatedSourceIds.has(id); diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js index 33ef8ae8f..015707b16 100644 --- a/src/firefox/src/ui/locales/ar.js +++ b/src/firefox/src/ui/locales/ar.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'نسخ', 'st.providers.duplicate_limit': 'يُسمح بنسخة واحدة فقط', 'st.providers.duplicate_unavailable': 'لا يمكن نسخ مزود الخدمة هذا', + 'st.providers.duplicate_inactive': 'احفظ مزود الخدمة هذا قبل نسخه', 'st.providers.remove_duplicate': 'حذف النسخة', 'st.providers.remove_duplicate_confirm': 'هل تريد حذف نسخة مزود الخدمة هذه وإعداداتها المحفوظة؟', 'st.providers.set_active': 'تعيينه نشطًا', diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index 4bd0fb9a4..8eba197dd 100644 --- a/src/firefox/src/ui/locales/bn.js +++ b/src/firefox/src/ui/locales/bn.js @@ -614,6 +614,7 @@ export default { 'st.providers.duplicate': 'ডুপ্লিকেট', 'st.providers.duplicate_limit': 'শুধু একটি ডুপ্লিকেট অনুমোদিত', 'st.providers.duplicate_unavailable': 'এই প্রদানকারীকে ডুপ্লিকেট করা যাবে না', + 'st.providers.duplicate_inactive': 'ডুপ্লিকেট করার আগে এই প্রদানকারীটি সংরক্ষণ করুন', 'st.providers.remove_duplicate': 'ডুপ্লিকেট মুছুন', 'st.providers.remove_duplicate_confirm': 'এই ডুপ্লিকেট প্রদানকারী ও এর সংরক্ষিত সেটিংস মুছবেন?', 'st.providers.set_active': "সক্রিয় সেট করুন", diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index 165ab2771..0ec807c42 100644 --- a/src/firefox/src/ui/locales/de.js +++ b/src/firefox/src/ui/locales/de.js @@ -609,6 +609,7 @@ export default { 'st.providers.duplicate': 'Duplizieren', 'st.providers.duplicate_limit': 'Es ist nur ein Duplikat zulässig', 'st.providers.duplicate_unavailable': 'Dieser Anbieter kann nicht dupliziert werden', + 'st.providers.duplicate_inactive': 'Speichern Sie diesen Anbieter, bevor Sie ihn duplizieren', 'st.providers.remove_duplicate': 'Duplikat entfernen', 'st.providers.remove_duplicate_confirm': 'Diesen duplizierten Anbieter und seine gespeicherte Konfiguration entfernen?', 'st.providers.set_active': 'Als aktiv setzen', diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index d89385da6..bd7cba64e 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -615,6 +615,7 @@ export default { 'st.providers.duplicate': 'Duplicate', 'st.providers.duplicate_limit': 'Only one duplicate is allowed', 'st.providers.duplicate_unavailable': 'This provider cannot be duplicated', + 'st.providers.duplicate_inactive': 'Save this provider before duplicating it', 'st.providers.remove_duplicate': 'Remove duplicate', 'st.providers.remove_duplicate_confirm': 'Remove this duplicate provider and its saved configuration?', 'st.providers.set_active': 'Set Active', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index 2d7a9548d..59673bbe0 100644 --- a/src/firefox/src/ui/locales/es.js +++ b/src/firefox/src/ui/locales/es.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'Duplicar', 'st.providers.duplicate_limit': 'Solo se permite un duplicado', 'st.providers.duplicate_unavailable': 'Este proveedor no se puede duplicar', + 'st.providers.duplicate_inactive': 'Guarda este proveedor antes de duplicarlo', 'st.providers.remove_duplicate': 'Eliminar duplicado', 'st.providers.remove_duplicate_confirm': '¿Eliminar este proveedor duplicado y su configuración guardada?', 'st.providers.set_active': 'Establecer activo', diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index ff575e048..497fc34b1 100644 --- a/src/firefox/src/ui/locales/fa.js +++ b/src/firefox/src/ui/locales/fa.js @@ -614,6 +614,7 @@ export default { 'st.providers.duplicate': 'تکثیر', 'st.providers.duplicate_limit': 'فقط یک نسخهٔ تکراری مجاز است', 'st.providers.duplicate_unavailable': 'امکان تکثیر این ارائه‌دهنده وجود ندارد', + 'st.providers.duplicate_inactive': 'پیش از تکثیر، این ارائه‌دهنده را ذخیره کنید', 'st.providers.remove_duplicate': 'حذف نسخهٔ تکراری', 'st.providers.remove_duplicate_confirm': 'این ارائه‌دهندهٔ تکراری و تنظیمات ذخیره‌شدهٔ آن حذف شود؟', 'st.providers.set_active': "فعال را تنظیم کنید", diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index 36f51fcb6..4e51a3d4b 100644 --- a/src/firefox/src/ui/locales/fr.js +++ b/src/firefox/src/ui/locales/fr.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'Dupliquer', 'st.providers.duplicate_limit': 'Un seul doublon est autorisé', 'st.providers.duplicate_unavailable': 'Ce fournisseur ne peut pas être dupliqué', + 'st.providers.duplicate_inactive': 'Enregistrez ce fournisseur avant de le dupliquer', 'st.providers.remove_duplicate': 'Supprimer le doublon', 'st.providers.remove_duplicate_confirm': 'Supprimer ce fournisseur dupliqué et sa configuration enregistrée ?', 'st.providers.set_active': 'Définir comme actif', diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index d7e048f81..5ea59abcd 100644 --- a/src/firefox/src/ui/locales/he.js +++ b/src/firefox/src/ui/locales/he.js @@ -576,6 +576,7 @@ export default { 'st.providers.duplicate': 'שכפול', 'st.providers.duplicate_limit': 'מותר שכפול אחד בלבד', 'st.providers.duplicate_unavailable': 'לא ניתן לשכפל ספק זה', + 'st.providers.duplicate_inactive': 'יש לשמור את הספק הזה לפני שכפולו', 'st.providers.remove_duplicate': 'הסרת השכפול', 'st.providers.remove_duplicate_confirm': 'להסיר את הספק המשוכפל ואת ההגדרות השמורות שלו?', "st.providers.set_active": "הגדר פעיל", diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index 58bc8c882..daf7fa59d 100644 --- a/src/firefox/src/ui/locales/hi.js +++ b/src/firefox/src/ui/locales/hi.js @@ -614,6 +614,7 @@ export default { 'st.providers.duplicate': 'डुप्लिकेट करें', 'st.providers.duplicate_limit': 'केवल एक डुप्लिकेट की अनुमति है', 'st.providers.duplicate_unavailable': 'इस प्रदाता को डुप्लिकेट नहीं किया जा सकता', + 'st.providers.duplicate_inactive': 'इस प्रदाता को डुप्लिकेट करने से पहले सहेजें', 'st.providers.remove_duplicate': 'डुप्लिकेट हटाएँ', 'st.providers.remove_duplicate_confirm': 'क्या इस डुप्लिकेट प्रदाता और इसकी सहेजी गई सेटिंग हटानी हैं?', 'st.providers.set_active': "सक्रिय सेट करें", diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index 1bc00842d..de1ed4e93 100644 --- a/src/firefox/src/ui/locales/id.js +++ b/src/firefox/src/ui/locales/id.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'Duplikat', 'st.providers.duplicate_limit': 'Hanya satu duplikat yang diizinkan', 'st.providers.duplicate_unavailable': 'Penyedia ini tidak dapat diduplikasi', + 'st.providers.duplicate_inactive': 'Simpan penyedia ini sebelum menduplikasinya', 'st.providers.remove_duplicate': 'Hapus duplikat', 'st.providers.remove_duplicate_confirm': 'Hapus penyedia duplikat ini beserta konfigurasi tersimpannya?', 'st.providers.set_active': 'Jadikan aktif', diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 55f6eccea..a2fa36234 100644 --- a/src/firefox/src/ui/locales/ja.js +++ b/src/firefox/src/ui/locales/ja.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': '複製', 'st.providers.duplicate_limit': '複製は1つだけ作成できます', 'st.providers.duplicate_unavailable': 'このプロバイダーは複製できません', + 'st.providers.duplicate_inactive': '複製する前にこのプロバイダーを保存してください', 'st.providers.remove_duplicate': '複製を削除', 'st.providers.remove_duplicate_confirm': 'この複製プロバイダーと保存済み設定を削除しますか?', 'st.providers.set_active': '有効化', diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index f30f76157..1bb308fa5 100644 --- a/src/firefox/src/ui/locales/ko.js +++ b/src/firefox/src/ui/locales/ko.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': '복제', 'st.providers.duplicate_limit': '복제본은 하나만 만들 수 있습니다', 'st.providers.duplicate_unavailable': '이 공급자는 복제할 수 없습니다', + 'st.providers.duplicate_inactive': '이 공급자를 복제하기 전에 저장하세요', 'st.providers.remove_duplicate': '복제본 삭제', 'st.providers.remove_duplicate_confirm': '이 복제 공급자와 저장된 설정을 삭제할까요?', 'st.providers.set_active': '활성으로 설정', diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index 448ebe441..2226859eb 100644 --- a/src/firefox/src/ui/locales/ms.js +++ b/src/firefox/src/ui/locales/ms.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'Duplikasi', 'st.providers.duplicate_limit': 'Hanya satu duplikasi dibenarkan', 'st.providers.duplicate_unavailable': 'Penyedia ini tidak boleh diduplikasi', + 'st.providers.duplicate_inactive': 'Simpan penyedia ini sebelum menduplikasikannya', 'st.providers.remove_duplicate': 'Alih keluar duplikasi', 'st.providers.remove_duplicate_confirm': 'Alih keluar penyedia duplikasi ini dan konfigurasi tersimpannya?', 'st.providers.set_active': 'Jadikan aktif', diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index 9c52377fc..2eeec52a7 100644 --- a/src/firefox/src/ui/locales/nl.js +++ b/src/firefox/src/ui/locales/nl.js @@ -590,6 +590,7 @@ export default { 'st.providers.duplicate': 'Dupliceren', 'st.providers.duplicate_limit': 'Er is slechts één duplicaat toegestaan', 'st.providers.duplicate_unavailable': 'Deze provider kan niet worden gedupliceerd', + 'st.providers.duplicate_inactive': 'Sla deze provider op voordat u deze dupliceert', 'st.providers.remove_duplicate': 'Duplicaat verwijderen', 'st.providers.remove_duplicate_confirm': 'Deze gedupliceerde provider en de opgeslagen configuratie verwijderen?', 'st.providers.set_active': 'Actief instellen', diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index f477b01de..cd8fa44bc 100644 --- a/src/firefox/src/ui/locales/pl.js +++ b/src/firefox/src/ui/locales/pl.js @@ -434,6 +434,7 @@ export default { 'st.providers.duplicate': 'Duplikuj', 'st.providers.duplicate_limit': 'Dozwolony jest tylko jeden duplikat', 'st.providers.duplicate_unavailable': 'Tego dostawcy nie można zduplikować', + 'st.providers.duplicate_inactive': 'Zapisz tego dostawcę przed jego zduplikowaniem', 'st.providers.remove_duplicate': 'Usuń duplikat', 'st.providers.remove_duplicate_confirm': 'Usunąć tego zduplikowanego dostawcę i jego zapisaną konfigurację?', 'st.providers.set_active': 'Ustaw jako aktywny', diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index 6b43fb270..0024e19ca 100644 --- a/src/firefox/src/ui/locales/pt.js +++ b/src/firefox/src/ui/locales/pt.js @@ -614,6 +614,7 @@ export default { 'st.providers.duplicate': 'Duplicar', 'st.providers.duplicate_limit': 'Só é permitida uma duplicata', 'st.providers.duplicate_unavailable': 'Este provedor não pode ser duplicado', + 'st.providers.duplicate_inactive': 'Salve este provedor antes de duplicá-lo', 'st.providers.remove_duplicate': 'Remover duplicata', 'st.providers.remove_duplicate_confirm': 'Remover este provedor duplicado e a configuração salva?', 'st.providers.set_active': "Definir ativo", diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index fc3fe6c40..29cd18dd4 100644 --- a/src/firefox/src/ui/locales/ru.js +++ b/src/firefox/src/ui/locales/ru.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'Дублировать', 'st.providers.duplicate_limit': 'Разрешён только один дубликат', 'st.providers.duplicate_unavailable': 'Этого провайдера нельзя дублировать', + 'st.providers.duplicate_inactive': 'Сохраните этого провайдера перед дублированием', 'st.providers.remove_duplicate': 'Удалить дубликат', 'st.providers.remove_duplicate_confirm': 'Удалить этого дублированного провайдера и его сохранённые настройки?', 'st.providers.set_active': 'Сделать активным', diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index 1d6f5e0f5..b836dc541 100644 --- a/src/firefox/src/ui/locales/th.js +++ b/src/firefox/src/ui/locales/th.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'ทำสำเนา', 'st.providers.duplicate_limit': 'อนุญาตให้มีสำเนาได้เพียงหนึ่งรายการ', 'st.providers.duplicate_unavailable': 'ไม่สามารถทำสำเนาผู้ให้บริการนี้ได้', + 'st.providers.duplicate_inactive': 'บันทึกผู้ให้บริการนี้ก่อนทำสำเนา', 'st.providers.remove_duplicate': 'ลบสำเนา', 'st.providers.remove_duplicate_confirm': 'ลบผู้ให้บริการสำเนานี้และการตั้งค่าที่บันทึกไว้หรือไม่', 'st.providers.set_active': 'ตั้งเป็นใช้งาน', diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index 611a48f55..9f834a016 100644 --- a/src/firefox/src/ui/locales/tl.js +++ b/src/firefox/src/ui/locales/tl.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'I-duplicate', 'st.providers.duplicate_limit': 'Isang duplicate lang ang pinapayagan', 'st.providers.duplicate_unavailable': 'Hindi maaaring i-duplicate ang provider na ito', + 'st.providers.duplicate_inactive': 'I-save ang provider na ito bago ito i-duplicate', 'st.providers.remove_duplicate': 'Alisin ang duplicate', 'st.providers.remove_duplicate_confirm': 'Alisin ang duplicate na provider na ito at ang naka-save nitong configuration?', 'st.providers.set_active': 'Itakda Bilang Aktibo', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index fbce84d5c..a939b11cd 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -306,6 +306,7 @@ export default { 'st.providers.duplicate': 'Çoğalt', 'st.providers.duplicate_limit': 'Yalnızca bir kopyaya izin verilir', 'st.providers.duplicate_unavailable': 'Bu sağlayıcı çoğaltılamaz', + 'st.providers.duplicate_inactive': 'Çoğaltmadan önce bu sağlayıcıyı kaydedin', 'st.providers.remove_duplicate': 'Kopyayı kaldır', 'st.providers.remove_duplicate_confirm': 'Bu çoğaltılmış sağlayıcı ve kayıtlı yapılandırması kaldırılsın mı?', 'st.providers.set_active': 'Etkinleştir', diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index b2e95dd2d..4bb550210 100644 --- a/src/firefox/src/ui/locales/uk.js +++ b/src/firefox/src/ui/locales/uk.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': 'Дублювати', 'st.providers.duplicate_limit': 'Дозволено лише один дублікат', 'st.providers.duplicate_unavailable': 'Цього постачальника не можна дублювати', + 'st.providers.duplicate_inactive': 'Збережіть цього постачальника перед дублюванням', 'st.providers.remove_duplicate': 'Видалити дублікат', 'st.providers.remove_duplicate_confirm': 'Видалити цього дубльованого постачальника та його збережені налаштування?', 'st.providers.set_active': 'Зробити активним', diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index 3babc281b..6840ae05e 100644 --- a/src/firefox/src/ui/locales/vi.js +++ b/src/firefox/src/ui/locales/vi.js @@ -614,6 +614,7 @@ export default { 'st.providers.duplicate': 'Nhân bản', 'st.providers.duplicate_limit': 'Chỉ được phép có một bản sao', 'st.providers.duplicate_unavailable': 'Không thể nhân bản nhà cung cấp này', + 'st.providers.duplicate_inactive': 'Lưu nhà cung cấp này trước khi nhân bản', 'st.providers.remove_duplicate': 'Xóa bản sao', 'st.providers.remove_duplicate_confirm': 'Xóa nhà cung cấp nhân bản này và cấu hình đã lưu?', 'st.providers.set_active': "Đặt hoạt động", diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index bc9cf5e82..20f92bf92 100644 --- a/src/firefox/src/ui/locales/zh.js +++ b/src/firefox/src/ui/locales/zh.js @@ -267,6 +267,7 @@ export default { 'st.providers.duplicate': '复制', 'st.providers.duplicate_limit': '最多只能创建一个副本', 'st.providers.duplicate_unavailable': '此服务商不可复制', + 'st.providers.duplicate_inactive': '请先保存此服务商,然后再复制', 'st.providers.remove_duplicate': '删除副本', 'st.providers.remove_duplicate_confirm': '要删除此服务商副本及其已保存的配置吗?', 'st.providers.set_active': '设为启用', diff --git a/src/firefox/src/ui/settings.html b/src/firefox/src/ui/settings.html index 201860b3e..253527996 100644 --- a/src/firefox/src/ui/settings.html +++ b/src/firefox/src/ui/settings.html @@ -1103,7 +1103,7 @@ } .btn-sign-in:hover { background: var(--accent2); } .btn-sign-in:disabled { opacity: 0.55; cursor: default; transform: none; } - .provider-card .btn-duplicate[aria-disabled="true"] { cursor: not-allowed; opacity: 0.48; transform: none; } + .provider-card .btn-duplicate:disabled { cursor: not-allowed; opacity: 0.48; transform: none; } .btn-sign-out { background: var(--bg3); color: var(--text); diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js index b1186e86c..7d2848ccc 100644 --- a/src/firefox/src/ui/settings.js +++ b/src/firefox/src/ui/settings.js @@ -310,6 +310,7 @@ let providersData = {}; // including temporarily invalid JSON while the user is still editing it. // Keep the raw UI draft separate from the last valid provider config. const providerCompatibilityJsonDrafts = new Map(); +const dirtyProviderIds = new Set(); let activeProviderId = ''; let providerActivationRequestId = 0; let requestedActiveProviderId = ''; @@ -2603,6 +2604,11 @@ function renderProviders() { ` : ''; const compatibilitySettings = renderProviderCompatibilitySettings(id, config); + const duplicateDisabledKey = config.hasDuplicate + ? 'st.providers.duplicate_limit' + : (!config.canDuplicate + ? 'st.providers.duplicate_unavailable' + : ((!isConfigured || dirtyProviderIds.has(id)) ? 'st.providers.duplicate_inactive' : '')); const body = ` ${fieldsHTML} @@ -2616,11 +2622,7 @@ function renderProviders() { ${!isSelected ? `` : ''} ${config.isDuplicate ? `` - : (config.canDuplicate - ? `` - : (config.hasDuplicate - ? `` - : ``))} + : ``}
`; @@ -2648,9 +2650,13 @@ function renderProviders() { document.querySelectorAll('.btn-activate').forEach(btn => { btn.addEventListener('click', () => activateProvider(btn.dataset.provider)); }); - document.querySelectorAll('.btn-duplicate:not([aria-disabled="true"])').forEach(btn => { + document.querySelectorAll('.btn-duplicate').forEach(btn => { btn.addEventListener('click', () => duplicateProvider(btn.dataset.provider)); }); + document.querySelectorAll('input[data-provider], select[data-provider], textarea[data-provider]').forEach(input => { + const eventName = input.tagName === 'SELECT' ? 'change' : 'input'; + input.addEventListener(eventName, () => markProviderDirty(input.dataset.provider)); + }); document.querySelectorAll('.btn-remove-duplicate').forEach(btn => { btn.addEventListener('click', () => removeDuplicateProvider(btn.dataset.provider)); }); @@ -2677,6 +2683,7 @@ function renderProviders() { input.style.display = 'none'; input.value = sel.value; } + markProviderDirty(providerId); refreshProviderCompatibilitySummary(providerId); refreshVisionStatus(providerId); }); @@ -2711,6 +2718,7 @@ function renderProviders() { textarea.value = ''; providerCompatibilityJsonDrafts.set(id, ''); } + markProviderDirty(id); refreshProviderCompatibilitySummary(id); }); }); @@ -2885,6 +2893,12 @@ function providerIsActive(id, config) { return id !== 'webbrain_cloud' && config?.configured === true; } +function markProviderDirty(id) { + if (!id || !providersData[id]) return; + dirtyProviderIds.add(id); + refreshProviderCardStatus(id); +} + function refreshActiveProviderFilterCount() { const count = Object.entries(providersData) .filter(([id, config]) => providerIsActive(id, config)) @@ -3044,6 +3058,7 @@ async function saveProvider(id, { showFlash = true, markConfigured = true } = {} } if (markConfigured) providersData[id].configured = id !== 'webbrain_cloud'; } + if (markConfigured) dirtyProviderIds.delete(id); refreshProviderCardStatus(id); refreshVisionStatus(id); @@ -3067,6 +3082,13 @@ function refreshProviderCardStatus(id) { const isSelected = id === activeProviderId; card.classList.toggle('configured', isConfigured); card.classList.toggle('selected', isSelected); + const duplicateButton = card.querySelector('.btn-duplicate'); + if (duplicateButton && providersData[id]?.canDuplicate && !providersData[id]?.hasDuplicate) { + const requiresSave = !isConfigured || dirtyProviderIds.has(id); + duplicateButton.disabled = requiresSave; + if (!requiresSave) duplicateButton.removeAttribute('title'); + else duplicateButton.title = t('st.providers.duplicate_inactive'); + } const badges = card.querySelector('.provider-status-badges'); if (!badges) return; badges.innerHTML = ` @@ -3146,10 +3168,10 @@ function restoreProviderDrafts(drafts) { } async function duplicateProvider(id) { + if (!providerIsActive(id, providersData[id]) || dirtyProviderIds.has(id)) return; try { syncInputsIntoProvidersData(); const providerDrafts = providersData; - await saveProvider(id, { showFlash: false, markConfigured: false }); const created = await sendToBackground('duplicate_provider', { providerId: id }); const refreshed = await sendToBackground('get_providers'); providersData = refreshed.providers; @@ -3174,6 +3196,7 @@ async function removeDuplicateProvider(id) { restoreProviderDrafts(providerDrafts); expandedProviders.delete(id); providerCompatibilityJsonDrafts.delete(id); + dirtyProviderIds.delete(id); renderProviders(); } catch (error) { setProviderTestResult(id, 'fail', t('st.providers.failed', { error: error.message })); diff --git a/test/run.js b/test/run.js index 70bc557a4..317d88853 100644 --- a/test/run.js +++ b/test/run.js @@ -47055,16 +47055,26 @@ test('ProviderManager creates one independent duplicate per provider', async () const writes = []; globalThis[runtimeKey] = makeProviderManagerWriteRuntime(writes); const manager = new PM(); + const defaults = manager._defaultConfigs(); const sourceConfig = { - ...manager._defaultConfigs().openai, + ...defaults.openai, apiKey: `${label}-work-key`, model: `${label}-work-model`, configured: true, compat: { reasoningEffort: 'medium' }, }; manager.providers.set('openai', manager._createProvider('openai', sourceConfig)); + manager.providers.set('kimi', manager._createProvider('kimi', { + ...defaults.kimi, + configured: false, + })); manager.activeProviderId = 'openai'; assert.equal(manager.getAll().openai.canDuplicate, true, `${label}: eligible sources should advertise duplicate availability`); + await assert.rejects( + () => manager.duplicateProvider('kimi'), + /save provider before duplicating/i, + `${label}: an unconfigured source should not be duplicated before Save`, + ); const created = await manager.duplicateProvider('openai'); assert.equal(created.providerId, 'openai__duplicate', `${label}: duplicate ID should be stable and safe`); @@ -47088,6 +47098,8 @@ test('ProviderManager creates one independent duplicate per provider', async () assert.equal(manager.providers.get(created.providerId)?.config.apiKey, `${label}-personal-key`); assert.equal(manager.providers.get(created.providerId)?.config.model, `${label}-personal-model`); const surfaced = manager.getAll(); + const surfacedIds = Object.keys(surfaced); + assert.equal(surfacedIds.indexOf(created.providerId), surfacedIds.indexOf('openai') + 1, `${label}: duplicate should appear immediately after its source`); assert.equal(surfaced.openai.hasDuplicate, true, `${label}: source should advertise its existing duplicate`); assert.equal(surfaced.openai.canDuplicate, false, `${label}: source should enforce the one-duplicate limit in UI metadata`); assert.equal(surfaced[created.providerId].isDuplicate, true, `${label}: duplicate metadata missing`); @@ -47298,6 +47310,12 @@ test('ProviderManager reloads one valid duplicate and purges forged duplicate en duplicateOf: 'webbrain_cloud', label: 'WebBrain Cloud 2', }, + kimi__duplicate: { + ...structuredClone(defaults.kimi), + duplicateOf: 'kimi', + label: 'Kimi 2', + configured: false, + }, groq: { ...structuredClone(defaults.groq), duplicateOf: 'openai', @@ -47314,12 +47332,14 @@ test('ProviderManager reloads one valid duplicate and purges forged duplicate en assert.equal(manager.providers.has('openai__duplicate_2'), false, `${label}: forged second duplicate should be rejected`); assert.equal(manager.providers.has('missing__duplicate'), false, `${label}: orphan duplicate should be rejected`); assert.equal(manager.providers.has('webbrain_cloud__duplicate'), false, `${label}: managed cloud duplicate should be rejected`); + assert.equal(manager.providers.has('kimi__duplicate'), false, `${label}: duplicate created before its source was saved should be rejected`); assert.equal(manager.providers.has('groq'), true, `${label}: forged metadata must not delete a built-in provider`); assert.equal(manager.providers.get('groq')?.config.duplicateOf, undefined, `${label}: forged duplicate metadata should be stripped from built-ins`); assert.equal(storageData.providers.openai__duplicate?.apiKey, `${label}-personal-key`, `${label}: valid duplicate was not persisted`); assert.equal(storageData.providers.openai__duplicate_2, undefined, `${label}: forged duplicate was not purged from storage`); assert.equal(storageData.providers.missing__duplicate, undefined, `${label}: orphan duplicate was not purged from storage`); assert.equal(storageData.providers.webbrain_cloud__duplicate, undefined, `${label}: managed duplicate was not purged from storage`); + assert.equal(storageData.providers.kimi__duplicate, undefined, `${label}: pre-Save duplicate was not purged from storage`); assert.equal(storageData.providers.groq?.duplicateOf, undefined, `${label}: forged built-in duplicate metadata was not purged`); } } finally { @@ -47342,10 +47362,28 @@ test('duplicate provider controls are wired through background and settings in b assert.match(settings, /class="btn-secondary btn-duplicate"[\s\S]*?st\.providers\.duplicate/, `${label}: duplicate button missing`); assert.match(settings, /class="btn-secondary btn-remove-duplicate"[\s\S]*?st\.providers\.remove_duplicate/, `${label}: remove duplicate button missing`); assert.match(settings, /config\.hasDuplicate[\s\S]*?st\.providers\.duplicate_limit[\s\S]*?st\.providers\.duplicate_unavailable/, `${label}: disabled duplicate buttons should explain limits and unsupported providers`); - assert.match(settings, /async function duplicateProvider\(id\)[\s\S]*?syncInputsIntoProvidersData\(\);[\s\S]*?const providerDrafts = providersData;[\s\S]*?saveProvider\(id, \{ showFlash: false, markConfigured: false \}\)[\s\S]*?sendToBackground\('duplicate_provider'[\s\S]*?providersData = refreshed\.providers;[\s\S]*?restoreProviderDrafts\(providerDrafts\)/, `${label}: duplicate action should preserve all current drafts while cloning`); + assert.match(settings, /const duplicateDisabledKey = config\.hasDuplicate[\s\S]*?!config\.canDuplicate[\s\S]*?!isConfigured \|\| dirtyProviderIds\.has\(id\)[\s\S]*?'st\.providers\.duplicate_inactive'/, `${label}: an inactive or edited source provider should disable Duplicate`); + assert.ok(settings.includes('${duplicateDisabledKey ? ` disabled title="'), `${label}: unavailable Duplicate actions should use native disabled buttons`); + assert.match(settings, /document\.querySelectorAll\('\.btn-duplicate'\)[\s\S]*?duplicateProvider\(btn\.dataset\.provider\)/, `${label}: initially disabled Duplicate buttons should be ready after Save enables them`); + assert.match(settings, /input\[data-provider\], select\[data-provider\], textarea\[data-provider\][\s\S]*?markProviderDirty\(input\.dataset\.provider\)/, `${label}: editing a saved provider should disable Duplicate until Save`); + assert.match(settings, /const duplicateButton = card\.querySelector\('\.btn-duplicate'\);[\s\S]*?const requiresSave = !isConfigured \|\| dirtyProviderIds\.has\(id\);[\s\S]*?duplicateButton\.disabled = requiresSave;[\s\S]*?duplicateButton\.removeAttribute\('title'\)[\s\S]*?st\.providers\.duplicate_inactive/, `${label}: Save should enable Duplicate without rebuilding provider cards`); + assert.match(settings, /async function duplicateProvider\(id\) \{\s*if \(!providerIsActive\(id, providersData\[id\]\) \|\| dirtyProviderIds\.has\(id\)\) return;/, `${label}: inactive and edited providers should also be guarded at the Duplicate handler`); + const duplicateAction = settings.match(/async function duplicateProvider\(id\)[\s\S]*?async function removeDuplicateProvider\(id\)/)?.[0] || ''; + assert.doesNotMatch(duplicateAction, /saveProvider\(/, `${label}: Duplicate must not implicitly save the source provider`); + assert.match(duplicateAction, /syncInputsIntoProvidersData\(\);[\s\S]*?const providerDrafts = providersData;[\s\S]*?sendToBackground\('duplicate_provider'[\s\S]*?providersData = refreshed\.providers;[\s\S]*?restoreProviderDrafts\(providerDrafts\)/, `${label}: duplicate action should preserve other provider drafts while cloning the last saved source`); assert.match(settings, /async function removeDuplicateProvider\(id\)[\s\S]*?syncInputsIntoProvidersData\(\);[\s\S]*?const providerDrafts = providersData;[\s\S]*?sendToBackground\('remove_duplicate_provider'[\s\S]*?providersData = refreshed\.providers;[\s\S]*?restoreProviderDrafts\(providerDrafts\)/, `${label}: remove duplicate action should preserve drafts for remaining providers`); assert.match(sidepanel, /appendProviderPickerOption\(id, name, t\('sp\.providers\.active'\), config\.sourceProviderId \|\| id\)/, `${label}: duplicate picker entries should reuse their source icon`); } + + const localeKeyPattern = /'st\.providers\.duplicate_inactive':\s*'([^']+)'/; + const chromeLocaleDir = path.join(ROOT, 'src/chrome/src/ui/locales'); + const firefoxLocaleDir = path.join(ROOT, 'src/firefox/src/ui/locales'); + for (const localeFile of fs.readdirSync(chromeLocaleDir).filter(file => file.endsWith('.js'))) { + const chromeMatch = fs.readFileSync(path.join(chromeLocaleDir, localeFile), 'utf8').match(localeKeyPattern); + const firefoxMatch = fs.readFileSync(path.join(firefoxLocaleDir, localeFile), 'utf8').match(localeKeyPattern); + assert.ok(chromeMatch?.[1], `chrome: ${localeFile} missing inactive Duplicate guidance`); + assert.equal(firefoxMatch?.[1], chromeMatch[1], `${localeFile}: inactive Duplicate guidance should match across browsers`); + } }); test('_defaultConfigs: every entry carries an explicit category', () => {