diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index a1c5e292..73e833e4 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON-formateerder", "emailValidator": "E-posvalidasie", "nosqlExplorer": "Databasis Verkenner", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Enkripsie-speelgrond", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Verken MongoDB-databasisse." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Een werkspasie vir MongoDB en Redis. Blaai, doen navrae en wysig met 'n versleutelde geloofsbriewe-winkel." + }, "emailValidator": { "title": "E-posvalidator", "description": "Verifieer en valideer e-posadresse." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Nog geen koppelings nie", + "emptyBody": "Voeg 'n MongoDB- of Redis-koppeling by om te begin blaai. Geloofsbriewe word op jou toestel versleutel.", + "addConnection": "Nuwe koppeling", + "importLegacy": "Voer in vanaf bestaande nutsmiddels", + "searchConnections": "Soek koppelings", + "openSidebar": "Open verbindings", + "unsupportedSource": "Nie-ondersteunde bron", + "unsupportedSourceHint": "Hierdie koppeling is deur 'n nuwer weergawe van die app geskep.", + "noResults": "Geen koppelings pas by jou soektog nie.", + "connectionActions": "Aksies vir {name}", + "menuEdit": "Wysig", + "menuDelete": "Vee uit", + "deleteTitle": "Vee koppeling uit?", + "deleteBody": "{name} en sy gestoorde geloofsbriewe sal van hierdie toestel verwyder word. Dit kan nie ongedaan gemaak word nie.", + "deleteDocTitle": "Vee dokument uit?", + "deleteDocBody": "Hierdie dokument sal permanent uit die versameling verwyder word. Dit kan nie ongedaan gemaak word nie.", + "closeAll": "Sluit alles", + "noTabsTitle": "Niks oop nie", + "noTabsBody": "Kies 'n koppeling in die sybalk om 'n oortjie oop te maak.", + "connectionDialog": { + "createTitle": "Nuwe koppeling", + "editTitle": "Wysig koppeling", + "pickSource": "Databron", + "name": "Naam", + "namePlaceholder": "My koppeling", + "folder": "Vouer", + "folderPlaceholder": "Opsionele groep", + "color": "Kleur", + "colorNone": "Geen kleur nie", + "dbType": "Databasistipe", + "connectionString": "Koppelingsstring", + "readOnly": "Leesalleen", + "readOnlyHint": "Blokkeer elke skryfbewerking vir hierdie koppeling.", + "test": "Toets koppeling", + "testOk": "Gekoppel", + "testFailed": "Kon nie koppel nie", + "save": "Stoor", + "cancel": "Kanselleer" + }, + "importDialog": { + "title": "Voer in vanaf bestaande nutsmiddels", + "loading": "Soek tans na koppelings in Databasis Verkenner en Redis Bestuurder…", + "notReady": "Besig om jou bestaande koppelings te laai — probeer oor 'n oomblik weer.", + "empty": "Geen koppelings gevind in Databasis Verkenner of Redis Bestuurder nie.", + "allSkipped": "{count, plural, one {# koppeling is} other {# koppelings is}} reeds ingevoer. Niks nuuts om in te bring nie.", + "previewHeading": "{count, plural, one {# koppeling} other {# koppelings}} sal ingevoer word:", + "skippedNote": "{count, plural, one {# koppeling is} other {# koppelings is}} reeds ingevoer en sal oorgeslaan word.", + "importing": "Voer tans in…", + "confirm": "Voer {count, plural, one {# koppeling} other {# koppelings}} in" + }, + "toast": { + "saved": "Koppeling gestoor", + "deleted": "Koppeling uitgevee", + "saveFailed": "Kon nie die koppeling stoor nie", + "deleteFailed": "Kon nie die koppeling uitvee nie", + "loadFailed": "Kon nie koppelings laai nie", + "fetchFailed": "Kon nie dokumente vir {collection} laai nie", + "docDeleted": "Dokument uitgevee", + "legacyImportFailed": "Kon nie bestaande koppelings van Databasis Verkenner of Redis Bestuurder lees nie", + "legacyImportResult": "{imported, plural, =0 {Geen nuwe koppelings ingevoer nie} one {# koppeling ingevoer} other {# koppelings ingevoer}}{skipped, plural, =0 {} other {, # reeds ingevoer}}{failed, plural, =0 {} other {, # het misluk}}" + }, + "validation": { + "connectionStringRequired": "Voer 'n koppelingsstring in.", + "connectionStringScheme": "Koppelingsstring moet begin met mongodb:// of mongodb+srv://.", + "redisUrlRequired": "Voer 'n Redis-URL in.", + "redisUrlScheme": "Redis-URL moet begin met redis:// of rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Bouer", + "url": "Redis-URL", + "urlPlaceholder": "redis://localhost:6379 of rediss://user:pass@host:6380", + "urlHint": "Ondersteun redis:// en rediss:// (TLS). Geloofsbriewe word voor stoor versleutel.", + "host": "Gasheer", + "port": "Poort", + "username": "Gebruikersnaam (ACL)", + "usernamePlaceholder": "default", + "password": "Wagwoord", + "dbIndex": "DB-indeks", + "dbIndexPlaceholder": "0", + "tls": "Gebruik TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Geen passende databasisse nie", + "tabKeyEditor": "Sleutelredigeerder", + "tabConsole": "Konsole", + "tabServer": "Bediener", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Skandeerder", + "tabSearch": "Soek", + "flush": "Spoel", + "flushTitle": "Spoel Sleutels", + "flushDescription": "Voer 'n patroon in om passende sleutels uit te vee, of laat leeg om die hele databasis te spoel (vereis DANGER_ZONE=true).", + "flushPatternLabel": "Patroon (opsioneel)", + "flushPatternPlaceholder": "bv. session:* — leeg = FLUSHDB", + "flushing": "Spoel tans…", + "flushedAll": "Databasis gespoel", + "flushedKeys": "{count, plural, one {# sleutel uitgevee} other {# sleutels uitgevee}}", + "flushFailed": "Spoel het misluk" + }, + "mongo": { + "refreshDatabases": "Verfris databasisse", + "serverMonitor": "Bediener-monitor", + "connecting": "Besig om te koppel…", + "connectionFailed": "Kon nie koppel nie", + "noDatabases": "Geen databasisse nie", + "loadingCollections": "Laai tans versamelings…", + "collectionsFailed": "Kon nie versamelings laai nie", + "noCollections": "Geen versamelings nie", + "noMatches": "Geen databasisse pas by jou soektog nie", + "databaseActions": "Aksies vir databasis {name}", + "collectionActions": "Aksies vir versameling {name}", + "selectCollection": "Kies versameling {name}", + "deleteSelected": "Vee {count} gekose uit", + "clearSelection": "Maak skoon", + "refresh": "Verfris", + "rename": "Hernoem", + "copyDbName": "Kopieer databasisnaam", + "browseGridfs": "Blaai deur GridFS", + "sync": "Sinkroniseer versameling", + "dropDatabase": "Laat databasis val", + "dropCollection": "Laat versameling val", + "toast": { + "connectFailed": "Kon nie aan {name} koppel nie", + "collectionsFailed": "Kon nie versamelings vir {name} laai nie", + "dbNameCopied": "Databasisnaam gekopieer", + "nameEmpty": "Voer 'n naam in.", + "nameTooLong": "Daardie naam is te lank.", + "nameForbiddenChars": "'n Databasisnaam mag nie / \\ . of $ bevat nie", + "nameConsecutiveDots": "'n Databasisnaam mag nie opeenvolgende punte bevat nie.", + "nameNullChar": "'n Versamelingnaam mag nie 'n nul-karakter bevat nie.", + "nameReservedPrefix": "'n Versamelingnaam mag nie met $ begin nie — daardie voorvoegsel is gereserveer.", + "nameInvalid": "Daardie naam is nie geldig nie.", + "renamed": "Hernoem na {name}", + "renameFailed": "Kon nie hernoem nie", + "dropped": "{name} laat val", + "dropFailed": "Kon nie {name} laat val nie", + "bulkDropped": "{count} versamelings laat val", + "bulkDropFailed": "Kon nie {count} versamelings laat val nie" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nuwe versoek", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 6a70afa1..db1c5a36 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -10,6 +10,7 @@ "jsonFormatter": "منسّق JSON", "emailValidator": "التحقق من البريد", "nosqlExplorer": "مستكشف قاعدة البيانات", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "ملعب التشفير", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "استكشف قواعد بيانات MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "مساحة عمل واحدة لـ MongoDB و Redis. تصفح واستعلم وعدّل باستخدام مخزن بيانات اعتماد مشفّر." + }, "emailValidator": { "title": "التحقق من البريد", "description": "تحقق من عناوين البريد الإلكتروني." @@ -1944,6 +1949,150 @@ "colCoverage": "التغطية" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "لا توجد اتصالات بعد", + "emptyBody": "أضف اتصال MongoDB أو Redis لبدء التصفح. تُشفَّر بيانات الاعتماد على جهازك.", + "addConnection": "اتصال جديد", + "importLegacy": "الاستيراد من الأدوات الحالية", + "searchConnections": "البحث في الاتصالات", + "openSidebar": "فتح الاتصالات", + "unsupportedSource": "مصدر غير مدعوم", + "unsupportedSourceHint": "تم إنشاء هذا الاتصال بواسطة إصدار أحدث من التطبيق.", + "noResults": "لا توجد اتصالات تطابق بحثك.", + "connectionActions": "إجراءات لـ {name}", + "menuEdit": "تعديل", + "menuDelete": "حذف", + "deleteTitle": "حذف الاتصال؟", + "deleteBody": "سيتم إزالة {name} وبيانات الاعتماد المحفوظة الخاصة به من هذا الجهاز. لا يمكن التراجع عن هذا الإجراء.", + "deleteDocTitle": "حذف المستند؟", + "deleteDocBody": "سيتم حذف هذا المستند نهائيًا من المجموعة. لا يمكن التراجع عن هذا الإجراء.", + "closeAll": "إغلاق الكل", + "noTabsTitle": "لا شيء مفتوح", + "noTabsBody": "اختر اتصالاً من الشريط الجانبي لفتح تبويب.", + "connectionDialog": { + "createTitle": "اتصال جديد", + "editTitle": "تعديل الاتصال", + "pickSource": "مصدر البيانات", + "name": "الاسم", + "namePlaceholder": "اتصالي", + "folder": "المجلد", + "folderPlaceholder": "مجموعة اختيارية", + "color": "اللون", + "colorNone": "بدون لون", + "dbType": "نوع قاعدة البيانات", + "connectionString": "سلسلة الاتصال", + "readOnly": "للقراءة فقط", + "readOnlyHint": "يمنع كل عمليات الكتابة لهذا الاتصال.", + "test": "اختبار الاتصال", + "testOk": "متصل", + "testFailed": "تعذّر الاتصال", + "save": "حفظ", + "cancel": "إلغاء" + }, + "importDialog": { + "title": "الاستيراد من الأدوات الحالية", + "loading": "جارٍ البحث عن اتصالات في مستكشف قاعدة البيانات ومنفّذ Redis…", + "notReady": "لا تزال اتصالاتك الحالية قيد التحميل — أعد المحاولة بعد قليل.", + "empty": "لم يتم العثور على اتصالات في مستكشف قاعدة البيانات أو منفّذ Redis.", + "allSkipped": "{count, plural, zero {لا اتصالات مستوردة بعد} one {تم استيراد اتصال واحد بالفعل} two {تم استيراد اتصالين بالفعل} few {تم استيراد # اتصالات بالفعل} many {تم استيراد # اتصالاً بالفعل} other {تم استيراد # اتصال بالفعل}}. لا جديد لإحضاره.", + "previewHeading": "{count, plural, zero {لا اتصالات لاستيرادها} one {سيتم استيراد اتصال واحد} two {سيتم استيراد اتصالين} few {سيتم استيراد # اتصالات} many {سيتم استيراد # اتصالاً} other {سيتم استيراد # اتصال}}:", + "skippedNote": "{count, plural, zero {لا اتصالات تم استيرادها مسبقًا} one {تم استيراد اتصال واحد مسبقًا وسيُتخطى} two {تم استيراد اتصالين مسبقًا وسيُتخطيان} few {تم استيراد # اتصالات مسبقًا وستُتخطى} many {تم استيراد # اتصالاً مسبقًا وستُتخطى} other {تم استيراد # اتصال مسبقًا وستُتخطى}}.", + "importing": "جارٍ الاستيراد…", + "confirm": "استيراد {count, plural, zero {# اتصالات} one {اتصال واحد} two {اتصالين} few {# اتصالات} many {# اتصالاً} other {# اتصال}}" + }, + "toast": { + "saved": "تم حفظ الاتصال", + "deleted": "تم حذف الاتصال", + "saveFailed": "تعذّر حفظ الاتصال", + "deleteFailed": "تعذّر حذف الاتصال", + "loadFailed": "تعذّر تحميل الاتصالات", + "fetchFailed": "فشل جلب مستندات {collection}", + "docDeleted": "تم حذف المستند", + "legacyImportFailed": "تعذّرت قراءة الاتصالات الحالية من مستكشف قاعدة البيانات أو منفّذ Redis", + "legacyImportResult": "{imported, plural, =0 {لم تُستورد أي اتصالات جديدة} one {تم استيراد اتصال واحد} two {تم استيراد اتصالين} few {تم استيراد # اتصالات} many {تم استيراد # اتصالاً} other {تم استيراد # اتصال}}{skipped, plural, =0 {} one {، اتصال واحد مستورد مسبقًا} two {، اتصالان مستوردان مسبقًا} few {، # اتصالات مستوردة مسبقًا} many {، # اتصالاً مستوردًا مسبقًا} other {، # اتصال مستورد مسبقًا}}{failed, plural, =0 {} one {، وفشل اتصال واحد} two {، وفشل اتصالان} few {، وفشلت # اتصالات} many {، وفشل # اتصالاً} other {، وفشل # اتصال}}" + }, + "validation": { + "connectionStringRequired": "أدخل سلسلة الاتصال.", + "connectionStringScheme": "يجب أن تبدأ سلسلة الاتصال بـ mongodb:// أو mongodb+srv://.", + "redisUrlRequired": "أدخل رابط Redis.", + "redisUrlScheme": "يجب أن يبدأ رابط Redis بـ redis:// أو rediss://." + }, + "redis": { + "urlMode": "الرابط", + "builderMode": "منشئ", + "url": "رابط Redis", + "urlPlaceholder": "redis://localhost:6379 أو rediss://user:pass@host:6380", + "urlHint": "يدعم redis:// و rediss:// (TLS). يتم تشفير بيانات الاعتماد قبل التخزين.", + "host": "المضيف", + "port": "المنفذ", + "username": "اسم المستخدم (ACL)", + "usernamePlaceholder": "default", + "password": "كلمة المرور", + "dbIndex": "فهرس قاعدة البيانات", + "dbIndexPlaceholder": "0", + "tls": "استخدام TLS (rediss://)", + "dbLabel": "قاعدة {index}", + "noMatches": "لا توجد قواعد بيانات مطابقة", + "tabKeyEditor": "محرر المفاتيح", + "tabConsole": "وحدة التحكم", + "tabServer": "الخادم", + "tabPubSub": "Pub/Sub", + "tabMonitor": "المراقبة", + "tabScanner": "الماسح", + "tabSearch": "البحث", + "flush": "تفريغ", + "flushTitle": "تفريغ المفاتيح", + "flushDescription": "أدخل نمطًا لحذف المفاتيح المطابقة، أو اتركه فارغًا لتفريغ قاعدة البيانات بأكملها (يتطلب DANGER_ZONE=true).", + "flushPatternLabel": "النمط (اختياري)", + "flushPatternPlaceholder": "مثال: session:* — الحقل الفارغ = FLUSHDB", + "flushing": "جارٍ التفريغ…", + "flushedAll": "تم تفريغ قاعدة البيانات", + "flushedKeys": "{count, plural, zero {لم يُحذف أي مفتاح} one {تم حذف مفتاح واحد} two {تم حذف مفتاحين} few {تم حذف # مفاتيح} many {تم حذف # مفتاحاً} other {تم حذف # مفتاح}}", + "flushFailed": "فشل التفريغ" + }, + "mongo": { + "refreshDatabases": "تحديث قواعد البيانات", + "serverMonitor": "مراقب الخادم", + "connecting": "جارٍ الاتصال…", + "connectionFailed": "تعذّر الاتصال", + "noDatabases": "لا قواعد بيانات", + "loadingCollections": "جارٍ تحميل المجموعات…", + "collectionsFailed": "تعذّر تحميل المجموعات", + "noCollections": "لا مجموعات", + "noMatches": "لا توجد قواعد بيانات تطابق بحثك", + "databaseActions": "إجراءات لقاعدة البيانات {name}", + "collectionActions": "إجراءات للمجموعة {name}", + "selectCollection": "تحديد المجموعة {name}", + "deleteSelected": "حذف {count} محدد", + "clearSelection": "مسح", + "refresh": "تحديث", + "rename": "إعادة تسمية", + "copyDbName": "نسخ اسم قاعدة البيانات", + "browseGridfs": "تصفح GridFS", + "sync": "مزامنة المجموعة", + "dropDatabase": "حذف قاعدة البيانات", + "dropCollection": "حذف المجموعة", + "toast": { + "connectFailed": "تعذّر الاتصال بـ {name}", + "collectionsFailed": "تعذّر تحميل مجموعات {name}", + "dbNameCopied": "تم نسخ اسم قاعدة البيانات", + "nameEmpty": "أدخل اسمًا.", + "nameTooLong": "هذا الاسم طويل جدًا.", + "nameForbiddenChars": "لا يمكن أن يحتوي اسم قاعدة البيانات على / \\ . أو $", + "nameConsecutiveDots": "لا يمكن أن يحتوي اسم قاعدة البيانات على نقاط متتالية.", + "nameNullChar": "لا يمكن أن يحتوي اسم المجموعة على حرف فارغ (null).", + "nameReservedPrefix": "لا يمكن أن يبدأ اسم المجموعة بـ $ — هذه البادئة محجوزة.", + "nameInvalid": "هذا الاسم غير صالح.", + "renamed": "تمت إعادة التسمية إلى {name}", + "renameFailed": "تعذّرت إعادة التسمية", + "dropped": "تم حذف {name}", + "dropFailed": "تعذّر حذف {name}", + "bulkDropped": "تم حذف {count} مجموعة", + "bulkDropFailed": "تعذّر حذف {count} مجموعة" + } + } + }, "ApiClient": { "defaults": { "newRequest": "طلب جديد", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 400d3e11..fa6797b4 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -10,6 +10,7 @@ "jsonFormatter": "Formatador JSON", "emailValidator": "Validador de correu", "nosqlExplorer": "Explorador de base de dades", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Terreny de joc del xifratge", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Explora bases de dades MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Un espai de treball per a MongoDB i Redis. Explora, consulta i edita amb un magatzem de credencials xifrat." + }, "emailValidator": { "title": "Validador de correu", "description": "Verifica i valida adreces de correu." @@ -1944,6 +1949,150 @@ "colCoverage": "Cobertura" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Encara no hi ha connexions", + "emptyBody": "Afegeix una connexió de MongoDB o Redis per començar a explorar. Les credencials es xifren al teu dispositiu.", + "addConnection": "Connexió nova", + "importLegacy": "Importa des d'eines existents", + "searchConnections": "Cerca connexions", + "openSidebar": "Obre les connexions", + "unsupportedSource": "Font no compatible", + "unsupportedSourceHint": "Aquesta connexió va ser creada per una versió més nova de l'aplicació.", + "noResults": "Cap connexió coincideix amb la teva cerca.", + "connectionActions": "Accions per a {name}", + "menuEdit": "Edita", + "menuDelete": "Elimina", + "deleteTitle": "Vols eliminar la connexió?", + "deleteBody": "{name} i les seves credencials desades s'eliminaran d'aquest dispositiu. Aquesta acció no es pot desfer.", + "deleteDocTitle": "Vols eliminar el document?", + "deleteDocBody": "Aquest document s'eliminarà permanentment de la col·lecció. Aquesta acció no es pot desfer.", + "closeAll": "Tanca-ho tot", + "noTabsTitle": "Res obert", + "noTabsBody": "Tria una connexió a la barra lateral per obrir una pestanya.", + "connectionDialog": { + "createTitle": "Connexió nova", + "editTitle": "Edita la connexió", + "pickSource": "Font de dades", + "name": "Nom", + "namePlaceholder": "La meva connexió", + "folder": "Carpeta", + "folderPlaceholder": "Grup opcional", + "color": "Color", + "colorNone": "Sense color", + "dbType": "Tipus de base de dades", + "connectionString": "Cadena de connexió", + "readOnly": "Només lectura", + "readOnlyHint": "Bloqueja totes les operacions d'escriptura per a aquesta connexió.", + "test": "Prova la connexió", + "testOk": "Connectat", + "testFailed": "No s'ha pogut connectar", + "save": "Desa", + "cancel": "Cancel·la" + }, + "importDialog": { + "title": "Importa des d'eines existents", + "loading": "Cercant connexions a Explorador de base de dades i Redis Commander…", + "notReady": "Encara s'estan carregant les teves connexions existents — torna-ho a provar d'aquí un moment.", + "empty": "No s'ha trobat cap connexió a Explorador de base de dades o Redis Commander.", + "allSkipped": "{count, plural, one {# connexió ja s'ha} other {# connexions ja s'han}} importat. No hi ha res nou per portar.", + "previewHeading": "{count, plural, one {# connexió} other {# connexions}} s'importaran:", + "skippedNote": "{count, plural, one {# connexió ja estava importada i s'ometrà} other {# connexions ja estaven importades i s'ometran}}", + "importing": "S'està important…", + "confirm": "Importa {count, plural, one {# connexió} other {# connexions}}" + }, + "toast": { + "saved": "Connexió desada", + "deleted": "Connexió eliminada", + "saveFailed": "No s'ha pogut desar la connexió", + "deleteFailed": "No s'ha pogut eliminar la connexió", + "loadFailed": "No s'han pogut carregar les connexions", + "fetchFailed": "No s'han pogut obtenir documents de {collection}", + "docDeleted": "Document eliminat", + "legacyImportFailed": "No s'han pogut llegir les connexions existents d'Explorador de base de dades o Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Cap connexió nova importada} one {# connexió importada} other {# connexions importades}}{skipped, plural, =0 {} other {, # ja importades}}{failed, plural, =0 {} other {, # fallides}}" + }, + "validation": { + "connectionStringRequired": "Introdueix una cadena de connexió.", + "connectionStringScheme": "La cadena de connexió ha de començar amb mongodb:// o mongodb+srv://.", + "redisUrlRequired": "Introdueix una URL de Redis.", + "redisUrlScheme": "La URL de Redis ha de començar amb redis:// o rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Constructor", + "url": "URL de Redis", + "urlPlaceholder": "redis://localhost:6379 o rediss://usuari:contrasenya@host:6380", + "urlHint": "Admet redis:// i rediss:// (TLS). Les credencials es xifren abans d'emmagatzemar-les.", + "host": "Amfitrió", + "port": "Port", + "username": "Nom d'usuari (ACL)", + "usernamePlaceholder": "default", + "password": "Contrasenya", + "dbIndex": "Índex de BD", + "dbIndexPlaceholder": "0", + "tls": "Utilitza TLS (rediss://)", + "dbLabel": "bd {index}", + "noMatches": "Cap base de dades coincident", + "tabKeyEditor": "Editor de claus", + "tabConsole": "Consola", + "tabServer": "Servidor", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Escàner", + "tabSearch": "Cerca", + "flush": "Buida", + "flushTitle": "Buida les claus", + "flushDescription": "Introdueix un patró per eliminar les claus coincidents, o deixa-ho en blanc per buidar tota la base de dades (requereix DANGER_ZONE=true).", + "flushPatternLabel": "Patró (opcional)", + "flushPatternPlaceholder": "p. ex. session:* — en blanc = FLUSHDB", + "flushing": "S'està buidant…", + "flushedAll": "Base de dades buidada", + "flushedKeys": "{count, plural, one {# clau eliminada} other {# claus eliminades}}", + "flushFailed": "Ha fallat el buidatge" + }, + "mongo": { + "refreshDatabases": "Actualitza les bases de dades", + "serverMonitor": "Monitor del servidor", + "connecting": "S'està connectant…", + "connectionFailed": "No s'ha pogut connectar", + "noDatabases": "Sense bases de dades", + "loadingCollections": "S'estan carregant les col·leccions…", + "collectionsFailed": "No s'han pogut carregar les col·leccions", + "noCollections": "Sense col·leccions", + "noMatches": "Cap base de dades coincideix amb la teva cerca", + "databaseActions": "Accions per a la base de dades {name}", + "collectionActions": "Accions per a la col·lecció {name}", + "selectCollection": "Selecciona la col·lecció {name}", + "deleteSelected": "Elimina {count} seleccionats", + "clearSelection": "Neteja", + "refresh": "Actualitza", + "rename": "Reanomena", + "copyDbName": "Copia el nom de la base de dades", + "browseGridfs": "Explora GridFS", + "sync": "Sincronitza la col·lecció", + "dropDatabase": "Elimina la base de dades", + "dropCollection": "Elimina la col·lecció", + "toast": { + "connectFailed": "No s'ha pogut connectar a {name}", + "collectionsFailed": "No s'han pogut carregar les col·leccions de {name}", + "dbNameCopied": "Nom de la base de dades copiat", + "nameEmpty": "Introdueix un nom.", + "nameTooLong": "Aquest nom és massa llarg.", + "nameForbiddenChars": "El nom d'una base de dades no pot contenir / \\ . ni $", + "nameConsecutiveDots": "El nom d'una base de dades no pot contenir punts consecutius.", + "nameNullChar": "El nom d'una col·lecció no pot contenir un caràcter nul.", + "nameReservedPrefix": "El nom d'una col·lecció no pot començar amb $ — aquest prefix està reservat.", + "nameInvalid": "Aquest nom no és vàlid.", + "renamed": "Reanomenat a {name}", + "renameFailed": "No s'ha pogut reanomenar", + "dropped": "{name} eliminat", + "dropFailed": "No s'ha pogut eliminar {name}", + "bulkDropped": "{count} col·leccions eliminades", + "bulkDropFailed": "No s'han pogut eliminar {count} col·leccions" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Petició nova", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index fc80c3c1..b66ffc78 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -10,6 +10,7 @@ "jsonFormatter": "Formátovač JSON", "emailValidator": "Validátor e-mailů", "nosqlExplorer": "Průzkumník databází", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Hraviště šifrování", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Prozkoumejte databáze MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Jeden pracovní prostor pro MongoDB a Redis. Prohlížejte, dotazujte se a upravujte pomocí šifrovaného úložiště přihlašovacích údajů." + }, "emailValidator": { "title": "Validátor e-mailu", "description": "Ověřujte a validujte e-mailové adresy." @@ -1944,6 +1949,150 @@ "colCoverage": "Pokrytí" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Zatím žádná připojení", + "emptyBody": "Přidejte připojení k MongoDB nebo Redis a začněte prohlížet. Přihlašovací údaje jsou na vašem zařízení šifrovány.", + "addConnection": "Nové připojení", + "importLegacy": "Import ze stávajících nástrojů", + "searchConnections": "Hledat připojení", + "openSidebar": "Otevřít připojení", + "unsupportedSource": "Nepodporovaný zdroj", + "unsupportedSourceHint": "Toto připojení bylo vytvořeno novější verzí aplikace.", + "noResults": "Žádná připojení neodpovídají vašemu hledání.", + "connectionActions": "Akce pro {name}", + "menuEdit": "Upravit", + "menuDelete": "Smazat", + "deleteTitle": "Smazat připojení?", + "deleteBody": "{name} a jeho uložené přihlašovací údaje budou odstraněny z tohoto zařízení. Tuto akci nelze vrátit zpět.", + "deleteDocTitle": "Smazat dokument?", + "deleteDocBody": "Tento dokument bude trvale odstraněn z kolekce. Tuto akci nelze vrátit zpět.", + "closeAll": "Zavřít vše", + "noTabsTitle": "Nic není otevřeno", + "noTabsBody": "Vyberte připojení v postranním panelu a otevřete kartu.", + "connectionDialog": { + "createTitle": "Nové připojení", + "editTitle": "Upravit připojení", + "pickSource": "Zdroj dat", + "name": "Název", + "namePlaceholder": "Moje připojení", + "folder": "Složka", + "folderPlaceholder": "Volitelná skupina", + "color": "Barva", + "colorNone": "Bez barvy", + "dbType": "Typ databáze", + "connectionString": "Připojovací řetězec", + "readOnly": "Pouze pro čtení", + "readOnlyHint": "Blokuje všechny operace zápisu pro toto připojení.", + "test": "Otestovat připojení", + "testOk": "Připojeno", + "testFailed": "Nepodařilo se připojit", + "save": "Uložit", + "cancel": "Zrušit" + }, + "importDialog": { + "title": "Import ze stávajících nástrojů", + "loading": "Hledání připojení v nástrojích Průzkumník databází a Redis Commander…", + "notReady": "Vaše stávající připojení se stále načítají – zkuste to za chvíli znovu.", + "empty": "V nástrojích Průzkumník databází ani Redis Commander nebyla nalezena žádná připojení.", + "allSkipped": "{count, plural, one {# připojení již bylo importováno} few {# připojení již byla importována} many {# připojení již bylo importováno} other {# připojení již bylo importováno}}. Není co nového k importu.", + "previewHeading": "{count, plural, one {# připojení bude importováno} few {# připojení budou importována} many {# připojení bude importováno} other {# připojení bude importováno}}:", + "skippedNote": "{count, plural, one {# připojení již bylo importováno a bude přeskočeno} few {# připojení již byla importována a budou přeskočena} many {# připojení již bylo importováno a bude přeskočeno} other {# připojení již bylo importováno a bude přeskočeno}}.", + "importing": "Importuje se…", + "confirm": "Importovat {count, plural, one {# připojení} few {# připojení} many {# připojení} other {# připojení}}" + }, + "toast": { + "saved": "Připojení uloženo", + "deleted": "Připojení smazáno", + "saveFailed": "Nepodařilo se uložit připojení", + "deleteFailed": "Nepodařilo se smazat připojení", + "loadFailed": "Nepodařilo se načíst připojení", + "fetchFailed": "Nepodařilo se načíst dokumenty pro {collection}", + "docDeleted": "Dokument smazán", + "legacyImportFailed": "Nepodařilo se načíst stávající připojení z nástrojů Průzkumník databází a Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Nebyla importována žádná nová připojení} one {Importováno # připojení} few {Importována # připojení} many {Importováno # připojení} other {Importováno # připojení}}{skipped, plural, =0 {} few {, # již importována} many {, # již importováno} other {, # již importováno}}{failed, plural, =0 {} few {, # selhala} many {, # selhalo} other {, # selhalo}}" + }, + "validation": { + "connectionStringRequired": "Zadejte připojovací řetězec.", + "connectionStringScheme": "Připojovací řetězec musí začínat na mongodb:// nebo mongodb+srv://.", + "redisUrlRequired": "Zadejte URL adresu Redis.", + "redisUrlScheme": "URL adresa Redis musí začínat na redis:// nebo rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Builder", + "url": "URL adresa Redis", + "urlPlaceholder": "redis://localhost:6379 nebo rediss://user:pass@host:6380", + "urlHint": "Podporuje redis:// a rediss:// (TLS). Přihlašovací údaje jsou před uložením šifrovány.", + "host": "Hostitel", + "port": "Port", + "username": "Uživatelské jméno (ACL)", + "usernamePlaceholder": "default", + "password": "Heslo", + "dbIndex": "Index DB", + "dbIndexPlaceholder": "0", + "tls": "Použít TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Žádné odpovídající databáze", + "tabKeyEditor": "Editor klíčů", + "tabConsole": "Konzole", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Skener", + "tabSearch": "Hledat", + "flush": "Vyprázdnit", + "flushTitle": "Vyprázdnit klíče", + "flushDescription": "Zadejte vzor pro smazání odpovídajících klíčů, nebo ponechte prázdné pro vyprázdnění celé databáze (vyžaduje DANGER_ZONE=true).", + "flushPatternLabel": "Vzor (volitelné)", + "flushPatternPlaceholder": "např. session:* — prázdné = FLUSHDB", + "flushing": "Vyprazdňuje se…", + "flushedAll": "Databáze vyprázdněna", + "flushedKeys": "{count, plural, one {# klíč smazán} few {# klíče smazány} many {# klíče smazáno} other {# klíčů smazáno}}", + "flushFailed": "Vyprázdnění se nezdařilo" + }, + "mongo": { + "refreshDatabases": "Obnovit databáze", + "serverMonitor": "Monitor serveru", + "connecting": "Připojování…", + "connectionFailed": "Nepodařilo se připojit", + "noDatabases": "Žádné databáze", + "loadingCollections": "Načítání kolekcí…", + "collectionsFailed": "Nepodařilo se načíst kolekce", + "noCollections": "Žádné kolekce", + "noMatches": "Žádné databáze neodpovídají vašemu hledání", + "databaseActions": "Akce pro databázi {name}", + "collectionActions": "Akce pro kolekci {name}", + "selectCollection": "Vybrat kolekci {name}", + "deleteSelected": "Smazat {count} vybraných", + "clearSelection": "Vymazat", + "refresh": "Obnovit", + "rename": "Přejmenovat", + "copyDbName": "Kopírovat název databáze", + "browseGridfs": "Procházet GridFS", + "sync": "Synchronizovat kolekci", + "dropDatabase": "Zahodit databázi", + "dropCollection": "Zahodit kolekci", + "toast": { + "connectFailed": "Nepodařilo se připojit k {name}", + "collectionsFailed": "Nepodařilo se načíst kolekce pro {name}", + "dbNameCopied": "Název databáze zkopírován", + "nameEmpty": "Zadejte název.", + "nameTooLong": "Tento název je příliš dlouhý.", + "nameForbiddenChars": "Název databáze nesmí obsahovat / \\ . ani $", + "nameConsecutiveDots": "Název databáze nesmí obsahovat dvě tečky za sebou.", + "nameNullChar": "Název kolekce nesmí obsahovat nulový znak.", + "nameReservedPrefix": "Název kolekce nesmí začínat znakem $ — tato předpona je vyhrazená.", + "nameInvalid": "Tento název není platný.", + "renamed": "Přejmenováno na {name}", + "renameFailed": "Nepodařilo se přejmenovat", + "dropped": "{name} zahozeno", + "dropFailed": "Nepodařilo se zahodit {name}", + "bulkDropped": "Zahozeno {count} kolekcí", + "bulkDropFailed": "Nepodařilo se zahodit {count} kolekcí" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nový požadavek", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index ae9a71f6..adbc5de6 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON-formatering", "emailValidator": "E-mailvalidering", "nosqlExplorer": "Databaseudforsker", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Krypteringslegeplads", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Udforsk MongoDB-databaser." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Ét arbejdsområde til MongoDB og Redis. Gennemse, forespørg og rediger med et krypteret lager til loginoplysninger." + }, "emailValidator": { "title": "E-mail-validator", "description": "Bekræft og valider e-mailadresser." @@ -1944,6 +1949,150 @@ "colCoverage": "Dækning" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Ingen forbindelser endnu", + "emptyBody": "Tilføj en MongoDB- eller Redis-forbindelse for at begynde at browse. Legitimationsoplysninger krypteres på din enhed.", + "addConnection": "Ny forbindelse", + "importLegacy": "Importer fra eksisterende værktøjer", + "searchConnections": "Søg forbindelser", + "openSidebar": "Åbn forbindelser", + "unsupportedSource": "Ikke-understøttet kilde", + "unsupportedSourceHint": "Denne forbindelse blev oprettet af en nyere version af appen.", + "noResults": "Ingen forbindelser matcher din søgning.", + "connectionActions": "Handlinger for {name}", + "menuEdit": "Rediger", + "menuDelete": "Slet", + "deleteTitle": "Slet forbindelse?", + "deleteBody": "{name} og dens gemte legitimationsoplysninger vil blive fjernet fra denne enhed. Dette kan ikke fortrydes.", + "deleteDocTitle": "Slet dokument?", + "deleteDocBody": "Dette dokument vil blive permanent fjernet fra samlingen. Dette kan ikke fortrydes.", + "closeAll": "Luk alle", + "noTabsTitle": "Intet åbent", + "noTabsBody": "Vælg en forbindelse i sidebjælken for at åbne en fane.", + "connectionDialog": { + "createTitle": "Ny forbindelse", + "editTitle": "Rediger forbindelse", + "pickSource": "Datakilde", + "name": "Navn", + "namePlaceholder": "Min forbindelse", + "folder": "Mappe", + "folderPlaceholder": "Valgfri gruppe", + "color": "Farve", + "colorNone": "Ingen farve", + "dbType": "Databasetype", + "connectionString": "Forbindelsesstreng", + "readOnly": "Skrivebeskyttet", + "readOnlyHint": "Blokerer alle skrivehandlinger for denne forbindelse.", + "test": "Test forbindelse", + "testOk": "Forbundet", + "testFailed": "Kunne ikke oprette forbindelse", + "save": "Gem", + "cancel": "Annuller" + }, + "importDialog": { + "title": "Importer fra eksisterende værktøjer", + "loading": "Leder efter forbindelser i Databaseudforsker og Redis Commander…", + "notReady": "Indlæser stadig dine eksisterende forbindelser — prøv igen om et øjeblik.", + "empty": "Ingen forbindelser fundet i Databaseudforsker eller Redis Commander.", + "allSkipped": "{count, plural, one {# forbindelse er} other {# forbindelser er}} allerede importeret. Intet nyt at hente.", + "previewHeading": "{count, plural, one {# forbindelse} other {# forbindelser}} vil blive importeret:", + "skippedNote": "{count, plural, one {# forbindelse var} other {# forbindelser var}} allerede importeret og springes over.", + "importing": "Importerer…", + "confirm": "Importer {count, plural, one {# forbindelse} other {# forbindelser}}" + }, + "toast": { + "saved": "Forbindelse gemt", + "deleted": "Forbindelse slettet", + "saveFailed": "Kunne ikke gemme forbindelsen", + "deleteFailed": "Kunne ikke slette forbindelsen", + "loadFailed": "Kunne ikke indlæse forbindelser", + "fetchFailed": "Kunne ikke hente dokumenter for {collection}", + "docDeleted": "Dokument slettet", + "legacyImportFailed": "Kunne ikke læse eksisterende forbindelser fra Databaseudforsker eller Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Ingen nye forbindelser importeret} one {Importerede # forbindelse} other {Importerede # forbindelser}}{skipped, plural, =0 {} other {, # allerede importeret}}{failed, plural, =0 {} other {, # mislykkedes}}" + }, + "validation": { + "connectionStringRequired": "Indtast en forbindelsesstreng.", + "connectionStringScheme": "Forbindelsesstrengen skal starte med mongodb:// eller mongodb+srv://.", + "redisUrlRequired": "Indtast en Redis-URL.", + "redisUrlScheme": "Redis-URL skal starte med redis:// eller rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Builder", + "url": "Redis-URL", + "urlPlaceholder": "redis://localhost:6379 eller rediss://bruger:kodeord@host:6380", + "urlHint": "Understøtter redis:// og rediss:// (TLS). Legitimationsoplysninger krypteres før lagring.", + "host": "Vært", + "port": "Port", + "username": "Brugernavn (ACL)", + "usernamePlaceholder": "default", + "password": "Adgangskode", + "dbIndex": "DB-indeks", + "dbIndexPlaceholder": "0", + "tls": "Brug TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Ingen matchende databaser", + "tabKeyEditor": "Nøgleeditor", + "tabConsole": "Konsol", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Søg", + "flush": "Tøm", + "flushTitle": "Tøm nøgler", + "flushDescription": "Indtast et mønster for at slette matchende nøgler, eller lad feltet stå tomt for at tømme hele databasen (kræver DANGER_ZONE=true).", + "flushPatternLabel": "Mønster (valgfrit)", + "flushPatternPlaceholder": "f.eks. session:* — tomt = FLUSHDB", + "flushing": "Tømmer…", + "flushedAll": "Database tømt", + "flushedKeys": "{count, plural, one {# nøgle slettet} other {# nøgler slettet}}", + "flushFailed": "Tømning mislykkedes" + }, + "mongo": { + "refreshDatabases": "Opdater databaser", + "serverMonitor": "Servermonitor", + "connecting": "Opretter forbindelse…", + "connectionFailed": "Kunne ikke oprette forbindelse", + "noDatabases": "Ingen databaser", + "loadingCollections": "Indlæser samlinger…", + "collectionsFailed": "Kunne ikke indlæse samlinger", + "noCollections": "Ingen samlinger", + "noMatches": "Ingen databaser matcher din søgning", + "databaseActions": "Handlinger for database {name}", + "collectionActions": "Handlinger for samling {name}", + "selectCollection": "Vælg samling {name}", + "deleteSelected": "Slet {count} valgte", + "clearSelection": "Ryd", + "refresh": "Opdater", + "rename": "Omdøb", + "copyDbName": "Kopier databasenavn", + "browseGridfs": "Gennemse GridFS", + "sync": "Synkroniser samling", + "dropDatabase": "Slet database", + "dropCollection": "Slet samling", + "toast": { + "connectFailed": "Kunne ikke oprette forbindelse til {name}", + "collectionsFailed": "Kunne ikke indlæse samlinger for {name}", + "dbNameCopied": "Databasenavn kopieret", + "nameEmpty": "Indtast et navn.", + "nameTooLong": "Dette navn er for langt.", + "nameForbiddenChars": "Et databasenavn må ikke indeholde / \\ . eller $", + "nameConsecutiveDots": "Et databasenavn må ikke indeholde flere punktummer i træk.", + "nameNullChar": "Et samlingsnavn må ikke indeholde et null-tegn.", + "nameReservedPrefix": "Et samlingsnavn må ikke starte med $ — dette præfiks er reserveret.", + "nameInvalid": "Dette navn er ikke gyldigt.", + "renamed": "Omdøbt til {name}", + "renameFailed": "Kunne ikke omdøbe", + "dropped": "{name} droppet", + "dropFailed": "Kunne ikke droppe {name}", + "bulkDropped": "{count} samlinger droppet", + "bulkDropFailed": "Kunne ikke droppe {count} samlinger" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Ny anmodning", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 433b93f6..4e58ed97 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -29,6 +29,7 @@ "apiClient": "API-Client", "httpStatusCodes": "HTTP-Statuscodes", "nosqlExplorer": "Datenbank-Explorer", + "dataExplorer": "Data Explorer", "jsonSchemaGenerator": "JSON-Schema-Generator", "imageToBase64": "Bild zu Base64", "imageCompressor": "Bildkomprimierung", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "MongoDB-Datenbanken erkunden." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Ein Arbeitsbereich für MongoDB und Redis. Durchsuchen, abfragen und bearbeiten mit einem verschlüsselten Anmeldedatenspeicher." + }, "emailValidator": { "title": "E-Mail-Validator", "description": "E-Mail-Adressen prüfen und validieren." @@ -1944,6 +1949,150 @@ "colCoverage": "Abdeckung" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Noch keine Verbindungen", + "emptyBody": "Fügen Sie eine MongoDB- oder Redis-Verbindung hinzu, um loszulegen. Anmeldedaten werden auf Ihrem Gerät verschlüsselt.", + "addConnection": "Neue Verbindung", + "importLegacy": "Aus vorhandenen Tools importieren", + "searchConnections": "Verbindungen suchen", + "openSidebar": "Verbindungen öffnen", + "unsupportedSource": "Nicht unterstützte Quelle", + "unsupportedSourceHint": "Diese Verbindung wurde mit einer neueren Version der App erstellt.", + "noResults": "Keine Verbindungen entsprechen Ihrer Suche.", + "connectionActions": "Aktionen für {name}", + "menuEdit": "Bearbeiten", + "menuDelete": "Löschen", + "deleteTitle": "Verbindung löschen?", + "deleteBody": "{name} und die gespeicherten Anmeldedaten werden von diesem Gerät entfernt. Dies kann nicht rückgängig gemacht werden.", + "deleteDocTitle": "Dokument löschen?", + "deleteDocBody": "Dieses Dokument wird dauerhaft aus der Sammlung entfernt. Dies kann nicht rückgängig gemacht werden.", + "closeAll": "Alle schließen", + "noTabsTitle": "Nichts geöffnet", + "noTabsBody": "Wählen Sie eine Verbindung in der Seitenleiste, um eine Registerkarte zu öffnen.", + "connectionDialog": { + "createTitle": "Neue Verbindung", + "editTitle": "Verbindung bearbeiten", + "pickSource": "Datenquelle", + "name": "Name", + "namePlaceholder": "Meine Verbindung", + "folder": "Ordner", + "folderPlaceholder": "Optionale Gruppe", + "color": "Farbe", + "colorNone": "Keine Farbe", + "dbType": "Datenbanktyp", + "connectionString": "Verbindungszeichenfolge", + "readOnly": "Schreibgeschützt", + "readOnlyHint": "Blockiert jeden Schreibvorgang für diese Verbindung.", + "test": "Verbindung testen", + "testOk": "Verbunden", + "testFailed": "Verbindung fehlgeschlagen", + "save": "Speichern", + "cancel": "Abbrechen" + }, + "importDialog": { + "title": "Aus vorhandenen Tools importieren", + "loading": "Suche nach Verbindungen in Datenbank-Explorer und Redis Commander…", + "notReady": "Ihre vorhandenen Verbindungen werden noch geladen — versuchen Sie es gleich noch einmal.", + "empty": "Keine Verbindungen in Datenbank-Explorer oder Redis Commander gefunden.", + "allSkipped": "{count, plural, one {# Verbindung wurde} other {# Verbindungen wurden}} bereits importiert. Nichts Neues zum Importieren.", + "previewHeading": "{count, plural, one {# Verbindung wird} other {# Verbindungen werden}} importiert:", + "skippedNote": "{count, plural, one {# Verbindung wurde} other {# Verbindungen wurden}} bereits importiert und übersprungen.", + "importing": "Wird importiert…", + "confirm": "{count, plural, one {# Verbindung} other {# Verbindungen}} importieren" + }, + "toast": { + "saved": "Verbindung gespeichert", + "deleted": "Verbindung gelöscht", + "saveFailed": "Verbindung konnte nicht gespeichert werden", + "deleteFailed": "Verbindung konnte nicht gelöscht werden", + "loadFailed": "Verbindungen konnten nicht geladen werden", + "fetchFailed": "Dokumente für {collection} konnten nicht geladen werden", + "docDeleted": "Dokument gelöscht", + "legacyImportFailed": "Vorhandene Verbindungen aus Datenbank-Explorer oder Redis Commander konnten nicht gelesen werden", + "legacyImportResult": "{imported, plural, =0 {Keine neuen Verbindungen importiert} one {# Verbindung importiert} other {# Verbindungen importiert}}{skipped, plural, =0 {} other {, # bereits importiert}}{failed, plural, =0 {} other {, # fehlgeschlagen}}" + }, + "validation": { + "connectionStringRequired": "Geben Sie eine Verbindungszeichenfolge ein.", + "connectionStringScheme": "Die Verbindungszeichenfolge muss mit mongodb:// oder mongodb+srv:// beginnen.", + "redisUrlRequired": "Geben Sie eine Redis-URL ein.", + "redisUrlScheme": "Die Redis-URL muss mit redis:// oder rediss:// beginnen." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Builder", + "url": "Redis-URL", + "urlPlaceholder": "redis://localhost:6379 oder rediss://user:pass@host:6380", + "urlHint": "Unterstützt redis:// und rediss:// (TLS). Anmeldedaten werden vor der Speicherung verschlüsselt.", + "host": "Host", + "port": "Port", + "username": "Benutzername (ACL)", + "usernamePlaceholder": "default", + "password": "Passwort", + "dbIndex": "DB-Index", + "dbIndexPlaceholder": "0", + "tls": "TLS verwenden (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Keine passenden Datenbanken", + "tabKeyEditor": "Schlüssel-Editor", + "tabConsole": "Konsole", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Suche", + "flush": "Leeren", + "flushTitle": "Schlüssel leeren", + "flushDescription": "Geben Sie ein Muster ein, um passende Schlüssel zu löschen, oder lassen Sie das Feld leer, um die gesamte Datenbank zu leeren (erfordert DANGER_ZONE=true).", + "flushPatternLabel": "Muster (optional)", + "flushPatternPlaceholder": "z. B. session:* — leer = FLUSHDB", + "flushing": "Wird geleert…", + "flushedAll": "Datenbank geleert", + "flushedKeys": "{count, plural, one {# Schlüssel gelöscht} other {# Schlüssel gelöscht}}", + "flushFailed": "Leeren fehlgeschlagen" + }, + "mongo": { + "refreshDatabases": "Datenbanken aktualisieren", + "serverMonitor": "Servermonitor", + "connecting": "Verbinde…", + "connectionFailed": "Verbindung fehlgeschlagen", + "noDatabases": "Keine Datenbanken", + "loadingCollections": "Sammlungen werden geladen…", + "collectionsFailed": "Sammlungen konnten nicht geladen werden", + "noCollections": "Keine Sammlungen", + "noMatches": "Keine Datenbanken entsprechen Ihrer Suche", + "databaseActions": "Aktionen für Datenbank {name}", + "collectionActions": "Aktionen für Sammlung {name}", + "selectCollection": "Sammlung {name} auswählen", + "deleteSelected": "{count} ausgewählte löschen", + "clearSelection": "Leeren", + "refresh": "Aktualisieren", + "rename": "Umbenennen", + "copyDbName": "Datenbankname kopieren", + "browseGridfs": "GridFS durchsuchen", + "sync": "Sammlung synchronisieren", + "dropDatabase": "Datenbank löschen", + "dropCollection": "Sammlung löschen", + "toast": { + "connectFailed": "Verbindung zu {name} fehlgeschlagen", + "collectionsFailed": "Sammlungen für {name} konnten nicht geladen werden", + "dbNameCopied": "Datenbankname kopiert", + "nameEmpty": "Geben Sie einen Namen ein.", + "nameTooLong": "Dieser Name ist zu lang.", + "nameForbiddenChars": "Ein Datenbankname darf nicht / \\ . oder $ enthalten", + "nameConsecutiveDots": "Ein Datenbankname darf keine aufeinanderfolgenden Punkte enthalten.", + "nameNullChar": "Ein Sammlungsname darf kein Null-Zeichen enthalten.", + "nameReservedPrefix": "Ein Sammlungsname darf nicht mit $ beginnen — dieses Präfix ist reserviert.", + "nameInvalid": "Dieser Name ist ungültig.", + "renamed": "Umbenannt in {name}", + "renameFailed": "Umbenennen fehlgeschlagen", + "dropped": "{name} gelöscht", + "dropFailed": "{name} konnte nicht gelöscht werden", + "bulkDropped": "{count} Sammlungen gelöscht", + "bulkDropFailed": "{count} Sammlungen konnten nicht gelöscht werden" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Neue Anfrage", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 2bf66c30..4d70a4cb 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -10,6 +10,7 @@ "jsonFormatter": "Μορφοποιητής JSON", "emailValidator": "Email Validator", "nosqlExplorer": "Εξερευνητής βάσεων δεδομένων", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Χώρος δοκιμών κρυπτογράφησης", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Εξερευνήστε βάσεις δεδομένων MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Ένας ενιαίος χώρος εργασίας για MongoDB και Redis. Περιηγηθείτε, εκτελέστε ερωτήματα και επεξεργαστείτε με ένα κρυπτογραφημένο αποθετήριο διαπιστευτηρίων." + }, "emailValidator": { "title": "Επικύρωση email", "description": "Επαληθεύστε και επικυρώστε διευθύνσεις email." @@ -1944,6 +1949,150 @@ "colCoverage": "Κάλυψη" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Δεν υπάρχουν ακόμα συνδέσεις", + "emptyBody": "Προσθέστε μια σύνδεση MongoDB ή Redis για να αρχίσετε την περιήγηση. Τα διαπιστευτήρια κρυπτογραφούνται στη συσκευή σας.", + "addConnection": "Νέα σύνδεση", + "importLegacy": "Εισαγωγή από υπάρχοντα εργαλεία", + "searchConnections": "Αναζήτηση συνδέσεων", + "openSidebar": "Άνοιγμα συνδέσεων", + "unsupportedSource": "Μη υποστηριζόμενη πηγή", + "unsupportedSourceHint": "Αυτή η σύνδεση δημιουργήθηκε από νεότερη έκδοση της εφαρμογής.", + "noResults": "Καμία σύνδεση δεν ταιριάζει με την αναζήτησή σας.", + "connectionActions": "Ενέργειες για {name}", + "menuEdit": "Επεξεργασία", + "menuDelete": "Διαγραφή", + "deleteTitle": "Διαγραφή σύνδεσης;", + "deleteBody": "Το {name} και τα αποθηκευμένα διαπιστευτήριά του θα αφαιρεθούν από αυτήν τη συσκευή. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", + "deleteDocTitle": "Διαγραφή εγγράφου;", + "deleteDocBody": "Αυτό το έγγραφο θα αφαιρεθεί οριστικά από τη συλλογή. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", + "closeAll": "Κλείσιμο όλων", + "noTabsTitle": "Τίποτα ανοιχτό", + "noTabsBody": "Επιλέξτε μια σύνδεση στην πλαϊνή γραμμή για να ανοίξετε μια καρτέλα.", + "connectionDialog": { + "createTitle": "Νέα σύνδεση", + "editTitle": "Επεξεργασία σύνδεσης", + "pickSource": "Πηγή δεδομένων", + "name": "Όνομα", + "namePlaceholder": "Η σύνδεσή μου", + "folder": "Φάκελος", + "folderPlaceholder": "Προαιρετική ομάδα", + "color": "Χρώμα", + "colorNone": "Χωρίς χρώμα", + "dbType": "Τύπος βάσης δεδομένων", + "connectionString": "Συμβολοσειρά σύνδεσης", + "readOnly": "Μόνο ανάγνωση", + "readOnlyHint": "Αποκλείει κάθε λειτουργία εγγραφής για αυτήν τη σύνδεση.", + "test": "Δοκιμή σύνδεσης", + "testOk": "Συνδέθηκε", + "testFailed": "Αποτυχία σύνδεσης", + "save": "Αποθήκευση", + "cancel": "Ακύρωση" + }, + "importDialog": { + "title": "Εισαγωγή από υπάρχοντα εργαλεία", + "loading": "Αναζήτηση συνδέσεων στο Εξερευνητής βάσεων δεδομένων και στο Redis Commander…", + "notReady": "Οι υπάρχουσες συνδέσεις σας φορτώνονται ακόμα — δοκιμάστε ξανά σε λίγο.", + "empty": "Δεν βρέθηκαν συνδέσεις στο Εξερευνητής βάσεων δεδομένων ή στο Redis Commander.", + "allSkipped": "{count, plural, one {# σύνδεση έχει} other {# συνδέσεις έχουν}} ήδη εισαχθεί. Τίποτα νέο για εισαγωγή.", + "previewHeading": "{count, plural, one {# σύνδεση} other {# συνδέσεις}} θα εισαχθούν:", + "skippedNote": "{count, plural, one {# σύνδεση είχε} other {# συνδέσεις είχαν}} ήδη εισαχθεί και θα παραλειφθούν.", + "importing": "Εισαγωγή…", + "confirm": "Εισαγωγή {count, plural, one {# σύνδεσης} other {# συνδέσεων}}" + }, + "toast": { + "saved": "Η σύνδεση αποθηκεύτηκε", + "deleted": "Η σύνδεση διαγράφηκε", + "saveFailed": "Δεν ήταν δυνατή η αποθήκευση της σύνδεσης", + "deleteFailed": "Δεν ήταν δυνατή η διαγραφή της σύνδεσης", + "loadFailed": "Δεν ήταν δυνατή η φόρτωση των συνδέσεων", + "fetchFailed": "Αποτυχία λήψης εγγράφων για {collection}", + "docDeleted": "Το έγγραφο διαγράφηκε", + "legacyImportFailed": "Δεν ήταν δυνατή η ανάγνωση υπαρχουσών συνδέσεων από το Εξερευνητής βάσεων δεδομένων ή το Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Δεν εισήχθησαν νέες συνδέσεις} one {Εισήχθη # σύνδεση} other {Εισήχθησαν # συνδέσεις}}{skipped, plural, =0 {} other {, # ήδη εισηγμένες}}{failed, plural, =0 {} other {, # απέτυχαν}}" + }, + "validation": { + "connectionStringRequired": "Εισαγάγετε μια συμβολοσειρά σύνδεσης.", + "connectionStringScheme": "Η συμβολοσειρά σύνδεσης πρέπει να ξεκινά με mongodb:// ή mongodb+srv://.", + "redisUrlRequired": "Εισαγάγετε ένα Redis URL.", + "redisUrlScheme": "Το Redis URL πρέπει να ξεκινά με redis:// ή rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Δημιουργός", + "url": "Redis URL", + "urlPlaceholder": "redis://localhost:6379 ή rediss://user:pass@host:6380", + "urlHint": "Υποστηρίζει redis:// και rediss:// (TLS). Τα διαπιστευτήρια κρυπτογραφούνται πριν την αποθήκευση.", + "host": "Κεντρικός υπολογιστής", + "port": "Θύρα", + "username": "Όνομα χρήστη (ACL)", + "usernamePlaceholder": "default", + "password": "Κωδικός πρόσβασης", + "dbIndex": "Ευρετήριο DB", + "dbIndexPlaceholder": "0", + "tls": "Χρήση TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Δεν υπάρχουν αντίστοιχες βάσεις δεδομένων", + "tabKeyEditor": "Επεξεργασία κλειδιών", + "tabConsole": "Κονσόλα", + "tabServer": "Διακομιστής", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Σαρωτής", + "tabSearch": "Αναζήτηση", + "flush": "Εκκαθάριση", + "flushTitle": "Εκκαθάριση κλειδιών", + "flushDescription": "Εισαγάγετε ένα μοτίβο για να διαγράψετε τα αντίστοιχα κλειδιά ή αφήστε το κενό για εκκαθάριση ολόκληρης της βάσης δεδομένων (απαιτεί DANGER_ZONE=true).", + "flushPatternLabel": "Μοτίβο (προαιρετικό)", + "flushPatternPlaceholder": "π.χ. session:* — κενό = FLUSHDB", + "flushing": "Εκκαθάριση…", + "flushedAll": "Η βάση δεδομένων εκκαθαρίστηκε", + "flushedKeys": "{count, plural, one {# κλειδί διαγράφηκε} other {# κλειδιά διαγράφηκαν}}", + "flushFailed": "Η εκκαθάριση απέτυχε" + }, + "mongo": { + "refreshDatabases": "Ανανέωση βάσεων δεδομένων", + "serverMonitor": "Παρακολούθηση διακομιστή", + "connecting": "Σύνδεση…", + "connectionFailed": "Αποτυχία σύνδεσης", + "noDatabases": "Δεν υπάρχουν βάσεις δεδομένων", + "loadingCollections": "Φόρτωση συλλογών…", + "collectionsFailed": "Αποτυχία φόρτωσης συλλογών", + "noCollections": "Δεν υπάρχουν συλλογές", + "noMatches": "Καμία βάση δεδομένων δεν ταιριάζει με την αναζήτησή σας", + "databaseActions": "Ενέργειες για τη βάση δεδομένων {name}", + "collectionActions": "Ενέργειες για τη συλλογή {name}", + "selectCollection": "Επιλογή συλλογής {name}", + "deleteSelected": "Διαγραφή {count} επιλεγμένων", + "clearSelection": "Εκκαθάριση", + "refresh": "Ανανέωση", + "rename": "Μετονομασία", + "copyDbName": "Αντιγραφή ονόματος βάσης δεδομένων", + "browseGridfs": "Περιήγηση στο GridFS", + "sync": "Συγχρονισμός συλλογής", + "dropDatabase": "Απόρριψη βάσης δεδομένων", + "dropCollection": "Απόρριψη συλλογής", + "toast": { + "connectFailed": "Αποτυχία σύνδεσης με {name}", + "collectionsFailed": "Δεν ήταν δυνατή η φόρτωση συλλογών για {name}", + "dbNameCopied": "Το όνομα της βάσης δεδομένων αντιγράφηκε", + "nameEmpty": "Εισαγάγετε ένα όνομα.", + "nameTooLong": "Αυτό το όνομα είναι πολύ μεγάλο.", + "nameForbiddenChars": "Το όνομα βάσης δεδομένων δεν μπορεί να περιέχει / \\ . ή $", + "nameConsecutiveDots": "Το όνομα βάσης δεδομένων δεν μπορεί να περιέχει διαδοχικές τελείες.", + "nameNullChar": "Το όνομα συλλογής δεν μπορεί να περιέχει null χαρακτήρα.", + "nameReservedPrefix": "Το όνομα συλλογής δεν μπορεί να ξεκινά με $ — αυτό το πρόθεμα είναι δεσμευμένο.", + "nameInvalid": "Αυτό το όνομα δεν είναι έγκυρο.", + "renamed": "Μετονομάστηκε σε {name}", + "renameFailed": "Δεν ήταν δυνατή η μετονομασία", + "dropped": "Το {name} απορρίφθηκε", + "dropFailed": "Δεν ήταν δυνατή η απόρριψη του {name}", + "bulkDropped": "{count} συλλογές απορρίφθηκαν", + "bulkDropFailed": "Δεν ήταν δυνατή η απόρριψη {count} συλλογών" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Νέο αίτημα", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 141a11f6..c503ce88 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -53,6 +53,7 @@ "apiClient": "API Client", "httpStatusCodes": "HTTP status codes", "nosqlExplorer": "Database Explorer", + "dataExplorer": "Data Explorer", "cssGradientBuilder": "CSS Gradient Builder", "gitignoreGenerator": ".gitignore Generator", "csvExcelJson": "CSV / Excel ↔ JSON", @@ -483,6 +484,10 @@ "title": "Database Explorer", "description": "Explore MongoDB and SQL databases." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "One workspace for MongoDB and Redis. Browse, query, and edit with an encrypted credential store." + }, "emailValidator": { "title": "Email Validator", "description": "Verify and validate email addresses." @@ -1874,6 +1879,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "No connections yet", + "emptyBody": "Add a MongoDB or Redis connection to start browsing. Credentials are encrypted on your device.", + "addConnection": "New connection", + "importLegacy": "Import from existing tools", + "searchConnections": "Search connections", + "openSidebar": "Open connections", + "unsupportedSource": "Unsupported source", + "unsupportedSourceHint": "This connection was created by a newer version of the app.", + "noResults": "No connections match your search.", + "connectionActions": "Actions for {name}", + "menuEdit": "Edit", + "menuDelete": "Delete", + "deleteTitle": "Delete connection?", + "deleteBody": "{name} and its saved credentials will be removed from this device. This cannot be undone.", + "deleteDocTitle": "Delete document?", + "deleteDocBody": "This document will be permanently removed from the collection. This cannot be undone.", + "closeAll": "Close all", + "noTabsTitle": "Nothing open", + "noTabsBody": "Pick a connection in the sidebar to open a tab.", + "connectionDialog": { + "createTitle": "New connection", + "editTitle": "Edit connection", + "pickSource": "Data source", + "name": "Name", + "namePlaceholder": "My connection", + "folder": "Folder", + "folderPlaceholder": "Optional group", + "color": "Colour", + "colorNone": "No colour", + "dbType": "Database type", + "connectionString": "Connection string", + "readOnly": "Read-only", + "readOnlyHint": "Blocks every write operation for this connection.", + "test": "Test connection", + "testOk": "Connected", + "testFailed": "Could not connect", + "save": "Save", + "cancel": "Cancel" + }, + "importDialog": { + "title": "Import from existing tools", + "loading": "Looking for connections in Database Explorer and Redis Commander…", + "notReady": "Still loading your existing connections — try again in a moment.", + "empty": "No connections found in Database Explorer or Redis Commander.", + "allSkipped": "{count, plural, one {# connection has} other {# connections have}} already been imported. Nothing new to bring in.", + "previewHeading": "{count, plural, one {# connection} other {# connections}} will be imported:", + "skippedNote": "{count, plural, one {# connection was} other {# connections were}} already imported and will be skipped.", + "importing": "Importing…", + "confirm": "Import {count, plural, one {# connection} other {# connections}}" + }, + "toast": { + "saved": "Connection saved", + "deleted": "Connection deleted", + "saveFailed": "Could not save the connection", + "deleteFailed": "Could not delete the connection", + "loadFailed": "Could not load connections", + "fetchFailed": "Failed to fetch documents for {collection}", + "docDeleted": "Document deleted", + "legacyImportFailed": "Could not read existing connections from Database Explorer or Redis Commander", + "legacyImportResult": "{imported, plural, =0 {No new connections imported} one {Imported # connection} other {Imported # connections}}{skipped, plural, =0 {} other {, # already imported}}{failed, plural, =0 {} other {, # failed}}" + }, + "validation": { + "connectionStringRequired": "Enter a connection string.", + "connectionStringScheme": "Connection string must start with mongodb:// or mongodb+srv://.", + "redisUrlRequired": "Enter a Redis URL.", + "redisUrlScheme": "Redis URL must start with redis:// or rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Builder", + "url": "Redis URL", + "urlPlaceholder": "redis://localhost:6379 or rediss://user:pass@host:6380", + "urlHint": "Supports redis:// and rediss:// (TLS). Credentials are encrypted before storage.", + "host": "Host", + "port": "Port", + "username": "Username (ACL)", + "usernamePlaceholder": "default", + "password": "Password", + "dbIndex": "DB index", + "dbIndexPlaceholder": "0", + "tls": "Use TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "No matching databases", + "tabKeyEditor": "Key Editor", + "tabConsole": "Console", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Search", + "flush": "Flush", + "flushTitle": "Flush Keys", + "flushDescription": "Enter a pattern to delete matching keys, or leave blank to flush the entire database (requires DANGER_ZONE=true).", + "flushPatternLabel": "Pattern (optional)", + "flushPatternPlaceholder": "e.g. session:* — blank = FLUSHDB", + "flushing": "Flushing…", + "flushedAll": "Database flushed", + "flushedKeys": "{count, plural, one {# key deleted} other {# keys deleted}}", + "flushFailed": "Flush failed" + }, + "mongo": { + "refreshDatabases": "Refresh databases", + "serverMonitor": "Server monitor", + "connecting": "Connecting…", + "connectionFailed": "Could not connect", + "noDatabases": "No databases", + "loadingCollections": "Loading collections…", + "collectionsFailed": "Could not load collections", + "noCollections": "No collections", + "noMatches": "No databases match your search", + "databaseActions": "Actions for database {name}", + "collectionActions": "Actions for collection {name}", + "selectCollection": "Select collection {name}", + "deleteSelected": "Delete {count} selected", + "clearSelection": "Clear", + "refresh": "Refresh", + "rename": "Rename", + "copyDbName": "Copy database name", + "browseGridfs": "Browse GridFS", + "sync": "Sync collection", + "dropDatabase": "Drop database", + "dropCollection": "Drop collection", + "toast": { + "connectFailed": "Could not connect to {name}", + "collectionsFailed": "Could not load collections for {name}", + "dbNameCopied": "Database name copied", + "nameEmpty": "Enter a name.", + "nameTooLong": "That name is too long.", + "nameForbiddenChars": "A database name cannot contain / \\ . or $", + "nameConsecutiveDots": "A database name cannot contain consecutive dots.", + "nameNullChar": "A collection name cannot contain a null character.", + "nameReservedPrefix": "A collection name cannot start with $ — that prefix is reserved.", + "nameInvalid": "That name is not valid.", + "renamed": "Renamed to {name}", + "renameFailed": "Could not rename", + "dropped": "Dropped {name}", + "dropFailed": "Could not drop {name}", + "bulkDropped": "Dropped {count} collections", + "bulkDropFailed": "Could not drop {count} collections" + } + } + }, "ApiClient": { "defaults": { "newRequest": "New Request", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index a4436952..cad5380c 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -29,6 +29,7 @@ "apiClient": "Cliente API", "httpStatusCodes": "Códigos de estado HTTP", "nosqlExplorer": "Explorador de bases de datos", + "dataExplorer": "Data Explorer", "jsonSchemaGenerator": "Generador de esquema JSON", "imageToBase64": "Imagen a Base64", "imageCompressor": "Compresor de imágenes", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Explora bases de datos MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Un solo espacio de trabajo para MongoDB y Redis. Explora, consulta y edita con un almacén de credenciales cifrado." + }, "emailValidator": { "title": "Validador de correo", "description": "Verifica y valida direcciones de correo." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Aún no hay conexiones", + "emptyBody": "Añade una conexión de MongoDB o Redis para empezar a explorar. Las credenciales se cifran en tu dispositivo.", + "addConnection": "Nueva conexión", + "importLegacy": "Importar desde herramientas existentes", + "searchConnections": "Buscar conexiones", + "openSidebar": "Abrir conexiones", + "unsupportedSource": "Origen no compatible", + "unsupportedSourceHint": "Esta conexión fue creada por una versión más reciente de la aplicación.", + "noResults": "Ninguna conexión coincide con tu búsqueda.", + "connectionActions": "Acciones para {name}", + "menuEdit": "Editar", + "menuDelete": "Eliminar", + "deleteTitle": "¿Eliminar conexión?", + "deleteBody": "{name} y sus credenciales guardadas se eliminarán de este dispositivo. Esta acción no se puede deshacer.", + "deleteDocTitle": "¿Eliminar documento?", + "deleteDocBody": "Este documento se eliminará permanentemente de la colección. Esta acción no se puede deshacer.", + "closeAll": "Cerrar todo", + "noTabsTitle": "Nada abierto", + "noTabsBody": "Elige una conexión en la barra lateral para abrir una pestaña.", + "connectionDialog": { + "createTitle": "Nueva conexión", + "editTitle": "Editar conexión", + "pickSource": "Origen de datos", + "name": "Nombre", + "namePlaceholder": "Mi conexión", + "folder": "Carpeta", + "folderPlaceholder": "Grupo opcional", + "color": "Color", + "colorNone": "Sin color", + "dbType": "Tipo de base de datos", + "connectionString": "Cadena de conexión", + "readOnly": "Solo lectura", + "readOnlyHint": "Bloquea todas las operaciones de escritura de esta conexión.", + "test": "Probar conexión", + "testOk": "Conectado", + "testFailed": "No se pudo conectar", + "save": "Guardar", + "cancel": "Cancelar" + }, + "importDialog": { + "title": "Importar desde herramientas existentes", + "loading": "Buscando conexiones en Explorador de bases de datos y Redis Commander…", + "notReady": "Aún se están cargando tus conexiones existentes — inténtalo de nuevo en un momento.", + "empty": "No se encontraron conexiones en Explorador de bases de datos ni en Redis Commander.", + "allSkipped": "{count, plural, one {# conexión ya se ha importado} other {# conexiones ya se han importado}}. No hay nada nuevo que traer.", + "previewHeading": "{count, plural, one {# conexión} other {# conexiones}} se importarán:", + "skippedNote": "{count, plural, one {# conexión ya estaba importada y se omitirá} other {# conexiones ya estaban importadas y se omitirán}}", + "importing": "Importando…", + "confirm": "Importar {count, plural, one {# conexión} other {# conexiones}}" + }, + "toast": { + "saved": "Conexión guardada", + "deleted": "Conexión eliminada", + "saveFailed": "No se pudo guardar la conexión", + "deleteFailed": "No se pudo eliminar la conexión", + "loadFailed": "No se pudieron cargar las conexiones", + "fetchFailed": "No se pudieron obtener documentos de {collection}", + "docDeleted": "Documento eliminado", + "legacyImportFailed": "No se pudieron leer las conexiones existentes de Explorador de bases de datos o Redis Commander", + "legacyImportResult": "{imported, plural, =0 {No se importaron conexiones nuevas} one {Se importó # conexión} other {Se importaron # conexiones}}{skipped, plural, =0 {} other {, # ya importadas}}{failed, plural, =0 {} other {, # fallidas}}" + }, + "validation": { + "connectionStringRequired": "Introduce una cadena de conexión.", + "connectionStringScheme": "La cadena de conexión debe empezar con mongodb:// o mongodb+srv://.", + "redisUrlRequired": "Introduce una URL de Redis.", + "redisUrlScheme": "La URL de Redis debe empezar con redis:// o rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Constructor", + "url": "URL de Redis", + "urlPlaceholder": "redis://localhost:6379 o rediss://usuario:contraseña@host:6380", + "urlHint": "Admite redis:// y rediss:// (TLS). Las credenciales se cifran antes de guardarse.", + "host": "Host", + "port": "Puerto", + "username": "Nombre de usuario (ACL)", + "usernamePlaceholder": "default", + "password": "Contraseña", + "dbIndex": "Índice de BD", + "dbIndexPlaceholder": "0", + "tls": "Usar TLS (rediss://)", + "dbLabel": "bd {index}", + "noMatches": "No hay bases de datos coincidentes", + "tabKeyEditor": "Editor de claves", + "tabConsole": "Consola", + "tabServer": "Servidor", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Escáner", + "tabSearch": "Buscar", + "flush": "Vaciar", + "flushTitle": "Vaciar claves", + "flushDescription": "Introduce un patrón para eliminar las claves coincidentes, o déjalo en blanco para vaciar toda la base de datos (requiere DANGER_ZONE=true).", + "flushPatternLabel": "Patrón (opcional)", + "flushPatternPlaceholder": "p. ej. session:* — en blanco = FLUSHDB", + "flushing": "Vaciando…", + "flushedAll": "Base de datos vaciada", + "flushedKeys": "{count, plural, one {# clave eliminada} other {# claves eliminadas}}", + "flushFailed": "Error al vaciar" + }, + "mongo": { + "refreshDatabases": "Actualizar bases de datos", + "serverMonitor": "Monitor del servidor", + "connecting": "Conectando…", + "connectionFailed": "No se pudo conectar", + "noDatabases": "Sin bases de datos", + "loadingCollections": "Cargando colecciones…", + "collectionsFailed": "No se pudieron cargar las colecciones", + "noCollections": "Sin colecciones", + "noMatches": "Ninguna base de datos coincide con tu búsqueda", + "databaseActions": "Acciones para la base de datos {name}", + "collectionActions": "Acciones para la colección {name}", + "selectCollection": "Seleccionar colección {name}", + "deleteSelected": "Eliminar {count} seleccionados", + "clearSelection": "Limpiar", + "refresh": "Actualizar", + "rename": "Renombrar", + "copyDbName": "Copiar nombre de la base de datos", + "browseGridfs": "Explorar GridFS", + "sync": "Sincronizar colección", + "dropDatabase": "Eliminar base de datos", + "dropCollection": "Eliminar colección", + "toast": { + "connectFailed": "No se pudo conectar a {name}", + "collectionsFailed": "No se pudieron cargar las colecciones de {name}", + "dbNameCopied": "Nombre de la base de datos copiado", + "nameEmpty": "Introduce un nombre.", + "nameTooLong": "Ese nombre es demasiado largo.", + "nameForbiddenChars": "El nombre de una base de datos no puede contener / \\ . ni $", + "nameConsecutiveDots": "El nombre de una base de datos no puede contener puntos consecutivos.", + "nameNullChar": "El nombre de una colección no puede contener un carácter nulo.", + "nameReservedPrefix": "El nombre de una colección no puede empezar con $ — ese prefijo está reservado.", + "nameInvalid": "Ese nombre no es válido.", + "renamed": "Renombrado a {name}", + "renameFailed": "No se pudo renombrar", + "dropped": "{name} eliminado", + "dropFailed": "No se pudo eliminar {name}", + "bulkDropped": "{count} colecciones eliminadas", + "bulkDropFailed": "No se pudieron eliminar {count} colecciones" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nueva solicitud", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index d8d0dcff..f27947ca 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -10,6 +10,7 @@ "jsonFormatter": "قالب‌ده JSON", "emailValidator": "اعتبار سنجی ایمیل", "nosqlExplorer": "کاوشگر پایگاه داده", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "زمین بازی رمزنگاری", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "پایگاه داده های MongoDB را کاوش کنید." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "یک فضای کاری برای MongoDB و Redis. با یک مخزن اطلاعات ورود رمزگذاری‌شده مرور، پرس‌وجو و ویرایش کنید." + }, "emailValidator": { "title": "اعتبار سنجی ایمیل", "description": "آدرس های ایمیل را تأیید و تأیید کنید." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "هنوز اتصالی وجود ندارد", + "emptyBody": "یک اتصال MongoDB یا Redis اضافه کنید تا مرور را شروع کنید. اطلاعات ورود روی دستگاه شما رمزگذاری می‌شوند.", + "addConnection": "اتصال جدید", + "importLegacy": "وارد کردن از ابزارهای موجود", + "searchConnections": "جستجوی اتصالات", + "openSidebar": "باز کردن اتصال‌ها", + "unsupportedSource": "منبع پشتیبانی‌نشده", + "unsupportedSourceHint": "این اتصال توسط نسخه جدیدتری از برنامه ایجاد شده است.", + "noResults": "هیچ اتصالی با جستجوی شما مطابقت ندارد.", + "connectionActions": "اقدامات برای {name}", + "menuEdit": "ویرایش", + "menuDelete": "حذف", + "deleteTitle": "اتصال حذف شود؟", + "deleteBody": "{name} و اطلاعات ورود ذخیره‌شده آن از این دستگاه حذف خواهد شد. این عمل قابل بازگشت نیست.", + "deleteDocTitle": "سند حذف شود؟", + "deleteDocBody": "این سند برای همیشه از مجموعه حذف خواهد شد. این عمل قابل بازگشت نیست.", + "closeAll": "بستن همه", + "noTabsTitle": "چیزی باز نیست", + "noTabsBody": "برای باز کردن یک برگه، اتصالی را از نوار کناری انتخاب کنید.", + "connectionDialog": { + "createTitle": "اتصال جدید", + "editTitle": "ویرایش اتصال", + "pickSource": "منبع داده", + "name": "نام", + "namePlaceholder": "اتصال من", + "folder": "پوشه", + "folderPlaceholder": "گروه اختیاری", + "color": "رنگ", + "colorNone": "بدون رنگ", + "dbType": "نوع پایگاه داده", + "connectionString": "رشته اتصال", + "readOnly": "فقط خواندنی", + "readOnlyHint": "تمام عملیات نوشتن را برای این اتصال مسدود می‌کند.", + "test": "تست اتصال", + "testOk": "متصل شد", + "testFailed": "اتصال ناموفق بود", + "save": "ذخیره", + "cancel": "لغو" + }, + "importDialog": { + "title": "وارد کردن از ابزارهای موجود", + "loading": "در حال جستجوی اتصالات در کاوشگر پایگاه داده و Redis Commander…", + "notReady": "اتصالات موجود شما هنوز در حال بارگذاری هستند — کمی بعد دوباره امتحان کنید.", + "empty": "هیچ اتصالی در کاوشگر پایگاه داده یا Redis Commander یافت نشد.", + "allSkipped": "{count, plural, one {# اتصال قبلاً وارد شده است} other {# اتصال قبلاً وارد شده است}}. موردی جدید برای وارد کردن نیست.", + "previewHeading": "{count, plural, one {# اتصال} other {# اتصال}} وارد خواهد شد:", + "skippedNote": "{count, plural, one {# اتصال قبلاً وارد شده و نادیده گرفته خواهد شد} other {# اتصال قبلاً وارد شده و نادیده گرفته خواهد شد}}.", + "importing": "در حال وارد کردن…", + "confirm": "وارد کردن {count, plural, one {# اتصال} other {# اتصال}}" + }, + "toast": { + "saved": "اتصال ذخیره شد", + "deleted": "اتصال حذف شد", + "saveFailed": "ذخیره اتصال ناموفق بود", + "deleteFailed": "حذف اتصال ناموفق بود", + "loadFailed": "بارگذاری اتصالات ناموفق بود", + "fetchFailed": "اسناد برای {collection} واکشی نشد", + "docDeleted": "سند حذف شد", + "legacyImportFailed": "خواندن اتصالات موجود از کاوشگر پایگاه داده یا Redis Commander ناموفق بود", + "legacyImportResult": "{imported, plural, =0 {هیچ اتصال جدیدی وارد نشد} one {# اتصال وارد شد} other {# اتصال وارد شد}}{skipped, plural, =0 {} other {، # مورد قبلاً وارد شده}}{failed, plural, =0 {} other {، # مورد ناموفق}}" + }, + "validation": { + "connectionStringRequired": "یک رشته اتصال وارد کنید.", + "connectionStringScheme": "رشته اتصال باید با mongodb:// یا mongodb+srv:// شروع شود.", + "redisUrlRequired": "آدرس Redis را وارد کنید.", + "redisUrlScheme": "آدرس Redis باید با redis:// یا rediss:// شروع شود." + }, + "redis": { + "urlMode": "آدرس", + "builderMode": "سازنده", + "url": "آدرس Redis", + "urlPlaceholder": "redis://localhost:6379 یا rediss://user:pass@host:6380", + "urlHint": "از redis:// و rediss:// (TLS) پشتیبانی می‌کند. اطلاعات ورود پیش از ذخیره رمزگذاری می‌شوند.", + "host": "میزبان", + "port": "پورت", + "username": "نام کاربری (ACL)", + "usernamePlaceholder": "default", + "password": "رمز عبور", + "dbIndex": "شماره پایگاه داده", + "dbIndexPlaceholder": "0", + "tls": "استفاده از TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "پایگاه داده‌ای مطابقت ندارد", + "tabKeyEditor": "ویرایشگر کلید", + "tabConsole": "کنسول", + "tabServer": "سرور", + "tabPubSub": "Pub/Sub", + "tabMonitor": "مانیتور", + "tabScanner": "اسکنر", + "tabSearch": "جستجو", + "flush": "پاک‌سازی", + "flushTitle": "پاک‌سازی کلیدها", + "flushDescription": "الگویی برای حذف کلیدهای مطابق وارد کنید، یا آن را خالی بگذارید تا کل پایگاه داده پاک‌سازی شود (نیازمند DANGER_ZONE=true).", + "flushPatternLabel": "الگو (اختیاری)", + "flushPatternPlaceholder": "مثلاً session:* — خالی = FLUSHDB", + "flushing": "در حال پاک‌سازی…", + "flushedAll": "پایگاه داده پاک‌سازی شد", + "flushedKeys": "{count, plural, one {# کلید حذف شد} other {# کلید حذف شد}}", + "flushFailed": "پاک‌سازی ناموفق بود" + }, + "mongo": { + "refreshDatabases": "تازه‌سازی پایگاه‌های داده", + "serverMonitor": "مانیتور سرور", + "connecting": "در حال اتصال…", + "connectionFailed": "اتصال ناموفق بود", + "noDatabases": "بدون پایگاه داده", + "loadingCollections": "در حال بارگذاری مجموعه‌ها…", + "collectionsFailed": "بارگذاری مجموعه‌ها ناموفق بود", + "noCollections": "بدون مجموعه", + "noMatches": "پایگاه داده‌ای با جستجوی شما مطابقت ندارد", + "databaseActions": "اقدامات برای پایگاه داده {name}", + "collectionActions": "اقدامات برای مجموعه {name}", + "selectCollection": "انتخاب مجموعه {name}", + "deleteSelected": "حذف {count} مورد انتخاب‌شده", + "clearSelection": "پاک کردن", + "refresh": "تازه کردن", + "rename": "تغییر نام", + "copyDbName": "کپی نام پایگاه داده", + "browseGridfs": "مرور GridFS", + "sync": "همگام‌سازی مجموعه", + "dropDatabase": "رها کردن پایگاه داده", + "dropCollection": "رها کردن مجموعه", + "toast": { + "connectFailed": "اتصال به {name} ناموفق بود", + "collectionsFailed": "بارگذاری مجموعه‌ها برای {name} ناموفق بود", + "dbNameCopied": "نام پایگاه داده کپی شد", + "nameEmpty": "نامی وارد کنید.", + "nameTooLong": "این نام خیلی طولانی است.", + "nameForbiddenChars": "نام پایگاه داده نمی‌تواند شامل / \\ . یا $ باشد", + "nameConsecutiveDots": "نام پایگاه داده نمی‌تواند شامل نقطه‌های متوالی باشد.", + "nameNullChar": "نام مجموعه نمی‌تواند شامل نویسه null باشد.", + "nameReservedPrefix": "نام مجموعه نمی‌تواند با $ شروع شود — این پیشوند رزرو شده است.", + "nameInvalid": "این نام معتبر نیست.", + "renamed": "به {name} تغییر نام یافت", + "renameFailed": "تغییر نام ناموفق بود", + "dropped": "{name} رها شد", + "dropFailed": "رها کردن {name} ناموفق بود", + "bulkDropped": "{count} مجموعه رها شد", + "bulkDropFailed": "رها کردن {count} مجموعه ناموفق بود" + } + } + }, "ApiClient": { "defaults": { "newRequest": "درخواست جدید", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index 49af343c..98b7b337 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -29,6 +29,7 @@ "apiClient": "Client API", "httpStatusCodes": "Codes d’état HTTP", "nosqlExplorer": "Explorateur de base de données", + "dataExplorer": "Data Explorer", "jsonSchemaGenerator": "Générateur de schéma JSON", "imageToBase64": "Image vers Base64", "imageCompressor": "Compresseur d’images", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Explorez les bases de données MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Un espace de travail unique pour MongoDB et Redis. Parcourez, interrogez et modifiez vos données avec un coffre d'identifiants chiffré." + }, "emailValidator": { "title": "Validateur d’e-mail", "description": "Vérifiez et validez les adresses e-mail." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Aucune connexion pour le moment", + "emptyBody": "Ajoutez une connexion MongoDB ou Redis pour commencer à explorer. Les identifiants sont chiffrés sur votre appareil.", + "addConnection": "Nouvelle connexion", + "importLegacy": "Importer depuis les outils existants", + "searchConnections": "Rechercher des connexions", + "openSidebar": "Ouvrir les connexions", + "unsupportedSource": "Source non prise en charge", + "unsupportedSourceHint": "Cette connexion a été créée par une version plus récente de l'application.", + "noResults": "Aucune connexion ne correspond à votre recherche.", + "connectionActions": "Actions pour {name}", + "menuEdit": "Modifier", + "menuDelete": "Supprimer", + "deleteTitle": "Supprimer la connexion ?", + "deleteBody": "{name} et ses identifiants enregistrés seront supprimés de cet appareil. Cette action est irréversible.", + "deleteDocTitle": "Supprimer le document ?", + "deleteDocBody": "Ce document sera définitivement supprimé de la collection. Cette action est irréversible.", + "closeAll": "Tout fermer", + "noTabsTitle": "Rien n'est ouvert", + "noTabsBody": "Choisissez une connexion dans la barre latérale pour ouvrir un onglet.", + "connectionDialog": { + "createTitle": "Nouvelle connexion", + "editTitle": "Modifier la connexion", + "pickSource": "Source de données", + "name": "Nom", + "namePlaceholder": "Ma connexion", + "folder": "Dossier", + "folderPlaceholder": "Groupe facultatif", + "color": "Couleur", + "colorNone": "Aucune couleur", + "dbType": "Type de base de données", + "connectionString": "Chaîne de connexion", + "readOnly": "Lecture seule", + "readOnlyHint": "Bloque toutes les opérations d'écriture pour cette connexion.", + "test": "Tester la connexion", + "testOk": "Connecté", + "testFailed": "Connexion impossible", + "save": "Enregistrer", + "cancel": "Annuler" + }, + "importDialog": { + "title": "Importer depuis les outils existants", + "loading": "Recherche de connexions dans Explorateur de base de données et Redis Commander…", + "notReady": "Chargement de vos connexions existantes en cours — réessayez dans un instant.", + "empty": "Aucune connexion trouvée dans Explorateur de base de données ou Redis Commander.", + "allSkipped": "{count, plural, one {# connexion a déjà été importée} other {# connexions ont déjà été importées}}. Rien de nouveau à importer.", + "previewHeading": "{count, plural, one {# connexion sera importée :} other {# connexions seront importées :}}", + "skippedNote": "{count, plural, one {# connexion a déjà été importée et sera ignorée} other {# connexions ont déjà été importées et seront ignorées}}.", + "importing": "Importation…", + "confirm": "Importer {count, plural, one {# connexion} other {# connexions}}" + }, + "toast": { + "saved": "Connexion enregistrée", + "deleted": "Connexion supprimée", + "saveFailed": "Impossible d'enregistrer la connexion", + "deleteFailed": "Impossible de supprimer la connexion", + "loadFailed": "Impossible de charger les connexions", + "fetchFailed": "Impossible de charger les documents pour {collection}", + "docDeleted": "Document supprimé", + "legacyImportFailed": "Impossible de lire les connexions existantes depuis Explorateur de base de données ou Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Aucune nouvelle connexion importée} one {# connexion importée} other {# connexions importées}}{skipped, plural, =0 {} other {, # déjà importée(s)}}{failed, plural, =0 {} other {, # échec(s)}}" + }, + "validation": { + "connectionStringRequired": "Saisissez une chaîne de connexion.", + "connectionStringScheme": "La chaîne de connexion doit commencer par mongodb:// ou mongodb+srv://.", + "redisUrlRequired": "Saisissez une URL Redis.", + "redisUrlScheme": "L'URL Redis doit commencer par redis:// ou rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Constructeur", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 ou rediss://utilisateur:motdepasse@hôte:6380", + "urlHint": "Prend en charge redis:// et rediss:// (TLS). Les identifiants sont chiffrés avant stockage.", + "host": "Hôte", + "port": "Port", + "username": "Nom d'utilisateur (ACL)", + "usernamePlaceholder": "default", + "password": "Mot de passe", + "dbIndex": "Index DB", + "dbIndexPlaceholder": "0", + "tls": "Utiliser TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Aucune base ne correspond", + "tabKeyEditor": "Éditeur de clés", + "tabConsole": "Console", + "tabServer": "Serveur", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Moniteur", + "tabScanner": "Scanner", + "tabSearch": "Recherche", + "flush": "Vider", + "flushTitle": "Vider les clés", + "flushDescription": "Saisissez un motif pour supprimer les clés correspondantes, ou laissez vide pour vider toute la base de données (nécessite DANGER_ZONE=true).", + "flushPatternLabel": "Motif (facultatif)", + "flushPatternPlaceholder": "ex. session:* — vide = FLUSHDB", + "flushing": "Vidage…", + "flushedAll": "Base de données vidée", + "flushedKeys": "{count, plural, one {# clé supprimée} other {# clés supprimées}}", + "flushFailed": "Échec du vidage" + }, + "mongo": { + "refreshDatabases": "Actualiser les bases", + "serverMonitor": "Moniteur du serveur", + "connecting": "Connexion…", + "connectionFailed": "Connexion impossible", + "noDatabases": "Aucune base", + "loadingCollections": "Chargement des collections…", + "collectionsFailed": "Impossible de charger les collections", + "noCollections": "Aucune collection", + "noMatches": "Aucune base ne correspond à votre recherche", + "databaseActions": "Actions pour la base {name}", + "collectionActions": "Actions pour la collection {name}", + "selectCollection": "Sélectionner la collection {name}", + "deleteSelected": "Supprimer {count} sélectionné(s)", + "clearSelection": "Effacer", + "refresh": "Actualiser", + "rename": "Renommer", + "copyDbName": "Copier le nom de la base", + "browseGridfs": "Parcourir GridFS", + "sync": "Synchroniser la collection", + "dropDatabase": "Supprimer la base", + "dropCollection": "Supprimer la collection", + "toast": { + "connectFailed": "Connexion impossible à {name}", + "collectionsFailed": "Impossible de charger les collections pour {name}", + "dbNameCopied": "Nom de la base copié", + "nameEmpty": "Saisissez un nom.", + "nameTooLong": "Ce nom est trop long.", + "nameForbiddenChars": "Le nom d'une base ne peut pas contenir / \\ . ou $", + "nameConsecutiveDots": "Le nom d'une base ne peut pas contenir de points consécutifs.", + "nameNullChar": "Le nom d'une collection ne peut pas contenir de caractère nul.", + "nameReservedPrefix": "Le nom d'une collection ne peut pas commencer par $ — ce préfixe est réservé.", + "nameInvalid": "Ce nom n'est pas valide.", + "renamed": "Renommé en {name}", + "renameFailed": "Impossible de renommer", + "dropped": "{name} supprimé", + "dropFailed": "Impossible de supprimer {name}", + "bulkDropped": "{count} collections supprimées", + "bulkDropFailed": "Impossible de supprimer {count} collections" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nouvelle requête", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 361df748..327c92a8 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -10,6 +10,7 @@ "jsonFormatter": "Pemformat JSON", "emailValidator": "Validator Email", "nosqlExplorer": "Penjelajah Basis Data", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Arena enkripsi", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Jelajahi basis data MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Satu ruang kerja untuk MongoDB dan Redis. Jelajahi, kueri, dan edit dengan penyimpanan kredensial terenkripsi." + }, "emailValidator": { "title": "Validator Email", "description": "Verifikasi dan validasi alamat email." @@ -1944,6 +1949,150 @@ "colCoverage": "Cakupan" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Belum ada koneksi", + "emptyBody": "Tambahkan koneksi MongoDB atau Redis untuk mulai menjelajahi. Kredensial dienkripsi di perangkat Anda.", + "addConnection": "Koneksi baru", + "importLegacy": "Impor dari alat yang sudah ada", + "searchConnections": "Cari koneksi", + "openSidebar": "Buka koneksi", + "unsupportedSource": "Sumber tidak didukung", + "unsupportedSourceHint": "Koneksi ini dibuat oleh versi aplikasi yang lebih baru.", + "noResults": "Tidak ada koneksi yang cocok dengan pencarian Anda.", + "connectionActions": "Tindakan untuk {name}", + "menuEdit": "Edit", + "menuDelete": "Hapus", + "deleteTitle": "Hapus koneksi?", + "deleteBody": "{name} dan kredensial tersimpannya akan dihapus dari perangkat ini. Tindakan ini tidak dapat dibatalkan.", + "deleteDocTitle": "Hapus dokumen?", + "deleteDocBody": "Dokumen ini akan dihapus secara permanen dari koleksi. Tindakan ini tidak dapat dibatalkan.", + "closeAll": "Tutup semua", + "noTabsTitle": "Tidak ada yang terbuka", + "noTabsBody": "Pilih koneksi di bilah sisi untuk membuka tab.", + "connectionDialog": { + "createTitle": "Koneksi baru", + "editTitle": "Edit koneksi", + "pickSource": "Sumber data", + "name": "Nama", + "namePlaceholder": "Koneksi saya", + "folder": "Folder", + "folderPlaceholder": "Grup opsional", + "color": "Warna", + "colorNone": "Tanpa warna", + "dbType": "Tipe basis data", + "connectionString": "String koneksi", + "readOnly": "Hanya baca", + "readOnlyHint": "Memblokir semua operasi tulis untuk koneksi ini.", + "test": "Tes koneksi", + "testOk": "Terhubung", + "testFailed": "Gagal terhubung", + "save": "Simpan", + "cancel": "Batal" + }, + "importDialog": { + "title": "Impor dari alat yang sudah ada", + "loading": "Mencari koneksi di Penjelajah Basis Data dan Redis Commander…", + "notReady": "Masih memuat koneksi Anda yang sudah ada — coba lagi sebentar.", + "empty": "Tidak ada koneksi ditemukan di Penjelajah Basis Data atau Redis Commander.", + "allSkipped": "{count, plural, one {# koneksi sudah diimpor} other {# koneksi sudah diimpor}}. Tidak ada yang baru untuk diimpor.", + "previewHeading": "{count, plural, one {# koneksi akan diimpor:} other {# koneksi akan diimpor:}}", + "skippedNote": "{count, plural, one {# koneksi sudah diimpor dan akan dilewati} other {# koneksi sudah diimpor dan akan dilewati}}.", + "importing": "Mengimpor…", + "confirm": "Impor {count, plural, one {# koneksi} other {# koneksi}}" + }, + "toast": { + "saved": "Koneksi disimpan", + "deleted": "Koneksi dihapus", + "saveFailed": "Gagal menyimpan koneksi", + "deleteFailed": "Gagal menghapus koneksi", + "loadFailed": "Gagal memuat koneksi", + "fetchFailed": "Gagal mengambil dokumen untuk {collection}", + "docDeleted": "Dokumen dihapus", + "legacyImportFailed": "Tidak dapat membaca koneksi yang sudah ada dari Penjelajah Basis Data atau Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Tidak ada koneksi baru yang diimpor} one {# koneksi diimpor} other {# koneksi diimpor}}{skipped, plural, =0 {} other {, # sudah diimpor}}{failed, plural, =0 {} other {, # gagal}}" + }, + "validation": { + "connectionStringRequired": "Masukkan string koneksi.", + "connectionStringScheme": "String koneksi harus dimulai dengan mongodb:// atau mongodb+srv://.", + "redisUrlRequired": "Masukkan URL Redis.", + "redisUrlScheme": "URL Redis harus dimulai dengan redis:// atau rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Pembuat", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 atau rediss://username:password@host:6380", + "urlHint": "Mendukung redis:// dan rediss:// (TLS). Kredensial dienkripsi sebelum disimpan.", + "host": "Host", + "port": "Port", + "username": "Nama pengguna (ACL)", + "usernamePlaceholder": "default", + "password": "Kata sandi", + "dbIndex": "Indeks DB", + "dbIndexPlaceholder": "0", + "tls": "Gunakan TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Tidak ada basis data yang cocok", + "tabKeyEditor": "Editor Kunci", + "tabConsole": "Console", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Pemindai", + "tabSearch": "Cari", + "flush": "Bersihkan", + "flushTitle": "Bersihkan Kunci", + "flushDescription": "Masukkan pola untuk menghapus kunci yang cocok, atau biarkan kosong untuk membersihkan seluruh basis data (memerlukan DANGER_ZONE=true).", + "flushPatternLabel": "Pola (opsional)", + "flushPatternPlaceholder": "mis. session:* — kosong = FLUSHDB", + "flushing": "Membersihkan…", + "flushedAll": "Basis data dibersihkan", + "flushedKeys": "{count, plural, one {# kunci dihapus} other {# kunci dihapus}}", + "flushFailed": "Gagal membersihkan" + }, + "mongo": { + "refreshDatabases": "Segarkan basis data", + "serverMonitor": "Monitor server", + "connecting": "Menghubungkan…", + "connectionFailed": "Gagal terhubung", + "noDatabases": "Tidak ada basis data", + "loadingCollections": "Memuat koleksi…", + "collectionsFailed": "Gagal memuat koleksi", + "noCollections": "Tidak ada koleksi", + "noMatches": "Tidak ada basis data yang cocok dengan pencarian Anda", + "databaseActions": "Tindakan untuk basis data {name}", + "collectionActions": "Tindakan untuk koleksi {name}", + "selectCollection": "Pilih koleksi {name}", + "deleteSelected": "Hapus {count} yang dipilih", + "clearSelection": "Bersihkan", + "refresh": "Segarkan", + "rename": "Ubah nama", + "copyDbName": "Salin nama basis data", + "browseGridfs": "Jelajahi GridFS", + "sync": "Sinkronkan koleksi", + "dropDatabase": "Jatuhkan basis data", + "dropCollection": "Jatuhkan koleksi", + "toast": { + "connectFailed": "Gagal terhubung ke {name}", + "collectionsFailed": "Gagal memuat koleksi untuk {name}", + "dbNameCopied": "Nama basis data disalin", + "nameEmpty": "Masukkan nama.", + "nameTooLong": "Nama itu terlalu panjang.", + "nameForbiddenChars": "Nama basis data tidak boleh mengandung / \\ . atau $", + "nameConsecutiveDots": "Nama basis data tidak boleh mengandung titik berurutan.", + "nameNullChar": "Nama koleksi tidak boleh mengandung karakter null.", + "nameReservedPrefix": "Nama koleksi tidak boleh dimulai dengan $ — awalan itu dicadangkan.", + "nameInvalid": "Nama itu tidak valid.", + "renamed": "Diganti nama menjadi {name}", + "renameFailed": "Gagal mengubah nama", + "dropped": "{name} dihapus", + "dropFailed": "Gagal menghapus {name}", + "bulkDropped": "{count} koleksi dihapus", + "bulkDropFailed": "Gagal menghapus {count} koleksi" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Permintaan baru", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 0ee93ac9..da6c1bf8 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -10,6 +10,7 @@ "jsonFormatter": "Formattatore JSON", "emailValidator": "Validatore di posta elettronica", "nosqlExplorer": "Esplora database", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Sandbox crittografia", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Esplora i database MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Un'unica area di lavoro per MongoDB e Redis. Sfoglia, interroga e modifica i dati con un archivio di credenziali crittografato." + }, "emailValidator": { "title": "Validatore di posta elettronica", "description": "Verificare e convalidare gli indirizzi e-mail." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Nessuna connessione ancora", + "emptyBody": "Aggiungi una connessione MongoDB o Redis per iniziare a esplorare. Le credenziali sono crittografate sul tuo dispositivo.", + "addConnection": "Nuova connessione", + "importLegacy": "Importa dagli strumenti esistenti", + "searchConnections": "Cerca connessioni", + "openSidebar": "Apri connessioni", + "unsupportedSource": "Origine non supportata", + "unsupportedSourceHint": "Questa connessione è stata creata da una versione più recente dell'app.", + "noResults": "Nessuna connessione corrisponde alla tua ricerca.", + "connectionActions": "Azioni per {name}", + "menuEdit": "Modifica", + "menuDelete": "Elimina", + "deleteTitle": "Eliminare la connessione?", + "deleteBody": "{name} e le relative credenziali salvate verranno rimosse da questo dispositivo. Questa azione non può essere annullata.", + "deleteDocTitle": "Eliminare il documento?", + "deleteDocBody": "Questo documento verrà rimosso definitivamente dalla raccolta. Questa azione non può essere annullata.", + "closeAll": "Chiudi tutto", + "noTabsTitle": "Nessuna scheda aperta", + "noTabsBody": "Scegli una connessione nella barra laterale per aprire una scheda.", + "connectionDialog": { + "createTitle": "Nuova connessione", + "editTitle": "Modifica connessione", + "pickSource": "Origine dati", + "name": "Nome", + "namePlaceholder": "La mia connessione", + "folder": "Cartella", + "folderPlaceholder": "Gruppo facoltativo", + "color": "Colore", + "colorNone": "Nessun colore", + "dbType": "Tipo di database", + "connectionString": "Stringa di connessione", + "readOnly": "Sola lettura", + "readOnlyHint": "Blocca ogni operazione di scrittura per questa connessione.", + "test": "Prova connessione", + "testOk": "Connesso", + "testFailed": "Connessione non riuscita", + "save": "Salva", + "cancel": "Annulla" + }, + "importDialog": { + "title": "Importa dagli strumenti esistenti", + "loading": "Ricerca connessioni in Esplora database e Redis Commander…", + "notReady": "Caricamento delle connessioni esistenti in corso — riprova tra un momento.", + "empty": "Nessuna connessione trovata in Esplora database o Redis Commander.", + "allSkipped": "{count, plural, one {# connessione è già stata importata} other {# connessioni sono già state importate}}. Niente di nuovo da importare.", + "previewHeading": "{count, plural, one {# connessione verrà importata:} other {# connessioni verranno importate:}}", + "skippedNote": "{count, plural, one {# connessione era già stata importata e verrà saltata} other {# connessioni erano già state importate e verranno saltate}}.", + "importing": "Importazione…", + "confirm": "Importa {count, plural, one {# connessione} other {# connessioni}}" + }, + "toast": { + "saved": "Connessione salvata", + "deleted": "Connessione eliminata", + "saveFailed": "Impossibile salvare la connessione", + "deleteFailed": "Impossibile eliminare la connessione", + "loadFailed": "Impossibile caricare le connessioni", + "fetchFailed": "Impossibile recuperare i documenti per {collection}", + "docDeleted": "Documento eliminato", + "legacyImportFailed": "Impossibile leggere le connessioni esistenti da Esplora database o Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Nessuna nuova connessione importata} one {# connessione importata} other {# connessioni importate}}{skipped, plural, =0 {} other {, # già importate}}{failed, plural, =0 {} other {, # non riuscite}}" + }, + "validation": { + "connectionStringRequired": "Inserisci una stringa di connessione.", + "connectionStringScheme": "La stringa di connessione deve iniziare con mongodb:// o mongodb+srv://.", + "redisUrlRequired": "Inserisci un URL Redis.", + "redisUrlScheme": "L'URL Redis deve iniziare con redis:// o rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Generatore", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 o rediss://nomeutente:password@host:6380", + "urlHint": "Supporta redis:// e rediss:// (TLS). Le credenziali sono crittografate prima dell'archiviazione.", + "host": "Host", + "port": "Porta", + "username": "Nome utente (ACL)", + "usernamePlaceholder": "default", + "password": "Password", + "dbIndex": "Indice DB", + "dbIndexPlaceholder": "0", + "tls": "Usa TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Nessun database corrispondente", + "tabKeyEditor": "Editor delle chiavi", + "tabConsole": "Console", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Ricerca", + "flush": "Svuota", + "flushTitle": "Svuota chiavi", + "flushDescription": "Inserisci un pattern per eliminare le chiavi corrispondenti, oppure lascia vuoto per svuotare l'intero database (richiede DANGER_ZONE=true).", + "flushPatternLabel": "Pattern (facoltativo)", + "flushPatternPlaceholder": "es. session:* — vuoto = FLUSHDB", + "flushing": "Svuotamento…", + "flushedAll": "Database svuotato", + "flushedKeys": "{count, plural, one {# chiave eliminata} other {# chiavi eliminate}}", + "flushFailed": "Svuotamento non riuscito" + }, + "mongo": { + "refreshDatabases": "Aggiorna database", + "serverMonitor": "Monitor del server", + "connecting": "Collegamento…", + "connectionFailed": "Connessione non riuscita", + "noDatabases": "Nessun database", + "loadingCollections": "Caricamento raccolte…", + "collectionsFailed": "Impossibile caricare le raccolte", + "noCollections": "Nessuna raccolta", + "noMatches": "Nessun database corrisponde alla tua ricerca", + "databaseActions": "Azioni per il database {name}", + "collectionActions": "Azioni per la raccolta {name}", + "selectCollection": "Seleziona la raccolta {name}", + "deleteSelected": "Elimina {count} selezionati", + "clearSelection": "Deseleziona", + "refresh": "Aggiorna", + "rename": "Rinomina", + "copyDbName": "Copia nome database", + "browseGridfs": "Sfoglia GridFS", + "sync": "Sincronizza raccolta", + "dropDatabase": "Elimina database", + "dropCollection": "Elimina raccolta", + "toast": { + "connectFailed": "Impossibile connettersi a {name}", + "collectionsFailed": "Impossibile caricare le raccolte per {name}", + "dbNameCopied": "Nome del database copiato", + "nameEmpty": "Inserisci un nome.", + "nameTooLong": "Il nome è troppo lungo.", + "nameForbiddenChars": "Il nome di un database non può contenere / \\ . o $", + "nameConsecutiveDots": "Il nome di un database non può contenere punti consecutivi.", + "nameNullChar": "Il nome di una raccolta non può contenere un carattere nullo.", + "nameReservedPrefix": "Il nome di una raccolta non può iniziare con $ — quel prefisso è riservato.", + "nameInvalid": "Il nome non è valido.", + "renamed": "Rinominato in {name}", + "renameFailed": "Impossibile rinominare", + "dropped": "{name} eliminato", + "dropFailed": "Impossibile eliminare {name}", + "bulkDropped": "{count} raccolte eliminate", + "bulkDropFailed": "Impossibile eliminare {count} raccolte" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nuova richiesta", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index ab30105b..a15f29b7 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON フォーマッター", "emailValidator": "メールバリデーター", "nosqlExplorer": "データベースエクスプローラー", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "暗号化プレイグラウンド", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "MongoDBデータベースを探索します。" }, + "dataExplorer": { + "title": "Data Explorer", + "description": "MongoDBとRedisを1つのワークスペースで。暗号化された認証情報ストアで閲覧、クエリ、編集を行います。" + }, "emailValidator": { "title": "メールバリデーター", "description": "メールアドレスの検証と確認を行います。" @@ -1944,6 +1949,150 @@ "colCoverage": "カバレッジ" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "接続がまだありません", + "emptyBody": "MongoDBまたはRedis接続を追加して閲覧を開始します。認証情報はお使いのデバイスで暗号化されます。", + "addConnection": "新しい接続", + "importLegacy": "既存のツールからインポート", + "searchConnections": "接続を検索", + "openSidebar": "接続を開く", + "unsupportedSource": "サポートされていないソース", + "unsupportedSourceHint": "この接続はより新しいバージョンのアプリで作成されました。", + "noResults": "検索条件に一致する接続がありません。", + "connectionActions": "{name}の操作", + "menuEdit": "編集", + "menuDelete": "削除", + "deleteTitle": "接続を削除しますか?", + "deleteBody": "{name}とその保存済み認証情報はこのデバイスから削除されます。この操作は元に戻せません。", + "deleteDocTitle": "ドキュメントを削除しますか?", + "deleteDocBody": "このドキュメントはコレクションから完全に削除されます。この操作は元に戻せません。", + "closeAll": "すべて閉じる", + "noTabsTitle": "開いているものがありません", + "noTabsBody": "サイドバーで接続を選択してタブを開きます。", + "connectionDialog": { + "createTitle": "新しい接続", + "editTitle": "接続を編集", + "pickSource": "データソース", + "name": "名前", + "namePlaceholder": "マイ接続", + "folder": "フォルダー", + "folderPlaceholder": "任意のグループ", + "color": "カラー", + "colorNone": "色なし", + "dbType": "データベースの種類", + "connectionString": "接続文字列", + "readOnly": "読み取り専用", + "readOnlyHint": "この接続でのすべての書き込み操作をブロックします。", + "test": "接続テスト", + "testOk": "接続しました", + "testFailed": "接続できませんでした", + "save": "保存", + "cancel": "キャンセル" + }, + "importDialog": { + "title": "既存のツールからインポート", + "loading": "Database ExplorerとRedis Commanderで接続を検索しています…", + "notReady": "既存の接続を読み込んでいます — しばらくしてから再試行してください。", + "empty": "Database ExplorerまたはRedis Commanderに接続が見つかりませんでした。", + "allSkipped": "{count, plural, one {#件の接続は} other {#件の接続は}}すでにインポート済みです。新しくインポートするものはありません。", + "previewHeading": "{count, plural, one {#件の接続がインポートされます:} other {#件の接続がインポートされます:}}", + "skippedNote": "{count, plural, one {#件の接続はすでにインポート済みのためスキップされます} other {#件の接続はすでにインポート済みのためスキップされます}}。", + "importing": "インポート中…", + "confirm": "{count, plural, one {#件の接続をインポート} other {#件の接続をインポート}}" + }, + "toast": { + "saved": "接続を保存しました", + "deleted": "接続を削除しました", + "saveFailed": "接続の保存に失敗しました", + "deleteFailed": "接続の削除に失敗しました", + "loadFailed": "接続の読み込みに失敗しました", + "fetchFailed": "{collection}のドキュメント取得に失敗しました", + "docDeleted": "ドキュメントを削除しました", + "legacyImportFailed": "Database ExplorerまたはRedis Commanderから既存の接続を読み込めませんでした", + "legacyImportResult": "{imported, plural, =0 {新しくインポートされた接続はありません} one {#件の接続をインポートしました} other {#件の接続をインポートしました}}{skipped, plural, =0 {} other {、#件はインポート済み}}{failed, plural, =0 {} other {、#件が失敗}}" + }, + "validation": { + "connectionStringRequired": "接続文字列を入力してください。", + "connectionStringScheme": "接続文字列はmongodb://またはmongodb+srv://で始まる必要があります。", + "redisUrlRequired": "Redis URLを入力してください。", + "redisUrlScheme": "Redis URLはredis://またはrediss://で始まる必要があります。" + }, + "redis": { + "urlMode": "URL", + "builderMode": "ビルダー", + "url": "Redis URL", + "urlPlaceholder": "redis://localhost:6379 または rediss://username:password@host:6380", + "urlHint": "redis://およびrediss://(TLS)に対応しています。認証情報は保存前に暗号化されます。", + "host": "ホスト", + "port": "ポート", + "username": "ユーザー名(ACL)", + "usernamePlaceholder": "default", + "password": "パスワード", + "dbIndex": "DBインデックス", + "dbIndexPlaceholder": "0", + "tls": "TLSを使用(rediss://)", + "dbLabel": "db {index}", + "noMatches": "一致するデータベースがありません", + "tabKeyEditor": "キーエディター", + "tabConsole": "コンソール", + "tabServer": "サーバー", + "tabPubSub": "Pub/Sub", + "tabMonitor": "モニター", + "tabScanner": "スキャナー", + "tabSearch": "検索", + "flush": "フラッシュ", + "flushTitle": "キーをフラッシュ", + "flushDescription": "パターンを入力すると一致するキーを削除します。空欄のままにするとデータベース全体をフラッシュします(DANGER_ZONE=trueが必要)。", + "flushPatternLabel": "パターン(任意)", + "flushPatternPlaceholder": "例:session:* — 空欄 = FLUSHDB", + "flushing": "フラッシュ中…", + "flushedAll": "データベースをフラッシュしました", + "flushedKeys": "{count, plural, one {#件のキーを削除しました} other {#件のキーを削除しました}}", + "flushFailed": "フラッシュに失敗しました" + }, + "mongo": { + "refreshDatabases": "データベースを更新", + "serverMonitor": "サーバーモニター", + "connecting": "接続中…", + "connectionFailed": "接続できませんでした", + "noDatabases": "データベースなし", + "loadingCollections": "コレクションを読み込んでいます…", + "collectionsFailed": "コレクションの読み込みに失敗しました", + "noCollections": "コレクションなし", + "noMatches": "検索条件に一致するデータベースがありません", + "databaseActions": "データベース{name}の操作", + "collectionActions": "コレクション{name}の操作", + "selectCollection": "コレクション{name}を選択", + "deleteSelected": "選択した{count}件を削除", + "clearSelection": "クリア", + "refresh": "更新", + "rename": "名前を変更", + "copyDbName": "データベース名をコピー", + "browseGridfs": "GridFSを閲覧", + "sync": "コレクションを同期", + "dropDatabase": "データベースを削除", + "dropCollection": "コレクションを削除", + "toast": { + "connectFailed": "{name}への接続に失敗しました", + "collectionsFailed": "{name}のコレクションの読み込みに失敗しました", + "dbNameCopied": "データベース名をコピーしました", + "nameEmpty": "名前を入力してください。", + "nameTooLong": "その名前は長すぎます。", + "nameForbiddenChars": "データベース名に / \\ . $ を含めることはできません", + "nameConsecutiveDots": "データベース名に連続したドットを含めることはできません。", + "nameNullChar": "コレクション名にnull文字を含めることはできません。", + "nameReservedPrefix": "コレクション名を$で始めることはできません — この接頭辞は予約されています。", + "nameInvalid": "その名前は無効です。", + "renamed": "{name}に名前を変更しました", + "renameFailed": "名前の変更に失敗しました", + "dropped": "{name}を削除しました", + "dropFailed": "{name}の削除に失敗しました", + "bulkDropped": "{count}件のコレクションを削除しました", + "bulkDropFailed": "{count}件のコレクションの削除に失敗しました" + } + } + }, "ApiClient": { "defaults": { "newRequest": "新しいリクエスト", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 7aaffd99..b4c09919 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON 포맷터", "emailValidator": "이메일 검증기", "nosqlExplorer": "데이터베이스 탐색기", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "암호화 연습장", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "MongoDB 데이터베이스를 탐색하세요." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "MongoDB와 Redis를 위한 하나의 작업 공간입니다. 암호화된 자격 증명 저장소로 찾아보고, 쿼리하고, 편집하세요." + }, "emailValidator": { "title": "이메일 검증기", "description": "이메일 주소를 검증하세요." @@ -1944,6 +1949,150 @@ "colCoverage": "커버리지" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "아직 연결이 없습니다", + "emptyBody": "MongoDB 또는 Redis 연결을 추가하여 탐색을 시작하세요. 자격 증명은 사용자의 기기에서 암호화됩니다.", + "addConnection": "새 연결", + "importLegacy": "기존 도구에서 가져오기", + "searchConnections": "연결 검색", + "openSidebar": "연결 열기", + "unsupportedSource": "지원되지 않는 소스", + "unsupportedSourceHint": "이 연결은 더 최신 버전의 앱에서 생성되었습니다.", + "noResults": "검색과 일치하는 연결이 없습니다.", + "connectionActions": "{name}에 대한 작업", + "menuEdit": "수정", + "menuDelete": "삭제", + "deleteTitle": "연결을 삭제하시겠습니까?", + "deleteBody": "{name} 및 저장된 자격 증명이 이 기기에서 제거됩니다. 이 작업은 되돌릴 수 없습니다.", + "deleteDocTitle": "문서를 삭제하시겠습니까?", + "deleteDocBody": "이 문서는 컬렉션에서 영구적으로 제거됩니다. 이 작업은 되돌릴 수 없습니다.", + "closeAll": "모두 닫기", + "noTabsTitle": "열려 있는 항목이 없습니다", + "noTabsBody": "사이드바에서 연결을 선택하여 탭을 여세요.", + "connectionDialog": { + "createTitle": "새 연결", + "editTitle": "연결 수정", + "pickSource": "데이터 소스", + "name": "이름", + "namePlaceholder": "내 연결", + "folder": "폴더", + "folderPlaceholder": "선택적 그룹", + "color": "색상", + "colorNone": "색상 없음", + "dbType": "데이터베이스 유형", + "connectionString": "연결 문자열", + "readOnly": "읽기 전용", + "readOnlyHint": "이 연결의 모든 쓰기 작업을 차단합니다.", + "test": "연결 테스트", + "testOk": "연결됨", + "testFailed": "연결하지 못했습니다", + "save": "저장", + "cancel": "취소" + }, + "importDialog": { + "title": "기존 도구에서 가져오기", + "loading": "Database Explorer 및 Redis Commander에서 연결을 찾는 중…", + "notReady": "기존 연결을 불러오는 중입니다 — 잠시 후 다시 시도하세요.", + "empty": "Database Explorer 또는 Redis Commander에서 연결을 찾을 수 없습니다.", + "allSkipped": "{count, plural, one {연결 #개가} other {연결 #개가}} 이미 가져와졌습니다. 새로 가져올 항목이 없습니다.", + "previewHeading": "{count, plural, one {연결 #개를 가져옵니다:} other {연결 #개를 가져옵니다:}}", + "skippedNote": "{count, plural, one {연결 #개는} other {연결 #개는}} 이미 가져와져서 건너뜁니다.", + "importing": "가져오는 중…", + "confirm": "{count, plural, one {연결 #개 가져오기} other {연결 #개 가져오기}}" + }, + "toast": { + "saved": "연결이 저장되었습니다", + "deleted": "연결이 삭제되었습니다", + "saveFailed": "연결을 저장하지 못했습니다", + "deleteFailed": "연결을 삭제하지 못했습니다", + "loadFailed": "연결을 불러오지 못했습니다", + "fetchFailed": "{collection}의 문서를 가져오는 데 실패했습니다", + "docDeleted": "문서가 삭제되었습니다", + "legacyImportFailed": "Database Explorer 또는 Redis Commander에서 기존 연결을 읽지 못했습니다", + "legacyImportResult": "{imported, plural, =0 {새로 가져온 연결이 없습니다} one {연결 #개 가져옴} other {연결 #개 가져옴}}{skipped, plural, =0 {} other {, #개 이미 가져옴}}{failed, plural, =0 {} other {, #개 실패}}" + }, + "validation": { + "connectionStringRequired": "연결 문자열을 입력하세요.", + "connectionStringScheme": "연결 문자열은 mongodb:// 또는 mongodb+srv://로 시작해야 합니다.", + "redisUrlRequired": "Redis URL을 입력하세요.", + "redisUrlScheme": "Redis URL은 redis:// 또는 rediss://로 시작해야 합니다." + }, + "redis": { + "urlMode": "URL", + "builderMode": "빌더", + "url": "Redis URL", + "urlPlaceholder": "redis://localhost:6379 또는 rediss://username:password@host:6380", + "urlHint": "redis:// 및 rediss://(TLS)를 지원합니다. 자격 증명은 저장 전에 암호화됩니다.", + "host": "호스트", + "port": "포트", + "username": "사용자 이름(ACL)", + "usernamePlaceholder": "default", + "password": "비밀번호", + "dbIndex": "DB 인덱스", + "dbIndexPlaceholder": "0", + "tls": "TLS 사용(rediss://)", + "dbLabel": "db {index}", + "noMatches": "일치하는 데이터베이스가 없습니다", + "tabKeyEditor": "키 편집기", + "tabConsole": "콘솔", + "tabServer": "서버", + "tabPubSub": "Pub/Sub", + "tabMonitor": "모니터", + "tabScanner": "스캐너", + "tabSearch": "검색", + "flush": "플러시", + "flushTitle": "키 플러시", + "flushDescription": "일치하는 키를 삭제하려면 패턴을 입력하고, 전체 데이터베이스를 플러시하려면 비워 두세요(DANGER_ZONE=true 필요).", + "flushPatternLabel": "패턴(선택 사항)", + "flushPatternPlaceholder": "예: session:* — 비워두면 FLUSHDB", + "flushing": "플러시 중…", + "flushedAll": "데이터베이스가 플러시되었습니다", + "flushedKeys": "{count, plural, one {키 #개 삭제됨} other {키 #개 삭제됨}}", + "flushFailed": "플러시 실패" + }, + "mongo": { + "refreshDatabases": "데이터베이스 새로고침", + "serverMonitor": "서버 모니터", + "connecting": "연결 중…", + "connectionFailed": "연결하지 못했습니다", + "noDatabases": "데이터베이스 없음", + "loadingCollections": "컬렉션을 불러오는 중…", + "collectionsFailed": "컬렉션을 불러오지 못했습니다", + "noCollections": "컬렉션 없음", + "noMatches": "검색과 일치하는 데이터베이스가 없습니다", + "databaseActions": "데이터베이스 {name}에 대한 작업", + "collectionActions": "컬렉션 {name}에 대한 작업", + "selectCollection": "컬렉션 {name} 선택", + "deleteSelected": "선택한 {count}개 삭제", + "clearSelection": "지우기", + "refresh": "새로고침", + "rename": "이름 변경", + "copyDbName": "데이터베이스 이름 복사", + "browseGridfs": "GridFS 찾아보기", + "sync": "컬렉션 동기화", + "dropDatabase": "데이터베이스 삭제", + "dropCollection": "컬렉션 삭제", + "toast": { + "connectFailed": "{name}에 연결하지 못했습니다", + "collectionsFailed": "{name}의 컬렉션을 불러오지 못했습니다", + "dbNameCopied": "데이터베이스 이름이 복사되었습니다", + "nameEmpty": "이름을 입력하세요.", + "nameTooLong": "이름이 너무 깁니다.", + "nameForbiddenChars": "데이터베이스 이름에는 / \\ . 또는 $를 포함할 수 없습니다", + "nameConsecutiveDots": "데이터베이스 이름에는 연속된 점을 포함할 수 없습니다.", + "nameNullChar": "컬렉션 이름에는 널 문자를 포함할 수 없습니다.", + "nameReservedPrefix": "컬렉션 이름은 $로 시작할 수 없습니다 — 해당 접두사는 예약되어 있습니다.", + "nameInvalid": "유효하지 않은 이름입니다.", + "renamed": "{name}(으)로 이름이 변경되었습니다", + "renameFailed": "이름을 변경하지 못했습니다", + "dropped": "{name} 삭제됨", + "dropFailed": "{name}을(를) 삭제하지 못했습니다", + "bulkDropped": "컬렉션 {count}개 삭제됨", + "bulkDropFailed": "컬렉션 {count}개를 삭제하지 못했습니다" + } + } + }, "ApiClient": { "defaults": { "newRequest": "새 요청", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index a416ab36..6b33f7b3 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -10,6 +10,7 @@ "jsonFormatter": "Pemformat JSON", "emailValidator": "Pengesah E-mel", "nosqlExplorer": "Penjelajah Pangkalan Data", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Taman permainan penyulitanan", @@ -354,6 +355,10 @@ "title": "Database Explorer", "description": "Terokai pangkalan data MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Satu ruang kerja untuk MongoDB dan Redis. Semak imbas, buat pertanyaan dan edit dengan storan kelayakan yang disulitkan." + }, "emailValidator": { "title": "Pengesah E-mel", "description": "Sahkan dan pengesahan alamat e-mel." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Belum ada sambungan", + "emptyBody": "Tambah sambungan MongoDB atau Redis untuk mula menyemak imbas. Kelayakan disulitkan pada peranti anda.", + "addConnection": "Sambungan baharu", + "importLegacy": "Import daripada alat sedia ada", + "searchConnections": "Cari sambungan", + "openSidebar": "Buka sambungan", + "unsupportedSource": "Sumber tidak disokong", + "unsupportedSourceHint": "Sambungan ini dicipta oleh versi aplikasi yang lebih baharu.", + "noResults": "Tiada sambungan sepadan dengan carian anda.", + "connectionActions": "Tindakan untuk {name}", + "menuEdit": "Edit", + "menuDelete": "Padam", + "deleteTitle": "Padam sambungan?", + "deleteBody": "{name} dan kelayakan tersimpannya akan dialih keluar daripada peranti ini. Tindakan ini tidak boleh dibuat asal.", + "deleteDocTitle": "Padam dokumen?", + "deleteDocBody": "Dokumen ini akan dialih keluar secara kekal daripada koleksi. Tindakan ini tidak boleh dibuat asal.", + "closeAll": "Tutup semua", + "noTabsTitle": "Tiada apa dibuka", + "noTabsBody": "Pilih sambungan dalam bar sisi untuk membuka tab.", + "connectionDialog": { + "createTitle": "Sambungan baharu", + "editTitle": "Edit sambungan", + "pickSource": "Sumber data", + "name": "Nama", + "namePlaceholder": "Sambungan saya", + "folder": "Folder", + "folderPlaceholder": "Kumpulan pilihan", + "color": "Warna", + "colorNone": "Tiada warna", + "dbType": "Jenis pangkalan data", + "connectionString": "Rentetan sambungan", + "readOnly": "Baca sahaja", + "readOnlyHint": "Menyekat semua operasi tulis untuk sambungan ini.", + "test": "Uji sambungan", + "testOk": "Disambungkan", + "testFailed": "Gagal menyambung", + "save": "Simpan", + "cancel": "Batal" + }, + "importDialog": { + "title": "Import daripada alat sedia ada", + "loading": "Mencari sambungan dalam Database Explorer dan Redis Commander…", + "notReady": "Masih memuatkan sambungan sedia ada anda — cuba lagi sebentar.", + "empty": "Tiada sambungan ditemui dalam Database Explorer atau Redis Commander.", + "allSkipped": "{count, plural, one {# sambungan telah} other {# sambungan telah}} diimport. Tiada yang baharu untuk diimport.", + "previewHeading": "{count, plural, one {# sambungan akan diimport:} other {# sambungan akan diimport:}}", + "skippedNote": "{count, plural, one {# sambungan telah diimport dan akan dilangkau} other {# sambungan telah diimport dan akan dilangkau}}.", + "importing": "Mengimport…", + "confirm": "Import {count, plural, one {# sambungan} other {# sambungan}}" + }, + "toast": { + "saved": "Sambungan disimpan", + "deleted": "Sambungan dipadam", + "saveFailed": "Gagal menyimpan sambungan", + "deleteFailed": "Gagal memadam sambungan", + "loadFailed": "Gagal memuatkan sambungan", + "fetchFailed": "Gagal mengambil dokumen untuk {collection}", + "docDeleted": "Dokumen dipadam", + "legacyImportFailed": "Tidak dapat membaca sambungan sedia ada daripada Database Explorer atau Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Tiada sambungan baharu diimport} one {# sambungan diimport} other {# sambungan diimport}}{skipped, plural, =0 {} other {, # telah diimport}}{failed, plural, =0 {} other {, # gagal}}" + }, + "validation": { + "connectionStringRequired": "Masukkan rentetan sambungan.", + "connectionStringScheme": "Rentetan sambungan mesti bermula dengan mongodb:// atau mongodb+srv://.", + "redisUrlRequired": "Masukkan URL Redis.", + "redisUrlScheme": "URL Redis mesti bermula dengan redis:// atau rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Pembina", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 atau rediss://namapengguna:katalaluan@hos:6380", + "urlHint": "Menyokong redis:// dan rediss:// (TLS). Kelayakan disulitkan sebelum disimpan.", + "host": "Hos", + "port": "Port", + "username": "Nama pengguna (ACL)", + "usernamePlaceholder": "default", + "password": "Kata laluan", + "dbIndex": "Indeks DB", + "dbIndexPlaceholder": "0", + "tls": "Guna TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Tiada pangkalan data sepadan", + "tabKeyEditor": "Editor Kunci", + "tabConsole": "Konsol", + "tabServer": "Pelayan", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Pengimbas", + "tabSearch": "Cari", + "flush": "Kosongkan", + "flushTitle": "Kosongkan Kunci", + "flushDescription": "Masukkan corak untuk memadam kunci yang sepadan, atau biarkan kosong untuk mengosongkan seluruh pangkalan data (memerlukan DANGER_ZONE=true).", + "flushPatternLabel": "Corak (pilihan)", + "flushPatternPlaceholder": "cth. session:* — kosong = FLUSHDB", + "flushing": "Mengosongkan…", + "flushedAll": "Pangkalan data dikosongkan", + "flushedKeys": "{count, plural, one {# kunci dipadam} other {# kunci dipadam}}", + "flushFailed": "Gagal mengosongkan" + }, + "mongo": { + "refreshDatabases": "Segar semula pangkalan data", + "serverMonitor": "Monitor pelayan", + "connecting": "Menyambung…", + "connectionFailed": "Gagal menyambung", + "noDatabases": "Tiada pangkalan data", + "loadingCollections": "Memuatkan koleksi…", + "collectionsFailed": "Gagal memuatkan koleksi", + "noCollections": "Tiada koleksi", + "noMatches": "Tiada pangkalan data sepadan dengan carian anda", + "databaseActions": "Tindakan untuk pangkalan data {name}", + "collectionActions": "Tindakan untuk koleksi {name}", + "selectCollection": "Pilih koleksi {name}", + "deleteSelected": "Padam {count} dipilih", + "clearSelection": "Kosongkan", + "refresh": "Segar semula", + "rename": "Namakan semula", + "copyDbName": "Salin nama pangkalan data", + "browseGridfs": "Semak imbas GridFS", + "sync": "Segerakkan koleksi", + "dropDatabase": "Gugurkan pangkalan data", + "dropCollection": "Gugurkan koleksi", + "toast": { + "connectFailed": "Gagal menyambung ke {name}", + "collectionsFailed": "Gagal memuatkan koleksi untuk {name}", + "dbNameCopied": "Nama pangkalan data disalin", + "nameEmpty": "Masukkan nama.", + "nameTooLong": "Nama itu terlalu panjang.", + "nameForbiddenChars": "Nama pangkalan data tidak boleh mengandungi / \\ . atau $", + "nameConsecutiveDots": "Nama pangkalan data tidak boleh mengandungi titik berturut-turut.", + "nameNullChar": "Nama koleksi tidak boleh mengandungi aksara null.", + "nameReservedPrefix": "Nama koleksi tidak boleh bermula dengan $ — awalan itu dikhaskan.", + "nameInvalid": "Nama itu tidak sah.", + "renamed": "Dinamakan semula kepada {name}", + "renameFailed": "Gagal menamakan semula", + "dropped": "{name} digugurkan", + "dropFailed": "Gagal menggugurkan {name}", + "bulkDropped": "{count} koleksi digugurkan", + "bulkDropFailed": "Gagal menggugurkan {count} koleksi" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Permintaan Baharu", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index 245d980d..87067334 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON-formaterer", "emailValidator": "E-postvalidering", "nosqlExplorer": "Databaseutforsker", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Krypteringslekeplass", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Utforsk MongoDB-databaser." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Ett arbeidsområde for MongoDB og Redis. Bla gjennom, spør og rediger med kryptert lagring av legitimasjon." + }, "emailValidator": { "title": "E-postvalidering", "description": "Verifiser og valider e-postadresser." @@ -1944,6 +1949,150 @@ "colCoverage": "Dekning" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Ingen tilkoblinger ennå", + "emptyBody": "Legg til en MongoDB- eller Redis-tilkobling for å begynne å utforske. Legitimasjon krypteres på enheten din.", + "addConnection": "Ny tilkobling", + "importLegacy": "Importer fra eksisterende verktøy", + "searchConnections": "Søk i tilkoblinger", + "openSidebar": "Åpne tilkoblinger", + "unsupportedSource": "Ikke-støttet kilde", + "unsupportedSourceHint": "Denne tilkoblingen ble opprettet av en nyere versjon av appen.", + "noResults": "Ingen tilkoblinger samsvarer med søket ditt.", + "connectionActions": "Handlinger for {name}", + "menuEdit": "Rediger", + "menuDelete": "Slett", + "deleteTitle": "Slette tilkobling?", + "deleteBody": "{name} og tilhørende lagrede legitimasjon fjernes fra denne enheten. Dette kan ikke angres.", + "deleteDocTitle": "Slette dokument?", + "deleteDocBody": "Dette dokumentet fjernes permanent fra samlingen. Dette kan ikke angres.", + "closeAll": "Lukk alle", + "noTabsTitle": "Ingenting åpent", + "noTabsBody": "Velg en tilkobling i sidefeltet for å åpne en fane.", + "connectionDialog": { + "createTitle": "Ny tilkobling", + "editTitle": "Rediger tilkobling", + "pickSource": "Datakilde", + "name": "Navn", + "namePlaceholder": "Min tilkobling", + "folder": "Mappe", + "folderPlaceholder": "Valgfri gruppe", + "color": "Farge", + "colorNone": "Ingen farge", + "dbType": "Databasetype", + "connectionString": "Tilkoblingsstreng", + "readOnly": "Skrivebeskyttet", + "readOnlyHint": "Blokkerer alle skriveoperasjoner for denne tilkoblingen.", + "test": "Test tilkobling", + "testOk": "Tilkoblet", + "testFailed": "Kunne ikke koble til", + "save": "Lagre", + "cancel": "Avbryt" + }, + "importDialog": { + "title": "Importer fra eksisterende verktøy", + "loading": "Ser etter tilkoblinger i Database Explorer og Redis Commander…", + "notReady": "Laster fortsatt inn de eksisterende tilkoblingene dine — prøv igjen om et øyeblikk.", + "empty": "Fant ingen tilkoblinger i Database Explorer eller Redis Commander.", + "allSkipped": "{count, plural, one {# tilkobling er} other {# tilkoblinger er}} allerede importert. Ingenting nytt å hente inn.", + "previewHeading": "{count, plural, one {# tilkobling} other {# tilkoblinger}} vil bli importert:", + "skippedNote": "{count, plural, one {# tilkobling ble} other {# tilkoblinger ble}} allerede importert og vil bli hoppet over.", + "importing": "Importerer…", + "confirm": "Importer {count, plural, one {# tilkobling} other {# tilkoblinger}}" + }, + "toast": { + "saved": "Tilkobling lagret", + "deleted": "Tilkobling slettet", + "saveFailed": "Kunne ikke lagre tilkoblingen", + "deleteFailed": "Kunne ikke slette tilkoblingen", + "loadFailed": "Kunne ikke laste tilkoblinger", + "fetchFailed": "Kunne ikke hente dokumenter for {collection}", + "docDeleted": "Dokument slettet", + "legacyImportFailed": "Kunne ikke lese eksisterende tilkoblinger fra Database Explorer eller Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Ingen nye tilkoblinger importert} one {Importerte # tilkobling} other {Importerte # tilkoblinger}}{skipped, plural, =0 {} other {, # allerede importert}}{failed, plural, =0 {} other {, # feilet}}" + }, + "validation": { + "connectionStringRequired": "Skriv inn en tilkoblingsstreng.", + "connectionStringScheme": "Tilkoblingsstrengen må starte med mongodb:// eller mongodb+srv://.", + "redisUrlRequired": "Skriv inn en Redis-URL.", + "redisUrlScheme": "Redis-URL-en må starte med redis:// eller rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Bygger", + "url": "Redis-URL", + "urlPlaceholder": "redis://localhost:6379 eller rediss://bruker:passord@host:6380", + "urlHint": "Støtter redis:// og rediss:// (TLS). Legitimasjon krypteres før lagring.", + "host": "Vert", + "port": "Port", + "username": "Brukernavn (ACL)", + "usernamePlaceholder": "default", + "password": "Passord", + "dbIndex": "DB-indeks", + "dbIndexPlaceholder": "0", + "tls": "Bruk TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Ingen samsvarende databaser", + "tabKeyEditor": "Nøkkelredigering", + "tabConsole": "Konsoll", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Skanner", + "tabSearch": "Søk", + "flush": "Tøm", + "flushTitle": "Tøm nøkler", + "flushDescription": "Skriv inn et mønster for å slette samsvarende nøkler, eller la stå tomt for å tømme hele databasen (krever DANGER_ZONE=true).", + "flushPatternLabel": "Mønster (valgfritt)", + "flushPatternPlaceholder": "f.eks. session:* — tomt = FLUSHDB", + "flushing": "Tømmer…", + "flushedAll": "Database tømt", + "flushedKeys": "{count, plural, one {# nøkkel slettet} other {# nøkler slettet}}", + "flushFailed": "Tømming mislyktes" + }, + "mongo": { + "refreshDatabases": "Oppdater databaser", + "serverMonitor": "Servermonitor", + "connecting": "Kobler til…", + "connectionFailed": "Kunne ikke koble til", + "noDatabases": "Ingen databaser", + "loadingCollections": "Laster samlinger…", + "collectionsFailed": "Kunne ikke laste samlinger", + "noCollections": "Ingen samlinger", + "noMatches": "Ingen databaser samsvarer med søket ditt", + "databaseActions": "Handlinger for database {name}", + "collectionActions": "Handlinger for samling {name}", + "selectCollection": "Velg samling {name}", + "deleteSelected": "Slett {count} valgte", + "clearSelection": "Fjern valg", + "refresh": "Oppdater", + "rename": "Gi nytt navn", + "copyDbName": "Kopier databasenavn", + "browseGridfs": "Bla gjennom GridFS", + "sync": "Synkroniser samling", + "dropDatabase": "Slett database", + "dropCollection": "Slett samling", + "toast": { + "connectFailed": "Kunne ikke koble til {name}", + "collectionsFailed": "Kunne ikke laste samlinger for {name}", + "dbNameCopied": "Databasenavn kopiert", + "nameEmpty": "Skriv inn et navn.", + "nameTooLong": "Det navnet er for langt.", + "nameForbiddenChars": "Et databasenavn kan ikke inneholde / \\ . eller $", + "nameConsecutiveDots": "Et databasenavn kan ikke inneholde påfølgende punktum.", + "nameNullChar": "Et samlingsnavn kan ikke inneholde et nulltegn.", + "nameReservedPrefix": "Et samlingsnavn kan ikke starte med $ — det prefikset er reservert.", + "nameInvalid": "Det navnet er ikke gyldig.", + "renamed": "Endret navn til {name}", + "renameFailed": "Kunne ikke endre navn", + "dropped": "Slettet {name}", + "dropFailed": "Kunne ikke slette {name}", + "bulkDropped": "Slettet {count} samlinger", + "bulkDropFailed": "Kunne ikke slette {count} samlinger" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Ny forespørsel", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 5bb1d08b..9308c445 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON-formatter", "emailValidator": "E-mailvalidator", "nosqlExplorer": "Databaseverkenner", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Encryptie-speeltuin", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Verken MongoDB-databases." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Eén werkruimte voor MongoDB en Redis. Blader, doorzoek en bewerk gegevens met een versleutelde inloggegevensopslag." + }, "emailValidator": { "title": "E-mailvalidator", "description": "Verifieer en valideer e-mailadressen." @@ -1944,6 +1949,150 @@ "colCoverage": "Dekking" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Nog geen verbindingen", + "emptyBody": "Voeg een MongoDB- of Redis-verbinding toe om te beginnen met bladeren. Inloggegevens worden versleuteld op je apparaat opgeslagen.", + "addConnection": "Nieuwe verbinding", + "importLegacy": "Importeren vanuit bestaande tools", + "searchConnections": "Verbindingen zoeken", + "openSidebar": "Verbindingen openen", + "unsupportedSource": "Niet-ondersteunde bron", + "unsupportedSourceHint": "Deze verbinding is aangemaakt met een nieuwere versie van de app.", + "noResults": "Geen verbindingen komen overeen met je zoekopdracht.", + "connectionActions": "Acties voor {name}", + "menuEdit": "Bewerken", + "menuDelete": "Verwijderen", + "deleteTitle": "Verbinding verwijderen?", + "deleteBody": "{name} en de opgeslagen inloggegevens worden van dit apparaat verwijderd. Dit kan niet ongedaan worden gemaakt.", + "deleteDocTitle": "Document verwijderen?", + "deleteDocBody": "Dit document wordt permanent uit de collectie verwijderd. Dit kan niet ongedaan worden gemaakt.", + "closeAll": "Alles sluiten", + "noTabsTitle": "Niets geopend", + "noTabsBody": "Kies een verbinding in de zijbalk om een tabblad te openen.", + "connectionDialog": { + "createTitle": "Nieuwe verbinding", + "editTitle": "Verbinding bewerken", + "pickSource": "Gegevensbron", + "name": "Naam", + "namePlaceholder": "Mijn verbinding", + "folder": "Map", + "folderPlaceholder": "Optionele groep", + "color": "Kleur", + "colorNone": "Geen kleur", + "dbType": "Databasetype", + "connectionString": "Verbindingsreeks", + "readOnly": "Alleen-lezen", + "readOnlyHint": "Blokkeert elke schrijfbewerking voor deze verbinding.", + "test": "Verbinding testen", + "testOk": "Verbonden", + "testFailed": "Kan geen verbinding maken", + "save": "Opslaan", + "cancel": "Annuleren" + }, + "importDialog": { + "title": "Importeren vanuit bestaande tools", + "loading": "Verbindingen zoeken in Database Explorer en Redis Commander…", + "notReady": "Je bestaande verbindingen worden nog geladen — probeer het straks opnieuw.", + "empty": "Geen verbindingen gevonden in Database Explorer of Redis Commander.", + "allSkipped": "{count, plural, one {# verbinding is al geïmporteerd} other {# verbindingen zijn al geïmporteerd}}. Niets nieuws om te importeren.", + "previewHeading": "{count, plural, one {# verbinding wordt geïmporteerd:} other {# verbindingen worden geïmporteerd:}}", + "skippedNote": "{count, plural, one {# verbinding werd al geïmporteerd en wordt overgeslagen.} other {# verbindingen werden al geïmporteerd en worden overgeslagen.}}", + "importing": "Importeren…", + "confirm": "{count, plural, one {# verbinding importeren} other {# verbindingen importeren}}" + }, + "toast": { + "saved": "Verbinding opgeslagen", + "deleted": "Verbinding verwijderd", + "saveFailed": "Kan de verbinding niet opslaan", + "deleteFailed": "Kan de verbinding niet verwijderen", + "loadFailed": "Kan verbindingen niet laden", + "fetchFailed": "Documenten ophalen mislukt voor {collection}", + "docDeleted": "Document verwijderd", + "legacyImportFailed": "Kan bestaande verbindingen niet lezen vanuit Database Explorer of Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Geen nieuwe verbindingen geïmporteerd} one {# verbinding geïmporteerd} other {# verbindingen geïmporteerd}}{skipped, plural, =0 {} other {, # al geïmporteerd}}{failed, plural, =0 {} other {, # mislukt}}" + }, + "validation": { + "connectionStringRequired": "Voer een verbindingsreeks in.", + "connectionStringScheme": "De verbindingsreeks moet beginnen met mongodb:// of mongodb+srv://.", + "redisUrlRequired": "Voer een Redis-URL in.", + "redisUrlScheme": "De Redis-URL moet beginnen met redis:// of rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Bouwer", + "url": "Redis-URL", + "urlPlaceholder": "redis://localhost:6379 of rediss://user:pass@host:6380", + "urlHint": "Ondersteunt redis:// en rediss:// (TLS). Inloggegevens worden versleuteld voordat ze worden opgeslagen.", + "host": "Host", + "port": "Poort", + "username": "Gebruikersnaam (ACL)", + "usernamePlaceholder": "default", + "password": "Wachtwoord", + "dbIndex": "DB-index", + "dbIndexPlaceholder": "0", + "tls": "TLS gebruiken (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Geen overeenkomende databases", + "tabKeyEditor": "Sleuteleditor", + "tabConsole": "Console", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Zoeken", + "flush": "Wissen", + "flushTitle": "Sleutels wissen", + "flushDescription": "Voer een patroon in om overeenkomende sleutels te verwijderen, of laat leeg om de volledige database te wissen (vereist DANGER_ZONE=true).", + "flushPatternLabel": "Patroon (optioneel)", + "flushPatternPlaceholder": "bijv. session:* — leeg = FLUSHDB", + "flushing": "Wissen…", + "flushedAll": "Database gewist", + "flushedKeys": "{count, plural, one {# sleutel verwijderd} other {# sleutels verwijderd}}", + "flushFailed": "Wissen mislukt" + }, + "mongo": { + "refreshDatabases": "Databases vernieuwen", + "serverMonitor": "Servermonitor", + "connecting": "Verbinden…", + "connectionFailed": "Kan geen verbinding maken", + "noDatabases": "Geen databases", + "loadingCollections": "Collecties laden…", + "collectionsFailed": "Kan collecties niet laden", + "noCollections": "Geen collecties", + "noMatches": "Geen databases komen overeen met je zoekopdracht", + "databaseActions": "Acties voor database {name}", + "collectionActions": "Acties voor collectie {name}", + "selectCollection": "Selecteer collectie {name}", + "deleteSelected": "{count} geselecteerde verwijderen", + "clearSelection": "Wissen", + "refresh": "Vernieuwen", + "rename": "Hernoemen", + "copyDbName": "Databasenaam kopiëren", + "browseGridfs": "GridFS bekijken", + "sync": "Collectie synchroniseren", + "dropDatabase": "Database verwijderen", + "dropCollection": "Collectie verwijderen", + "toast": { + "connectFailed": "Kan geen verbinding maken met {name}", + "collectionsFailed": "Kan collecties niet laden voor {name}", + "dbNameCopied": "Databasenaam gekopieerd", + "nameEmpty": "Voer een naam in.", + "nameTooLong": "Die naam is te lang.", + "nameForbiddenChars": "Een databasenaam mag geen / \\ . of $ bevatten", + "nameConsecutiveDots": "Een databasenaam mag geen opeenvolgende punten bevatten.", + "nameNullChar": "Een collectienaam mag geen null-teken bevatten.", + "nameReservedPrefix": "Een collectienaam mag niet beginnen met $ — dat voorvoegsel is gereserveerd.", + "nameInvalid": "Die naam is ongeldig.", + "renamed": "Hernoemd naar {name}", + "renameFailed": "Kan niet hernoemen", + "dropped": "{name} verwijderd", + "dropFailed": "Kan {name} niet verwijderen", + "bulkDropped": "{count} collecties verwijderd", + "bulkDropFailed": "Kan {count} collecties niet verwijderen" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nieuw verzoek", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 3c49c6b4..a47c0004 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -10,6 +10,7 @@ "jsonFormatter": "Formatowanie JSON", "emailValidator": "Email Validator", "nosqlExplorer": "Eksplorator baz danych", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Piaskownica szyfrowania", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Explore MongoDB and SQL databases." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Jedna przestrzeń robocza dla MongoDB i Redis. Przeglądaj, odpytuj i edytuj dane dzięki szyfrowanemu magazynowi poświadczeń." + }, "emailValidator": { "title": "Email Validator", "description": "Verify and validate email addresses." @@ -1944,6 +1949,150 @@ "colCoverage": "Pokrycie" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Brak połączeń", + "emptyBody": "Dodaj połączenie z MongoDB lub Redis, aby rozpocząć przeglądanie. Dane uwierzytelniające są szyfrowane na Twoim urządzeniu.", + "addConnection": "Nowe połączenie", + "importLegacy": "Importuj z istniejących narzędzi", + "searchConnections": "Szukaj połączeń", + "openSidebar": "Otwórz połączenia", + "unsupportedSource": "Nieobsługiwane źródło", + "unsupportedSourceHint": "To połączenie zostało utworzone przez nowszą wersję aplikacji.", + "noResults": "Żadne połączenia nie pasują do wyszukiwania.", + "connectionActions": "Akcje dla {name}", + "menuEdit": "Edytuj", + "menuDelete": "Usuń", + "deleteTitle": "Usunąć połączenie?", + "deleteBody": "{name} i zapisane dane uwierzytelniające zostaną usunięte z tego urządzenia. Tej operacji nie można cofnąć.", + "deleteDocTitle": "Usunąć dokument?", + "deleteDocBody": "Ten dokument zostanie trwale usunięty z kolekcji. Tej operacji nie można cofnąć.", + "closeAll": "Zamknij wszystko", + "noTabsTitle": "Nic nie jest otwarte", + "noTabsBody": "Wybierz połączenie w panelu bocznym, aby otworzyć kartę.", + "connectionDialog": { + "createTitle": "Nowe połączenie", + "editTitle": "Edytuj połączenie", + "pickSource": "Źródło danych", + "name": "Nazwa", + "namePlaceholder": "Moje połączenie", + "folder": "Folder", + "folderPlaceholder": "Opcjonalna grupa", + "color": "Kolor", + "colorNone": "Bez koloru", + "dbType": "Typ bazy danych", + "connectionString": "Ciąg połączenia", + "readOnly": "Tylko do odczytu", + "readOnlyHint": "Blokuje każdą operację zapisu dla tego połączenia.", + "test": "Testuj połączenie", + "testOk": "Połączono", + "testFailed": "Nie udało się połączyć", + "save": "Zapisz", + "cancel": "Anuluj" + }, + "importDialog": { + "title": "Importuj z istniejących narzędzi", + "loading": "Szukanie połączeń w Database Explorer i Redis Commander…", + "notReady": "Wciąż wczytujemy Twoje istniejące połączenia — spróbuj ponownie za chwilę.", + "empty": "Nie znaleziono połączeń w Database Explorer ani Redis Commander.", + "allSkipped": "Już zaimportowano {count, plural, one {# połączenie} few {# połączenia} many {# połączeń} other {# połączenia}}. Nie ma nic nowego do zaimportowania.", + "previewHeading": "{count, plural, one {Zostanie zaimportowane # połączenie:} few {Zostaną zaimportowane # połączenia:} many {Zostanie zaimportowanych # połączeń:} other {Zostanie zaimportowanych # połączenia:}}", + "skippedNote": "{count, plural, one {# połączenie zostało już zaimportowane i zostanie pominięte.} few {# połączenia zostały już zaimportowane i zostaną pominięte.} many {# połączeń zostało już zaimportowanych i zostanie pominiętych.} other {# połączenia zostało już zaimportowane i zostanie pominięte.}}", + "importing": "Importowanie…", + "confirm": "Importuj {count, plural, one {# połączenie} few {# połączenia} many {# połączeń} other {# połączenia}}" + }, + "toast": { + "saved": "Połączenie zapisane", + "deleted": "Połączenie usunięte", + "saveFailed": "Nie udało się zapisać połączenia", + "deleteFailed": "Nie udało się usunąć połączenia", + "loadFailed": "Nie udało się wczytać połączeń", + "fetchFailed": "Nie udało się pobrać dokumentów dla {collection}", + "docDeleted": "Dokument usunięty", + "legacyImportFailed": "Nie udało się odczytać istniejących połączeń z Database Explorer ani Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Nie zaimportowano żadnych nowych połączeń} one {Zaimportowano # połączenie} few {Zaimportowano # połączenia} many {Zaimportowano # połączeń} other {Zaimportowano # połączenia}}{skipped, plural, =0 {} one {, # już zaimportowane} few {, # już zaimportowane} many {, # już zaimportowanych} other {, # już zaimportowanych}}{failed, plural, =0 {} one {, # nieudane} few {, # nieudane} many {, # nieudanych} other {, # nieudanych}}" + }, + "validation": { + "connectionStringRequired": "Wprowadź ciąg połączenia.", + "connectionStringScheme": "Ciąg połączenia musi zaczynać się od mongodb:// lub mongodb+srv://.", + "redisUrlRequired": "Wprowadź URL Redis.", + "redisUrlScheme": "URL Redis musi zaczynać się od redis:// lub rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Kreator", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 lub rediss://user:pass@host:6380", + "urlHint": "Obsługuje redis:// oraz rediss:// (TLS). Dane uwierzytelniające są szyfrowane przed zapisaniem.", + "host": "Host", + "port": "Port", + "username": "Nazwa użytkownika (ACL)", + "usernamePlaceholder": "default", + "password": "Hasło", + "dbIndex": "Indeks DB", + "dbIndexPlaceholder": "0", + "tls": "Użyj TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Brak pasujących baz danych", + "tabKeyEditor": "Edytor kluczy", + "tabConsole": "Konsola", + "tabServer": "Serwer", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Skaner", + "tabSearch": "Szukaj", + "flush": "Wyczyść", + "flushTitle": "Wyczyść klucze", + "flushDescription": "Wprowadź wzorzec, aby usunąć pasujące klucze, lub zostaw puste, aby wyczyścić całą bazę danych (wymaga DANGER_ZONE=true).", + "flushPatternLabel": "Wzorzec (opcjonalnie)", + "flushPatternPlaceholder": "np. session:* — puste = FLUSHDB", + "flushing": "Czyszczenie…", + "flushedAll": "Baza danych wyczyszczona", + "flushedKeys": "{count, plural, one {Usunięto # klucz} few {Usunięto # klucze} many {Usunięto # kluczy} other {Usunięto # klucza}}", + "flushFailed": "Czyszczenie nie powiodło się" + }, + "mongo": { + "refreshDatabases": "Odśwież bazy danych", + "serverMonitor": "Monitor serwera", + "connecting": "Łączenie…", + "connectionFailed": "Nie udało się połączyć", + "noDatabases": "Brak baz danych", + "loadingCollections": "Wczytywanie kolekcji…", + "collectionsFailed": "Nie udało się wczytać kolekcji", + "noCollections": "Brak kolekcji", + "noMatches": "Żadne bazy danych nie pasują do wyszukiwania", + "databaseActions": "Akcje dla bazy danych {name}", + "collectionActions": "Akcje dla kolekcji {name}", + "selectCollection": "Wybierz kolekcję {name}", + "deleteSelected": "Usuń {count, plural, one {# kolekcję} few {# kolekcje} many {# kolekcji} other {# kolekcji}}", + "clearSelection": "Wyczyść", + "refresh": "Odśwież", + "rename": "Zmień nazwę", + "copyDbName": "Kopiuj nazwę bazy danych", + "browseGridfs": "Przeglądaj GridFS", + "sync": "Synchronizuj kolekcję", + "dropDatabase": "Usuń bazę danych", + "dropCollection": "Usuń kolekcję", + "toast": { + "connectFailed": "Nie udało się połączyć z {name}", + "collectionsFailed": "Nie udało się wczytać kolekcji dla {name}", + "dbNameCopied": "Skopiowano nazwę bazy danych", + "nameEmpty": "Wprowadź nazwę.", + "nameTooLong": "Ta nazwa jest za długa.", + "nameForbiddenChars": "Nazwa bazy danych nie może zawierać znaków / \\ . ani $", + "nameConsecutiveDots": "Nazwa bazy danych nie może zawierać kolejnych po sobie kropek.", + "nameNullChar": "Nazwa kolekcji nie może zawierać znaku null.", + "nameReservedPrefix": "Nazwa kolekcji nie może zaczynać się od $ — ten prefiks jest zarezerwowany.", + "nameInvalid": "Ta nazwa jest nieprawidłowa.", + "renamed": "Zmieniono nazwę na {name}", + "renameFailed": "Nie udało się zmienić nazwy", + "dropped": "Usunięto {name}", + "dropFailed": "Nie udało się usunąć {name}", + "bulkDropped": "{count, plural, one {Usunięto # kolekcję} few {Usunięto # kolekcje} many {Usunięto # kolekcji} other {Usunięto # kolekcji}}", + "bulkDropFailed": "{count, plural, one {Nie udało się usunąć # kolekcji} few {Nie udało się usunąć # kolekcji} many {Nie udało się usunąć # kolekcji} other {Nie udało się usunąć # kolekcji}}" + } + } + }, "ApiClient": { "defaults": { "newRequest": "New Request", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index c208a94d..12bb4dc7 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -10,6 +10,7 @@ "jsonFormatter": "Formatador JSON", "emailValidator": "Validador de E-mail", "nosqlExplorer": "Explorador de banco de dados", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Laboratório de criptografia", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Explore bancos de dados MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Um espaço de trabalho único para MongoDB e Redis. Navegue, consulte e edite com um armazenamento de credenciais criptografado." + }, "emailValidator": { "title": "Validador de E-mail", "description": "Verifique e valide endereços de e-mail." @@ -1944,6 +1949,150 @@ "colCoverage": "Cobertura" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Nenhuma conexão ainda", + "emptyBody": "Adicione uma conexão MongoDB ou Redis para começar a navegar. As credenciais são criptografadas no seu dispositivo.", + "addConnection": "Nova conexão", + "importLegacy": "Importar de ferramentas existentes", + "searchConnections": "Buscar conexões", + "openSidebar": "Abrir conexões", + "unsupportedSource": "Fonte não suportada", + "unsupportedSourceHint": "Esta conexão foi criada por uma versão mais recente do aplicativo.", + "noResults": "Nenhuma conexão corresponde à sua busca.", + "connectionActions": "Ações para {name}", + "menuEdit": "Editar", + "menuDelete": "Excluir", + "deleteTitle": "Excluir conexão?", + "deleteBody": "{name} e suas credenciais salvas serão removidas deste dispositivo. Essa ação não pode ser desfeita.", + "deleteDocTitle": "Excluir documento?", + "deleteDocBody": "Este documento será removido permanentemente da coleção. Essa ação não pode ser desfeita.", + "closeAll": "Fechar todas", + "noTabsTitle": "Nada aberto", + "noTabsBody": "Escolha uma conexão na barra lateral para abrir uma aba.", + "connectionDialog": { + "createTitle": "Nova conexão", + "editTitle": "Editar conexão", + "pickSource": "Fonte de dados", + "name": "Nome", + "namePlaceholder": "Minha conexão", + "folder": "Pasta", + "folderPlaceholder": "Grupo opcional", + "color": "Cor", + "colorNone": "Sem cor", + "dbType": "Tipo de banco de dados", + "connectionString": "String de conexão", + "readOnly": "Somente leitura", + "readOnlyHint": "Bloqueia toda operação de escrita para esta conexão.", + "test": "Testar conexão", + "testOk": "Conectado", + "testFailed": "Não foi possível conectar", + "save": "Salvar", + "cancel": "Cancelar" + }, + "importDialog": { + "title": "Importar de ferramentas existentes", + "loading": "Procurando conexões no Database Explorer e no Redis Commander…", + "notReady": "Ainda carregando suas conexões existentes — tente novamente em instantes.", + "empty": "Nenhuma conexão encontrada no Database Explorer ou no Redis Commander.", + "allSkipped": "{count, plural, one {# conexão já foi importada} other {# conexões já foram importadas}}. Não há nada novo para trazer.", + "previewHeading": "{count, plural, one {# conexão será importada:} other {# conexões serão importadas:}}", + "skippedNote": "{count, plural, one {# conexão já foi importada e será ignorada.} other {# conexões já foram importadas e serão ignoradas.}}", + "importing": "Importando…", + "confirm": "Importar {count, plural, one {# conexão} other {# conexões}}" + }, + "toast": { + "saved": "Conexão salva", + "deleted": "Conexão excluída", + "saveFailed": "Não foi possível salvar a conexão", + "deleteFailed": "Não foi possível excluir a conexão", + "loadFailed": "Não foi possível carregar as conexões", + "fetchFailed": "Falha ao buscar documentos para {collection}", + "docDeleted": "Documento excluído", + "legacyImportFailed": "Não foi possível ler as conexões existentes do Database Explorer ou do Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Nenhuma conexão nova importada} one {Importada # conexão} other {Importadas # conexões}}{skipped, plural, =0 {} one {, # já importada} other {, # já importadas}}{failed, plural, =0 {} other {, # com falha}}" + }, + "validation": { + "connectionStringRequired": "Digite uma string de conexão.", + "connectionStringScheme": "A string de conexão deve começar com mongodb:// ou mongodb+srv://.", + "redisUrlRequired": "Digite uma URL do Redis.", + "redisUrlScheme": "A URL do Redis deve começar com redis:// ou rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Construtor", + "url": "URL do Redis", + "urlPlaceholder": "redis://localhost:6379 ou rediss://user:pass@host:6380", + "urlHint": "Suporta redis:// e rediss:// (TLS). As credenciais são criptografadas antes de serem armazenadas.", + "host": "Host", + "port": "Porta", + "username": "Usuário (ACL)", + "usernamePlaceholder": "default", + "password": "Senha", + "dbIndex": "Índice do DB", + "dbIndexPlaceholder": "0", + "tls": "Usar TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Nenhum banco de dados correspondente", + "tabKeyEditor": "Editor de Chaves", + "tabConsole": "Console", + "tabServer": "Servidor", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Buscar", + "flush": "Limpar", + "flushTitle": "Limpar Chaves", + "flushDescription": "Digite um padrão para excluir as chaves correspondentes, ou deixe em branco para limpar todo o banco de dados (requer DANGER_ZONE=true).", + "flushPatternLabel": "Padrão (opcional)", + "flushPatternPlaceholder": "ex.: session:* — em branco = FLUSHDB", + "flushing": "Limpando…", + "flushedAll": "Banco de dados limpo", + "flushedKeys": "{count, plural, one {# chave excluída} other {# chaves excluídas}}", + "flushFailed": "Falha ao limpar" + }, + "mongo": { + "refreshDatabases": "Atualizar bancos de dados", + "serverMonitor": "Monitor do servidor", + "connecting": "Conectando…", + "connectionFailed": "Não foi possível conectar", + "noDatabases": "Nenhum banco de dados", + "loadingCollections": "Carregando coleções…", + "collectionsFailed": "Não foi possível carregar as coleções", + "noCollections": "Nenhuma coleção", + "noMatches": "Nenhum banco de dados corresponde à sua busca", + "databaseActions": "Ações para o banco de dados {name}", + "collectionActions": "Ações para a coleção {name}", + "selectCollection": "Selecionar coleção {name}", + "deleteSelected": "Excluir {count} selecionadas", + "clearSelection": "Limpar", + "refresh": "Atualizar", + "rename": "Renomear", + "copyDbName": "Copiar nome do banco de dados", + "browseGridfs": "Navegar pelo GridFS", + "sync": "Sincronizar coleção", + "dropDatabase": "Descartar banco de dados", + "dropCollection": "Descartar coleção", + "toast": { + "connectFailed": "Não foi possível conectar a {name}", + "collectionsFailed": "Não foi possível carregar as coleções de {name}", + "dbNameCopied": "Nome do banco de dados copiado", + "nameEmpty": "Digite um nome.", + "nameTooLong": "Esse nome é muito longo.", + "nameForbiddenChars": "O nome do banco de dados não pode conter / \\ . ou $", + "nameConsecutiveDots": "O nome do banco de dados não pode conter pontos consecutivos.", + "nameNullChar": "O nome da coleção não pode conter um caractere nulo.", + "nameReservedPrefix": "O nome da coleção não pode começar com $ — esse prefixo é reservado.", + "nameInvalid": "Esse nome não é válido.", + "renamed": "Renomeado para {name}", + "renameFailed": "Não foi possível renomear", + "dropped": "{name} descartado", + "dropFailed": "Não foi possível descartar {name}", + "bulkDropped": "{count} coleções descartadas", + "bulkDropFailed": "Não foi possível descartar {count} coleções" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Nova Requisição", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 243ed3dc..4237a997 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -10,6 +10,7 @@ "jsonFormatter": "Formatador JSON", "emailValidator": "Validador de E-mail", "nosqlExplorer": "Explorador de banco de dados", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Laboratório de encriptação", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Explora bases de dados MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Um espaço de trabalho único para MongoDB e Redis. Explora, consulta e edita com um armazenamento de credenciais encriptado." + }, "emailValidator": { "title": "Validador de E-mail", "description": "Verifica e valida endereços de e-mail." @@ -1944,6 +1949,150 @@ "colCoverage": "Cobertura" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Ainda sem ligações", + "emptyBody": "Adiciona uma ligação MongoDB ou Redis para começares a explorar. As credenciais são encriptadas no teu dispositivo.", + "addConnection": "Nova ligação", + "importLegacy": "Importar de ferramentas existentes", + "searchConnections": "Pesquisar ligações", + "openSidebar": "Abrir ligações", + "unsupportedSource": "Fonte não suportada", + "unsupportedSourceHint": "Esta ligação foi criada por uma versão mais recente da aplicação.", + "noResults": "Nenhuma ligação corresponde à tua pesquisa.", + "connectionActions": "Ações para {name}", + "menuEdit": "Editar", + "menuDelete": "Eliminar", + "deleteTitle": "Eliminar ligação?", + "deleteBody": "{name} e as credenciais guardadas serão removidas deste dispositivo. Esta ação não pode ser anulada.", + "deleteDocTitle": "Eliminar documento?", + "deleteDocBody": "Este documento será removido permanentemente da coleção. Esta ação não pode ser anulada.", + "closeAll": "Fechar todas", + "noTabsTitle": "Nada aberto", + "noTabsBody": "Escolhe uma ligação na barra lateral para abrir um separador.", + "connectionDialog": { + "createTitle": "Nova ligação", + "editTitle": "Editar ligação", + "pickSource": "Fonte de dados", + "name": "Nome", + "namePlaceholder": "A minha ligação", + "folder": "Pasta", + "folderPlaceholder": "Grupo opcional", + "color": "Cor", + "colorNone": "Sem cor", + "dbType": "Tipo de base de dados", + "connectionString": "String de ligação", + "readOnly": "Apenas leitura", + "readOnlyHint": "Bloqueia todas as operações de escrita para esta ligação.", + "test": "Testar ligação", + "testOk": "Ligado", + "testFailed": "Não foi possível ligar", + "save": "Guardar", + "cancel": "Cancelar" + }, + "importDialog": { + "title": "Importar de ferramentas existentes", + "loading": "A procurar ligações no Database Explorer e no Redis Commander…", + "notReady": "Ainda a carregar as tuas ligações existentes — tenta novamente daqui a pouco.", + "empty": "Nenhuma ligação encontrada no Database Explorer ou no Redis Commander.", + "allSkipped": "{count, plural, one {# ligação já foi importada} other {# ligações já foram importadas}}. Não há nada novo para importar.", + "previewHeading": "{count, plural, one {# ligação será importada:} other {# ligações serão importadas:}}", + "skippedNote": "{count, plural, one {# ligação já foi importada e será ignorada.} other {# ligações já foram importadas e serão ignoradas.}}", + "importing": "A importar…", + "confirm": "Importar {count, plural, one {# ligação} other {# ligações}}" + }, + "toast": { + "saved": "Ligação guardada", + "deleted": "Ligação eliminada", + "saveFailed": "Não foi possível guardar a ligação", + "deleteFailed": "Não foi possível eliminar a ligação", + "loadFailed": "Não foi possível carregar as ligações", + "fetchFailed": "Falha ao obter documentos para {collection}", + "docDeleted": "Documento eliminado", + "legacyImportFailed": "Não foi possível ler as ligações existentes do Database Explorer ou do Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Nenhuma ligação nova importada} one {Importada # ligação} other {Importadas # ligações}}{skipped, plural, =0 {} one {, # já importada} other {, # já importadas}}{failed, plural, =0 {} other {, # com falha}}" + }, + "validation": { + "connectionStringRequired": "Introduz uma string de ligação.", + "connectionStringScheme": "A string de ligação tem de começar com mongodb:// ou mongodb+srv://.", + "redisUrlRequired": "Introduz um URL do Redis.", + "redisUrlScheme": "O URL do Redis tem de começar com redis:// ou rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Construtor", + "url": "URL do Redis", + "urlPlaceholder": "redis://localhost:6379 ou rediss://user:pass@host:6380", + "urlHint": "Suporta redis:// e rediss:// (TLS). As credenciais são encriptadas antes de serem guardadas.", + "host": "Host", + "port": "Porta", + "username": "Nome de utilizador (ACL)", + "usernamePlaceholder": "default", + "password": "Palavra-passe", + "dbIndex": "Índice da BD", + "dbIndexPlaceholder": "0", + "tls": "Usar TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Nenhuma base de dados correspondente", + "tabKeyEditor": "Editor de Chaves", + "tabConsole": "Consola", + "tabServer": "Servidor", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Scanner", + "tabSearch": "Pesquisar", + "flush": "Limpar", + "flushTitle": "Limpar Chaves", + "flushDescription": "Introduz um padrão para eliminar as chaves correspondentes, ou deixa em branco para limpar toda a base de dados (requer DANGER_ZONE=true).", + "flushPatternLabel": "Padrão (opcional)", + "flushPatternPlaceholder": "ex.: session:* — em branco = FLUSHDB", + "flushing": "A limpar…", + "flushedAll": "Base de dados limpa", + "flushedKeys": "{count, plural, one {# chave eliminada} other {# chaves eliminadas}}", + "flushFailed": "Falha ao limpar" + }, + "mongo": { + "refreshDatabases": "Atualizar bases de dados", + "serverMonitor": "Monitor do servidor", + "connecting": "A ligar…", + "connectionFailed": "Não foi possível ligar", + "noDatabases": "Sem bases de dados", + "loadingCollections": "A carregar coleções…", + "collectionsFailed": "Não foi possível carregar as coleções", + "noCollections": "Sem coleções", + "noMatches": "Nenhuma base de dados corresponde à tua pesquisa", + "databaseActions": "Ações para a base de dados {name}", + "collectionActions": "Ações para a coleção {name}", + "selectCollection": "Selecionar coleção {name}", + "deleteSelected": "Eliminar {count} selecionadas", + "clearSelection": "Limpar", + "refresh": "Atualizar", + "rename": "Renomear", + "copyDbName": "Copiar nome da base de dados", + "browseGridfs": "Explorar GridFS", + "sync": "Sincronizar coleção", + "dropDatabase": "Descartar base de dados", + "dropCollection": "Descartar coleção", + "toast": { + "connectFailed": "Não foi possível ligar a {name}", + "collectionsFailed": "Não foi possível carregar as coleções de {name}", + "dbNameCopied": "Nome da base de dados copiado", + "nameEmpty": "Introduz um nome.", + "nameTooLong": "Esse nome é demasiado longo.", + "nameForbiddenChars": "O nome de uma base de dados não pode conter / \\ . ou $", + "nameConsecutiveDots": "O nome de uma base de dados não pode conter pontos consecutivos.", + "nameNullChar": "O nome de uma coleção não pode conter um carácter nulo.", + "nameReservedPrefix": "O nome de uma coleção não pode começar com $ — esse prefixo está reservado.", + "nameInvalid": "Esse nome não é válido.", + "renamed": "Renomeado para {name}", + "renameFailed": "Não foi possível renomear", + "dropped": "{name} descartado", + "dropFailed": "Não foi possível descartar {name}", + "bulkDropped": "{count} coleções descartadas", + "bulkDropFailed": "Não foi possível descartar {count} coleções" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Novo Pedido", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 81272a0e..8b43a238 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -10,6 +10,7 @@ "jsonFormatter": "Форматировщик JSON", "emailValidator": "Валидатор email", "nosqlExplorer": "Обозреватель баз данных", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Песочница шифрования", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Исследуйте базы данных MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Единое рабочее пространство для MongoDB и Redis. Просматривайте, запрашивайте и редактируйте данные в зашифрованном хранилище учётных данных." + }, "emailValidator": { "title": "Валидатор email", "description": "Проверяйте и валидируйте адреса электронной почты." @@ -1944,6 +1949,150 @@ "colCoverage": "Покрытие" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Пока нет подключений", + "emptyBody": "Добавьте подключение к MongoDB или Redis, чтобы начать просмотр. Учётные данные шифруются на вашем устройстве.", + "addConnection": "Новое подключение", + "importLegacy": "Импорт из существующих инструментов", + "searchConnections": "Поиск подключений", + "openSidebar": "Открыть подключения", + "unsupportedSource": "Неподдерживаемый источник", + "unsupportedSourceHint": "Это подключение было создано более новой версией приложения.", + "noResults": "Подключения по вашему запросу не найдены.", + "connectionActions": "Действия для {name}", + "menuEdit": "Редактировать", + "menuDelete": "Удалить", + "deleteTitle": "Удалить подключение?", + "deleteBody": "{name} и сохранённые учётные данные будут удалены с этого устройства. Это действие нельзя отменить.", + "deleteDocTitle": "Удалить документ?", + "deleteDocBody": "Этот документ будет безвозвратно удалён из коллекции. Это действие нельзя отменить.", + "closeAll": "Закрыть все", + "noTabsTitle": "Ничего не открыто", + "noTabsBody": "Выберите подключение на боковой панели, чтобы открыть вкладку.", + "connectionDialog": { + "createTitle": "Новое подключение", + "editTitle": "Редактировать подключение", + "pickSource": "Источник данных", + "name": "Название", + "namePlaceholder": "Моё подключение", + "folder": "Папка", + "folderPlaceholder": "Необязательная группа", + "color": "Цвет", + "colorNone": "Без цвета", + "dbType": "Тип базы данных", + "connectionString": "Строка подключения", + "readOnly": "Только для чтения", + "readOnlyHint": "Блокирует любые операции записи для этого подключения.", + "test": "Тест подключения", + "testOk": "Подключено", + "testFailed": "Не удалось подключиться", + "save": "Сохранить", + "cancel": "Отмена" + }, + "importDialog": { + "title": "Импорт из существующих инструментов", + "loading": "Поиск подключений в Database Explorer и Redis Commander…", + "notReady": "Существующие подключения ещё загружаются — повторите попытку через некоторое время.", + "empty": "В Database Explorer и Redis Commander подключения не найдены.", + "allSkipped": "{count, plural, one {# подключение уже импортировано} few {# подключения уже импортировано} many {# подключений уже импортировано} other {# подключения уже импортировано}}. Новых для добавления нет.", + "previewHeading": "{count, plural, one {# подключение} few {# подключения} many {# подключений} other {# подключения}} будет импортировано:", + "skippedNote": "{count, plural, one {# подключение} few {# подключения} many {# подключений} other {# подключения}} уже импортировано и будет пропущено.", + "importing": "Импорт…", + "confirm": "Импортировать {count, plural, one {# подключение} few {# подключения} many {# подключений} other {# подключения}}" + }, + "toast": { + "saved": "Подключение сохранено", + "deleted": "Подключение удалено", + "saveFailed": "Не удалось сохранить подключение", + "deleteFailed": "Не удалось удалить подключение", + "loadFailed": "Не удалось загрузить подключения", + "fetchFailed": "Не удалось загрузить документы для {collection}", + "docDeleted": "Документ удалён", + "legacyImportFailed": "Не удалось прочитать существующие подключения из Database Explorer или Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Новые подключения не импортированы} one {Импортировано # подключение} few {Импортировано # подключения} many {Импортировано # подключений} other {Импортировано # подключения}}{skipped, plural, =0 {} one {, # уже импортировано} few {, # уже импортировано} many {, # уже импортировано} other {, # уже импортировано}}{failed, plural, =0 {} one {, # не выполнено} few {, # не выполнено} many {, # не выполнено} other {, # не выполнено}}" + }, + "validation": { + "connectionStringRequired": "Введите строку подключения.", + "connectionStringScheme": "Строка подключения должна начинаться с mongodb:// или mongodb+srv://.", + "redisUrlRequired": "Введите URL Redis.", + "redisUrlScheme": "URL Redis должен начинаться с redis:// или rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Конструктор", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 или rediss://user:pass@host:6380", + "urlHint": "Поддерживает redis:// и rediss:// (TLS). Учётные данные шифруются перед сохранением.", + "host": "Хост", + "port": "Порт", + "username": "Имя пользователя (ACL)", + "usernamePlaceholder": "default", + "password": "Пароль", + "dbIndex": "Индекс БД", + "dbIndexPlaceholder": "0", + "tls": "Использовать TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Нет подходящих баз данных", + "tabKeyEditor": "Редактор ключей", + "tabConsole": "Консоль", + "tabServer": "Сервер", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Монитор", + "tabScanner": "Сканер", + "tabSearch": "Поиск", + "flush": "Очистить", + "flushTitle": "Очистить ключи", + "flushDescription": "Введите шаблон для удаления соответствующих ключей или оставьте поле пустым, чтобы очистить всю базу данных (требуется DANGER_ZONE=true).", + "flushPatternLabel": "Шаблон (необязательно)", + "flushPatternPlaceholder": "напр. session:* — пусто = FLUSHDB", + "flushing": "Очистка…", + "flushedAll": "База данных очищена", + "flushedKeys": "{count, plural, one {Удалён # ключ} few {Удалено # ключа} many {Удалено # ключей} other {Удалено # ключа}}", + "flushFailed": "Ошибка очистки" + }, + "mongo": { + "refreshDatabases": "Обновить базы данных", + "serverMonitor": "Монитор сервера", + "connecting": "Подключение…", + "connectionFailed": "Не удалось подключиться", + "noDatabases": "Нет баз данных", + "loadingCollections": "Загрузка коллекций…", + "collectionsFailed": "Не удалось загрузить коллекции", + "noCollections": "Нет коллекций", + "noMatches": "Базы данных по вашему запросу не найдены", + "databaseActions": "Действия для базы данных {name}", + "collectionActions": "Действия для коллекции {name}", + "selectCollection": "Выбрать коллекцию {name}", + "deleteSelected": "{count, plural, one {Удалить # выбранную коллекцию} few {Удалить # выбранные коллекции} many {Удалить # выбранных коллекций} other {Удалить # выбранных коллекций}}", + "clearSelection": "Очистить", + "refresh": "Обновить", + "rename": "Переименовать", + "copyDbName": "Копировать имя базы данных", + "browseGridfs": "Просмотр GridFS", + "sync": "Синхронизировать коллекцию", + "dropDatabase": "Удалить базу данных", + "dropCollection": "Удалить коллекцию", + "toast": { + "connectFailed": "Не удалось подключиться к {name}", + "collectionsFailed": "Не удалось загрузить коллекции для {name}", + "dbNameCopied": "Имя базы данных скопировано", + "nameEmpty": "Введите название.", + "nameTooLong": "Это название слишком длинное.", + "nameForbiddenChars": "Название базы данных не может содержать / \\ . или $", + "nameConsecutiveDots": "Название базы данных не может содержать подряд идущие точки.", + "nameNullChar": "Название коллекции не может содержать нулевой символ.", + "nameReservedPrefix": "Название коллекции не может начинаться с $ — этот префикс зарезервирован.", + "nameInvalid": "Это название недопустимо.", + "renamed": "Переименовано в {name}", + "renameFailed": "Не удалось переименовать", + "dropped": "{name} удалена", + "dropFailed": "Не удалось удалить {name}", + "bulkDropped": "{count, plural, one {Удалена # коллекция} few {Удалено # коллекции} many {Удалено # коллекций} other {Удалено # коллекций}}", + "bulkDropFailed": "{count, plural, one {Не удалось удалить # коллекцию} few {Не удалось удалить # коллекции} many {Не удалось удалить # коллекций} other {Не удалось удалить # коллекций}}" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Новый запрос", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 4dda261b..cbafa601 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON-formaterare", "emailValidator": "E-postvalidering", "nosqlExplorer": "Databasutforskare", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Krypteringslekplats", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Utforska MongoDB-databaser." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "En arbetsyta för MongoDB och Redis. Bläddra, sök och redigera med krypterad lagring av inloggningsuppgifter." + }, "emailValidator": { "title": "E-postvalidering", "description": "Verifiera och validera e-postadresser." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Inga anslutningar än", + "emptyBody": "Lägg till en MongoDB- eller Redis-anslutning för att börja bläddra. Inloggningsuppgifter krypteras på din enhet.", + "addConnection": "Ny anslutning", + "importLegacy": "Importera från befintliga verktyg", + "searchConnections": "Sök anslutningar", + "openSidebar": "Öppna anslutningar", + "unsupportedSource": "Ej stödd källa", + "unsupportedSourceHint": "Denna anslutning skapades av en nyare version av appen.", + "noResults": "Inga anslutningar matchar din sökning.", + "connectionActions": "Åtgärder för {name}", + "menuEdit": "Redigera", + "menuDelete": "Ta bort", + "deleteTitle": "Ta bort anslutning?", + "deleteBody": "{name} och dess sparade inloggningsuppgifter tas bort från denna enhet. Detta kan inte ångras.", + "deleteDocTitle": "Ta bort dokument?", + "deleteDocBody": "Detta dokument tas bort permanent från samlingen. Detta kan inte ångras.", + "closeAll": "Stäng alla", + "noTabsTitle": "Inget öppet", + "noTabsBody": "Välj en anslutning i sidofältet för att öppna en flik.", + "connectionDialog": { + "createTitle": "Ny anslutning", + "editTitle": "Redigera anslutning", + "pickSource": "Datakälla", + "name": "Namn", + "namePlaceholder": "Min anslutning", + "folder": "Mapp", + "folderPlaceholder": "Valfri grupp", + "color": "Färg", + "colorNone": "Ingen färg", + "dbType": "Databastyp", + "connectionString": "Anslutningssträng", + "readOnly": "Skrivskyddad", + "readOnlyHint": "Blockerar alla skrivåtgärder för denna anslutning.", + "test": "Testa anslutning", + "testOk": "Ansluten", + "testFailed": "Kunde inte ansluta", + "save": "Spara", + "cancel": "Avbryt" + }, + "importDialog": { + "title": "Importera från befintliga verktyg", + "loading": "Söker efter anslutningar i Databasutforskare och Redis Commander…", + "notReady": "Läser fortfarande in dina befintliga anslutningar — försök igen om en liten stund.", + "empty": "Inga anslutningar hittades i Databasutforskare eller Redis Commander.", + "allSkipped": "{count, plural, one {# anslutning har} other {# anslutningar har}} redan importerats. Inget nytt att hämta in.", + "previewHeading": "{count, plural, one {# anslutning} other {# anslutningar}} kommer att importeras:", + "skippedNote": "{count, plural, one {# anslutning var redan importerad och hoppas över.} other {# anslutningar var redan importerade och hoppas över.}}", + "importing": "Importerar…", + "confirm": "Importera {count, plural, one {# anslutning} other {# anslutningar}}" + }, + "toast": { + "saved": "Anslutning sparad", + "deleted": "Anslutning borttagen", + "saveFailed": "Det gick inte att spara anslutningen", + "deleteFailed": "Det gick inte att ta bort anslutningen", + "loadFailed": "Det gick inte att läsa in anslutningar", + "fetchFailed": "Det gick inte att hämta dokument för {collection}", + "docDeleted": "Dokument borttaget", + "legacyImportFailed": "Det gick inte att läsa befintliga anslutningar från Databasutforskare eller Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Inga nya anslutningar importerade} one {Importerade # anslutning} other {Importerade # anslutningar}}{skipped, plural, =0 {} other {, # redan importerade}}{failed, plural, =0 {} other {, # misslyckades}}" + }, + "validation": { + "connectionStringRequired": "Ange en anslutningssträng.", + "connectionStringScheme": "Anslutningssträngen måste börja med mongodb:// eller mongodb+srv://.", + "redisUrlRequired": "Ange en Redis-URL.", + "redisUrlScheme": "Redis-URL:en måste börja med redis:// eller rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Byggare", + "url": "Redis-URL", + "urlPlaceholder": "redis://localhost:6379 eller rediss://användare:lösenord@värd:6380", + "urlHint": "Stöder redis:// och rediss:// (TLS). Inloggningsuppgifter krypteras innan de lagras.", + "host": "Värd", + "port": "Port", + "username": "Användarnamn (ACL)", + "usernamePlaceholder": "default", + "password": "Lösenord", + "dbIndex": "DB-index", + "dbIndexPlaceholder": "0", + "tls": "Använd TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Inga matchande databaser", + "tabKeyEditor": "Nyckeleditor", + "tabConsole": "Konsol", + "tabServer": "Server", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitor", + "tabScanner": "Skanner", + "tabSearch": "Sök", + "flush": "Töm", + "flushTitle": "Töm nycklar", + "flushDescription": "Ange ett mönster för att ta bort matchande nycklar, eller lämna tomt för att tömma hela databasen (kräver DANGER_ZONE=true).", + "flushPatternLabel": "Mönster (valfritt)", + "flushPatternPlaceholder": "t.ex. session:* — tomt = FLUSHDB", + "flushing": "Tömmer…", + "flushedAll": "Databasen tömd", + "flushedKeys": "{count, plural, one {# nyckel borttagen} other {# nycklar borttagna}}", + "flushFailed": "Tömning misslyckades" + }, + "mongo": { + "refreshDatabases": "Uppdatera databaser", + "serverMonitor": "Servermonitor", + "connecting": "Ansluter…", + "connectionFailed": "Kunde inte ansluta", + "noDatabases": "Inga databaser", + "loadingCollections": "Läser in samlingar…", + "collectionsFailed": "Det gick inte att läsa in samlingar", + "noCollections": "Inga samlingar", + "noMatches": "Inga databaser matchar din sökning", + "databaseActions": "Åtgärder för databasen {name}", + "collectionActions": "Åtgärder för samlingen {name}", + "selectCollection": "Välj samlingen {name}", + "deleteSelected": "Ta bort {count} valda", + "clearSelection": "Rensa", + "refresh": "Uppdatera", + "rename": "Byt namn", + "copyDbName": "Kopiera databasnamn", + "browseGridfs": "Bläddra i GridFS", + "sync": "Synkronisera samling", + "dropDatabase": "Ta bort databas", + "dropCollection": "Ta bort samling", + "toast": { + "connectFailed": "Det gick inte att ansluta till {name}", + "collectionsFailed": "Det gick inte att läsa in samlingar för {name}", + "dbNameCopied": "Databasnamn kopierat", + "nameEmpty": "Ange ett namn.", + "nameTooLong": "Det namnet är för långt.", + "nameForbiddenChars": "Ett databasnamn får inte innehålla / \\ . eller $", + "nameConsecutiveDots": "Ett databasnamn får inte innehålla på varandra följande punkter.", + "nameNullChar": "Ett samlingsnamn får inte innehålla ett nolltecken.", + "nameReservedPrefix": "Ett samlingsnamn får inte börja med $ — det prefixet är reserverat.", + "nameInvalid": "Det namnet är inte giltigt.", + "renamed": "Bytte namn till {name}", + "renameFailed": "Det gick inte att byta namn", + "dropped": "Tog bort {name}", + "dropFailed": "Det gick inte att ta bort {name}", + "bulkDropped": "Tog bort {count} samlingar", + "bulkDropFailed": "Det gick inte att ta bort {count} samlingar" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Ny förfrågan", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index d1d6a5fb..c865eec4 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON biçimleyici", "emailValidator": "E-posta Doğrulayıcı", "nosqlExplorer": "Veritabanı Gezgini", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Şifreleme oyun alanı", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "MongoDB veritabanlarını keşfedin." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "MongoDB ve Redis için tek çalışma alanı. Şifrelenmiş bir kimlik bilgisi deposuyla gözatın, sorgulayın ve düzenleyin." + }, "emailValidator": { "title": "E-posta Doğrulayıcı", "description": "E-posta adreslerini doğrulayın ve kontrol edin." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Henüz bağlantı yok", + "emptyBody": "Göz atmaya başlamak için bir MongoDB veya Redis bağlantısı ekleyin. Kimlik bilgileri cihazınızda şifrelenir.", + "addConnection": "Yeni bağlantı", + "importLegacy": "Mevcut araçlardan içe aktar", + "searchConnections": "Bağlantılarda ara", + "openSidebar": "Bağlantıları aç", + "unsupportedSource": "Desteklenmeyen kaynak", + "unsupportedSourceHint": "Bu bağlantı, uygulamanın daha yeni bir sürümü tarafından oluşturuldu.", + "noResults": "Aramanızla eşleşen bağlantı yok.", + "connectionActions": "{name} için işlemler", + "menuEdit": "Düzenle", + "menuDelete": "Sil", + "deleteTitle": "Bağlantı silinsin mi?", + "deleteBody": "{name} ve kayıtlı kimlik bilgileri bu cihazdan kaldırılacak. Bu işlem geri alınamaz.", + "deleteDocTitle": "Belge silinsin mi?", + "deleteDocBody": "Bu belge koleksiyondan kalıcı olarak silinecek. Bu işlem geri alınamaz.", + "closeAll": "Tümünü kapat", + "noTabsTitle": "Açık bir şey yok", + "noTabsBody": "Bir sekme açmak için kenar çubuğundan bir bağlantı seçin.", + "connectionDialog": { + "createTitle": "Yeni bağlantı", + "editTitle": "Bağlantıyı düzenle", + "pickSource": "Veri kaynağı", + "name": "Ad", + "namePlaceholder": "Bağlantım", + "folder": "Klasör", + "folderPlaceholder": "İsteğe bağlı grup", + "color": "Renk", + "colorNone": "Renk yok", + "dbType": "Veritabanı türü", + "connectionString": "Bağlantı dizesi", + "readOnly": "Salt okunur", + "readOnlyHint": "Bu bağlantı için tüm yazma işlemlerini engeller.", + "test": "Bağlantıyı test et", + "testOk": "Bağlandı", + "testFailed": "Bağlanılamadı", + "save": "Kaydet", + "cancel": "İptal" + }, + "importDialog": { + "title": "Mevcut araçlardan içe aktar", + "loading": "Veritabanı Gezgini ve Redis Commander içinde bağlantılar aranıyor…", + "notReady": "Mevcut bağlantılarınız hâlâ yükleniyor — birazdan tekrar deneyin.", + "empty": "Veritabanı Gezgini veya Redis Commander içinde bağlantı bulunamadı.", + "allSkipped": "{count, plural, one {# bağlantı zaten içe aktarıldı} other {# bağlantı zaten içe aktarıldı}}. İçe aktarılacak yeni bir şey yok.", + "previewHeading": "{count, plural, one {# bağlantı} other {# bağlantı}} içe aktarılacak:", + "skippedNote": "{count, plural, one {# bağlantı zaten içe aktarıldı ve atlanacak} other {# bağlantı zaten içe aktarıldı ve atlanacak}}.", + "importing": "İçe aktarılıyor…", + "confirm": "{count, plural, one {# Bağlantıyı} other {# Bağlantıyı}} İçe Aktar" + }, + "toast": { + "saved": "Bağlantı kaydedildi", + "deleted": "Bağlantı silindi", + "saveFailed": "Bağlantı kaydedilemedi", + "deleteFailed": "Bağlantı silinemedi", + "loadFailed": "Bağlantılar yüklenemedi", + "fetchFailed": "{collection} için belgeler getirilemedi", + "docDeleted": "Belge silindi", + "legacyImportFailed": "Veritabanı Gezgini veya Redis Commander'dan mevcut bağlantılar okunamadı", + "legacyImportResult": "{imported, plural, =0 {Yeni bağlantı içe aktarılmadı} one {# bağlantı içe aktarıldı} other {# bağlantı içe aktarıldı}}{skipped, plural, =0 {} other {, # zaten içe aktarılmıştı}}{failed, plural, =0 {} other {, # başarısız oldu}}" + }, + "validation": { + "connectionStringRequired": "Bir bağlantı dizesi girin.", + "connectionStringScheme": "Bağlantı dizesi mongodb:// veya mongodb+srv:// ile başlamalıdır.", + "redisUrlRequired": "Bir Redis URL'si girin.", + "redisUrlScheme": "Redis URL'si redis:// veya rediss:// ile başlamalıdır." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Oluşturucu", + "url": "Redis URL'si", + "urlPlaceholder": "redis://localhost:6379 veya rediss://user:pass@host:6380", + "urlHint": "redis:// ve rediss:// (TLS) desteklenir. Kimlik bilgileri depolamadan önce şifrelenir.", + "host": "Ana bilgisayar", + "port": "Port", + "username": "Kullanıcı adı (ACL)", + "usernamePlaceholder": "default", + "password": "Parola", + "dbIndex": "DB indeksi", + "dbIndexPlaceholder": "0", + "tls": "TLS kullan (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Eşleşen veritabanı yok", + "tabKeyEditor": "Anahtar Düzenleyici", + "tabConsole": "Konsol", + "tabServer": "Sunucu", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Monitör", + "tabScanner": "Tarayıcı", + "tabSearch": "Ara", + "flush": "Temizle", + "flushTitle": "Anahtarları Temizle", + "flushDescription": "Eşleşen anahtarları silmek için bir desen girin veya tüm veritabanını temizlemek için boş bırakın (DANGER_ZONE=true gerektirir).", + "flushPatternLabel": "Desen (isteğe bağlı)", + "flushPatternPlaceholder": "örn. session:* — boş = FLUSHDB", + "flushing": "Temizleniyor…", + "flushedAll": "Veritabanı temizlendi", + "flushedKeys": "{count, plural, one {# anahtar silindi} other {# anahtar silindi}}", + "flushFailed": "Temizleme başarısız oldu" + }, + "mongo": { + "refreshDatabases": "Veritabanlarını yenile", + "serverMonitor": "Sunucu monitörü", + "connecting": "Bağlanıyor…", + "connectionFailed": "Bağlanılamadı", + "noDatabases": "Veritabanı yok", + "loadingCollections": "Koleksiyonlar yükleniyor…", + "collectionsFailed": "Koleksiyonlar yüklenemedi", + "noCollections": "Koleksiyon yok", + "noMatches": "Aramanızla eşleşen veritabanı yok", + "databaseActions": "{name} veritabanı için işlemler", + "collectionActions": "{name} koleksiyonu için işlemler", + "selectCollection": "{name} koleksiyonunu seç", + "deleteSelected": "Seçili {count} öğeyi sil", + "clearSelection": "Temizle", + "refresh": "Yenile", + "rename": "Yeniden adlandır", + "copyDbName": "Veritabanı adını kopyala", + "browseGridfs": "GridFS'e göz at", + "sync": "Koleksiyonu senkronize et", + "dropDatabase": "Veritabanını sil", + "dropCollection": "Koleksiyonu sil", + "toast": { + "connectFailed": "{name} bağlantısı kurulamadı", + "collectionsFailed": "{name} için koleksiyonlar yüklenemedi", + "dbNameCopied": "Veritabanı adı kopyalandı", + "nameEmpty": "Bir ad girin.", + "nameTooLong": "Bu ad çok uzun.", + "nameForbiddenChars": "Veritabanı adı / \\ . veya $ içeremez", + "nameConsecutiveDots": "Veritabanı adı art arda nokta içeremez.", + "nameNullChar": "Koleksiyon adı boş (null) karakter içeremez.", + "nameReservedPrefix": "Koleksiyon adı $ ile başlayamaz — bu önek ayrılmıştır.", + "nameInvalid": "Bu ad geçerli değil.", + "renamed": "{name} olarak yeniden adlandırıldı", + "renameFailed": "Yeniden adlandırılamadı", + "dropped": "{name} silindi", + "dropFailed": "{name} silinemedi", + "bulkDropped": "{count} koleksiyon silindi", + "bulkDropFailed": "{count} koleksiyon silinemedi" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Yeni İstek", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 3582164d..5b41c32d 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -10,6 +10,7 @@ "jsonFormatter": "Форматувальник JSON", "emailValidator": "Перевірка email", "nosqlExplorer": "Провідник баз даних", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Пісочниця шифрування", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Досліджуйте бази даних MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Єдиний робочий простір для MongoDB і Redis. Переглядайте, виконуйте запити та редагуйте із зашифрованим сховищем облікових даних." + }, "emailValidator": { "title": "Перевірка email", "description": "Перевіряйте та валідуйте email-адреси." @@ -1859,6 +1864,150 @@ "exportHtml": "HTML report" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Поки що немає підключень", + "emptyBody": "Додайте підключення до MongoDB або Redis, щоб почати перегляд. Облікові дані шифруються на вашому пристрої.", + "addConnection": "Нове підключення", + "importLegacy": "Імпорт з наявних інструментів", + "searchConnections": "Пошук підключень", + "openSidebar": "Відкрити підключення", + "unsupportedSource": "Непідтримуване джерело", + "unsupportedSourceHint": "Це підключення створено новішою версією застосунку.", + "noResults": "Жодне підключення не відповідає вашому пошуку.", + "connectionActions": "Дії для {name}", + "menuEdit": "Редагувати", + "menuDelete": "Видалити", + "deleteTitle": "Видалити підключення?", + "deleteBody": "{name} та його збережені облікові дані буде видалено з цього пристрою. Цю дію не можна скасувати.", + "deleteDocTitle": "Видалити документ?", + "deleteDocBody": "Цей документ буде остаточно видалено з колекції. Цю дію не можна скасувати.", + "closeAll": "Закрити всі", + "noTabsTitle": "Нічого не відкрито", + "noTabsBody": "Виберіть підключення на бічній панелі, щоб відкрити вкладку.", + "connectionDialog": { + "createTitle": "Нове підключення", + "editTitle": "Редагувати підключення", + "pickSource": "Джерело даних", + "name": "Назва", + "namePlaceholder": "Моє підключення", + "folder": "Тека", + "folderPlaceholder": "Необов'язкова група", + "color": "Колір", + "colorNone": "Без кольору", + "dbType": "Тип бази даних", + "connectionString": "Рядок підключення", + "readOnly": "Лише читання", + "readOnlyHint": "Блокує всі операції запису для цього підключення.", + "test": "Перевірити підключення", + "testOk": "Підключено", + "testFailed": "Не вдалося підключитися", + "save": "Зберегти", + "cancel": "Скасувати" + }, + "importDialog": { + "title": "Імпорт з наявних інструментів", + "loading": "Пошук підключень у Провіднику баз даних і Redis Commander…", + "notReady": "Ваші наявні підключення ще завантажуються — спробуйте ще раз за мить.", + "empty": "У Провіднику баз даних та Redis Commander підключень не знайдено.", + "allSkipped": "{count, plural, one {# підключення вже імпортовано} few {# підключення вже імпортовано} many {# підключень вже імпортовано} other {# підключення вже імпортовано}}. Нічого нового для імпорту немає.", + "previewHeading": "{count, plural, one {# підключення буде імпортовано} few {# підключення буде імпортовано} many {# підключень буде імпортовано} other {# підключення буде імпортовано}}:", + "skippedNote": "{count, plural, one {# підключення вже імпортовано і буде пропущено} few {# підключення вже імпортовано і буде пропущено} many {# підключень вже імпортовано і буде пропущено} other {# підключення вже імпортовано і буде пропущено}}.", + "importing": "Імпортування…", + "confirm": "Імпортувати {count, plural, one {# підключення} few {# підключення} many {# підключень} other {# підключення}}" + }, + "toast": { + "saved": "Підключення збережено", + "deleted": "Підключення видалено", + "saveFailed": "Не вдалося зберегти підключення", + "deleteFailed": "Не вдалося видалити підключення", + "loadFailed": "Не вдалося завантажити підключення", + "fetchFailed": "Не вдалося завантажити документи для {collection}", + "docDeleted": "Документ видалено", + "legacyImportFailed": "Не вдалося прочитати наявні підключення з Провідника баз даних і Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Нові підключення не імпортовано} one {Імпортовано # підключення} few {Імпортовано # підключення} many {Імпортовано # підключень} other {Імпортовано # підключення}}{skipped, plural, =0 {} few {, # вже імпортовано} many {, # вже імпортовано} other {, # вже імпортовано}}{failed, plural, =0 {} few {, # не вдалося} many {, # не вдалося} other {, # не вдалося}}" + }, + "validation": { + "connectionStringRequired": "Введіть рядок підключення.", + "connectionStringScheme": "Рядок підключення має починатися з mongodb:// або mongodb+srv://.", + "redisUrlRequired": "Введіть URL Redis.", + "redisUrlScheme": "URL Redis має починатися з redis:// або rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Конструктор", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 або rediss://user:pass@host:6380", + "urlHint": "Підтримує redis:// і rediss:// (TLS). Облікові дані шифруються перед збереженням.", + "host": "Хост", + "port": "Порт", + "username": "Ім'я користувача (ACL)", + "usernamePlaceholder": "default", + "password": "Пароль", + "dbIndex": "Індекс БД", + "dbIndexPlaceholder": "0", + "tls": "Використовувати TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Немає відповідних баз даних", + "tabKeyEditor": "Редактор ключів", + "tabConsole": "Консоль", + "tabServer": "Сервер", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Монітор", + "tabScanner": "Сканер", + "tabSearch": "Пошук", + "flush": "Очистити", + "flushTitle": "Очистити ключі", + "flushDescription": "Введіть шаблон для видалення відповідних ключів або залиште порожнім, щоб очистити всю базу даних (потрібно DANGER_ZONE=true).", + "flushPatternLabel": "Шаблон (необов'язково)", + "flushPatternPlaceholder": "напр. session:* — порожньо = FLUSHDB", + "flushing": "Очищення…", + "flushedAll": "Базу даних очищено", + "flushedKeys": "{count, plural, one {# ключ видалено} few {# ключі видалено} many {# ключів видалено} other {# ключа видалено}}", + "flushFailed": "Не вдалося очистити" + }, + "mongo": { + "refreshDatabases": "Оновити бази даних", + "serverMonitor": "Монітор сервера", + "connecting": "Підключення…", + "connectionFailed": "Не вдалося підключитися", + "noDatabases": "Немає баз даних", + "loadingCollections": "Завантаження колекцій…", + "collectionsFailed": "Не вдалося завантажити колекції", + "noCollections": "Немає колекцій", + "noMatches": "Жодна база даних не відповідає вашому пошуку", + "databaseActions": "Дії для бази даних {name}", + "collectionActions": "Дії для колекції {name}", + "selectCollection": "Вибрати колекцію {name}", + "deleteSelected": "Видалити {count} обраних", + "clearSelection": "Очистити", + "refresh": "Оновити", + "rename": "Перейменувати", + "copyDbName": "Копіювати назву бази даних", + "browseGridfs": "Переглянути GridFS", + "sync": "Синхронізувати колекцію", + "dropDatabase": "Видалити базу даних", + "dropCollection": "Видалити колекцію", + "toast": { + "connectFailed": "Не вдалося підключитися до {name}", + "collectionsFailed": "Не вдалося завантажити колекції для {name}", + "dbNameCopied": "Назву бази даних скопійовано", + "nameEmpty": "Введіть назву.", + "nameTooLong": "Ця назва занадто довга.", + "nameForbiddenChars": "Назва бази даних не може містити / \\ . або $", + "nameConsecutiveDots": "Назва бази даних не може містити послідовні крапки.", + "nameNullChar": "Назва колекції не може містити нульовий символ.", + "nameReservedPrefix": "Назва колекції не може починатися з $ — цей префікс зарезервовано.", + "nameInvalid": "Ця назва недійсна.", + "renamed": "Перейменовано на {name}", + "renameFailed": "Не вдалося перейменувати", + "dropped": "{name} видалено", + "dropFailed": "Не вдалося видалити {name}", + "bulkDropped": "Видалено {count} колекцій", + "bulkDropFailed": "Не вдалося видалити {count} колекцій" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Новий запит", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 60580e20..e8b079f3 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -10,6 +10,7 @@ "jsonFormatter": "Trình định dạng JSON", "emailValidator": "Xác thực email", "nosqlExplorer": "Trình khám phá cơ sở dữ liệu", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "Sân chơi mã hóa", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "Khám phá cơ sở dữ liệu MongoDB." }, + "dataExplorer": { + "title": "Data Explorer", + "description": "Một không gian làm việc cho MongoDB và Redis. Duyệt, truy vấn và chỉnh sửa với kho lưu trữ thông tin xác thực được mã hóa." + }, "emailValidator": { "title": "Xác thực Email", "description": "Xác minh và kiểm tra địa chỉ email." @@ -1585,9 +1590,6 @@ "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone.", - "bulkDeleteFailed": "{count, plural, one {Không thể xóa # bộ sưu tập} other {Không thể xóa # bộ sưu tập}}", - "bulkDeleted": "{count, plural, one {Đã xóa # bộ sưu tập} other {Đã xóa # bộ sưu tập}}", - "bulkDeleteButton": "{count, plural, one {Xóa # bộ sưu tập} other {Xóa # bộ sưu tập}}", "bulkDeleteFailed": "{count, plural, other {Không xóa được # bộ sưu tập}}", "bulkDeleted": "{count, plural, other {Đã xóa # bộ sưu tập}}", "bulkDeleteButton": "Xóa {count, plural, other {# bộ sưu tập}}" @@ -1675,24 +1677,6 @@ "explainDocsExamined": "{n} được kiểm tra", "explainCollscanHint": "Truy vấn này quét mọi tài liệu trong collection. Cân nhắc tạo chỉ mục trên các trường được lọc.", "explainRawLabel": "Kết quả explain", - "docInsertFailed": "Không thể chèn tài liệu", - "docUpdateFailed": "Không thể cập nhật tài liệu", - "indexesLoadFail": "Không thể tải chỉ mục", - "bulkDeleted": "{count, plural, one {Đã xóa # tài liệu} other {Đã xóa # tài liệu}}", - "bulkDeleteFailed": "Xóa hàng loạt thất bại", - "selectedCount": "{count, plural, one {Đã chọn # tài liệu} other {Đã chọn # tài liệu}}", - "deleteSelected": "Xóa mục đã chọn", - "clearSelection": "Xóa", - "statusLoading": "Đang tải…", - "statusShowing": "Hiển thị {from}–{to} trong {total} tài liệu", - "statusEmpty": "0 tài liệu", - "statusSelected": "Đã chọn {count}", - "bulkDeleteTitle": "Xóa {count, plural, one {# tài liệu} other {# tài liệu}}?", - "bulkDeleteDescription": "Thao tác này sẽ xóa vĩnh viễn {count, plural, one {# tài liệu} other {# tài liệu}} khỏi {collection}. Không thể hoàn tác.", - "bulkDeleting": "Đang xóa…", - "bulkDeleteConfirm": "Xóa tất cả", - "queryErrorTitle": "Không thể tải tài liệu", - "retry": "Thử lại", "docInsertFailed": "Không chèn được tài liệu", "docUpdateFailed": "Không cập nhật được tài liệu", "indexesLoadFail": "Không tải được chỉ mục", @@ -1789,7 +1773,6 @@ "export": "Xuất", "sheetName": "Dữ liệu", "formatJson": "JSON", - "pageOnlyNote": "Chỉ xuất trang hiện tại ({count, plural, one {# tài liệu} other {# tài liệu}})", "pageOnlyNote": "Chỉ xuất trang hiện tại ({count, plural, other {# tài liệu}})" }, "jsonTree": { @@ -1806,80 +1789,6 @@ "previewEmpty": "Không có tài liệu ở giai đoạn này", "previewFail": "Xem trước thất bại" }, - "indexManager": { - "loading": "Đang tải chỉ mục…", - "retry": "Thử lại", - "countLabel": "{count, plural, one {# chỉ mục} other {# chỉ mục}}", - "totalSize": "tổng {size}", - "statsDocs": "{count, plural, one {# tài liệu} other {# tài liệu}}", - "statsStorage": "{size} lưu trữ", - "statsAvgObj": "{size} TB/tài liệu", - "newIndex": "Chỉ mục mới", - "createTitle": "Tạo chỉ mục", - "fieldPlaceholder": "Tên trường", - "ascending": "Tăng dần", - "descending": "Giảm dần", - "addField": "Thêm trường", - "unique": "Duy nhất", - "sparse": "Sparse", - "ttlLabel": "TTL (giây)", - "ttlPlaceholder": "vd. 3600", - "cancel": "Hủy", - "create": "Tạo", - "creating": "Đang tạo…", - "created": "Đã tạo chỉ mục", - "createFailed": "Không thể tạo chỉ mục", - "fieldRequired": "Tên trường là bắt buộc", - "dropped": "Đã xóa chỉ mục \"{name}\"", - "dropFailed": "Không thể xóa chỉ mục", - "dropTitle": "Xóa chỉ mục?", - "dropDescription": "Thao tác này sẽ xóa vĩnh viễn chỉ mục \"{name}\". Các truy vấn dùng chỉ mục này sẽ chậm hơn.", - "dropping": "Đang xóa…", - "dropConfirm": "Xóa chỉ mục", - "badgeSystem": "hệ thống", - "badgeUnique": "duy nhất", - "badgeSparse": "sparse", - "badgeTtl": "TTL", - "empty": "Không tìm thấy chỉ mục" - }, - "importDialog": { - "title": "Nhập tài liệu vào {collection}", - "onlyJson": "Chỉ hỗ trợ tệp .json", - "invalidStructure": "Tệp phải chứa mảng hoặc đối tượng JSON", - "invalidJson": "JSON không hợp lệ: không thể phân tích tệp", - "imported": "{count, plural, one {Đã nhập # tài liệu} other {Đã nhập # tài liệu}}", - "importFailed": "Nhập thất bại", - "dropHint": "Kéo và thả hoặc nhấp để tải lên", - "dropSubHint": "Hỗ trợ mảng JSON hoặc NDJSON", - "docsCount": "{count, plural, one {# tài liệu} other {# tài liệu}}", - "previewLabel": "Xem trước (3 tài liệu đầu)", - "moreDocs": "… và {count} tài liệu nữa", - "cancel": "Hủy", - "importing": "Đang nhập…", - "importCount": "{count, plural, one {Nhập # tài liệu} other {Nhập # tài liệu}}", - "importBtn": "Nhập" - }, - "schemaView": { - "analyzing": "Đang phân tích lược đồ…", - "loadFailed": "Không thể phân tích lược đồ", - "retry": "Thử lại", - "noDocs": "Không có tài liệu để phân tích", - "sampled": "Đã lấy mẫu {docs} tài liệu · {fields} trường", - "colField": "Trường", - "colTypes": "Kiểu", - "colCoverage": "Độ phủ", - "sampleRandom": "Random", - "sampleFirst": "First", - "sampleLast": "Last", - "sampleAll": "All", - "sampleSizeN": "{n} docs", - "allCapped": "capped at 10k", - "validatorTitle": "Validation rules", - "exportSchemaBtn": "Export", - "exportJsonSchema": "JSON Schema", - "exportMongoose": "Mongoose model", - "exportHtml": "HTML report" - }, "indexManager": { "loading": "Đang tải chỉ mục...", "retry": "Thử lại", @@ -1944,6 +1853,150 @@ "colCoverage": "Độ phủ" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "Chưa có kết nối nào", + "emptyBody": "Thêm kết nối MongoDB hoặc Redis để bắt đầu duyệt. Thông tin xác thực được mã hóa trên thiết bị của bạn.", + "addConnection": "Kết nối mới", + "importLegacy": "Nhập từ các công cụ hiện có", + "searchConnections": "Tìm kiếm kết nối", + "openSidebar": "Mở kết nối", + "unsupportedSource": "Nguồn không được hỗ trợ", + "unsupportedSourceHint": "Kết nối này được tạo bởi phiên bản ứng dụng mới hơn.", + "noResults": "Không có kết nối nào khớp với tìm kiếm của bạn.", + "connectionActions": "Hành động cho {name}", + "menuEdit": "Sửa", + "menuDelete": "Xóa", + "deleteTitle": "Xóa kết nối?", + "deleteBody": "{name} và thông tin xác thực đã lưu sẽ bị xóa khỏi thiết bị này. Không thể hoàn tác.", + "deleteDocTitle": "Xóa tài liệu?", + "deleteDocBody": "Tài liệu này sẽ bị xóa vĩnh viễn khỏi bộ sưu tập. Không thể hoàn tác.", + "closeAll": "Đóng tất cả", + "noTabsTitle": "Không có gì đang mở", + "noTabsBody": "Chọn một kết nối trong thanh bên để mở tab.", + "connectionDialog": { + "createTitle": "Kết nối mới", + "editTitle": "Sửa kết nối", + "pickSource": "Nguồn dữ liệu", + "name": "Tên", + "namePlaceholder": "Kết nối của tôi", + "folder": "Thư mục", + "folderPlaceholder": "Nhóm tùy chọn", + "color": "Màu", + "colorNone": "Không màu", + "dbType": "Loại cơ sở dữ liệu", + "connectionString": "Chuỗi kết nối", + "readOnly": "Chỉ đọc", + "readOnlyHint": "Chặn mọi thao tác ghi cho kết nối này.", + "test": "Kiểm tra kết nối", + "testOk": "Đã kết nối", + "testFailed": "Không thể kết nối", + "save": "Lưu", + "cancel": "Hủy" + }, + "importDialog": { + "title": "Nhập từ các công cụ hiện có", + "loading": "Đang tìm kết nối trong Trình khám phá cơ sở dữ liệu và Redis Commander…", + "notReady": "Các kết nối hiện có của bạn vẫn đang tải — thử lại sau một chút.", + "empty": "Không tìm thấy kết nối nào trong Trình khám phá cơ sở dữ liệu hoặc Redis Commander.", + "allSkipped": "{count, plural, one {# kết nối đã được nhập} other {# kết nối đã được nhập}}. Không có gì mới để nhập.", + "previewHeading": "{count, plural, one {# kết nối} other {# kết nối}} sẽ được nhập:", + "skippedNote": "{count, plural, one {# kết nối đã được nhập trước đó và sẽ bị bỏ qua} other {# kết nối đã được nhập trước đó và sẽ bị bỏ qua}}.", + "importing": "Đang nhập…", + "confirm": "Nhập {count, plural, one {# kết nối} other {# kết nối}}" + }, + "toast": { + "saved": "Đã lưu kết nối", + "deleted": "Đã xóa kết nối", + "saveFailed": "Không thể lưu kết nối", + "deleteFailed": "Không thể xóa kết nối", + "loadFailed": "Không thể tải kết nối", + "fetchFailed": "Không thể lấy tài liệu cho {collection}", + "docDeleted": "Đã xóa tài liệu", + "legacyImportFailed": "Không thể đọc các kết nối hiện có từ Trình khám phá cơ sở dữ liệu hoặc Redis Commander", + "legacyImportResult": "{imported, plural, =0 {Không có kết nối mới nào được nhập} one {Đã nhập # kết nối} other {Đã nhập # kết nối}}{skipped, plural, =0 {} other {, # đã được nhập trước đó}}{failed, plural, =0 {} other {, # thất bại}}" + }, + "validation": { + "connectionStringRequired": "Nhập chuỗi kết nối.", + "connectionStringScheme": "Chuỗi kết nối phải bắt đầu bằng mongodb:// hoặc mongodb+srv://.", + "redisUrlRequired": "Nhập URL Redis.", + "redisUrlScheme": "URL Redis phải bắt đầu bằng redis:// hoặc rediss://." + }, + "redis": { + "urlMode": "URL", + "builderMode": "Trình tạo", + "url": "URL Redis", + "urlPlaceholder": "redis://localhost:6379 hoặc rediss://user:pass@host:6380", + "urlHint": "Hỗ trợ redis:// và rediss:// (TLS). Thông tin xác thực được mã hóa trước khi lưu trữ.", + "host": "Máy chủ", + "port": "Cổng", + "username": "Tên người dùng (ACL)", + "usernamePlaceholder": "default", + "password": "Mật khẩu", + "dbIndex": "Chỉ số DB", + "dbIndexPlaceholder": "0", + "tls": "Dùng TLS (rediss://)", + "dbLabel": "db {index}", + "noMatches": "Không có cơ sở dữ liệu phù hợp", + "tabKeyEditor": "Trình chỉnh sửa khóa", + "tabConsole": "Bảng điều khiển", + "tabServer": "Máy chủ", + "tabPubSub": "Pub/Sub", + "tabMonitor": "Giám sát", + "tabScanner": "Quét", + "tabSearch": "Tìm kiếm", + "flush": "Xóa sạch", + "flushTitle": "Xóa sạch khóa", + "flushDescription": "Nhập mẫu để xóa các khóa khớp, hoặc để trống để xóa sạch toàn bộ cơ sở dữ liệu (yêu cầu DANGER_ZONE=true).", + "flushPatternLabel": "Mẫu (tùy chọn)", + "flushPatternPlaceholder": "vd. session:* — để trống = FLUSHDB", + "flushing": "Đang xóa sạch…", + "flushedAll": "Đã xóa sạch cơ sở dữ liệu", + "flushedKeys": "{count, plural, one {# khóa đã xóa} other {# khóa đã xóa}}", + "flushFailed": "Xóa sạch thất bại" + }, + "mongo": { + "refreshDatabases": "Làm mới cơ sở dữ liệu", + "serverMonitor": "Giám sát máy chủ", + "connecting": "Đang kết nối…", + "connectionFailed": "Không thể kết nối", + "noDatabases": "Không có cơ sở dữ liệu", + "loadingCollections": "Đang tải bộ sưu tập…", + "collectionsFailed": "Không thể tải bộ sưu tập", + "noCollections": "Không có bộ sưu tập", + "noMatches": "Không có cơ sở dữ liệu phù hợp với tìm kiếm", + "databaseActions": "Hành động cho cơ sở dữ liệu {name}", + "collectionActions": "Hành động cho bộ sưu tập {name}", + "selectCollection": "Chọn bộ sưu tập {name}", + "deleteSelected": "Xóa {count} mục đã chọn", + "clearSelection": "Xóa lựa chọn", + "refresh": "Làm mới", + "rename": "Đổi tên", + "copyDbName": "Sao chép tên cơ sở dữ liệu", + "browseGridfs": "Duyệt GridFS", + "sync": "Đồng bộ bộ sưu tập", + "dropDatabase": "Xóa cơ sở dữ liệu", + "dropCollection": "Xóa bộ sưu tập", + "toast": { + "connectFailed": "Không thể kết nối đến {name}", + "collectionsFailed": "Không thể tải bộ sưu tập cho {name}", + "dbNameCopied": "Đã sao chép tên cơ sở dữ liệu", + "nameEmpty": "Nhập tên.", + "nameTooLong": "Tên đó quá dài.", + "nameForbiddenChars": "Tên cơ sở dữ liệu không được chứa / \\ . hoặc $", + "nameConsecutiveDots": "Tên cơ sở dữ liệu không được chứa các dấu chấm liên tiếp.", + "nameNullChar": "Tên bộ sưu tập không được chứa ký tự null.", + "nameReservedPrefix": "Tên bộ sưu tập không được bắt đầu bằng $ — tiền tố đó được dành riêng.", + "nameInvalid": "Tên đó không hợp lệ.", + "renamed": "Đã đổi tên thành {name}", + "renameFailed": "Không thể đổi tên", + "dropped": "Đã xóa {name}", + "dropFailed": "Không thể xóa {name}", + "bulkDropped": "Đã xóa {count} bộ sưu tập", + "bulkDropFailed": "Không thể xóa {count} bộ sưu tập" + } + } + }, "ApiClient": { "defaults": { "newRequest": "Yêu cầu mới", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 5088ac61..e1418754 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -10,6 +10,7 @@ "jsonFormatter": "JSON 格式化器", "emailValidator": "邮箱验证", "nosqlExplorer": "数据库浏览器", + "dataExplorer": "Data Explorer", "toDo": "Tasks", "notes": "Notes", "encryptionPlayground": "加密演练场", @@ -348,6 +349,10 @@ "title": "Database Explorer", "description": "浏览 MongoDB 数据库。" }, + "dataExplorer": { + "title": "Data Explorer", + "description": "MongoDB 与 Redis 的统一工作区。通过加密的凭据存储浏览、查询和编辑数据。" + }, "emailValidator": { "title": "邮箱验证", "description": "验证电子邮件地址。" @@ -1585,9 +1590,6 @@ "confirmDeleteConnectionDesc": "This will permanently remove this connection. The database itself will not be affected.", "confirmDropDbDesc": "This will permanently delete the database and all its collections. This cannot be undone.", "confirmDropCollectionDesc": "This will permanently delete the collection and all its documents. This cannot be undone.", - "bulkDeleteFailed": "{count, plural, one {删除 # 个集合失败} other {删除 # 个集合失败}}", - "bulkDeleted": "{count, plural, one {已删除 # 个集合} other {已删除 # 个集合}}", - "bulkDeleteButton": "{count, plural, one {删除 # 个集合} other {删除 # 个集合}}", "bulkDeleteFailed": "{count, plural, other {删除 # 个集合失败}}", "bulkDeleted": "{count, plural, other {已删除 # 个集合}}", "bulkDeleteButton": "删除{count, plural, other { # 个集合}}" @@ -1678,24 +1680,6 @@ "docInsertFailed": "插入文档失败", "docUpdateFailed": "更新文档失败", "indexesLoadFail": "加载索引失败", - "bulkDeleted": "{count, plural, one {已删除 # 个文档} other {已删除 # 个文档}}", - "bulkDeleteFailed": "批量删除失败", - "selectedCount": "{count, plural, one {已选 # 个文档} other {已选 # 个文档}}", - "deleteSelected": "删除所选", - "clearSelection": "清除", - "statusLoading": "加载中…", - "statusShowing": "显示第 {from}–{to} 条,共 {total} 个文档", - "statusEmpty": "0 个文档", - "statusSelected": "已选 {count} 个", - "bulkDeleteTitle": "删除 {count, plural, one {# 个文档} other {# 个文档}}?", - "bulkDeleteDescription": "这将从 {collection} 中永久删除 {count, plural, one {# 个文档} other {# 个文档}}。此操作无法撤销。", - "bulkDeleting": "删除中…", - "bulkDeleteConfirm": "全部删除", - "queryErrorTitle": "加载文档失败", - "retry": "重试", - "docInsertFailed": "插入文档失败", - "docUpdateFailed": "更新文档失败", - "indexesLoadFail": "加载索引失败", "bulkDeleted": "{count, plural, other {已删除 # 个文档}}", "bulkDeleteFailed": "批量删除失败", "selectedCount": "{count, plural, other {已选择 # 个文档}}", @@ -1789,7 +1773,6 @@ "export": "导出", "sheetName": "数据", "formatJson": "JSON", - "pageOnlyNote": "仅导出当前页({count, plural, one {# 个文档} other {# 个文档}})", "pageOnlyNote": "仅导出当前页({count, plural, other {# 个文档}})" }, "jsonTree": { @@ -1806,80 +1789,6 @@ "previewEmpty": "此阶段没有文档", "previewFail": "预览失败" }, - "indexManager": { - "loading": "加载索引中…", - "retry": "重试", - "countLabel": "{count, plural, one {# 个索引} other {# 个索引}}", - "totalSize": "共 {size}", - "statsDocs": "{count, plural, one {# 个文档} other {# 个文档}}", - "statsStorage": "{size} 存储", - "statsAvgObj": "{size} 平均/文档", - "newIndex": "新建索引", - "createTitle": "创建索引", - "fieldPlaceholder": "字段名", - "ascending": "升序", - "descending": "降序", - "addField": "添加字段", - "unique": "唯一", - "sparse": "稀疏", - "ttlLabel": "TTL(秒)", - "ttlPlaceholder": "如 3600", - "cancel": "取消", - "create": "创建", - "creating": "创建中…", - "created": "索引已创建", - "createFailed": "创建索引失败", - "fieldRequired": "字段名为必填项", - "dropped": "索引“{name}”已删除", - "dropFailed": "删除索引失败", - "dropTitle": "删除索引?", - "dropDescription": "这将永久删除索引“{name}”。使用该索引的查询将变慢。", - "dropping": "删除中…", - "dropConfirm": "删除索引", - "badgeSystem": "系统", - "badgeUnique": "唯一", - "badgeSparse": "稀疏", - "badgeTtl": "TTL", - "empty": "未找到索引" - }, - "importDialog": { - "title": "将文档导入到 {collection}", - "onlyJson": "仅支持 .json 文件", - "invalidStructure": "文件必须包含 JSON 数组或对象", - "invalidJson": "无效的 JSON:无法解析文件", - "imported": "{count, plural, one {已导入 # 个文档} other {已导入 # 个文档}}", - "importFailed": "导入失败", - "dropHint": "拖放或点击上传", - "dropSubHint": "支持 JSON 数组或 NDJSON", - "docsCount": "{count, plural, one {# 个文档} other {# 个文档}}", - "previewLabel": "预览(前 3 个文档)", - "moreDocs": "… 以及另外 {count} 个文档", - "cancel": "取消", - "importing": "导入中…", - "importCount": "{count, plural, one {导入 # 个文档} other {导入 # 个文档}}", - "importBtn": "导入" - }, - "schemaView": { - "analyzing": "分析结构中…", - "loadFailed": "分析结构失败", - "retry": "重试", - "noDocs": "没有可分析的文档", - "sampled": "已采样 {docs} 个文档 · {fields} 个字段", - "colField": "字段", - "colTypes": "类型", - "colCoverage": "覆盖率", - "sampleRandom": "Random", - "sampleFirst": "First", - "sampleLast": "Last", - "sampleAll": "All", - "sampleSizeN": "{n} docs", - "allCapped": "capped at 10k", - "validatorTitle": "Validation rules", - "exportSchemaBtn": "Export", - "exportJsonSchema": "JSON Schema", - "exportMongoose": "Mongoose model", - "exportHtml": "HTML report" - }, "indexManager": { "loading": "正在加载索引…", "retry": "重试", @@ -1944,6 +1853,150 @@ "colCoverage": "覆盖率" } }, + "DataExplorer": { + "title": "Data Explorer", + "emptyTitle": "暂无连接", + "emptyBody": "添加 MongoDB 或 Redis 连接以开始浏览。凭据会在您的设备上加密存储。", + "addConnection": "新建连接", + "importLegacy": "从现有工具导入", + "searchConnections": "搜索连接", + "openSidebar": "打开连接", + "unsupportedSource": "不支持的来源", + "unsupportedSourceHint": "此连接由更新版本的应用创建。", + "noResults": "没有连接与您的搜索匹配。", + "connectionActions": "{name} 的操作", + "menuEdit": "编辑", + "menuDelete": "删除", + "deleteTitle": "删除连接?", + "deleteBody": "{name} 及其保存的凭据将从此设备中移除。此操作无法撤销。", + "deleteDocTitle": "删除文档?", + "deleteDocBody": "此文档将从集合中永久删除。此操作无法撤销。", + "closeAll": "全部关闭", + "noTabsTitle": "未打开任何内容", + "noTabsBody": "在侧边栏中选择一个连接以打开标签页。", + "connectionDialog": { + "createTitle": "新建连接", + "editTitle": "编辑连接", + "pickSource": "数据源", + "name": "名称", + "namePlaceholder": "我的连接", + "folder": "文件夹", + "folderPlaceholder": "可选分组", + "color": "颜色", + "colorNone": "无颜色", + "dbType": "数据库类型", + "connectionString": "连接字符串", + "readOnly": "只读", + "readOnlyHint": "阻止此连接的所有写入操作。", + "test": "测试连接", + "testOk": "已连接", + "testFailed": "无法连接", + "save": "保存", + "cancel": "取消" + }, + "importDialog": { + "title": "从现有工具导入", + "loading": "正在数据库浏览器和 Redis 指挥官中查找连接…", + "notReady": "仍在加载您现有的连接 — 请稍后再试。", + "empty": "在数据库浏览器或 Redis 指挥官中未找到连接。", + "allSkipped": "{count, plural, one {# 个连接} other {# 个连接}}已导入。没有新内容可导入。", + "previewHeading": "{count, plural, one {# 个连接} other {# 个连接}}将被导入:", + "skippedNote": "{count, plural, one {# 个连接} other {# 个连接}}已导入,将被跳过。", + "importing": "导入中…", + "confirm": "导入{count, plural, one {# 个连接} other {# 个连接}}" + }, + "toast": { + "saved": "连接已保存", + "deleted": "连接已删除", + "saveFailed": "无法保存连接", + "deleteFailed": "无法删除连接", + "loadFailed": "无法加载连接", + "fetchFailed": "获取 {collection} 的文档失败", + "docDeleted": "文档已删除", + "legacyImportFailed": "无法从数据库浏览器或 Redis 指挥官读取现有连接", + "legacyImportResult": "{imported, plural, =0 {未导入新连接} one {已导入 # 个连接} other {已导入 # 个连接}}{skipped, plural, =0 {} other {,# 个已导入}}{failed, plural, =0 {} other {,# 个失败}}" + }, + "validation": { + "connectionStringRequired": "请输入连接字符串。", + "connectionStringScheme": "连接字符串必须以 mongodb:// 或 mongodb+srv:// 开头。", + "redisUrlRequired": "请输入 Redis URL。", + "redisUrlScheme": "Redis URL 必须以 redis:// 或 rediss:// 开头。" + }, + "redis": { + "urlMode": "URL", + "builderMode": "构建器", + "url": "Redis URL", + "urlPlaceholder": "redis://localhost:6379 或 rediss://用户名:密码@主机:6380", + "urlHint": "支持 redis:// 和 rediss://(TLS)。凭据在存储前会被加密。", + "host": "主机", + "port": "端口", + "username": "用户名(ACL)", + "usernamePlaceholder": "default", + "password": "密码", + "dbIndex": "数据库索引", + "dbIndexPlaceholder": "0", + "tls": "使用 TLS(rediss://)", + "dbLabel": "db {index}", + "noMatches": "没有匹配的数据库", + "tabKeyEditor": "键编辑器", + "tabConsole": "控制台", + "tabServer": "服务器", + "tabPubSub": "Pub/Sub", + "tabMonitor": "监控", + "tabScanner": "扫描器", + "tabSearch": "搜索", + "flush": "清空", + "flushTitle": "清空键", + "flushDescription": "输入一个模式以删除匹配的键,留空则清空整个数据库(需要 DANGER_ZONE=true)。", + "flushPatternLabel": "模式(可选)", + "flushPatternPlaceholder": "例如 session:* — 留空 = FLUSHDB", + "flushing": "正在清空…", + "flushedAll": "数据库已清空", + "flushedKeys": "{count, plural, one {已删除 # 个键} other {已删除 # 个键}}", + "flushFailed": "清空失败" + }, + "mongo": { + "refreshDatabases": "刷新数据库", + "serverMonitor": "服务器监视器", + "connecting": "正在连接…", + "connectionFailed": "无法连接", + "noDatabases": "无数据库", + "loadingCollections": "正在加载集合…", + "collectionsFailed": "无法加载集合", + "noCollections": "无集合", + "noMatches": "没有数据库与您的搜索匹配", + "databaseActions": "数据库 {name} 的操作", + "collectionActions": "集合 {name} 的操作", + "selectCollection": "选择集合 {name}", + "deleteSelected": "删除已选的 {count} 项", + "clearSelection": "清除", + "refresh": "刷新", + "rename": "重命名", + "copyDbName": "复制数据库名", + "browseGridfs": "浏览 GridFS", + "sync": "同步集合", + "dropDatabase": "删除数据库", + "dropCollection": "删除集合", + "toast": { + "connectFailed": "无法连接到 {name}", + "collectionsFailed": "无法加载 {name} 的集合", + "dbNameCopied": "数据库名已复制", + "nameEmpty": "请输入名称。", + "nameTooLong": "该名称过长。", + "nameForbiddenChars": "数据库名称不能包含 / \\ . 或 $", + "nameConsecutiveDots": "数据库名称不能包含连续的点。", + "nameNullChar": "集合名称不能包含空字符。", + "nameReservedPrefix": "集合名称不能以 $ 开头 — 该前缀为保留字符。", + "nameInvalid": "该名称无效。", + "renamed": "已重命名为 {name}", + "renameFailed": "无法重命名", + "dropped": "已删除 {name}", + "dropFailed": "无法删除 {name}", + "bulkDropped": "已删除 {count} 个集合", + "bulkDropFailed": "无法删除 {count} 个集合" + } + } + }, "ApiClient": { "defaults": { "newRequest": "新请求", diff --git a/apps/desktop-ui/src/app/app/data-explorer/layout.tsx b/apps/desktop-ui/src/app/app/data-explorer/layout.tsx new file mode 100644 index 00000000..9ca91ea5 --- /dev/null +++ b/apps/desktop-ui/src/app/app/data-explorer/layout.tsx @@ -0,0 +1,7 @@ +import { generateToolMetadata } from '@/lib/metadata' + +export const metadata = generateToolMetadata('data-explorer') + +export default function DataExplorerLayout({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/apps/desktop-ui/src/app/app/data-explorer/page.tsx b/apps/desktop-ui/src/app/app/data-explorer/page.tsx new file mode 100644 index 00000000..0ff02bd3 --- /dev/null +++ b/apps/desktop-ui/src/app/app/data-explorer/page.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { IconAlertTriangle, IconMenu2 } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import useAuth from "@/utils/useAuth"; +import { useMasterKeyStore } from "@/store/master-key-store"; +import { useVaultGuard } from "@/hooks/use-vault-guard"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; +import { UnifiedTabBar } from "@/components/data-explorer/unified-tab-bar"; +import { ConnectionDialog } from "@/components/data-explorer/connection-dialog"; +import { ImportLegacyDialog } from "@/components/data-explorer/import-legacy-dialog"; +import { UnifiedSidebar } from "@/components/data-explorer/unified-sidebar"; +import { listConnections, touchConnection } from "@/components/data-explorer/connection-service"; +import { getAdapter } from "@/components/data-explorer/sources"; +import { parseStoredTabs } from "@/lib/data-explorer/tab-storage"; +import type { + OpenTabRequest, + UnifiedConnection, + UnifiedTab, +} from "@/components/data-explorer/types"; + +export default function DataExplorerPage() { + const t = useTranslations("DataExplorer"); + const { user } = useAuth(); + const { encryptionKey } = useMasterKeyStore(); + const { isUnlocked, isRestoring } = useVaultGuard(); + const isWide = useMediaQuery("(min-width: 768px)"); + + const [connections, setConnections] = useState([]); + const [tabs, setTabs] = useState([]); + const [activeTabId, setActiveTabId] = useState(null); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const [connectionDialogOpen, setConnectionDialogOpen] = useState(false); + const [editingConnection, setEditingConnection] = useState(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + /** True once a connection fetch has *succeeded*. Gates tab pruning. */ + const [connectionsLoaded, setConnectionsLoaded] = useState(false); + + const reloadConnections = useCallback(async () => { + if (!user || !encryptionKey) return; + try { + setConnections(await listConnections(encryptionKey)); + setConnectionsLoaded(true); + } catch (e) { + toast.error(e instanceof Error ? e.message : t("toast.loadFailed")); + } + }, [user, encryptionKey]); + + useEffect(() => { + void reloadConnections(); + }, [reloadConnections]); + + // Restore tabs, adapter `state` included. + useEffect(() => { + if (!user) return; + const tabsKey = `data_explorer_tabs_${user.uid}`; + const raw = localStorage.getItem(tabsKey); + if (raw) { + // Shape-validated: a value that parses but isn't an array of tabs + // would otherwise crash `tabs.find` on mount and never be cleared. + const restored = parseStoredTabs(raw); + if (restored.length > 0) setTabs(restored); + else localStorage.removeItem(tabsKey); + } + const savedActive = localStorage.getItem(`data_explorer_active_tab_id_${user.uid}`); + if (savedActive) setActiveTabId(savedActive); + setIsInitialized(true); + }, [user]); + + // Persists the whole tab array, adapter `state` included — that is what + // restores a Mongo tab's page/query/sort across sessions. Adapter state is + // contractually structural-only: fetched rows live in the adapter's actions + // hook, never on the tab, so this stays far below the localStorage quota. + useEffect(() => { + if (!isInitialized || !user) return; + const key = `data_explorer_tabs_${user.uid}`; + try { + localStorage.setItem(key, JSON.stringify(tabs)); + } catch (e) { + console.warn("data-explorer tabs: localStorage write failed", e); + try { + localStorage.removeItem(key); + } catch { + /* noop */ + } + } + }, [tabs, isInitialized, user]); + + useEffect(() => { + if (!isInitialized || !user) return; + const key = `data_explorer_active_tab_id_${user.uid}`; + try { + if (activeTabId) localStorage.setItem(key, activeTabId); + else localStorage.removeItem(key); + } catch { + /* noop */ + } + }, [activeTabId, isInitialized, user]); + + const openTab = useCallback( + (connection: UnifiedConnection, req: OpenTabRequest) => { + const id = `${connection.id}:${req.key}`; + setMobileSidebarOpen(false); + setTabs((prev) => { + if (prev.some((tab) => tab.id === id)) return prev; + return [ + ...prev, + { + id, + connectionId: connection.id, + sourceId: connection.sourceId, + title: req.title, + subtitle: req.subtitle, + connectionColor: connection.color ?? null, + // No readOnly copy — the tab bar and the panes read it + // live off the connection, so toggling it takes effect + // on already-open tabs. + state: req.state, + }, + ]; + }); + setActiveTabId(id); + void touchConnection(connection.id).catch(() => {}); + }, + [] + ); + + // Drop tabs whose connection was deleted, so they never fall through to the + // "unsupported source" pane. Gated on BOTH flags: `connections` is [] until + // the first fetch resolves and tabs are restored from localStorage on mount, + // so pruning any earlier would wipe every restored tab on a cold start. A + // failed fetch never sets `connectionsLoaded`, so it never prunes either. + // Zero connections is a legitimate loaded state and does prune. + useEffect(() => { + if (!connectionsLoaded || !isInitialized) return; + const live = new Set(connections.map((c) => c.id)); + if (tabs.some((tab) => !live.has(tab.connectionId))) { + setTabs((prev) => prev.filter((tab) => live.has(tab.connectionId))); + } + const active = tabs.find((tab) => tab.id === activeTabId); + if (active && !live.has(active.connectionId)) setActiveTabId(null); + }, [connections, connectionsLoaded, isInitialized, tabs, activeTabId]); + + const setTabState = useCallback((tabId: string, updater: (prev: unknown) => unknown) => { + setTabs((prev) => + prev.map((tab) => (tab.id === tabId ? { ...tab, state: updater(tab.state) } : tab)) + ); + }, []); + + const closeTab = useCallback( + (tabId: string) => { + setTabs((prev) => { + const idx = prev.findIndex((tab) => tab.id === tabId); + const next = prev.filter((tab) => tab.id !== tabId); + if (activeTabId === tabId) { + setActiveTabId((prev[idx - 1] ?? prev[idx + 1])?.id ?? null); + } + return next; + }); + }, + [activeTabId] + ); + + const closeAllTabs = useCallback(() => { + setTabs([]); + setActiveTabId(null); + }, []); + + // Stable identities — the sidebar memoises its connection rows on them. + const handleAddConnection = useCallback(() => { + setEditingConnection(null); + setConnectionDialogOpen(true); + }, []); + + const handleEditConnection = useCallback((connection: UnifiedConnection) => { + setEditingConnection(connection); + setConnectionDialogOpen(true); + }, []); + + const handleConnectionsChanged = useCallback(() => { + void reloadConnections(); + }, [reloadConnections]); + + const handleImportLegacy = useCallback(() => setImportDialogOpen(true), []); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId) ?? null, + [tabs, activeTabId] + ); + const activeConnection = useMemo( + () => (activeTab ? connections.find((c) => c.id === activeTab.connectionId) ?? null : null), + [activeTab, connections] + ); + + if (isRestoring) return ; + if (!isUnlocked) return ; + + const sidebar = ( + + ); + + const body = (() => { + if (!activeTab) { + return ( +
+

{t("noTabsTitle")}

+

{t("noTabsBody")}

+
+ ); + } + const adapter = getAdapter(activeTab.sourceId); + if (!adapter || !activeConnection?.config) { + return ( +
+ +

{t("unsupportedSource")}

+

+ {t("unsupportedSourceHint")} +

+
+ ); + } + const Pane = adapter.Pane; + return ( + unknown) => setTabState(activeTab.id, updater)} + /> + ); + })(); + + return ( +
+ {isWide ? ( +
{sidebar}
+ ) : ( + + + + {t("title")} + + {sidebar} + + + )} +
+ {/* Narrow viewports keep the sidebar in the Sheet; without this + trigger there is no way to add a connection or open a tab. */} + {!isWide && ( +
+ +
+ )} + +
{body}
+
+ { + setConnectionDialogOpen(open); + if (!open) setEditingConnection(null); + }} + editing={editingConnection} + onSaved={() => void reloadConnections()} + /> + void reloadConnections()} + /> +
+ ); +} diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts new file mode 100644 index 00000000..24ee268a --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/connection-service.test.ts @@ -0,0 +1,93 @@ +import { webcrypto } from "node:crypto"; + +// encryption.ts reaches for `window.crypto` / `window.btoa` / `window.atob`, but +// jest-environment-node exposes none of the `window` global. Same shim as +// src/lib/__tests__/content-envelope.test.ts — polyfill from node:crypto before +// the module under test loads. Do not mock the crypto primitives themselves. +if (typeof globalThis.crypto === "undefined") { + Object.defineProperty(globalThis, "crypto", { value: webcrypto, writable: true, configurable: true }); +} +const g = globalThis as unknown as { window?: Record; btoa: unknown; atob: unknown }; +g.window = g.window ?? {}; +g.window.crypto = globalThis.crypto; +g.window.btoa = g.btoa; +g.window.atob = g.atob; + +import { decodeConnection } from "../connection-service"; +import { encryptData } from "@/lib/encryption"; + +async function testKey(): Promise { + return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [ + "encrypt", + "decrypt", + ]); +} + +describe("decodeConnection", () => { + it("round-trips a Mongo config", async () => { + const key = await testKey(); + const config = { connectionString: "mongodb://localhost:27017", dbType: "mongodb" }; + const { encrypted, iv } = await encryptData(key, JSON.stringify(config)); + + const decoded = await decodeConnection( + { + id: "c1", + userId: "desktop-local", + sourceId: "mongodb", + name: "local", + encryptedData: encrypted, + iv, + createdAt: 1, + lastUsedAt: 1, + }, + key + ); + + expect(decoded.config).toEqual(config); + expect(decoded.sourceId).toBe("mongodb"); + }); + + it("round-trips a Redis config", async () => { + const key = await testKey(); + const config = { redisUrl: "redis://localhost:6379" }; + const { encrypted, iv } = await encryptData(key, JSON.stringify(config)); + + const decoded = await decodeConnection( + { + id: "c2", + userId: "desktop-local", + sourceId: "redis", + name: "cache", + encryptedData: encrypted, + iv, + createdAt: 1, + lastUsedAt: 1, + }, + key + ); + + expect(decoded.config).toEqual(config); + }); + + it("rejects a payload encrypted with a different key", async () => { + const keyA = await testKey(); + const keyB = await testKey(); + const { encrypted, iv } = await encryptData(keyA, JSON.stringify({ redisUrl: "x" })); + + await expect( + decodeConnection( + { + id: "c3", + userId: "desktop-local", + sourceId: "redis", + name: "bad", + encryptedData: encrypted, + iv, + createdAt: 1, + lastUsedAt: 1, + }, + keyB + ) + ).rejects.toBeDefined(); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts new file mode 100644 index 00000000..b4dacfa8 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/mongodb-adapter.test.ts @@ -0,0 +1,72 @@ +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) + +import { mongodbAdapter, mongoNameErrorKey } from "../adapters/mongodb"; +import { validateCollectionName, validateDbName } from "@/lib/nosql-error-sanitizer"; +import messages from "../../../../messages/en.json"; + +/** Resolve a `DataExplorer`-relative key path, e.g. "validation.connectionStringRequired". */ +function resolvesInEnMessages(key: string): boolean { + const parts = key.split("."); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let node: any = (messages as any).DataExplorer; + for (const part of parts) { + if (node == null || typeof node !== "object" || !(part in node)) return false; + node = node[part]; + } + return typeof node === "string" && node.length > 0; +} + +describe("mongodb adapter", () => { + it("starts blank on the generic dialect", () => { + expect(mongodbAdapter.blankConfig()).toEqual({ connectionString: "", dbType: "mongodb" }); + }); + + it("rejects an empty or non-mongo connection string with a real i18n key", () => { + const emptyKey = mongodbAdapter.validate({ connectionString: "", dbType: "mongodb" }); + expect(emptyKey).toBe("validation.connectionStringRequired"); + expect(resolvesInEnMessages(emptyKey as string)).toBe(true); + + const schemeKey = mongodbAdapter.validate({ + connectionString: "redis://localhost:6379", + dbType: "mongodb", + }); + expect(schemeKey).toBe("validation.connectionStringScheme"); + expect(resolvesInEnMessages(schemeKey as string)).toBe(true); + }); + + it("accepts mongodb:// and mongodb+srv://", () => { + expect( + mongodbAdapter.validate({ connectionString: "mongodb://localhost:27017", dbType: "mongodb" }) + ).toBeNull(); + expect( + mongodbAdapter.validate({ + connectionString: "mongodb+srv://u:p@cluster0.abcde.mongodb.net", + dbType: "mongodb", + }) + ).toBeNull(); + }); + + // The mapper matches on the validator's English prose, so it breaks silently + // if that prose changes. This locks each failure mode to a distinct, real key. + it("maps every name-validator failure mode to its own existing key", () => { + const cases: Array<[string, string]> = [ + [mongoNameErrorKey(validateDbName("")), "mongo.toast.nameEmpty"], + [mongoNameErrorKey(validateDbName("d".repeat(65))), "mongo.toast.nameTooLong"], + [mongoNameErrorKey(validateDbName("bad/name")), "mongo.toast.nameForbiddenChars"], + [mongoNameErrorKey(validateCollectionName("a\0b")), "mongo.toast.nameNullChar"], + [mongoNameErrorKey(validateCollectionName("$reserved")), "mongo.toast.nameReservedPrefix"], + [mongoNameErrorKey(validateCollectionName("c".repeat(121))), "mongo.toast.nameTooLong"], + ]; + for (const [actual, expected] of cases) { + expect(actual).toBe(expected); + expect(resolvesInEnMessages(actual)).toBe(true); + } + expect(resolvesInEnMessages("mongo.toast.nameInvalid")).toBe(true); + expect(resolvesInEnMessages("mongo.toast.nameConsecutiveDots")).toBe(true); + }); + + it("identifies itself consistently", () => { + expect(mongodbAdapter.id).toBe("mongodb"); + expect(mongodbAdapter.label).toBe("MongoDB"); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts new file mode 100644 index 00000000..48f236f0 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/redis-adapter.test.ts @@ -0,0 +1,162 @@ +// redis.tsx (via RedisConnectionForm) imports next-intl's useTranslations; +// mock it per this repo's established pattern for test files that +// transitively import a next-intl-consuming component (see +// mongodb-adapter.test.ts and sources.test.ts). +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) +// redis.tsx's pane imports @/components/ui/resizable, whose react-resizable-panels +// build ships as ESM and cannot be parsed under this node-environment jest +// config. These logic tests never render the pane, so a stub is enough. +jest.mock("react-resizable-panels", () => ({ + Panel: () => null, + PanelGroup: () => null, + PanelResizeHandle: () => null, +})) + +import { redisAdapter, buildRedisUrl, parseRedisUrl, sanitizeRedisError } from "../adapters/redis"; + +describe("parseRedisUrl", () => { + it("round-trips a URL built by buildRedisUrl", () => { + const url = buildRedisUrl({ + host: "prod", + port: "6380", + username: "u@x", + password: "p:w", + db: "3", + tls: true, + }); + expect(parseRedisUrl(url)).toEqual({ + host: "prod", + port: "6380", + username: "u@x", + password: "p:w", + db: "3", + tls: true, + }); + }); + + it("defaults host and port for a bare URL", () => { + expect(parseRedisUrl("redis://localhost:6379")).toEqual({ + host: "localhost", + port: "6379", + username: "", + password: "", + db: "", + tls: false, + }); + }); + + // A failed parse must fall back to the form's defaults, never throw — + // `validate` only checks the scheme, so a malformed URL is reachable. + it("returns null instead of throwing on unparseable or foreign URLs", () => { + expect(parseRedisUrl("redis://")).toBeNull(); + expect(parseRedisUrl("not a url")).toBeNull(); + expect(parseRedisUrl("http://example.com")).toBeNull(); + expect(parseRedisUrl("")).toBeNull(); + }); +}); + +describe("buildRedisUrl", () => { + const base = { host: "", port: "", username: "", password: "", db: "", tls: false }; + + it("defaults host and port", () => { + expect(buildRedisUrl(base)).toBe("redis://localhost:6379"); + }); + + it("switches scheme for TLS", () => { + expect(buildRedisUrl({ ...base, tls: true })).toBe("rediss://localhost:6379"); + }); + + it("percent-encodes credentials and appends the db index", () => { + expect( + buildRedisUrl({ ...base, host: "h", port: "6380", username: "u@x", password: "p:w", db: "3" }) + ).toBe("redis://u%40x:p%3Aw@h:6380/3"); + }); +}); + +describe("redis adapter", () => { + it("starts blank on the conventional local URL", () => { + expect(redisAdapter.blankConfig()).toEqual({ redisUrl: "redis://localhost:6379" }); + }); + + // Assert the exact KEY, not mere truthiness — a truthiness check would + // still pass if the adapter regressed to returning raw English. + it("rejects an empty or non-redis URL with the right i18n keys", () => { + expect(redisAdapter.validate({ redisUrl: "" })).toBe("validation.redisUrlRequired"); + expect(redisAdapter.validate({ redisUrl: "mongodb://localhost:27017" })).toBe( + "validation.redisUrlScheme" + ); + }); + + // Mirror Task 8's guard: a misspelled key renders as a broken path to the + // user, and an exact-match assertion alone would not catch it. + it("returns only keys that resolve in messages/en.json", () => { + const messages = require("../../../../messages/en.json"); + for (const key of ["validation.redisUrlRequired", "validation.redisUrlScheme"]) { + const resolved = key + .split(".") + .reduce((node, part) => (node as Record)?.[part], + messages.DataExplorer); + expect(typeof resolved).toBe("string"); + expect(resolved).not.toBe(""); + } + }); + + it("accepts redis:// and rediss://", () => { + expect(redisAdapter.validate({ redisUrl: "redis://localhost:6379" })).toBeNull(); + expect(redisAdapter.validate({ redisUrl: "rediss://user:pass@host:6380/2" })).toBeNull(); + }); + + it("identifies itself consistently", () => { + expect(redisAdapter.id).toBe("redis"); + expect(redisAdapter.label).toBe("Redis"); + }); +}); + +describe("sanitizeRedisError", () => { + // CRITICAL regression: the old implementation failed OPEN — when + // `new URL(redisUrl)` threw, the catch returned the raw, unsanitized + // message. `validate()` only checks the scheme regex, not full URL + // well-formedness, so a `redisUrl` that clears validation can still + // blow up `new URL()` (e.g. a non-numeric port). + it("still strips credentials when redisUrl fails to parse", () => { + // Passes validate()'s scheme check but is invalid to `new URL()` + // (non-numeric port) — this is the exact fail-open trigger. + expect(() => new URL("redis://host:notaport")).toThrow(); + expect(redisAdapter.validate({ redisUrl: "redis://host:notaport" })).toBeNull(); + + const message = "connect ECONNREFUSED to redis://admin:hunter2@host:notaport"; + const result = sanitizeRedisError(message, "redis://host:notaport"); + expect(result).not.toContain("hunter2"); + expect(result).not.toContain("admin"); + }); + + // A password containing a character that percent-encodes (@) must be + // stripped whichever form (raw or encoded) it shows up in — the old + // implementation only stripped the exact encoded value `new URL()` + // yields, so a driver echoing the raw password would slip through. + it("strips a credential in both its raw and percent-encoded form", () => { + const redisUrl = buildRedisUrl({ + host: "h", port: "6379", username: "u", password: "p@ss", db: "", tls: false, + }); + expect(redisUrl).toBe("redis://u:p%40ss@h:6379"); + + const rawLeak = sanitizeRedisError("auth failed for password p@ss", redisUrl); + expect(rawLeak).not.toContain("p@ss"); + + const encodedLeak = sanitizeRedisError("auth failed for password p%40ss", redisUrl); + expect(encodedLeak).not.toContain("p%40ss"); + }); + + it("strips a plain redis:// URL's username and password from a message", () => { + const message = "connect ECONNREFUSED at redis://user:pass@host:6379"; + const result = sanitizeRedisError(message, "redis://user:pass@host:6379"); + expect(result).not.toContain("user:pass"); + expect(result).not.toContain("user"); + expect(result).not.toContain("pass"); + }); + + it("passes through a message with no URL in it, still readable", () => { + const message = "ECONNREFUSED 127.0.0.1:6379"; + expect(sanitizeRedisError(message, "redis://localhost:6379")).toBe(message); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts new file mode 100644 index 00000000..127e7a29 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/sources.test.ts @@ -0,0 +1,46 @@ +// sources.ts now pulls in a real adapter (mongodb.tsx) whose form uses +// next-intl; mock it per this repo's established pattern for test files that +// transitively import a next-intl-consuming component (see +// api-client/__tests__/collections-context.test.ts). +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) +// The Redis adapter's pane imports @/components/ui/resizable, whose +// react-resizable-panels build ships as ESM and cannot be parsed under this +// node-environment jest config. The registry tests never render, so stub it. +jest.mock("react-resizable-panels", () => ({ + Panel: () => null, + PanelGroup: () => null, + PanelResizeHandle: () => null, +})) + +import { SOURCES, SOURCE_ORDER, getAdapter } from "../sources"; + +describe("source registry", () => { + it("keys every adapter by its own id", () => { + for (const [key, adapter] of Object.entries(SOURCES)) { + expect(adapter.id).toBe(key); + } + }); + + it("orders exactly the registered sources, no more, no less", () => { + expect([...SOURCE_ORDER].sort()).toEqual(Object.keys(SOURCES).sort()); + }); + + it("gives every adapter the full contract", () => { + for (const adapter of Object.values(SOURCES)) { + expect(typeof adapter.label).toBe("string"); + expect(adapter.label.length).toBeGreaterThan(0); + expect(typeof adapter.accent).toBe("string"); + expect(typeof adapter.blankConfig).toBe("function"); + expect(typeof adapter.validate).toBe("function"); + expect(typeof adapter.testConnection).toBe("function"); + expect(adapter.icon).toBeDefined(); + expect(adapter.ConnectionForm).toBeDefined(); + expect(adapter.SidebarTree).toBeDefined(); + expect(adapter.Pane).toBeDefined(); + } + }); + + it("returns null for an unknown source instead of throwing", () => { + expect(getAdapter("not-a-real-source")).toBeNull(); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/__tests__/tab-storage.test.ts b/apps/desktop-ui/src/components/data-explorer/__tests__/tab-storage.test.ts new file mode 100644 index 00000000..566b7d92 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/__tests__/tab-storage.test.ts @@ -0,0 +1,42 @@ +import { parseStoredTabs } from "@/lib/data-explorer/tab-storage"; + +const validTab = { + id: "c1:orders", + connectionId: "c1", + sourceId: "mongodb", + title: "orders", + subtitle: "prod › orders", + connectionColor: "#ff0000", + state: { dbName: "shop", collectionName: "orders" }, +}; + +describe("parseStoredTabs", () => { + it("keeps a well-formed tab", () => { + expect(parseStoredTabs(JSON.stringify([validTab]))).toEqual([validTab]); + }); + + it("returns [] for non-array and unparseable values", () => { + expect(parseStoredTabs("{}")).toEqual([]); + expect(parseStoredTabs("null")).toEqual([]); + expect(parseStoredTabs('"nope"')).toEqual([]); + expect(parseStoredTabs("not json")).toEqual([]); + }); + + it("drops entries missing fields the shell or panes read", () => { + const bad = [ + null, + { ...validTab, id: undefined }, + { ...validTab, connectionId: 7 }, + { ...validTab, sourceId: undefined }, + { ...validTab, title: undefined }, + { ...validTab, state: undefined }, + { ...validTab, state: null }, + ]; + expect(parseStoredTabs(JSON.stringify([...bad, validTab]))).toEqual([validTab]); + }); + + it("drops the retired readOnly snapshot instead of carrying it forward", () => { + const [restored] = parseStoredTabs(JSON.stringify([{ ...validTab, readOnly: true }])); + expect(restored).not.toHaveProperty("readOnly"); + }); +}); diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx new file mode 100644 index 00000000..e2fac74c --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/adapters/mongodb.tsx @@ -0,0 +1,1121 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + IconActivity, + IconAlertCircle, + IconArrowsExchange, + IconBrandMongodb, + IconChevronRight, + IconCopy, + IconDatabase, + IconDots, + IconEdit, + IconFiles, + IconFolder, + IconRefresh, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { + sanitizeError, + validateCollectionName, + validateDbName, +} from "@/lib/nosql-error-sanitizer"; +import { + DB_DIALECTS, + detectDbType, + normalizeConnectionString, + type DbType, +} from "@/lib/nosql-dialects"; +import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; +import { + SidebarDialogs, + type BulkDeleteState, + type DeleteConnState, + type DropCollState, + type DropDbState, + type RenameCollectionState, + type RenameDatabaseState, +} from "@/components/nosql-explorer/sidebar-dialogs"; +import { DocumentView } from "@/components/nosql-explorer/document-view"; +import { GridFsBrowser } from "@/components/nosql-explorer/gridfs-browser"; +import { SyncDialog } from "@/components/nosql-explorer/sync-dialog"; +import { ServerMonitor } from "@/components/nosql-explorer/server-monitor"; +import type { Collection, Database, SavedConnection } from "@/components/nosql-explorer/types"; +import { useMongoActions } from "@/lib/data-explorer/mongo-actions"; +import type { ConnectionFormProps, PaneProps, SidebarTreeProps, SourceAdapter } from "../types"; + +export interface MongoConfig { + connectionString: string; + dbType: DbType; +} + +export interface MongoTabState { + dbName: string; + collectionName: string; + page: number; + limit: number; + query: string; + sortField: string | null; + sortDirection: "asc" | "desc"; +} + +function blankConfig(): MongoConfig { + return { connectionString: "", dbType: "mongodb" }; +} + +/** Keys are relative to the `DataExplorer` namespace; the dialog resolves them via t(). */ +function validate(config: MongoConfig): string | null { + const value = config.connectionString.trim(); + if (!value) return "validation.connectionStringRequired"; + if (!/^mongodb(\+srv)?:\/\//i.test(value)) { + return "validation.connectionStringScheme"; + } + return null; +} + +async function testConnection(config: MongoConfig): Promise { + const connectionString = normalizeConnectionString( + config.dbType, + config.connectionString.trim() + ); + const res = await apiFetch("/api/nosql/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionString }), + }); + const data = await res.json(); + // Sanitised so credentials embedded in the connection string never surface. + // No server message to report → empty message; the dialog supplies its own copy. + if (!res.ok || !data.success) throw new Error(data.error ? sanitizeError(data.error) : ""); +} + +/** + * Controlled-input form: reports values up via `onSubmit`. Never persists, + * never calls the connection service, never toasts — the connection dialog + * owns validate → testConnection → persist and renders `error` inline. + */ +function MongoConnectionForm({ + initial, + saving, + error, + onTest, + testState, + onSubmit, + onCancel, +}: ConnectionFormProps) { + const t = useTranslations("DataExplorer.connectionDialog"); + const [name, setName] = useState(initial.name); + const [folder, setFolder] = useState(initial.folder ?? ""); + const [color, setColor] = useState(initial.color ?? null); + const [readOnly, setReadOnly] = useState(initial.readOnly ?? false); + const [connectionString, setConnectionString] = useState(initial.config.connectionString); + + /** + * Inferred from the connection string, not picked by the user — the source + * dropdown already said "MongoDB" and asking again read as a duplicate. + * + * `dbType` is still load-bearing: `normalizeConnectionString` appends + * `retryWrites=false` for DocumentDB and Cosmos, which reject the driver's + * default and would otherwise fail writes silently. Both are identifiable + * by their managed hostnames, which is exactly what `detectDbType` matches. + * It falls back to "mongodb" when it cannot tell, so keep whatever the + * connection was saved with in that case — an explicit FerretDB choice from + * an older build then survives an edit. + */ + const dbType = useMemo(() => { + const detected = detectDbType(connectionString); + return detected === "mongodb" ? initial.config.dbType : detected; + }, [connectionString, initial.config.dbType]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + onSubmit({ name, folder, color, readOnly, config: { connectionString, dbType } }); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder={t("namePlaceholder")} + disabled={saving} + /> +
+ +
+ + setFolder(e.target.value)} + placeholder={t("folderPlaceholder")} + disabled={saving} + /> +
+ +
+ + setConnectionString(e.target.value)} + placeholder={DB_DIALECTS[dbType].placeholder} + disabled={saving} + className="font-mono text-sm" + /> +
+ +
+ +
+ + {CONNECTION_COLORS.map((c) => ( +
+
+ +
+
+ +

{t("readOnlyHint")}

+
+ +
+ + {error && ( +
+ {error} +
+ )} + {testState === "ok" && !error && ( +
+ {t("testOk")} +
+ )} + +
+ + + +
+
+ ); +} + +/* ------------------------------------------------------------------ tree */ + +/** + * POST helper for every tree endpoint. Errors are sanitised here so a + * connection string (which carries the password) can never reach a toast. + */ +async function post(path: string, body: Record): Promise { + const res = await apiFetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data?.success === false) throw new Error(sanitizeError(data?.error ?? "")); + return data as T; +} + +/** + * Sanitised server message when there is one, otherwise translated fallback. + * Sanitised again here: `post` already sanitises what it throws, but this also + * runs on errors from anywhere else, and connection strings carry passwords. + */ +function errorText(e: unknown, fallback: string): string { + const message = e instanceof Error ? sanitizeError(e.message) : ""; + return message || fallback; +} + +/** + * Maps a `validateDbName` / `validateCollectionName` failure onto its own + * translated key, so the user learns *why* the name was rejected. The library + * returns English prose; matching its distinct phrases keeps the reason precise + * without duplicating the rules. Exported so a unit test locks the mapping to + * the library's real output. + */ +export function mongoNameErrorKey(result: { valid: boolean; error?: string }): string { + const e = result.error ?? ""; + if (/non-empty/i.test(e)) return "mongo.toast.nameEmpty"; + if (/exceed/i.test(e)) return "mongo.toast.nameTooLong"; + if (/forbidden characters/i.test(e)) return "mongo.toast.nameForbiddenChars"; + if (/consecutive dots/i.test(e)) return "mongo.toast.nameConsecutiveDots"; + if (/null character/i.test(e)) return "mongo.toast.nameNullChar"; + if (/start with/i.test(e)) return "mongo.toast.nameReservedPrefix"; + return "mongo.toast.nameInvalid"; +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +interface CollectionsState { + loading: boolean; + items: Collection[] | null; + error: string | null; +} + +interface CollectionRowProps { + dbName: string; + collection: Collection; + readOnly: boolean; + selected: boolean; + /** All callbacks take the entity as arguments so the parent can keep them + * `useCallback`-stable — an inline arrow per row would defeat React.memo. */ + onOpen: (dbName: string, collectionName: string) => void; + onToggleSelect: (dbName: string, collectionName: string) => void; + onRename: (dbName: string, collectionName: string) => void; + onSync: (dbName: string, collectionName: string) => void; + onDrop: (dbName: string, collectionName: string) => void; +} + +const CollectionRow = React.memo(function CollectionRow({ + dbName, + collection, + readOnly, + selected, + onOpen, + onToggleSelect, + onRename, + onSync, + onDrop, +}: CollectionRowProps) { + const t = useTranslations("DataExplorer"); + const count = collection.documentCount; + + return ( +
+ {!readOnly && ( + onToggleSelect(dbName, collection.name)} + aria-label={t("mongo.selectCollection", { name: collection.name })} + className={cn( + "mx-1 size-3 shrink-0 cursor-pointer accent-primary transition-opacity", + selected + ? "opacity-100" + : "opacity-100 md:opacity-0 md:group-focus-within/col:opacity-100 md:group-hover/col:opacity-100" + )} + /> + )} + + {!readOnly && ( + + + + + + onRename(dbName, collection.name)}> + + {t("mongo.rename")} + + onSync(dbName, collection.name)}> + + {t("mongo.sync")} + + + onDrop(dbName, collection.name)} + > + + {t("mongo.dropCollection")} + + + + )} +
+ ); +}); + +// Closed-dialog constants: stable identities, and the shared `SidebarDialogs` +// only reads `open` / names / `newName` from these — `connection` stays null +// because this tree already owns the target connection. +const CLOSED_RENAME_COLL: RenameCollectionState = { + open: false, connection: null, dbName: "", collectionName: "", newName: "", +}; +const CLOSED_RENAME_DB: RenameDatabaseState = { + open: false, connection: null, dbName: "", newName: "", +}; +const CLOSED_DROP_DB: DropDbState = { open: false, connIndex: null, dbName: "" }; +const CLOSED_DROP_COLL: DropCollState = { + open: false, connIndex: null, dbName: "", collectionName: "", +}; +// Connection delete lives in the unified shell, not in this tree — that branch +// of `SidebarDialogs` stays permanently closed. +const NO_DELETE_CONN: DeleteConnState = { open: false, index: null }; +const noop = () => { }; +/** `SidebarDialogs` wants a `{ open }` object; these keep the identity stable. */ +const BULK_STATE: Record<"open" | "closed", BulkDeleteState> = { + open: { open: true }, + closed: { open: false }, +}; + +/** Selection key format `SidebarDialogs` parses for its bulk-delete list. */ +function selectionKey(connectionId: string, dbName: string, collectionName: string): string { + return `${connectionId}|${dbName}|${collectionName}`; +} + +function MongoSidebarTree({ + connection, + config, + openTab, + onConnectionsChanged, + searchQuery, + siblings, +}: SidebarTreeProps) { + const t = useTranslations("DataExplorer"); + const { copyToClipboard } = useCopyToClipboard(); + const readOnly = !!connection.readOnly; + // DocumentDB/Cosmos reject the driver default retryWrites=true. + const connectionString = useMemo( + () => normalizeConnectionString(config?.dbType ?? "mongodb", config?.connectionString ?? ""), + [config?.dbType, config?.connectionString] + ); + + const [databases, setDatabases] = useState(null); + // Seeded true: the mount effect connects immediately, and a false start + // flashes "No databases" for one paint. + const [dbLoading, setDbLoading] = useState(true); + const [dbError, setDbError] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set()); + const [collections, setCollections] = useState>({}); + /** Multi-select: key → the entity, so nothing has to re-parse the key. */ + const [selected, setSelected] = useState>( + () => new Map() + ); + const [bulkOpen, setBulkOpen] = useState(false); + const [bulkDeleting, setBulkDeleting] = useState(false); + + const [monitorOpen, setMonitorOpen] = useState(false); + const [gridfsDb, setGridfsDb] = useState(null); + const [syncSource, setSyncSource] = useState<{ dbName: string; collectionName: string } | null>(null); + const [renameColl, setRenameColl] = useState(CLOSED_RENAME_COLL); + const [renameDb, setRenameDb] = useState(CLOSED_RENAME_DB); + const [dropDb, setDropDb] = useState(CLOSED_DROP_DB); + const [dropColl, setDropColl] = useState(CLOSED_DROP_COLL); + + /** There is no databases endpoint — the list comes back from connect. */ + const loadDatabases = useCallback(async () => { + setDbLoading(true); + setDbError(null); + try { + const data = await post<{ databases?: Database[] }>("/api/nosql/connect", { + connectionString, + }); + setDatabases(data.databases ?? []); + } catch (e) { + setDatabases(null); + setDbError(errorText(e, t("mongo.connectionFailed"))); + toast.error(t("mongo.toast.connectFailed", { name: connection.name })); + } finally { + setDbLoading(false); + } + }, [connectionString, connection.name, t]); + + const loadCollections = useCallback( + async (dbName: string) => { + setCollections((prev) => ({ + ...prev, + [dbName]: { loading: true, items: prev[dbName]?.items ?? null, error: null }, + })); + try { + const data = await post<{ collections?: Collection[] }>("/api/nosql/collections", { + connectionString, + dbName, + }); + setCollections((prev) => ({ + ...prev, + [dbName]: { loading: false, items: data.collections ?? [], error: null }, + })); + } catch (e) { + setCollections((prev) => ({ + ...prev, + [dbName]: { + loading: false, + items: null, + error: errorText(e, t("mongo.collectionsFailed")), + }, + })); + toast.error(t("mongo.toast.collectionsFailed", { name: dbName })); + } + }, + [connectionString, t] + ); + + // The tree only mounts once its connection row is expanded, so mounting is + // the expand. Keyed on the server alone: `loadDatabases` also closes over + // `t`, whose identity next-intl does not guarantee across renders. + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { void loadDatabases(); }, [connectionString]); + + const toggleDatabase = useCallback( + (dbName: string) => { + const isOpen = expanded.has(dbName); + setExpanded((prev) => { + const next = new Set(prev); + if (isOpen) next.delete(dbName); + else next.add(dbName); + return next; + }); + if (!isOpen && !collections[dbName]) void loadCollections(dbName); + }, + [expanded, collections, loadCollections] + ); + + const openCollection = useCallback( + (dbName: string, collectionName: string) => { + openTab({ + key: `${dbName}/${collectionName}`, + title: collectionName, + subtitle: `${connection.name} › ${dbName}`, + state: { + dbName, + collectionName, + page: 1, + limit: 50, + query: "{}", + sortField: null, + sortDirection: "asc", + }, + }); + }, + [openTab, connection.name] + ); + + const toggleSelect = useCallback( + (dbName: string, collectionName: string) => { + const key = selectionKey(connection.id, dbName, collectionName); + setSelected((prev) => { + const next = new Map(prev); + if (!next.delete(key)) next.set(key, { dbName, collectionName }); + return next; + }); + }, + [connection.id] + ); + + const requestRenameCollection = useCallback((dbName: string, collectionName: string) => { + setRenameColl({ open: true, connection: null, dbName, collectionName, newName: collectionName }); + }, []); + + const requestSync = useCallback((dbName: string, collectionName: string) => { + setSyncSource({ dbName, collectionName }); + }, []); + + const requestDropCollection = useCallback((dbName: string, collectionName: string) => { + setDropColl({ open: true, connIndex: 0, dbName, collectionName }); + }, []); + + async function handleRenameCollection() { + const { dbName, collectionName, newName } = renameColl; + const name = newName.trim(); + const check = validateCollectionName(name); + if (!check.valid) { + toast.error(t(mongoNameErrorKey(check))); + return; + } + try { + await post("/api/nosql/collection/rename", { + connectionString, + dbName, + collectionName, + newCollectionName: name, + }); + toast.success(t("mongo.toast.renamed", { name })); + setRenameColl(CLOSED_RENAME_COLL); + void loadCollections(dbName); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.renameFailed"))); + } + } + + async function handleRenameDatabase() { + const { dbName, newName } = renameDb; + const name = newName.trim(); + const check = validateDbName(name); + if (!check.valid) { + toast.error(t(mongoNameErrorKey(check))); + return; + } + try { + await post("/api/nosql/database/rename", { + connectionString, + oldDbName: dbName, + newDbName: name, + }); + toast.success(t("mongo.toast.renamed", { name })); + setRenameDb(CLOSED_RENAME_DB); + void loadDatabases(); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.renameFailed"))); + } + } + + async function handleDropDatabase() { + const { dbName } = dropDb; + try { + await post("/api/nosql/database/drop", { connectionString, dbName }); + toast.success(t("mongo.toast.dropped", { name: dbName })); + void loadDatabases(); + onConnectionsChanged(); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.dropFailed", { name: dbName }))); + } finally { + setDropDb(CLOSED_DROP_DB); + } + } + + async function handleDropCollection() { + const { dbName, collectionName } = dropColl; + try { + await post("/api/nosql/collection/drop", { connectionString, dbName, collectionName }); + toast.success(t("mongo.toast.dropped", { name: collectionName })); + // Drop it from the multi-select too, or the footer counts a row that is gone. + setSelected((prev) => { + const next = new Map(prev); + next.delete(selectionKey(connection.id, dbName, collectionName)); + return next; + }); + void loadCollections(dbName); + onConnectionsChanged(); + } catch (e) { + toast.error(errorText(e, t("mongo.toast.dropFailed", { name: collectionName }))); + } finally { + setDropColl(CLOSED_DROP_COLL); + } + } + + /** One drop per selected collection — the API has no bulk endpoint. */ + async function handleBulkDelete() { + setBulkDeleting(true); + const targets = Array.from(selected.values()); + const touchedDbs = new Set(targets.map((s) => s.dbName)); + let failed = 0; + for (const { dbName, collectionName } of targets) { + try { + await post("/api/nosql/collection/drop", { connectionString, dbName, collectionName }); + } catch { + // Reason is per-collection and already sanitised; the summary + // toast below reports the count so one failure cannot spam N toasts. + failed += 1; + } + } + setBulkDeleting(false); + setBulkOpen(false); + setSelected(new Map()); + for (const dbName of touchedDbs) void loadCollections(dbName); + if (failed > 0) toast.error(t("mongo.toast.bulkDropFailed", { count: failed })); + else toast.success(t("mongo.toast.bulkDropped", { count: targets.length })); + onConnectionsChanged(); + } + + // Every saved Mongo-family connection is a sync target, so a collection can + // be copied to another server — `siblings` already carries decrypted configs. + const syncTargets = useMemo( + () => + siblings.flatMap((sibling) => { + const cfg = sibling.config as MongoConfig | undefined; + if (!cfg?.connectionString) return []; + return [ + { + id: sibling.id, + userId: sibling.userId, + encryptedData: sibling.encryptedData, + iv: sibling.iv, + connectionString: normalizeConnectionString( + cfg.dbType ?? "mongodb", + cfg.connectionString + ), + name: sibling.name, + color: sibling.color, + readOnly: sibling.readOnly, + dbType: cfg.dbType, + createdAt: sibling.createdAt, + lastUsedAt: sibling.lastUsedAt, + }, + ]; + }), + [siblings] + ); + + // Search comes from the shell's one search box. A connection whose own name + // matches shows its whole tree; otherwise levels filter on their own names, + // and a database survives when one of its loaded collections matches. + const q = searchQuery.trim().toLowerCase(); + const showAll = !q || connection.name.toLowerCase().includes(q); + const matches = (value: string) => value.toLowerCase().includes(q); + const visibleDatabases = (databases ?? []).filter( + (db) => + showAll || + matches(db.name) || + (collections[db.name]?.items ?? []).some((col) => matches(col.name)) + ); + + /** Only this connection is named in the bulk-delete list `SidebarDialogs` renders. */ + const dialogRows = useMemo( + () => syncTargets.filter((c) => c.id === connection.id).map((c) => ({ connection: c })), + [syncTargets, connection.id] + ); + const selectedKeys = useMemo(() => new Set(selected.keys()), [selected]); + + return ( +
+
+ + +
+ + {dbLoading && databases === null ? ( +

{t("mongo.connecting")}

+ ) : dbError ? ( +

+ + {t("mongo.connectionFailed")} +

+ ) : databases === null || databases.length === 0 ? ( +

{t("mongo.noDatabases")}

+ ) : visibleDatabases.length === 0 ? ( +

{t("mongo.noMatches")}

+ ) : ( + visibleDatabases.map((db) => { + const isOpen = expanded.has(db.name); + const state = collections[db.name]; + const dbMatches = showAll || matches(db.name); + const visibleCols = state?.items + ? state.items + .filter((col) => dbMatches || matches(col.name)) + .sort((a, b) => a.name.localeCompare(b.name)) + : null; + return ( +
+
+ + + + + + + void loadCollections(db.name)}> + + {t("mongo.refresh")} + + + void copyToClipboard(db.name, t("mongo.toast.dbNameCopied")) + } + > + + {t("mongo.copyDbName")} + + setGridfsDb(db.name)}> + + {t("mongo.browseGridfs")} + + {!readOnly && ( + <> + + setRenameDb({ + open: true, + connection: null, + dbName: db.name, + newName: db.name, + }) + } + > + + {t("mongo.rename")} + + + + setDropDb({ open: true, connIndex: 0, dbName: db.name }) + } + > + + {t("mongo.dropDatabase")} + + + )} + + +
+ + {isOpen && ( +
+ {state?.loading || !state ? ( +

+ {t("mongo.loadingCollections")} +

+ ) : state.error ? ( +

+ + {t("mongo.collectionsFailed")} +

+ ) : !visibleCols || visibleCols.length === 0 ? ( +

+ {t("mongo.noCollections")} +

+ ) : ( + visibleCols.map((col) => ( + + )) + )} +
+ )} +
+ ); + }) + )} + + {selected.size > 0 && ( +
+ + +
+ )} + + void handleRenameCollection()} + renameDatabaseDialog={renameDb} + setRenameDatabaseDialog={setRenameDb} + onRenameDatabase={() => void handleRenameDatabase()} + deleteConnDialog={NO_DELETE_CONN} + setDeleteConnDialog={noop} + onConfirmDeleteConnection={noop} + dropDbDialog={dropDb} + setDropDbDialog={setDropDb} + onConfirmDropDatabase={() => void handleDropDatabase()} + dropCollDialog={dropColl} + setDropCollDialog={setDropColl} + onConfirmDropCollection={() => void handleDropCollection()} + bulkDeleteDialog={BULK_STATE[bulkOpen ? "open" : "closed"]} + setBulkDeleteDialog={(s) => setBulkOpen(s.open)} + selectedCollections={selectedKeys} + isBulkDeleting={bulkDeleting} + onConfirmBulkDelete={() => void handleBulkDelete()} + /> + + {monitorOpen && ( + setMonitorOpen(false)} + /> + )} + {gridfsDb && ( + setGridfsDb(null)} + /> + )} + {syncSource && ( + setSyncSource(null)} + /> + )} +
+ ); +} + +/* ------------------------------------------------------------------ pane */ + +/** + * Document pane. `DocumentView` already hosts the query builder, aggregation + * pipeline builder, schema view, index manager, explain, import/export and + * codegen, so wiring its props is the whole feature. + * + * A `setState` here is what triggers a refetch — `useMongoActions` refreshes + * from an effect keyed on the tab state, so no handler calls `refresh()` itself + * (the legacy page did, and doing both would double-fetch). + * + * Errors and toasts are owned downstream: mutations sanitise and toast inside + * the hook before re-throwing, and `IndexManager` toasts what `dropIndex` / + * `createIndex` throw and reloads the index list after each mutation. + * + * Single-document delete is the one exception. `DocumentView`'s trash button + * calls `onDelete` straight away with no prompt of its own (unlike bulk delete, + * which it confirms internally), so the confirmation has to live out here — the + * legacy page owns the same dialog. Without it one misclick deletes a document + * irreversibly. + */ +function MongoPane({ connection, config, state, setState }: PaneProps) { + const t = useTranslations("DataExplorer"); + const readOnly = connection.readOnly ?? false; + const actions = useMongoActions(config, state, readOnly); + const [confirmId, setConfirmId] = useState(null); + const [deleting, setDeleting] = useState(false); + + async function confirmDelete() { + if (!confirmId || deleting) return; + setDeleting(true); + try { + await actions.remove(confirmId); + } catch { + // `remove` already toasted the sanitised reason and re-threw only so a + // caller could stay open. Swallowed so the rejection cannot go + // uncaught; like the legacy dialog, this one closes either way. + } finally { + setDeleting(false); + setConfirmId(null); + } + } + + return ( + <> + setConfirmId(id)} + onSearch={(query) => setState((prev) => ({ ...prev, query, page: 1 }))} + onPageChange={(page) => setState((prev) => ({ ...prev, page }))} + onLimitChange={(limit) => setState((prev) => ({ ...prev, limit, page: 1 }))} + onSortChange={(sortField, sortDirection) => + setState((prev) => ({ ...prev, sortField, sortDirection, page: 1 })) + } + onBulkDelete={actions.bulkDelete} + onImport={actions.importDocuments} + onLoadSchema={actions.loadSchema} + onLoadIndexes={actions.loadIndexes} + onDropIndex={actions.dropIndex} + onCreateIndex={actions.createIndex} + onExplain={actions.explain} + onPreviewPipeline={actions.previewPipeline} + readOnly={readOnly} + /> + + { + if (!open && !deleting) setConfirmId(null); + }} + > + + + {t("deleteDocTitle")} + {t("deleteDocBody")} + + + + {t("connectionDialog.cancel")} + + { + e.preventDefault(); + void confirmDelete(); + }} + > + {t("menuDelete")} + + + + + + ); +} + +export const mongodbAdapter: SourceAdapter = { + id: "mongodb", + label: "MongoDB", + icon: IconBrandMongodb, + accent: "text-green-500", + blankConfig, + validate, + testConnection, + ConnectionForm: MongoConnectionForm, + SidebarTree: MongoSidebarTree, + Pane: MongoPane, +}; diff --git a/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx new file mode 100644 index 00000000..9d3f36ac --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/adapters/redis.tsx @@ -0,0 +1,766 @@ +"use client"; + +import React, { useCallback, useState, type FormEvent } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + IconActivity, + IconBrandRedux, + IconBroadcast, + IconChartBar, + IconDatabase, + IconEye, + IconSearch, + IconTerminal2, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { sanitizeError } from "@/lib/nosql-error-sanitizer"; +import { CONNECTION_COLORS } from "@/components/nosql-explorer/connection-form"; +import { KeyBrowser } from "@/components/redis-commander/key-browser"; +import { ValueEditor } from "@/components/redis-commander/value-editor"; +import { CommandPanel } from "@/components/redis-commander/command-panel"; +import { ServerDashboard } from "@/components/redis-commander/server-dashboard"; +import { MetricsPane } from "@/components/redis-commander/metrics-pane"; +import { BulkActions } from "@/components/redis-commander/bulk-actions"; +import { PubSubPane } from "@/components/redis-commander/pubsub-pane"; +import { MonitorPane } from "@/components/redis-commander/monitor-pane"; +import { ScannerPane } from "@/components/redis-commander/scanner-pane"; +import { SearchWorkbench } from "@/components/redis-commander/search-workbench"; +import type { ConnectionFormProps, PaneProps, SidebarTreeProps, SourceAdapter } from "../types"; + +export interface RedisConfig { + redisUrl: string; +} + +export interface RedisTabState { + db: number; + selectedKey: string | null; + /** Bumped to force the key browser to refetch. */ + refreshTick: number; +} + +/** Ported verbatim from redis-commander/connection-form.tsx:33-43. */ +export function buildRedisUrl(opts: { + host: string; + port: string; + username: string; + password: string; + db: string; + tls: boolean; +}): string { + const proto = opts.tls ? "rediss" : "redis"; + const auth = + opts.username || opts.password + ? `${encodeURIComponent(opts.username)}:${encodeURIComponent(opts.password)}@` + : ""; + const host = opts.host || "localhost"; + const port = opts.port || "6379"; + const db = opts.db ? `/${opts.db}` : ""; + return `${proto}://${auth}${host}:${port}${db}`; +} + +/** + * Inverse of `buildRedisUrl`: splits a stored URL back into builder fields so + * editing a saved connection and switching to the Builder tab does not rebuild + * `redis://localhost:6379` over the real one. Returns `null` when the URL is + * not a parseable redis(s) URL — the caller then keeps its defaults rather + * than throwing. The password comes back for re-assembly only; the caller + * never renders or logs it. + */ +export function parseRedisUrl(url: string): { + host: string; + port: string; + username: string; + password: string; + db: string; + tls: boolean; +} | null { + try { + const parsed = new URL(url.trim()); + const protocol = parsed.protocol.toLowerCase(); + if (protocol !== "redis:" && protocol !== "rediss:") return null; + // `redis://` parses with an empty host under WHATWG's non-special + // scheme rules; nothing usable to seed from. + if (!parsed.hostname) return null; + return { + host: parsed.hostname, + port: parsed.port || "6379", + username: safeDecode(parsed.username), + password: safeDecode(parsed.password), + db: parsed.pathname.replace(/^\//, ""), + tls: protocol === "rediss:", + }; + } catch { + return null; + } +} + +function blankConfig(): RedisConfig { + return { redisUrl: "redis://localhost:6379" }; +} + +// `validate` returns an i18n KEY under the `DataExplorer` namespace, never +// English prose — the dialog resolves it with `t()`. Contract established in +// Task 8; add these keys to the `DataExplorer.validation` block in en.json. +function validate(config: RedisConfig): string | null { + const value = config.redisUrl.trim(); + if (!value) return "validation.redisUrlRequired"; + if (!/^rediss?:\/\//i.test(value)) return "validation.redisUrlScheme"; + return null; +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** + * Strip credentials before any message reaches the UI. Fail-CLOSED: every + * path runs the message through regex scrubbing first, so a message survives + * sanitised even when `redisUrl` is too malformed for `new URL()` to parse — + * `validate()` only checks the scheme, not full URL well-formedness, so that + * case is reachable. + */ +export function sanitizeRedisError(message: string, redisUrl: string): string { + // 1. Scrub any redis(s):// URL in the message wholesale — mirrors + // nosql-error-sanitizer's mongodb pattern — so the whole URL never + // survives regardless of what credentials it embeds. + let out = message.replace(/rediss?:\/\/[^/\s]+(\/[^\s]*)*/gi, "redis://***SANITIZED***"); + // 2. Generic `user:password@` and email-style-username scrub shared with + // the Mongo adapter. + out = sanitizeError(out); + + // 3. Best-effort extra layer: strip this connection's specific + // credentials in both their raw and percent-encoded forms, in case one + // leaked into the message outside URL shape (e.g. a driver echoing + // just the password). Additive only — steps 1-2 already sanitised + // `out` unconditionally, so a parse failure here changes nothing. + try { + const parsed = new URL(redisUrl); + for (const encoded of [parsed.username, parsed.password]) { + if (!encoded) continue; + const decoded = safeDecode(encoded); + out = out.split(encoded).join("***").split(decoded).join("***"); + } + } catch { + // redisUrl itself didn't parse — nothing more to strip; `out` is + // already sanitised by steps 1-2. + } + + return out; +} + +async function testConnection(config: RedisConfig): Promise { + const redisUrl = config.redisUrl.trim(); + const res = await apiFetch("/api/redis-commander/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ redisUrl, db: 0 }), + }); + const data = await res.json(); + if (!res.ok || data.error) { + // Real server errors pass through the sanitiser (they are user-facing + // and untranslatable). With nothing reportable, throw an EMPTY message + // — the dialog then renders `t("connectionDialog.testFailed")`. + // Contract established in Task 8. + throw new Error(data.error ? sanitizeRedisError(data.error, redisUrl) : ""); + } +} + +/** + * Controlled-input form: reports values up via `onSubmit`. Never persists, + * never calls the connection service, never toasts — the connection dialog + * owns validate → testConnection → persist and renders `error` inline. + * + * Two-mode layout ported from redis-commander/connection-form.tsx: a raw URL + * input, or a host/port/user/password/db/TLS builder that feeds `buildRedisUrl`. + * Unlike that legacy form, this one never calls the connection service or a + * store directly — it only ever reports `{ redisUrl }` up through `onSubmit`. + */ +function RedisConnectionForm({ + initial, + saving, + error, + onTest, + testState, + onSubmit, + onCancel, +}: ConnectionFormProps) { + const t = useTranslations("DataExplorer.connectionDialog"); + const tr = useTranslations("DataExplorer.redis"); + const [name, setName] = useState(initial.name); + const [folder, setFolder] = useState(initial.folder ?? ""); + const [color, setColor] = useState(initial.color ?? null); + + const [mode, setMode] = useState<"url" | "builder">("url"); + const [redisUrl, setRedisUrl] = useState(initial.config.redisUrl); + + // Builder fields — only assembled into a URL on submit/preview, never + // stored independently, so the URL input stays the single source of truth. + // Seeded from the connection being edited (parsed once): without this, + // opening the Builder tab on a saved connection and saving would submit + // the defaults and silently destroy the stored host and credentials. + const [seed] = useState(() => parseRedisUrl(initial.config.redisUrl)); + const [host, setHost] = useState(seed?.host ?? "localhost"); + const [port, setPort] = useState(seed?.port ?? "6379"); + const [username, setUsername] = useState(seed?.username ?? ""); + // The stored password is deliberately NOT seeded into the input — it is + // never rendered. It is re-attached on submit unless the user types a + // replacement, so a builder save keeps the existing credential. + const [password, setPassword] = useState(""); + const [passwordEdited, setPasswordEdited] = useState(false); + const [dbIdx, setDbIdx] = useState(seed?.db ?? ""); + const [useTls, setUseTls] = useState(seed?.tls ?? false); + + function currentUrl(): string { + if (mode !== "builder") return redisUrl.trim(); + return buildRedisUrl({ + host, + port, + username, + password: passwordEdited ? password : seed?.password ?? "", + db: dbIdx, + tls: useTls, + }); + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + // `readOnly` is passed through untouched, never edited here — see the + // note where the switch used to be. + onSubmit({ name, folder, color, readOnly: initial.readOnly, config: { redisUrl: currentUrl() } }); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder={t("namePlaceholder")} + disabled={saving} + /> +
+ +
+ + setFolder(e.target.value)} + placeholder={t("folderPlaceholder")} + disabled={saving} + /> +
+ + setMode(v as "url" | "builder")}> + + + {tr("urlMode")} + + + {tr("builderMode")} + + + + + + setRedisUrl(e.target.value)} + placeholder={tr("urlPlaceholder")} + disabled={saving} + className="font-mono text-sm" + /> +

{tr("urlHint")}

+
+ + +
+
+ + setHost(e.target.value)} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+ + setPort(e.target.value)} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+
+
+ + setUsername(e.target.value)} + placeholder={tr("usernamePlaceholder")} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+ + { + setPassword(e.target.value); + setPasswordEdited(true); + }} + // Language-neutral marker that a stored + // password is kept unless replaced. The + // password itself is never put in the DOM. + placeholder={seed?.password ? "••••••••" : undefined} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+
+
+
+ + setDbIdx(e.target.value)} + placeholder={tr("dbIndexPlaceholder")} + disabled={saving} + className="h-8 font-mono text-xs" + /> +
+ +
+
+
+ +
+ +
+ + {CONNECTION_COLORS.map((c) => ( +
+
+ + {/* No read-only switch for Redis. `connectionDialog.readOnlyHint` + promises "blocks every write operation", and this adapter cannot + keep that promise: ValueEditor (save/delete/rename/copy) and + CommandPanel (arbitrary DEL / FLUSHALL) live in + components/redis-commander/ and accept no `readOnly` prop, so the + flag would only hide Flush and BulkActions. The field itself stays + in the data model — an already-read-only connection keeps that + partial gating in the pane. Restore this switch when + value-editor.tsx, command-panel.tsx and bulk-actions.tsx all + accept a `readOnly` prop. */} + + {error && ( +
+ {error} +
+ )} + {testState === "ok" && !error && ( +
+ {t("testOk")} +
+ )} + +
+ + + +
+
+ ); +} + +/* ------------------------------------------------------------------ tree */ + +/** Redis has a fixed 16 logical databases; there is nothing to fetch. */ +const DB_INDEXES = Array.from({ length: 16 }, (_, i) => i); + +interface DbRowProps { + index: number; + label: string; + /** Takes the index so the parent can keep one `useCallback`-stable handler — + * an inline arrow per row would defeat React.memo. */ + onOpen: (index: number) => void; +} + +const DbRow = React.memo(function DbRow({ index, label, onOpen }: DbRowProps) { + return ( + + ); +}); + +function RedisSidebarTree({ + connection, + openTab, + searchQuery, +}: SidebarTreeProps) { + const t = useTranslations("DataExplorer"); + + const openDb = useCallback( + (index: number) => { + openTab({ + key: `db${index}`, + title: `db ${index}`, + subtitle: connection.name, + state: { db: index, selectedKey: null, refreshTick: 0 }, + }); + }, + [openTab, connection.name] + ); + + // Search comes from the shell's one box (never a second input here). A + // connection whose own name matches shows all 16; otherwise the rows filter + // on their own labels — same rule as the Mongo tree. + const q = searchQuery.trim().toLowerCase(); + const showAll = !q || connection.name.toLowerCase().includes(q); + const rows = DB_INDEXES.map((index) => ({ index, label: t("redis.dbLabel", { index }) })).filter( + (row) => showAll || row.label.toLowerCase().includes(q) + ); + + if (rows.length === 0) { + return

{t("redis.noMatches")}

; + } + + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +} + +/* ------------------------------------------------------------------ pane */ + +const INNER_TAB = + "h-8 rounded-none border-r px-3 text-xs data-[state=active]:bg-accent data-[state=active]:shadow-none"; + +/** + * Key-browsing workbench, mirroring `redis-commander/page.tsx:384-505`. + * + * Every imported pane takes `{ redisUrl, db }` and fetches for itself, so this + * is composition: the only shared state is the selected key (browser → + * editor), the db index, and a refresh tick that remounts `KeyBrowser`. + * + * `ValueEditor`'s `onKeyDeleted` / `onKeyRenamed` fire *after* the mutation has + * already succeeded inside the editor (value-editor.tsx:172,198) — they are + * notifications, not actions, so reacting to them is safe. + * + * Known and inherited: the imported panes carry hardcoded English. Only the + * chrome written here is translated. + */ +function RedisPane({ connection, config, tab, state, setState }: PaneProps) { + const t = useTranslations("DataExplorer"); + const readOnly = !!connection.readOnly; + // Reported up by KeyBrowser and rendered back into it; pane-local because + // nothing else needs it and it must not survive a tab restore. + const [dbSize, setDbSize] = useState(0); + const [flushOpen, setFlushOpen] = useState(false); + const [flushPattern, setFlushPattern] = useState(""); + const [flushing, setFlushing] = useState(false); + + const bumpRefresh = () => setState((prev) => ({ ...prev, refreshTick: prev.refreshTick + 1 })); + const selectKey = (key: string) => setState((prev) => ({ ...prev, selectedKey: key })); + + /** Ported from redis-commander/page.tsx:146-176. Errors are sanitised — + * a Redis URL can carry a username and password. */ + async function handleFlush() { + setFlushing(true); + try { + const res = await apiFetch("/api/redis-commander/flush", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + redisUrl: config.redisUrl, + db: state.db, + pattern: flushPattern.trim() || undefined, + }), + }); + const data = (await res.json().catch(() => ({}))) as { deleted?: number; error?: string }; + // The legacy page only checked `data.error`; a non-OK response with + // no error body silently read as success. `!res.ok` closes that. + if (!res.ok || data.error) throw new Error(data.error ?? ""); + // `deleted === -1` is the FLUSHDB (no pattern) reply — a count is + // meaningless there. + toast.success( + data.deleted === -1 + ? t("redis.flushedAll") + : t("redis.flushedKeys", { count: data.deleted ?? 0 }) + ); + setState((prev) => ({ + ...prev, + selectedKey: null, + refreshTick: prev.refreshTick + 1, + })); + } catch (err) { + const raw = err instanceof Error ? err.message : ""; + toast.error(raw ? sanitizeRedisError(raw, config.redisUrl) : t("redis.flushFailed")); + } finally { + setFlushing(false); + setFlushOpen(false); + setFlushPattern(""); + } + } + + return ( +
+ {/* No db selector here: the tab's id and title encode the db and are + shell-owned, so switching db in-pane would leave the tab strip + lying about which database it shows (and let a second tab open on + the same one). The sidebar tree is the only way to switch — it + opens or focuses the correctly-identified tab. */} + {!readOnly && ( +
+ + +
+ )} + +
+ + + + + + + + + + + {t("redis.tabKeyEditor")} + + + + {t("redis.tabConsole")} + + + + {t("redis.tabServer")} + + + + {t("redis.tabPubSub")} + + + + {t("redis.tabMonitor")} + + + + {t("redis.tabScanner")} + + + + {t("redis.tabSearch")} + + + + + setState((prev) => ({ + ...prev, + selectedKey: prev.selectedKey === k ? null : prev.selectedKey, + refreshTick: prev.refreshTick + 1, + })) + } + onKeyRenamed={(oldKey, newKey) => + setState((prev) => + prev.selectedKey === oldKey + ? { ...prev, selectedKey: newKey } + : prev + ) + } + onRefreshKeys={bumpRefresh} + /> + + + + + +
+ +
+ +
+ + + + + + + + + + + + +
+
+
+
+ + { if (!flushing) setFlushOpen(open); }}> + + + {t("redis.flushTitle")} + {t("redis.flushDescription")} + +
+ + setFlushPattern(e.target.value)} + /> +
+ + + {t("connectionDialog.cancel")} + + { + e.preventDefault(); + void handleFlush(); + }} + disabled={flushing} + > + {flushing ? t("redis.flushing") : t("redis.flush")} + + +
+
+
+ ); +} + +export const redisAdapter: SourceAdapter = { + id: "redis", + label: "Redis", + icon: IconBrandRedux, + accent: "text-red-500", + blankConfig, + validate, + testConnection, + ConnectionForm: RedisConnectionForm, + SidebarTree: RedisSidebarTree, + Pane: RedisPane, +}; diff --git a/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx new file mode 100644 index 00000000..51fd0600 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/connection-dialog.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { useMasterKeyStore } from "@/store/master-key-store"; +import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; +import { saveConnection, updateConnection } from "./connection-service"; +import type { ConnectionFormValues, SourceId, UnifiedConnection } from "./types"; + +interface ConnectionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Null when creating. */ + editing: UnifiedConnection | null; + onSaved: () => void; +} + +export function ConnectionDialog({ open, onOpenChange, editing, onSaved }: ConnectionDialogProps) { + const t = useTranslations("DataExplorer"); + const { encryptionKey } = useMasterKeyStore(); + const [sourceId, setSourceId] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [testState, setTestState] = useState<"idle" | "testing" | "ok">("idle"); + + useEffect(() => { + if (!open) return; + // Creating defaults to the first registered source so the dialog opens + // ready to type. Editing pins the connection's own source — changing + // what kind of database an existing connection is was never coherent. + setSourceId(editing?.sourceId ?? SOURCE_ORDER[0] ?? null); + setError(null); + setSaving(false); + setTestState("idle"); + }, [open, editing]); + + const adapter = sourceId ? getAdapter(sourceId) : null; + + /** Switching source discards any error or test result from the previous one. */ + function handleSourceChange(next: SourceId) { + setSourceId(next); + setError(null); + setTestState("idle"); + } + + /** + * Explicit, optional user action. Saving never waits on it — a rename or a + * recolour must still persist off-VPN, and a connection to a server that is + * not running yet must be creatable. Both outcomes render inline in the + * dialog so the form (and anything half-typed in it) survives a retry. + */ + async function handleTest(config: unknown) { + if (!adapter) return; + setError(null); + + const invalidKey = adapter.validate(config); + if (invalidKey) { + setError(t(invalidKey)); + return; + } + + setTestState("testing"); + try { + await adapter.testConnection(config); + setTestState("ok"); + } catch (e) { + // Adapters throw pre-sanitised messages, or an empty one when there + // is nothing reportable. Never a raw config. + const message = e instanceof Error ? e.message.trim() : ""; + setError(message || t("connectionDialog.testFailed")); + setTestState("idle"); + } + } + + async function handleSubmit(values: ConnectionFormValues) { + if (!adapter || !encryptionKey) return; + setError(null); + + const invalidKey = adapter.validate(values.config); + if (invalidKey) { + setError(t(invalidKey)); + return; + } + + setSaving(true); + try { + if (editing) { + await updateConnection(editing.id, values, encryptionKey); + } else { + await saveConnection(adapter.id, values, encryptionKey); + } + toast.success(t("toast.saved")); + onSaved(); + onOpenChange(false); + } catch (e) { + const message = e instanceof Error ? e.message.trim() : ""; + setError(message || t("toast.saveFailed")); + } finally { + setSaving(false); + } + } + + return ( + + + + + {editing ? t("connectionDialog.editTitle") : t("connectionDialog.createTitle")} + + + +
+ + +
+ + {adapter && ( + onOpenChange(false)} + /> + )} +
+
+ ); +} diff --git a/apps/desktop-ui/src/components/data-explorer/connection-service.ts b/apps/desktop-ui/src/components/data-explorer/connection-service.ts new file mode 100644 index 00000000..15b01030 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/connection-service.ts @@ -0,0 +1,113 @@ +import { encryptData, decryptData } from "@/lib/encryption"; +import { proxyJsonAuthed } from "@/lib/backend-auth"; +import { toast } from "sonner"; +import type { ConnectionFormValues, SourceId, UnifiedConnection } from "./types"; + +const BACKEND_BASE_URL: string = + process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || + process.env.NEXT_PUBLIC_BACKEND_BASE_URL || + "http://localhost:8000"; + +const BASE_PATH = "/api/v1/data-explorer/connections"; + +/** Raw store row — the decrypted `config` is never present on the wire. */ +export type UnifiedConnectionRaw = Omit; + +async function proxyRequest(method: string, path: string, body?: unknown): Promise { + const { status, data } = await proxyJsonAuthed(BACKEND_BASE_URL, method, path, body); + if (status < 200 || status >= 300) { + const err = data as Record | null; + throw new Error( + (typeof err?.detail === "string" ? err.detail : null) || + (typeof err?.error === "string" ? err.error : null) || + `Request failed (${status})` + ); + } + return data as T; +} + +/** Decrypt one row's config blob. Throws when the master key does not match. */ +export async function decodeConnection( + raw: UnifiedConnectionRaw, + encryptionKey: CryptoKey +): Promise { + const json = await decryptData(encryptionKey, raw.encryptedData, raw.iv); + return { ...raw, config: JSON.parse(json) as unknown }; +} + +export async function listConnections(encryptionKey: CryptoKey): Promise { + const rawAll = (await proxyRequest("GET", BASE_PATH)) ?? []; + // Drop malformed rows — a row missing id/encryptedData/iv would crash the + // list render that keys off conn.id. + const raw = rawAll.filter( + (c): c is UnifiedConnectionRaw => + !!c && + typeof c === "object" && + typeof c.id === "string" && + typeof c.sourceId === "string" && + !!c.encryptedData && + !!c.iv + ); + + const results = await Promise.allSettled(raw.map((c) => decodeConnection(c, encryptionKey))); + + const connections: UnifiedConnection[] = []; + let failedCount = 0; + for (const result of results) { + if (result.status === "fulfilled") connections.push(result.value); + else failedCount++; + } + + if (failedCount > 0) { + toast.error( + `${failedCount} connection${failedCount > 1 ? "s" : ""} could not be decrypted. ` + + "Delete and re-add them." + ); + } + + return connections; +} + +export async function saveConnection( + sourceId: SourceId, + values: ConnectionFormValues, + encryptionKey: CryptoKey +): Promise { + const { encrypted, iv } = await encryptData(encryptionKey, JSON.stringify(values.config)); + const created = await proxyRequest("POST", BASE_PATH, { + sourceId, + name: values.name, + folder: values.folder ?? "", + color: values.color ?? null, + readOnly: values.readOnly ?? false, + encryptedData: encrypted, + iv, + }); + return { ...created, config: values.config }; +} + +export async function updateConnection( + id: string, + patch: Partial>, + encryptionKey: CryptoKey +): Promise { + const body: Record = {}; + if (patch.name !== undefined) body.name = patch.name; + if (patch.folder !== undefined) body.folder = patch.folder; + if (patch.color !== undefined) body.color = patch.color; + if (patch.readOnly !== undefined) body.readOnly = patch.readOnly; + if (patch.config !== undefined) { + const { encrypted, iv } = await encryptData(encryptionKey, JSON.stringify(patch.config)); + body.encryptedData = encrypted; + body.iv = iv; + } + await proxyRequest("PATCH", `${BASE_PATH}/${id}`, body); +} + +export async function deleteConnection(id: string): Promise { + await proxyRequest("DELETE", `${BASE_PATH}/${id}`); +} + +export async function touchConnection(id: string): Promise { + await proxyRequest("POST", `${BASE_PATH}/${id}/touch`); +} diff --git a/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx new file mode 100644 index 00000000..96926695 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/import-legacy-dialog.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import useAuth from "@/utils/useAuth"; +import { useMasterKeyStore } from "@/store/master-key-store"; +import { getConnections as getMongoConnections } from "@/components/nosql-explorer/connection-service"; +import { getConnections as getRedisConnections } from "@/components/redis-commander/connection-service"; +import { + dedupeAgainstExisting, + legacyMongoToUnified, + legacyRedisToUnified, + type LegacyCandidate, +} from "@/lib/data-explorer/legacy-import"; +import { SOURCES } from "./sources"; +import { saveConnection } from "./connection-service"; +import type { UnifiedConnection } from "./types"; + +interface ImportLegacyDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Current unified connections, used to dedupe by (sourceId, name, config). */ + existing: UnifiedConnection[]; + /** + * True once the shell's own connection list has genuinely finished + * loading (see `page.tsx`'s `connectionsLoaded`). A failed or in-flight + * load leaves `existing` stale or empty — deduping against that would + * silently re-import connections the user already has, so the dialog + * refuses to fetch legacy candidates until this is true. + */ + connectionsLoaded: boolean; + onImported: () => void; +} + +/** + * One-shot, opt-in copy of connections from Database Explorer and Redis + * Commander into the unified store. Both legacy stores are read-only here — + * this never writes to or deletes from them, so the old tools keep working + * unchanged. Re-running it is safe: `dedupeAgainstExisting` skips anything + * already imported by (sourceId, name, config). + */ +export function ImportLegacyDialog({ + open, + onOpenChange, + existing, + connectionsLoaded, + onImported, +}: ImportLegacyDialogProps) { + const t = useTranslations("DataExplorer"); + const { user } = useAuth(); + const { encryptionKey } = useMasterKeyStore(); + + const [loading, setLoading] = useState(false); + const [candidates, setCandidates] = useState([]); + const [skipped, setSkipped] = useState(0); + const [importing, setImporting] = useState(false); + + useEffect(() => { + if (!open) return; + setCandidates([]); + setSkipped(0); + // The unified list hasn't genuinely loaded yet — `existing` cannot be + // trusted for dedupe, so don't fetch legacy candidates against it. + // The effect re-runs once `connectionsLoaded` flips true. + if (!connectionsLoaded) { + setLoading(false); + return; + } + if (!user || !encryptionKey) return; + + let cancelled = false; + setLoading(true); + + (async () => { + try { + const [mongoRows, redisRows] = await Promise.all([ + getMongoConnections(user.uid, encryptionKey), + getRedisConnections(encryptionKey), + ]); + + const mongoCandidates = mongoRows.map((row) => + legacyMongoToUnified( + { name: row.name, color: row.color, readOnly: row.readOnly, dbType: row.dbType }, + row.connectionString + ) + ); + const redisCandidates = redisRows + .filter((row) => !!row.config) + .map((row) => + legacyRedisToUnified( + { name: row.name, folder: row.folder }, + row.config as { redisUrl: string; folder?: string } + ) + ); + + const { toImport, skipped: skippedCount } = dedupeAgainstExisting( + [...mongoCandidates, ...redisCandidates], + existing + ); + if (cancelled) return; + setCandidates(toImport); + setSkipped(skippedCount); + } catch { + // Never surface the raw error: this flow reads decrypted + // credentials, so an uncontrolled message is a risk, not just + // an i18n gap. + if (cancelled) return; + toast.error(t("toast.legacyImportFailed")); + onOpenChange(false); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + // Re-run only when the dialog opens or its inputs change identity — + // not on every `existing` array reference from a parent re-render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, user, encryptionKey, connectionsLoaded]); + + async function handleConfirm() { + if (!encryptionKey || candidates.length === 0 || !connectionsLoaded) return; + setImporting(true); + let imported = 0; + let failed = 0; + // Per-row failure must not abort the batch — collect and report the count. + for (const candidate of candidates) { + try { + await saveConnection(candidate.sourceId, candidate.values, encryptionKey); + imported++; + } catch { + failed++; + } + } + setImporting(false); + const message = t("toast.legacyImportResult", { imported, skipped, failed }); + // A total failure is not a success — say so. + if (imported === 0 && failed > 0) toast.error(message); + else toast.success(message); + onImported(); + onOpenChange(false); + } + + return ( + !importing && onOpenChange(next)}> + + + {t("importDialog.title")} + + + {loading ? ( +

+ {t("importDialog.loading")} +

+ ) : !connectionsLoaded ? ( +

+ {t("importDialog.notReady")} +

+ ) : candidates.length === 0 ? ( +

+ {skipped > 0 + ? t("importDialog.allSkipped", { count: skipped }) + : t("importDialog.empty")} +

+ ) : ( + <> +

+ {t("importDialog.previewHeading", { count: candidates.length })} +

+ +
    + {candidates.map((candidate, i) => ( +
  • + {candidate.values.name} + + {SOURCES[candidate.sourceId].label} + +
  • + ))} +
+
+ {skipped > 0 && ( +

+ {t("importDialog.skippedNote", { count: skipped })} +

+ )} + + )} + + + + + +
+
+ ); +} diff --git a/apps/desktop-ui/src/components/data-explorer/sources.ts b/apps/desktop-ui/src/components/data-explorer/sources.ts new file mode 100644 index 00000000..feb78bad --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/sources.ts @@ -0,0 +1,26 @@ +import { mongodbAdapter } from "./adapters/mongodb"; +import { redisAdapter } from "./adapters/redis"; +import type { SourceAdapter, SourceId } from "./types"; + +/** + * The one registry. Adding a data source type means writing an adapter file + * and adding it here — nothing else in the shell, sidebar, tab bar, store, + * Rust router, or the six global tool registries changes. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const SOURCES: Record> = { + mongodb: mongodbAdapter, + redis: redisAdapter, +}; + +/** Display order in the sidebar and the source picker. */ +export const SOURCE_ORDER: SourceId[] = ["mongodb", "redis"]; + +/** + * Resolve an adapter. Returns null for an unknown id — a connection saved by a + * newer build must degrade to an "unsupported source" row, not crash the list. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getAdapter(sourceId: SourceId): SourceAdapter | null { + return SOURCES[sourceId] ?? null; +} diff --git a/apps/desktop-ui/src/components/data-explorer/types.ts b/apps/desktop-ui/src/components/data-explorer/types.ts new file mode 100644 index 00000000..d2df24cf --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/types.ts @@ -0,0 +1,149 @@ +import type React from "react"; + +/** + * Opaque identifier for a data source type, persisted on every connection. + * Never renamed once shipped — stored connections carry it. + */ +export type SourceId = string; + +export interface UnifiedConnection { + id: string; + userId: string; + sourceId: SourceId; + name: string; + /** Optional sidebar grouping; empty means ungrouped. */ + folder?: string; + /** Environment accent colour (hex). Not sensitive. */ + color?: string | null; + /** Blocks write operations in the UI when true. */ + readOnly?: boolean; + /** AES-GCM ciphertext of the JSON-serialised adapter config. */ + encryptedData: string; + iv: string; + /** Decrypted client-side after fetch. Never sent to the store. */ + config?: unknown; + createdAt: number; + lastUsedAt: number; + /** Content-edit clock (sync LWW); store-managed. */ + updatedAt?: number; +} + +export interface UnifiedTab { + id: string; + connectionId: string; + sourceId: SourceId; + /** Tab strip label — e.g. a collection name or "db 0". */ + title: string; + /** Tooltip second line — e.g. "prod-atlas › orders". */ + subtitle?: string; + connectionColor?: string | null; + /** + * No `readOnly` here on purpose. A tab is persisted to localStorage and + * outlives any edit to its connection, so a copy taken at open time goes + * stale the moment the user toggles read-only. Read + * `connection.readOnly` — it is the only live value. + */ + /** Adapter-owned. The shell stores and passes it through, never inspects it. */ + state: unknown; +} + +export interface OpenTabRequest { + /** + * Stable per (connection, leaf). The shell builds the tab id as + * `${connection.id}:${key}`, so reopening the same leaf focuses the + * existing tab instead of duplicating it. + */ + key: string; + title: string; + subtitle?: string; + state: TabState; +} + +export interface ConnectionFormValues { + name: string; + folder?: string; + color?: string | null; + readOnly?: boolean; + config: Config; +} + +export interface ConnectionFormProps { + /** Existing values when editing; adapter defaults when creating. */ + initial: ConnectionFormValues; + /** True while the shell is persisting. Disable the submit button. */ + saving: boolean; + /** Persist or test-connection failure, already sanitised. Render inline. */ + error: string | null; + /** + * Runs the adapter's `testConnection` against the config currently in the + * form. An explicit, optional user action — never a precondition for + * saving, so a metadata edit still saves with the server unreachable. + * Failures arrive back through `error`; success through `testState`. + */ + onTest: (config: Config) => void; + /** `"testing"` while a test is in flight, `"ok"` after one succeeded. */ + testState: "idle" | "testing" | "ok"; + onSubmit: (values: ConnectionFormValues) => void; + onCancel: () => void; +} + +export interface SidebarTreeProps { + connection: UnifiedConnection; + config: Config; + openTab: (req: OpenTabRequest) => void; + /** Call after a destructive tree action so the shell refetches connections. */ + onConnectionsChanged: () => void; + /** + * Raw text from the shell's single search box, trimmed of nothing. The tree + * filters its own levels (databases, collections, keyspaces…) with it — + * the shell only filters connection *names*, so without this a search can + * never reach inside a tree. Never render a second search input. + */ + searchQuery: string; + /** + * Every saved connection sharing this connection's `sourceId`, including + * this one, with `config` already decrypted. For cross-connection actions + * such as "sync this collection into another server". The shell selects + * them by `sourceId` equality and never inspects the configs. + */ + siblings: UnifiedConnection[]; +} + +export interface PaneProps { + connection: UnifiedConnection; + config: Config; + tab: UnifiedTab; + state: TabState; + setState: (updater: (prev: TabState) => TabState) => void; +} + +export interface SourceAdapter { + /** Must equal this adapter's key in SOURCES. */ + id: SourceId; + /** Proper noun — never translated. */ + label: string; + icon: React.ComponentType<{ className?: string }>; + /** Tailwind text colour class for the sidebar group and tab glyph. */ + accent: string; + + blankConfig: () => Config; + /** + * Returns an i18n key path relative to the `DataExplorer` namespace (e.g. + * `"validation.connectionStringRequired"`) identifying the problem, or + * `null` when the config is valid. The dialog resolves the key via + * `t(key)` before rendering it — never a raw message. + */ + validate: (config: Config) => string | null; + /** + * Throws on failure. When there is a server-derived, already sanitised, + * credential-free message to show, throw with that message and the + * dialog renders it as-is. When there is nothing reportable, throw with + * an empty message and the dialog supplies its own translated fallback + * copy. + */ + testConnection: (config: Config) => Promise; + + ConnectionForm: React.ComponentType>; + SidebarTree: React.ComponentType>; + Pane: React.ComponentType>; +} diff --git a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx new file mode 100644 index 00000000..e3f07700 --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx @@ -0,0 +1,431 @@ +"use client"; + +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { motion, AnimatePresence } from "framer-motion"; +import { + IconAlertTriangle, + IconChevronRight, + IconDots, + IconDownload, + IconEdit, + IconLock, + IconPlus, + IconSearch, + IconTrash, +} from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; +import { deleteConnection } from "./connection-service"; +import type { OpenTabRequest, SourceId, UnifiedConnection } from "./types"; + +interface UnifiedSidebarProps { + connections: UnifiedConnection[]; + onOpenTab: (connection: UnifiedConnection, req: OpenTabRequest) => void; + onAddConnection: () => void; + onEditConnection: (connection: UnifiedConnection) => void; + onConnectionsChanged: () => void; + onImportLegacy: () => void; +} + +interface ConnectionRowProps { + connection: UnifiedConnection; + expanded: boolean; + onToggle: (connectionId: string) => void; + onOpenTab: (connection: UnifiedConnection, req: OpenTabRequest) => void; + onEdit: (connection: UnifiedConnection) => void; + onDelete: (connection: UnifiedConnection) => void; + onConnectionsChanged: () => void; + /** Forwarded to the tree so it can filter its own levels. */ + searchQuery: string; + /** Connections with the same `sourceId`, for cross-connection tree actions. */ + siblings: UnifiedConnection[]; +} + +const ConnectionRow = React.memo(function ConnectionRow({ + connection, + expanded, + onToggle, + onOpenTab, + onEdit, + onDelete, + onConnectionsChanged, + searchQuery, + siblings, +}: ConnectionRowProps) { + const t = useTranslations("DataExplorer"); + const adapter = getAdapter(connection.sourceId); + const supported = adapter !== null; + + const openTab = useCallback( + (req: OpenTabRequest) => onOpenTab(connection, req), + [onOpenTab, connection] + ); + + const Icon = adapter?.icon ?? IconAlertTriangle; + + return ( +
+
+ + + + + + + {supported && ( + <> + onEdit(connection)}> + + {t("menuEdit")} + + + + )} + onDelete(connection)} + > + + {t("menuDelete")} + + + +
+ + {!supported && ( +

+ {t("unsupportedSource")} +

+ )} + + + {supported && expanded && ( + + + + )} + +
+ ); +}); + +/** Bucket for connections whose sourceId has no registered adapter. */ +const UNSUPPORTED_GROUP = "__unsupported__"; + +/** Stable identity so an empty sibling list never re-renders a memoised row. */ +const NO_SIBLINGS: UnifiedConnection[] = []; + +export function UnifiedSidebar({ + connections, + onOpenTab, + onAddConnection, + onEditConnection, + onConnectionsChanged, + onImportLegacy, +}: UnifiedSidebarProps) { + const t = useTranslations("DataExplorer"); + const [query, setQuery] = useState(""); + const [expandedIds, setExpandedIds] = useState>(new Set()); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [pendingDelete, setPendingDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const safe = connections.filter( + (c): c is UnifiedConnection => !!c && typeof c.id === "string" + ); + // A search hides connections whose name does not match — except the ones + // the user has expanded. Those keep their row so their tree can filter + // its own databases/collections against the same query, which is how an + // inner match stays reachable. (The legacy sidebar could only match + // inner levels of expanded connections either: collapsed ones have no + // loaded children to match against.) + return q + ? safe.filter((c) => c.name.toLowerCase().includes(q) || expandedIds.has(c.id)) + : safe; + }, [connections, query, expandedIds]); + + /** Connections bucketed by source, so each tree gets its own source's peers. */ + const siblingsBySource = useMemo(() => { + const map = new Map(); + for (const c of connections) { + if (!c || typeof c.id !== "string") continue; + const bucket = map.get(c.sourceId); + if (bucket) bucket.push(c); + else map.set(c.sourceId, [c]); + } + return map; + }, [connections]); + + const groups = useMemo(() => { + const byId = new Map(); + for (const connection of filtered) { + const key = getAdapter(connection.sourceId) ? connection.sourceId : UNSUPPORTED_GROUP; + const bucket = byId.get(key); + if (bucket) bucket.push(connection); + else byId.set(key, [connection]); + } + return [...SOURCE_ORDER, UNSUPPORTED_GROUP] + .map((id) => ({ id, items: byId.get(id) ?? [] })) + .filter((group) => group.items.length > 0); + }, [filtered]); + + const toggleConnection = useCallback((connectionId: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (!next.delete(connectionId)) next.add(connectionId); + return next; + }); + }, []); + + const toggleGroup = useCallback((groupId: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (!next.delete(groupId)) next.add(groupId); + return next; + }); + }, []); + + const requestDelete = useCallback((connection: UnifiedConnection) => { + setPendingDelete(connection); + }, []); + + async function confirmDelete() { + if (!pendingDelete) return; + setDeleting(true); + try { + await deleteConnection(pendingDelete.id); + toast.success(t("toast.deleted")); + setPendingDelete(null); + onConnectionsChanged(); + } catch (e) { + toast.error(e instanceof Error ? e.message : t("toast.deleteFailed")); + } finally { + setDeleting(false); + } + } + + return ( +
+
+
+ {t("title")} + +
+
+ + setQuery(e.target.value)} + placeholder={t("searchConnections")} + className="h-9 pl-7 text-xs" + /> +
+
+ +
+ {connections.length === 0 ? ( +
+

{t("emptyTitle")}

+

{t("emptyBody")}

+ +
+ ) : groups.length === 0 ? ( +

+ {t("noResults")} +

+ ) : ( + groups.map((group) => { + const source = SOURCES[group.id]; + const GroupIcon = source?.icon ?? IconAlertTriangle; + const open = !collapsedGroups.has(group.id); + return ( +
+ + + {open && ( + + {group.items.map((connection) => ( + + ))} + + )} + +
+ ); + }) + )} +
+ +
+ +
+ + { + if (!open && !deleting) setPendingDelete(null); + }} + > + + + {t("deleteTitle")} + + {t("deleteBody", { name: pendingDelete?.name ?? "" })} + + + + + {t("connectionDialog.cancel")} + + { + e.preventDefault(); + void confirmDelete(); + }} + > + {t("menuDelete")} + + + + +
+ ); +} diff --git a/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx new file mode 100644 index 00000000..59e5019b --- /dev/null +++ b/apps/desktop-ui/src/components/data-explorer/unified-tab-bar.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { IconX, IconLock, IconAlertTriangle } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { getAdapter } from "./sources"; +import type { UnifiedConnection, UnifiedTab } from "./types"; + +interface UnifiedTabBarProps { + tabs: UnifiedTab[]; + /** Live connections — the lock glyph reads read-only from these, never + * from a snapshot on the tab, so toggling it updates every open tab. */ + connections: UnifiedConnection[]; + activeTabId: string | null; + onTabChange: (tabId: string) => void; + onTabClose: (tabId: string) => void; + onCloseAll: () => void; +} + +export function UnifiedTabBar({ + tabs, + connections, + activeTabId, + onTabChange, + onTabClose, + onCloseAll, +}: UnifiedTabBarProps) { + const t = useTranslations("DataExplorer"); + const readOnlyIds = useMemo( + () => new Set(connections.filter((c) => c.readOnly).map((c) => c.id)), + [connections] + ); + + // Defensive: a malformed entry would crash the whole strip on `tab.id`. + const safeTabs = tabs.filter((tab): tab is UnifiedTab => !!tab && typeof tab.id === "string"); + + if (safeTabs.length === 0) return
; + + return ( +
+ +
+ {safeTabs.map((tab) => { + const adapter = getAdapter(tab.sourceId); + const Icon = adapter?.icon ?? IconAlertTriangle; + const isActive = activeTabId === tab.id; + return ( + + + +
onTabChange(tab.id)} + style={ + tab.connectionColor + ? { boxShadow: `inset 0 2px 0 0 ${tab.connectionColor}` } + : undefined + } + > + + {readOnlyIds.has(tab.connectionId) && ( + + )} + + {tab.title} + + +
+
+ +
{tab.title}
+ {tab.subtitle && ( +
{tab.subtitle}
+ )} +
+
+
+ ); + })} +
+ +
+
+ +
+
+ ); +} diff --git a/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts b/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts index 4dc88d37..1c994b8d 100644 --- a/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts +++ b/apps/desktop-ui/src/components/sidebar/data/sidebar-data.ts @@ -525,6 +525,12 @@ export const sidebarData: SidebarData = { icon: IconBrandRedux, description: 'Browse keys, inspect values, run raw commands, and flush patterns — encrypted credential store.', }, + { + title: 'Data Explorer', + url: '/app/data-explorer', + icon: IconDatabase, + description: 'One workspace for MongoDB and Redis. Browse, query, and edit with an encrypted credential store.', + }, ], }, { diff --git a/apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts b/apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts new file mode 100644 index 00000000..63de2318 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/data-explorer-legacy-import.test.ts @@ -0,0 +1,158 @@ +import { + legacyMongoToUnified, + legacyRedisToUnified, + dedupeAgainstExisting, +} from "../data-explorer/legacy-import"; +import type { UnifiedConnection } from "@/components/data-explorer/types"; + +describe("legacyMongoToUnified", () => { + it("wraps the bare connection string into a JSON config", () => { + const result = legacyMongoToUnified( + { name: "local", color: "#ef4444", readOnly: true, dbType: "mongodb" }, + "mongodb://localhost:27017" + ); + expect(result.sourceId).toBe("mongodb"); + expect(result.values.config).toEqual({ + connectionString: "mongodb://localhost:27017", + dbType: "mongodb", + }); + expect(result.values.name).toBe("local"); + expect(result.values.color).toBe("#ef4444"); + expect(result.values.readOnly).toBe(true); + }); + + it("infers dbType for rows saved before the field existed", () => { + const result = legacyMongoToUnified( + { name: "cosmos" }, + "mongodb://acct:key@acct.mongo.cosmos.azure.com:10255/?ssl=true" + ); + expect((result.values.config as { dbType: string }).dbType).toBe("cosmosdb"); + }); + + it("always sets folder to empty — legacy Mongo has no folder concept", () => { + const result = legacyMongoToUnified({ name: "local" }, "mongodb://localhost:27017"); + expect(result.values.folder).toBe(""); + }); +}); + +describe("legacyRedisToUnified", () => { + it("lifts folder out of the encrypted blob to the top level", () => { + const result = legacyRedisToUnified( + { name: "cache" }, + { redisUrl: "redis://localhost:6379", folder: "prod" } + ); + expect(result.sourceId).toBe("redis"); + expect(result.values.config).toEqual({ redisUrl: "redis://localhost:6379" }); + expect(result.values.folder).toBe("prod"); + }); + + it("falls back to the row's folder column when the blob carries none", () => { + const result = legacyRedisToUnified( + { name: "cache", folder: "staging" }, + { redisUrl: "redis://localhost:6379" } + ); + expect(result.values.folder).toBe("staging"); + }); + + it("defaults folder to empty when neither the blob nor the row has one", () => { + const result = legacyRedisToUnified({ name: "cache" }, { redisUrl: "redis://localhost:6379" }); + expect(result.values.folder).toBe(""); + }); +}); + +describe("dedupeAgainstExisting", () => { + const existing = [ + { + id: "1", + sourceId: "redis", + name: "cache", + config: { redisUrl: "redis://localhost:6379" }, + } as unknown as UnifiedConnection, + ]; + + it("skips a candidate matching an existing (sourceId, name, config) triple", () => { + const dupe = legacyRedisToUnified({ name: "cache" }, { redisUrl: "redis://localhost:6379" }); + const fresh = legacyRedisToUnified({ name: "other" }, { redisUrl: "redis://localhost:6379" }); + const { toImport, skipped } = dedupeAgainstExisting([dupe, fresh], existing); + expect(skipped).toBe(1); + expect(toImport).toHaveLength(1); + expect(toImport[0].values.name).toBe("other"); + }); + + it("does not treat the same name under a different source as a duplicate", () => { + const mongo = legacyMongoToUnified({ name: "cache" }, "mongodb://localhost:27017"); + const { toImport, skipped } = dedupeAgainstExisting([mongo], existing); + expect(skipped).toBe(0); + expect(toImport).toHaveLength(1); + }); + + it("imports two same-named rows that differ only by config", () => { + const a = legacyMongoToUnified({ name: "cache" }, "mongodb://host-a:27017"); + const b = legacyMongoToUnified({ name: "cache" }, "mongodb://host-b:27017"); + const { toImport, skipped } = dedupeAgainstExisting([a, b], []); + expect(skipped).toBe(0); + expect(toImport).toHaveLength(2); + }); + + it("dedupes two identical candidates within a single batch", () => { + const a = legacyMongoToUnified({ name: "cache" }, "mongodb://localhost:27017"); + const b = legacyMongoToUnified({ name: "cache" }, "mongodb://localhost:27017"); + const { toImport, skipped } = dedupeAgainstExisting([a, b], []); + expect(skipped).toBe(1); + expect(toImport).toHaveLength(1); + }); + + it("a genuine re-import of the same connection dedupes to zero on the second run", () => { + const first = legacyMongoToUnified({ name: "cache" }, "mongodb://localhost:27017"); + // Simulate what listConnections would return after the first import + // saved `first.values` verbatim: same sourceId, name and decrypted config. + const alreadyImported = [ + { + id: "1", + sourceId: first.sourceId, + name: first.values.name, + config: first.values.config, + } as unknown as UnifiedConnection, + ]; + const second = legacyMongoToUnified({ name: "cache" }, "mongodb://localhost:27017"); + const { toImport, skipped } = dedupeAgainstExisting([second], alreadyImported); + expect(skipped).toBe(1); + expect(toImport).toHaveLength(0); + }); + + it("normalises name case and whitespace for comparison without mutating the stored name", () => { + const existingWithSpacing = [ + { + id: "1", + sourceId: "redis", + name: "Cache", + config: { redisUrl: "redis://localhost:6379" }, + } as unknown as UnifiedConnection, + ]; + const candidate = legacyRedisToUnified( + { name: " cache " }, + { redisUrl: "redis://localhost:6379" } + ); + const { toImport, skipped } = dedupeAgainstExisting([candidate], existingWithSpacing); + expect(skipped).toBe(1); + expect(toImport).toHaveLength(0); + // The candidate's own stored name is untouched — only the comparison key normalises. + expect(candidate.values.name).toBe(" cache "); + }); + + it("does not throw on an existing row with a missing name, and still matches an unrelated candidate", () => { + const nameless = [ + { + id: "1", + sourceId: "redis", + // `name` missing entirely, as a pre-name-column legacy row would arrive. + config: { redisUrl: "redis://localhost:6379" }, + } as unknown as UnifiedConnection, + ]; + const candidate = legacyRedisToUnified({ name: "other" }, { redisUrl: "redis://other:6379" }); + expect(() => dedupeAgainstExisting([candidate], nameless)).not.toThrow(); + const { toImport, skipped } = dedupeAgainstExisting([candidate], nameless); + expect(skipped).toBe(0); + expect(toImport).toHaveLength(1); + }); +}); diff --git a/apps/desktop-ui/src/lib/data-explorer/legacy-import.ts b/apps/desktop-ui/src/lib/data-explorer/legacy-import.ts new file mode 100644 index 00000000..eeef58f0 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/legacy-import.ts @@ -0,0 +1,101 @@ +import { detectDbType, type DbType } from "@/lib/nosql-dialects"; +import type { ConnectionFormValues, UnifiedConnection } from "@/components/data-explorer/types"; +import type { MongoConfig } from "@/components/data-explorer/adapters/mongodb"; +import type { RedisConfig } from "@/components/data-explorer/adapters/redis"; + +export interface LegacyCandidate { + sourceId: "mongodb" | "redis"; + values: ConnectionFormValues; +} + +/** + * The legacy Mongo store encrypts a bare connection string; the unified store + * always encrypts JSON. `dbType` is absent on rows saved before that field + * existed — infer it from the host. + */ +export function legacyMongoToUnified( + row: { name: string; color?: string | null; readOnly?: boolean; dbType?: DbType }, + connectionString: string +): LegacyCandidate { + return { + sourceId: "mongodb", + values: { + name: row.name, + folder: "", + color: row.color ?? null, + readOnly: row.readOnly ?? false, + config: { connectionString, dbType: row.dbType ?? detectDbType(connectionString) }, + }, + }; +} + +/** Legacy Redis kept `folder` inside the encrypted blob; it is top-level now. */ +export function legacyRedisToUnified( + row: { name: string; folder?: string }, + config: { redisUrl: string; folder?: string } +): LegacyCandidate { + return { + sourceId: "redis", + values: { + name: row.name, + folder: config.folder ?? row.folder ?? "", + color: null, + readOnly: false, + config: { redisUrl: config.redisUrl }, + }, + }; +} + +/** + * The one field that makes two same-named connections distinct, pulled from + * whichever adapter config shape is present. A structural check on the + * config's own fields, not a branch on sourceId — legacy Mongo defaults every + * unnamed row's name to "My Connection" (nosql-explorer/connection-service.ts), + * so name alone is not enough to tell two rows apart. + */ +function configDiscriminator(config: unknown): string { + if (config && typeof config === "object") { + const c = config as Record; + if (typeof c.connectionString === "string") return c.connectionString; + if (typeof c.redisUrl === "string") return c.redisUrl; + } + return ""; +} + +/** + * Comparison-only normalisation — never mutates the name that gets stored. + * `listConnections`' row filter checks id/sourceId/encryptedData/iv but not + * name, so a stored row can reach here with a missing name; guard it rather + * than let `.trim()` throw and take down the whole legacy import. + */ +function normalizeName(name: string): string { + return String(name ?? "").trim().toLowerCase(); +} + +function dedupeKey(sourceId: string, name: string, config: unknown): string { + return `${sourceId}:${normalizeName(name)}:${configDiscriminator(config)}`; +} + +/** + * Re-import is safe: an already-imported (sourceId, name, config) triple is + * skipped. Also resolves duplicates within a single batch (two same-named, + * same-config rows from the same legacy store) via the `seen.add` below. + */ +export function dedupeAgainstExisting( + candidates: LegacyCandidate[], + existing: UnifiedConnection[] +): { toImport: LegacyCandidate[]; skipped: number } { + const seen = new Set(existing.map((c) => dedupeKey(c.sourceId, c.name, c.config))); + const toImport: LegacyCandidate[] = []; + let skipped = 0; + for (const candidate of candidates) { + const key = dedupeKey(candidate.sourceId, candidate.values.name, candidate.values.config); + if (seen.has(key)) { + skipped++; + continue; + } + seen.add(key); + toImport.push(candidate); + } + return { toImport, skipped }; +} diff --git a/apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts b/apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts new file mode 100644 index 00000000..749fd4e3 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/mongo-actions.ts @@ -0,0 +1,348 @@ +// ponytail: handler bodies copied from src/app/app/database-explorer/page.tsx +// (lines 215-583) rather than extracted, because the existing tool must not be +// modified. If these two copies start drifting, extract a shared hook and point +// both pages at it. + +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { normalizeConnectionString } from "@/lib/nosql-dialects"; +import { sanitizeError } from "@/lib/nosql-error-sanitizer"; +import type { MongoConfig, MongoTabState } from "@/components/data-explorer/adapters/mongodb"; +import type { Document } from "@/components/nosql-explorer/types"; + +export interface MongoActions { + documents: Document[]; + total: number; + loading: boolean; + error: string | null; + refresh: () => Promise; + insert: (doc: unknown) => Promise; + update: (id: string, update: unknown, mode?: "merge" | "replace") => Promise; + remove: (id: string) => Promise; + bulkDelete: (ids: string[]) => Promise; + importDocuments: (documents: unknown[]) => Promise; + loadSchema: (opts: { sampleMode: string; sampleSize: number }) => Promise<{ + fields: unknown[]; + sampleSize: number; + validator?: unknown; + }>; + loadIndexes: () => Promise<{ indexes: unknown[]; totalIndexSize?: number; stats?: unknown }>; + dropIndex: (indexName: string) => Promise; + createIndex: (keys: Record, options: Record) => Promise; + explain: (query: string) => Promise; + previewPipeline: (pipelineJson: string) => Promise<{ documents: unknown[] }>; +} + +/** + * Every MongoDB API call the Data Explorer document pane makes. + * + * The shell resolved the connection already, so this hook takes the raw config + * and the read-only flag instead of looking a connection up. Server errors are + * always sanitised before they reach a toast or a thrown message — connection + * strings carry passwords. + */ +export function useMongoActions( + config: MongoConfig, + state: MongoTabState, + readOnly: boolean +): MongoActions { + const t = useTranslations("DataExplorer.toast"); + const [documents, setDocuments] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // DocumentDB / Cosmos DB reject the driver's default retryWrites=true. + const connectionString = useMemo( + () => normalizeConnectionString(config.dbType, config.connectionString), + [config.dbType, config.connectionString] + ); + + const { dbName, collectionName, page, limit, query, sortField, sortDirection } = state; + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const skip = (page - 1) * limit; + const res = await apiFetch("/api/nosql/documents/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + query, + limit, + skip, + sortField, + sortDirection: sortDirection || "asc", + }), + }); + const data = await res.json(); + + if (!res.ok) throw new Error(sanitizeError(data.error)); + + setDocuments(data.documents); + setTotal(data.total); + setLoading(false); + } catch (e: unknown) { + setLoading(false); + setError(sanitizeError(e)); + toast.error(t("fetchFailed", { collection: collectionName })); + } + }, [connectionString, dbName, collectionName, page, limit, query, sortField, sortDirection, t]); + + // A setState on the tab is what triggers a refetch now — the legacy page + // called performFetch imperatively from every handler instead. + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbName, collectionName, page, limit, query, sortField, sortDirection]); + + const insert = async (doc: unknown) => { + try { + const res = await apiFetch("/api/nosql/documents", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + document: doc, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const update = async (id: string, updateDoc: unknown, mode: "merge" | "replace" = "merge") => { + try { + const res = await apiFetch("/api/nosql/documents", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documentId: id, + update: updateDoc, + mode, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const remove = async (id: string) => { + try { + const res = await apiFetch("/api/nosql/documents", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documentId: id, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + toast.success(t("docDeleted")); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const bulkDelete = async (ids: string[]) => { + try { + const res = await apiFetch("/api/nosql/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documentIds: ids, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const importDocuments = async (docs: unknown[]) => { + try { + const res = await apiFetch("/api/nosql/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + documents: docs, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + await refresh(); + } catch (e: unknown) { + toast.error(sanitizeError(e)); + throw e; + } + }; + + const loadSchema = useCallback( + async (opts?: { sampleMode: string; sampleSize: number }) => { + const res = await apiFetch("/api/nosql/schema", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + sampleMode: opts?.sampleMode ?? "random", + sampleSize: opts?.sampleSize ?? 200, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return data; + }, + [connectionString, dbName, collectionName] + ); + + const loadIndexes = useCallback(async () => { + const res = await apiFetch("/api/nosql/indexes/list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return data; + }, [connectionString, dbName, collectionName]); + + const dropIndex = async (indexName: string) => { + const res = await apiFetch("/api/nosql/indexes", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + indexName, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + }; + + const createIndex = async (keys: Record, options: Record) => { + const res = await apiFetch("/api/nosql/indexes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + readOnly, + dbName, + collectionName, + keys, + options, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + }; + + const explain = useCallback( + async (explainQuery: string) => { + const res = await apiFetch("/api/nosql/explain", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + query: explainQuery, + sortField, + sortDirection: sortDirection || "asc", + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return data.explain; + }, + [connectionString, dbName, collectionName, sortField, sortDirection] + ); + + const previewPipeline = useCallback( + async (pipelineJson: string) => { + const res = await apiFetch("/api/nosql/documents/query", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString, + dbName, + collectionName, + query: pipelineJson, + limit: 10, + skip: 0, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(sanitizeError(data.error)); + return { documents: data.documents as unknown[] }; + }, + [connectionString, dbName, collectionName] + ); + + return { + documents, + total, + loading, + error, + refresh, + insert, + update, + remove, + bulkDelete, + importDocuments, + loadSchema, + loadIndexes, + dropIndex, + createIndex, + explain, + previewPipeline, + }; +} diff --git a/apps/desktop-ui/src/lib/data-explorer/tab-storage.ts b/apps/desktop-ui/src/lib/data-explorer/tab-storage.ts new file mode 100644 index 00000000..cc9a5973 --- /dev/null +++ b/apps/desktop-ui/src/lib/data-explorer/tab-storage.ts @@ -0,0 +1,49 @@ +import type { UnifiedTab } from "@/components/data-explorer/types"; + +/** + * A restored tab is only as trustworthy as localStorage: an older build, a + * future shape change, or any other writer on the same key can leave a value + * that parses as JSON but is not an array of tabs. `JSON.parse` succeeding is + * not validation — the shell then calls `tabs.find(...)` and the pane reads + * `state.dbName`, so an unchecked value white-screens the tool with no way to + * clear it. + */ +function isStoredTab(value: unknown): value is UnifiedTab { + if (typeof value !== "object" || value === null) return false; + const tab = value as Record; + return ( + typeof tab.id === "string" && + typeof tab.connectionId === "string" && + typeof tab.sourceId === "string" && + typeof tab.title === "string" && + // Every adapter's pane reads its own fields off `state` unguarded. + typeof tab.state === "object" && + tab.state !== null + ); +} + +/** + * Parses persisted tabs, dropping anything malformed. Returns [] for a value + * that is not an array at all — the caller clears the key when nothing + * survives. Each surviving tab is rebuilt field by field, so keys retired + * since it was written (the old `readOnly` snapshot) are dropped instead of + * being carried forward on the next save. + */ +export function parseStoredTabs(raw: string): UnifiedTab[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter(isStoredTab).map((tab) => ({ + id: tab.id, + connectionId: tab.connectionId, + sourceId: tab.sourceId, + title: tab.title, + subtitle: typeof tab.subtitle === "string" ? tab.subtitle : undefined, + connectionColor: typeof tab.connectionColor === "string" ? tab.connectionColor : null, + state: tab.state, + })); +} diff --git a/apps/desktop-ui/src/lib/metadata.ts b/apps/desktop-ui/src/lib/metadata.ts index 2ae1529b..229520de 100644 --- a/apps/desktop-ui/src/lib/metadata.ts +++ b/apps/desktop-ui/src/lib/metadata.ts @@ -93,6 +93,12 @@ export const toolsMetadata: Record = { keywords: ['redis client', 'redis manager', 'redis browser', 'redis gui', 'redis commander', 'redis keys', 'redis cli'], aiSummary: 'Redis Commander lets you connect to Redis instances, browse and edit all key types (string, list, set, zset, hash), run raw commands via a console, and flush keys by pattern — with credentials encrypted locally.', }, + 'data-explorer': { + title: 'Data Explorer', + description: 'One desktop workspace for MongoDB and Redis. Browse collections, query documents, inspect keys, and run commands with credentials encrypted on your device.', + keywords: ['data explorer', 'mongodb and redis client', 'database gui', 'nosql explorer', 'redis browser', 'multi database client'], + aiSummary: 'Data Explorer is an offline desktop workspace for multiple data sources. Pick MongoDB or Redis per connection and get the full toolset for that source — collection browsing, document queries, aggregation pipelines, index management for MongoDB; key browsing, value editing, pub/sub, and a command console for Redis — with credentials encrypted locally.', + }, 's3-drive': { title: 'S3 Drive', description: 'Manage AWS S3 and DigitalOcean Spaces buckets. Browse, upload, download, and delete files with end-to-end encrypted credentials.', diff --git a/apps/desktop-ui/src/lib/route-config.ts b/apps/desktop-ui/src/lib/route-config.ts index b677a776..ea71efe1 100644 --- a/apps/desktop-ui/src/lib/route-config.ts +++ b/apps/desktop-ui/src/lib/route-config.ts @@ -89,6 +89,7 @@ export const routeConfig: Record = { '/app/api-client': { title: 'API Client', icon: Globe }, '/app/http-status-codes': { title: 'HTTP Status Codes', icon: List, namespace: 'HttpStatusCodes' }, '/app/database-explorer': { title: 'Database Explorer', icon: Database }, + '/app/data-explorer': { title: 'Data Explorer', icon: Database }, '/app/email-validator': { title: 'Email Validator', icon: Globe }, '/app/url-encode': { title: 'URL Encoder', icon: Link2, namespace: 'UrlEncode' }, '/app/uuid-generator': { title: 'UUID Generator', icon: Fingerprint, namespace: 'UuidGenerator' }, diff --git a/apps/desktop-ui/src/lib/tab-registry.tsx b/apps/desktop-ui/src/lib/tab-registry.tsx index 4e4f25b4..86d4c454 100644 --- a/apps/desktop-ui/src/lib/tab-registry.tsx +++ b/apps/desktop-ui/src/lib/tab-registry.tsx @@ -39,6 +39,7 @@ const TAB_REGISTRY: Record = { '/app/api-client': dynamic(() => import('@/app/app/api-client/page'), { ssr: false, loading }), '/app/http-status-codes': dynamic(() => import('@/app/app/http-status-codes/page'), { ssr: false, loading }), '/app/database-explorer': dynamic(() => import('@/app/app/database-explorer/page'), { ssr: false, loading }), + '/app/data-explorer': dynamic(() => import('@/app/app/data-explorer/page'), { ssr: false, loading }), '/app/email-validator': dynamic(() => import('@/app/app/email-validator/page'), { ssr: false, loading }), '/app/url-encode': dynamic(() => import('@/app/app/url-encode/page'), { ssr: false, loading }), '/app/uuid-generator': dynamic(() => import('@/app/app/uuid-generator/page'), { ssr: false, loading }), diff --git a/apps/desktop-ui/src/lib/tool-categories.ts b/apps/desktop-ui/src/lib/tool-categories.ts index 2017b8ed..e2601cb4 100644 --- a/apps/desktop-ui/src/lib/tool-categories.ts +++ b/apps/desktop-ui/src/lib/tool-categories.ts @@ -55,6 +55,7 @@ export const toolCategoryMap: Record = { 'mime-type-lookup': 'Network & API', 'sql-client': 'Database', 'database-explorer': 'Database', + 'data-explorer': 'Database', 's3-drive': 'Database', 'redis-commander': 'Database', 'color-picker': 'Media & Design', diff --git a/apps/desktop-ui/src/lib/tool-i18n.ts b/apps/desktop-ui/src/lib/tool-i18n.ts index 878ff3b0..f398a688 100644 --- a/apps/desktop-ui/src/lib/tool-i18n.ts +++ b/apps/desktop-ui/src/lib/tool-i18n.ts @@ -38,6 +38,7 @@ export const TOOL_PATH_TO_MESSAGE_KEY: Record = { '/app/api-client': 'apiClient', '/app/http-status-codes': 'httpStatusCodes', '/app/database-explorer': 'nosqlExplorer', + '/app/data-explorer': 'dataExplorer', '/app/redis-commander': 'redisCommander', '/app/image-to-base64': 'imageToBase64', '/app/image-compressor': 'imageCompressor', diff --git a/apps/desktop/src-tauri/src/router/mod.rs b/apps/desktop/src-tauri/src/router/mod.rs index 672d1e1f..ae3f82aa 100644 --- a/apps/desktop/src-tauri/src/router/mod.rs +++ b/apps/desktop/src-tauri/src/router/mod.rs @@ -105,6 +105,7 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str> ("/redis-commander/connections", "redis_connections"), ("/nosql/connections", "nosql_connections"), ("/s3-drive/connections", "s3_connections"), + ("/data-explorer/connections", "data_explorer_connections"), ] { if rel.starts_with(prefix) { // rest starts at "/connections..." @@ -499,4 +500,38 @@ mod tests { assert_eq!(r.status, 200); assert_eq!(body_json(&r).as_array().unwrap().len(), 1); } + + #[test] + fn data_explorer_connection_vault_crud() { + let state = AppState::in_memory(); + let base = "/api/v1/data-explorer/connections"; + + // Create — sourceId is opaque to the store, any string round-trips. + let payload = r#"{"sourceId":"redis","name":"cache","encryptedData":"AAA","iv":"BBB"}"#; + let r = route(&state, "POST", base, Some(payload)).unwrap(); + assert_eq!(r.status, 200, "{}", r.body); + let created = body_json(&r); + let id = created["id"].as_str().unwrap().to_string(); + assert_eq!(created["sourceId"], "redis"); + assert_eq!(created["encryptedData"], "AAA"); + + // List + let r = route(&state, "GET", base, None).unwrap(); + assert_eq!(body_json(&r).as_array().unwrap().len(), 1); + + // Patch advances updatedAt and merges fields + let r = route(&state, "PATCH", &format!("{base}/{id}"), Some(r#"{"name":"cache-prod"}"#)).unwrap(); + assert_eq!(body_json(&r)["name"], "cache-prod"); + assert_eq!(body_json(&r)["sourceId"], "redis"); + + // Touch bumps lastUsedAt only + let r = route(&state, "POST", &format!("{base}/{id}/touch"), None).unwrap(); + assert_eq!(r.status, 200); + + // Delete tombstones + let r = route(&state, "DELETE", &format!("{base}/{id}"), None).unwrap(); + assert_eq!(r.status, 204); + let r = route(&state, "GET", base, None).unwrap(); + assert_eq!(body_json(&r).as_array().unwrap().len(), 0); + } }