From 2ee506d5b7ab4c729a7ad24b9cff40c7f4585d6b Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 22 Jul 2026 21:25:57 +0530 Subject: [PATCH 1/7] Optimisation on db tool --- apps/desktop-ui/messages/af.json | 25 +- apps/desktop-ui/messages/ar.json | 25 +- apps/desktop-ui/messages/ca.json | 25 +- apps/desktop-ui/messages/cs.json | 25 +- apps/desktop-ui/messages/da.json | 25 +- apps/desktop-ui/messages/de.json | 25 +- apps/desktop-ui/messages/el.json | 25 +- apps/desktop-ui/messages/en.json | 29 +- apps/desktop-ui/messages/es.json | 25 +- apps/desktop-ui/messages/fa.json | 25 +- apps/desktop-ui/messages/fr.json | 25 +- apps/desktop-ui/messages/id.json | 25 +- apps/desktop-ui/messages/it.json | 25 +- apps/desktop-ui/messages/ja.json | 25 +- apps/desktop-ui/messages/ko.json | 25 +- apps/desktop-ui/messages/ms.json | 25 +- apps/desktop-ui/messages/nb.json | 25 +- apps/desktop-ui/messages/nl.json | 25 +- apps/desktop-ui/messages/pl.json | 25 +- apps/desktop-ui/messages/pt-BR.json | 25 +- apps/desktop-ui/messages/pt.json | 25 +- apps/desktop-ui/messages/ru.json | 25 +- apps/desktop-ui/messages/sv.json | 25 +- apps/desktop-ui/messages/tr.json | 25 +- apps/desktop-ui/messages/uk.json | 25 +- apps/desktop-ui/messages/vi.json | 25 +- apps/desktop-ui/messages/zh.json | 25 +- .../src/app/app/database-explorer/page.tsx | 4 +- .../nosql-explorer/connection-form.tsx | 60 +++- .../nosql-explorer/connection-service.ts | 10 +- .../nosql-explorer/document-view.tsx | 126 +++++++- .../nosql-explorer/explorer-sidebar.tsx | 57 +++- .../nosql-explorer/export-dialog.tsx | 10 +- .../nosql-explorer/gridfs-browser.tsx | 213 +++++++++++++ .../nosql-explorer/import-dialog.tsx | 26 +- .../nosql-explorer/query-builder.tsx | 28 +- .../components/nosql-explorer/schema-view.tsx | 75 ++++- .../nosql-explorer/server-monitor.tsx | 288 ++++++++++++++++++ .../components/nosql-explorer/sync-dialog.tsx | 169 ++++++++++ .../src/components/nosql-explorer/types.ts | 5 + .../src/lib/__tests__/nosql-dialects.test.ts | 31 ++ .../src/lib/__tests__/nosql-snippets.test.ts | 29 ++ apps/desktop-ui/src/lib/nosql-dialects.ts | 68 +++++ apps/desktop-ui/src/lib/nosql-snippets.ts | 119 ++++++++ apps/desktop/src-tauri/src/dbtools/mongo.rs | 249 ++++++++++++++- 45 files changed, 2106 insertions(+), 140 deletions(-) create mode 100644 apps/desktop-ui/src/components/nosql-explorer/gridfs-browser.tsx create mode 100644 apps/desktop-ui/src/components/nosql-explorer/server-monitor.tsx create mode 100644 apps/desktop-ui/src/components/nosql-explorer/sync-dialog.tsx create mode 100644 apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts create mode 100644 apps/desktop-ui/src/lib/__tests__/nosql-snippets.test.ts create mode 100644 apps/desktop-ui/src/lib/nosql-dialects.ts create mode 100644 apps/desktop-ui/src/lib/nosql-snippets.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index 4973c624..e3a8f852 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Wysig koppeling", - "titleConnect": "Koppel aan MongoDB", + "titleConnect": "Koppel aan databasis", "subtitleEdit": "Werk jou koppelingsbesonderhede hieronder by.", - "subtitleConnect": "Voer jou MongoDB-koppelingsstring in om jou data te begin verken.", + "subtitleConnect": "Voer jou verbindingstring in om jou data te verken.", "labelName": "Naam", + "labelDbType": "Databasistipe", "placeholderName": "bv. Produksie-DB", "labelConnectionString": "Koppelingsstring", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Hernoem", "refresh": "Verfris", "copyDbName": "Kopieer DB-naam", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Databasisnaam na knipbord gekopieer", "dropDatabase": "Laat databasis val", "dropCollection": "Laat versameling val", "menuRename": "Hernoem", "menuCopyConnectionString": "Kopieer koppelingsstring", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Vee koppeling uit", "toastSidebarStringCopied": "Koppelingsstring gekopieer!", "cancel": "Kanselleer", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Sorteer dalend", "cellCopyValue": "Kopieer waarde", "cellEditValue": "Wysig waarde", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Uitvoeringsplan", "explainLoading": "Explain word uitgevoer…", "explainFail": "Explain het misluk", @@ -1748,6 +1759,7 @@ "formatLabel": "Uitvoerformaat", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Kies velde", "selectAll": "Kies alles", "fieldsSelected": "{count, plural, one {# veld gekies} other {# velde gekies}}", @@ -1832,7 +1844,14 @@ "sampled": "{docs} dokumente gemonster · {fields} velde", "colField": "Veld", "colTypes": "Tipes", - "colCoverage": "Dekking" + "colCoverage": "Dekking", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 0d61ee09..a591087d 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "تعديل الاتصال", - "titleConnect": "الاتصال بـ MongoDB", + "titleConnect": "الاتصال بقاعدة البيانات", "subtitleEdit": "حدّث تفاصيل الاتصال أدناه.", - "subtitleConnect": "أدخل سلسلة اتصال MongoDB لبدء استكشاف بياناتك.", + "subtitleConnect": "أدخل سلسلة الاتصال لبدء استكشاف بياناتك.", "labelName": "الاسم", + "labelDbType": "نوع قاعدة البيانات", "placeholderName": "مثال: قاعدة الإنتاج", "labelConnectionString": "سلسلة الاتصال", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "إعادة تسمية", "refresh": "تحديث", "copyDbName": "نسخ اسم قاعدة البيانات", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "تم نسخ اسم قاعدة البيانات", "dropDatabase": "حذف قاعدة البيانات", "dropCollection": "حذف المجموعة", "menuRename": "إعادة تسمية", "menuCopyConnectionString": "نسخ سلسلة الاتصال", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "حذف الاتصال", "toastSidebarStringCopied": "تم نسخ سلسلة الاتصال!", "cancel": "إلغاء", @@ -1655,6 +1659,13 @@ "cellSortDesc": "ترتيب تنازلي", "cellCopyValue": "نسخ القيمة", "cellEditValue": "تحرير القيمة", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "خطة التنفيذ", "explainLoading": "جارٍ تنفيذ explain…", "explainFail": "فشل explain", @@ -1769,6 +1780,7 @@ "formatLabel": "صيغة التصدير", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "اختر الحقول", "selectAll": "تحديد الكل", "fieldsSelected": "{count, plural, one {# حقل محدد} other {# حقول محددة}}", @@ -1854,7 +1866,14 @@ "sampled": "تم أخذ عينة من {docs} مستند · {fields} حقل", "colField": "الحقل", "colTypes": "الأنواع", - "colCoverage": "التغطية" + "colCoverage": "التغطية", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "جارٍ تحميل الفهارس...", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 8db95bdf..71ff1333 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Edita la connexió", - "titleConnect": "Connecta a MongoDB", + "titleConnect": "Connecta't a la base de dades", "subtitleEdit": "Actualitza els detalls de connexió a continuació.", - "subtitleConnect": "Introdueix la cadena de connexió de MongoDB per començar a explorar les dades.", + "subtitleConnect": "Introdueix la teva cadena de connexió per començar a explorar les teves dades.", "labelName": "Nom", + "labelDbType": "Tipus de base de dades", "placeholderName": "p. ex. BD de producció", "labelConnectionString": "Cadena de connexió", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Reanomena", "refresh": "Actualitza", "copyDbName": "Copia el nom de la BD", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nom de la base de dades copiat al porta-retalls", "dropDatabase": "Elimina la base de dades", "dropCollection": "Elimina la col·lecció", "menuRename": "Reanomena", "menuCopyConnectionString": "Copia la cadena de connexió", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Elimina la connexió", "toastSidebarStringCopied": "Cadena de connexió copiada!", "cancel": "Cancel·la", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Ordena descendent", "cellCopyValue": "Copia el valor", "cellEditValue": "Edita el valor", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Pla d'execució", "explainLoading": "Executant explain…", "explainFail": "L'explain ha fallat", @@ -1769,6 +1780,7 @@ "formatLabel": "Format d'exportació", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Selecciona camps", "selectAll": "Selecciona-ho tot", "fieldsSelected": "{count, plural, one {# camp seleccionat} other {# camps seleccionats}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} documents mostrejats · {fields} camps", "colField": "Camp", "colTypes": "Tipus", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "S'estan carregant els índexs…", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index d2d63a26..1d193091 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Upravit připojení", - "titleConnect": "Připojit k MongoDB", + "titleConnect": "Připojit k databázi", "subtitleEdit": "Upravte údaje o připojení níže.", - "subtitleConnect": "Zadejte connection string MongoDB a začněte prozkoumávat data.", + "subtitleConnect": "Zadejte připojovací řetězec a začněte prozkoumávat svá data.", "labelName": "Název", + "labelDbType": "Typ databáze", "placeholderName": "např. Produkční DB", "labelConnectionString": "Connection string", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Přejmenovat", "refresh": "Obnovit", "copyDbName": "Kopírovat název DB", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Název databáze zkopírován", "dropDatabase": "Smazat databázi", "dropCollection": "Smazat kolekci", "menuRename": "Přejmenovat", "menuCopyConnectionString": "Kopírovat connection string", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Smazat připojení", "toastSidebarStringCopied": "Connection string zkopírován!", "cancel": "Zrušit", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Seřadit sestupně", "cellCopyValue": "Kopírovat hodnotu", "cellEditValue": "Upravit hodnotu", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Plán provedení", "explainLoading": "Probíhá explain…", "explainFail": "Explain se nezdařil", @@ -1769,6 +1780,7 @@ "formatLabel": "Formát exportu", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Vybrat pole", "selectAll": "Vybrat vše", "fieldsSelected": "{count, plural, one {# pole vybráno} few {# pole vybrána} many {# polí vybráno} other {# polí vybráno}}", @@ -1854,7 +1866,14 @@ "sampled": "Vzorkováno {docs} dokumentů · {fields} polí", "colField": "Pole", "colTypes": "Typy", - "colCoverage": "Pokrytí" + "colCoverage": "Pokrytí", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Načítání indexů…", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index d2020c77..2e814042 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Rediger forbindelse", - "titleConnect": "Opret forbindelse til MongoDB", + "titleConnect": "Opret forbindelse til database", "subtitleEdit": "Opdater dine forbindelsesoplysninger nedenfor.", - "subtitleConnect": "Indtast din MongoDB-forbindelsesstreng for at begynde at udforske dine data.", + "subtitleConnect": "Indtast din forbindelsesstreng for at begynde at udforske dine data.", "labelName": "Navn", + "labelDbType": "Databasetype", "placeholderName": "f.eks. Produktion DB", "labelConnectionString": "Forbindelsesstreng", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Omdøb", "refresh": "Opfriske", "copyDbName": "Kopier DB-navn", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Databasenavn kopieret til udklipsholder", "dropDatabase": "Drop database", "dropCollection": "Drop Collection", "menuRename": "Omdøb", "menuCopyConnectionString": "Kopier forbindelsesstreng", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Slet forbindelse", "toastSidebarStringCopied": "Forbindelsesstrengen er kopieret!", "cancel": "Ophæve", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Sorter faldende", "cellCopyValue": "Kopiér værdi", "cellEditValue": "Rediger værdi", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Eksekveringsplan", "explainLoading": "Kører explain…", "explainFail": "Explain mislykkedes", @@ -1769,6 +1780,7 @@ "formatLabel": "Eksporter format", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Vælg felter", "selectAll": "Vælg alle", "fieldsSelected": "{count, plural, one {# field selected} other {# fields selected}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} dokumenter analyseret · {fields} felter", "colField": "Felt", "colTypes": "Typer", - "colCoverage": "Dækning" + "colCoverage": "Dækning", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Indlæser indekser …", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 6c29a90c..60c752fa 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Verbindung bearbeiten", - "titleConnect": "Mit MongoDB verbinden", + "titleConnect": "Mit Datenbank verbinden", "subtitleEdit": "Verbindungsdaten unten anpassen.", - "subtitleConnect": "MongoDB-Verbindungszeichenfolge eingeben, um zu starten.", + "subtitleConnect": "Geben Sie Ihre Verbindungszeichenfolge ein, um Ihre Daten zu erkunden.", "labelName": "Name", + "labelDbType": "Datenbanktyp", "placeholderName": "z. B. Produktions-DB", "labelConnectionString": "Verbindungszeichenfolge", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Umbenennen", "refresh": "Aktualisieren", "copyDbName": "DB-Name kopieren", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Datenbankname in die Zwischenablage kopiert", "dropDatabase": "Datenbank löschen", "dropCollection": "Sammlung löschen", "menuRename": "Umbenennen", "menuCopyConnectionString": "Verbindungszeichenfolge kopieren", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Verbindung löschen", "toastSidebarStringCopied": "Verbindungszeichenfolge kopiert!", "cancel": "Abbrechen", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Absteigend sortieren", "cellCopyValue": "Wert kopieren", "cellEditValue": "Wert bearbeiten", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Ausführungsplan", "explainLoading": "Explain wird ausgeführt…", "explainFail": "Explain fehlgeschlagen", @@ -1769,6 +1780,7 @@ "formatLabel": "Exportformat", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Felder wählen", "selectAll": "Alle wählen", "fieldsSelected": "{count, plural, one {# Feld gewählt} other {# Felder gewählt}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} Dokumente untersucht · {fields} Felder", "colField": "Feld", "colTypes": "Typen", - "colCoverage": "Abdeckung" + "colCoverage": "Abdeckung", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Indizes werden geladen …", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 7c76d021..338dca7c 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Επεξεργασία σύνδεσης", - "titleConnect": "Συνδεθείτε στο MongoDB", + "titleConnect": "Σύνδεση σε βάση δεδομένων", "subtitleEdit": "Ενημερώστε τα στοιχεία σύνδεσής σας παρακάτω.", - "subtitleConnect": "Εισαγάγετε τη συμβολοσειρά σύνδεσης MongoDB για να ξεκινήσετε την εξερεύνηση των δεδομένων σας.", + "subtitleConnect": "Εισαγάγετε τη συμβολοσειρά σύνδεσης για να αρχίσετε να εξερευνάτε τα δεδομένα σας.", "labelName": "Ονομα", + "labelDbType": "Τύπος βάσης δεδομένων", "placeholderName": "π.χ. DB Παραγωγής", "labelConnectionString": "Συμβολοσειρά σύνδεσης", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Μετονομάζω", "refresh": "Φρεσκάρω", "copyDbName": "Αντιγραφή ονόματος DB", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Το όνομα βάσης δεδομένων αντιγράφηκε στο πρόχειρο", "dropDatabase": "Απόθεση βάσης δεδομένων", "dropCollection": "Drop Collection", "menuRename": "Μετονομάζω", "menuCopyConnectionString": "Αντιγραφή συμβολοσειράς σύνδεσης", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Διαγραφή σύνδεσης", "toastSidebarStringCopied": "Η συμβολοσειρά σύνδεσης αντιγράφηκε!", "cancel": "Ματαίωση", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Φθίνουσα ταξινόμηση", "cellCopyValue": "Αντιγραφή τιμής", "cellEditValue": "Επεξεργασία τιμής", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Σχέδιο εκτέλεσης", "explainLoading": "Εκτελείται explain…", "explainFail": "Αποτυχία explain", @@ -1769,6 +1780,7 @@ "formatLabel": "Μορφή εξαγωγής", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Επιλέξτε Πεδία", "selectAll": "Επιλέξτε Όλα", "fieldsSelected": "{count, plural, one {# επιλεγμένο πεδίο} other {# επιλεγμένα πεδία}}", @@ -1854,7 +1866,14 @@ "sampled": "Δείγμα {docs} εγγράφων · {fields} πεδία", "colField": "Πεδίο", "colTypes": "Τύποι", - "colCoverage": "Κάλυψη" + "colCoverage": "Κάλυψη", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Φόρτωση ευρετηρίων...", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 860099b0..03dbbd37 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1516,10 +1516,11 @@ }, "connection": { "titleEdit": "Edit Connection", - "titleConnect": "Connect to MongoDB", + "titleConnect": "Connect to Database", "subtitleEdit": "Update your connection details below.", - "subtitleConnect": "Enter your MongoDB connection string to start exploring your data.", + "subtitleConnect": "Enter your connection string to start exploring your data.", "labelName": "Name", + "labelDbType": "Database Type", "placeholderName": "e.g. Production DB", "labelConnectionString": "Connection String", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1585,11 +1586,14 @@ "rename": "Rename", "refresh": "Refresh", "copyDbName": "Copy DB Name", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Database name copied to clipboard", "dropDatabase": "Drop Database", "dropCollection": "Drop Collection", "menuRename": "Rename", "menuCopyConnectionString": "Copy Connection String", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Delete Connection", "toastSidebarStringCopied": "Connection string copied!", "cancel": "Cancel", @@ -1667,6 +1671,13 @@ "cellSortDesc": "Sort descending", "cellCopyValue": "Copy value", "cellEditValue": "Edit value", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Explain Plan", "explainLoading": "Running explain…", "explainFail": "Explain failed", @@ -1763,6 +1774,7 @@ "formatLabel": "Export Format", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Select Fields", "selectAll": "Select All", "fieldsSelected": "{count, plural, one {# field selected} other {# fields selected}}", @@ -1824,13 +1836,13 @@ }, "importDialog": { "title": "Import Documents into {collection}", - "onlyJson": "Only .json files are supported", + "onlyJson": "Supported files: .json, .csv, .tsv", "invalidStructure": "File must contain a JSON array or object", "invalidJson": "Invalid JSON: could not parse file", "imported": "{count, plural, one {Imported # document} other {Imported # documents}}", "importFailed": "Import failed", "dropHint": "Drag & drop or click to upload", - "dropSubHint": "Supports JSON array or NDJSON", + "dropSubHint": "JSON array, NDJSON, CSV or TSV", "docsCount": "{count, plural, one {# doc} other {# docs}}", "previewLabel": "Preview (first 3 documents)", "moreDocs": "... and {count} more documents", @@ -1847,7 +1859,14 @@ "sampled": "Sampled {docs} documents · {fields} fields", "colField": "Field", "colTypes": "Types", - "colCoverage": "Coverage" + "colCoverage": "Coverage", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index a93bd0c0..1648957c 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Editar conexión", - "titleConnect": "Conectar a MongoDB", + "titleConnect": "Conectar a la base de datos", "subtitleEdit": "Actualiza los datos de conexión abajo.", - "subtitleConnect": "Introduce la cadena de conexión de MongoDB para empezar.", + "subtitleConnect": "Introduce tu cadena de conexión para empezar a explorar tus datos.", "labelName": "Nombre", + "labelDbType": "Tipo de base de datos", "placeholderName": "p. ej. Producción", "labelConnectionString": "Cadena de conexión", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Renombrar", "refresh": "Actualizar", "copyDbName": "Copiar nombre de BD", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nombre copiado al portapapeles", "dropDatabase": "Eliminar base de datos", "dropCollection": "Eliminar colección", "menuRename": "Renombrar", "menuCopyConnectionString": "Copiar cadena de conexión", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Eliminar conexión", "toastSidebarStringCopied": "¡Cadena de conexión copiada!", "cancel": "Cancelar", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Orden descendente", "cellCopyValue": "Copiar valor", "cellEditValue": "Editar valor", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Plan de ejecución", "explainLoading": "Ejecutando explain…", "explainFail": "Explain falló", @@ -1748,6 +1759,7 @@ "formatLabel": "Formato", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Campos", "selectAll": "Seleccionar todo", "fieldsSelected": "{count, plural, one {# campo seleccionado} other {# campos seleccionados}}", @@ -1832,7 +1844,14 @@ "sampled": "Muestra de {docs} documentos · {fields} campos", "colField": "Campo", "colTypes": "Tipos", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 414e3619..7fbdc670 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "ویرایش اتصال", - "titleConnect": "به MongoDB متصل شوید", + "titleConnect": "اتصال به پایگاه داده", "subtitleEdit": "جزئیات اتصال خود را در زیر به روز کنید.", - "subtitleConnect": "رشته اتصال MongoDB خود را وارد کنید تا کاوش داده های خود را شروع کنید.", + "subtitleConnect": "رشته اتصال خود را وارد کنید تا کاوش داده‌هایتان را آغاز کنید.", "labelName": "نام", + "labelDbType": "نوع پایگاه داده", "placeholderName": "به عنوان مثال دی بی تولید", "labelConnectionString": "رشته اتصال", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "تغییر نام دهید", "refresh": "تازه کردن", "copyDbName": "نام DB را کپی کنید", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "نام پایگاه داده در کلیپ بورد کپی شد", "dropDatabase": "پایگاه داده را رها کنید", "dropCollection": "رها کردن مجموعه", "menuRename": "تغییر نام دهید", "menuCopyConnectionString": "رشته اتصال را کپی کنید", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "اتصال را حذف کنید", "toastSidebarStringCopied": "رشته اتصال کپی شد!", "cancel": "لغو کنید", @@ -1652,6 +1656,13 @@ "cellSortDesc": "مرتب‌سازی نزولی", "cellCopyValue": "کپی مقدار", "cellEditValue": "ویرایش مقدار", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "طرح اجرا", "explainLoading": "در حال اجرای explain…", "explainFail": "explain ناموفق بود", @@ -1748,6 +1759,7 @@ "formatLabel": "فرمت صادرات", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "فیلدها را انتخاب کنید", "selectAll": "همه را انتخاب کنید", "fieldsSelected": "{count, plural, one {# field selected} other {# fields selected}}", @@ -1832,7 +1844,14 @@ "sampled": "نمونه‌گیری از {docs} سند · {fields} فیلد", "colField": "فیلد", "colTypes": "انواع", - "colCoverage": "پوشش" + "colCoverage": "پوشش", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index b1035452..704f93ca 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Modifier la connexion", - "titleConnect": "Connexion à MongoDB", + "titleConnect": "Se connecter à la base de données", "subtitleEdit": "Mettez à jour les détails ci-dessous.", - "subtitleConnect": "Saisissez la chaîne de connexion MongoDB pour explorer vos données.", + "subtitleConnect": "Saisissez votre chaîne de connexion pour commencer à explorer vos données.", "labelName": "Nom", + "labelDbType": "Type de base de données", "placeholderName": "ex. Prod", "labelConnectionString": "Chaîne de connexion", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Renommer", "refresh": "Actualiser", "copyDbName": "Copier le nom de la base", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nom de base copié", "dropDatabase": "Supprimer la base", "dropCollection": "Supprimer la collection", "menuRename": "Renommer", "menuCopyConnectionString": "Copier la chaîne de connexion", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Supprimer la connexion", "toastSidebarStringCopied": "Chaîne copiée !", "cancel": "Annuler", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Tri décroissant", "cellCopyValue": "Copier la valeur", "cellEditValue": "Modifier la valeur", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Plan d'exécution", "explainLoading": "Explain en cours…", "explainFail": "Échec de l'explain", @@ -1748,6 +1759,7 @@ "formatLabel": "Format", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Champs", "selectAll": "Tout sélectionner", "fieldsSelected": "{count, plural, one {# champ sélectionné} other {# champs sélectionnés}}", @@ -1832,7 +1844,14 @@ "sampled": "Échantillon de {docs} documents · {fields} champs", "colField": "Champ", "colTypes": "Types", - "colCoverage": "Couverture" + "colCoverage": "Couverture", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 1f7c7675..5327eee0 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Edit Koneksi", - "titleConnect": "Hubungkan ke MongoDB", + "titleConnect": "Hubungkan ke basis data", "subtitleEdit": "Perbarui detail koneksi Anda di bawah ini.", - "subtitleConnect": "Masukkan string koneksi MongoDB Anda untuk mulai menjelajahi data Anda.", + "subtitleConnect": "Masukkan string koneksi Anda untuk mulai menjelajahi data Anda.", "labelName": "Name", + "labelDbType": "Tipe basis data", "placeholderName": "misalnya Production DB", "labelConnectionString": "String koneksi", "placeholderConnectionString": "mongodb :// localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Ubah nama", "refresh": "Segarkan", "copyDbName": "Salin Nama DB", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nama basis data disalin ke clipboard", "dropDatabase": "Jatuhkan Basis Data", "dropCollection": "Jatuhkan Koleksi", "menuRename": "Ubah nama", "menuCopyConnectionString": "String koneksi", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Hapus Koneksi", "toastSidebarStringCopied": "String koneksi disalin!", "cancel": "Batalkan", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Urutkan turun", "cellCopyValue": "Salin nilai", "cellEditValue": "Edit nilai", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Rencana eksekusi", "explainLoading": "Menjalankan explain…", "explainFail": "Explain gagal", @@ -1769,6 +1780,7 @@ "formatLabel": "Format Ekspor", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Pilih Bidang", "selectAll": "Pilih Semua", "fieldsSelected": "{count, plural, one {# bidang dipilih} other {# bidang dipilih}}", @@ -1854,7 +1866,14 @@ "sampled": "Sampel {docs} dokumen · {fields} bidang", "colField": "Bidang", "colTypes": "Tipe", - "colCoverage": "Cakupan" + "colCoverage": "Cakupan", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Memuat indeks...", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index cc6e30a0..95ac6482 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Modifica connessione", - "titleConnect": "Connettiti a MongoDB", + "titleConnect": "Connetti al database", "subtitleEdit": "Aggiorna i dettagli della tua connessione di seguito.", - "subtitleConnect": "Inserisci la stringa di connessione MongoDB per iniziare a esplorare i tuoi dati.", + "subtitleConnect": "Inserisci la stringa di connessione per iniziare a esplorare i tuoi dati.", "labelName": "Nome", + "labelDbType": "Tipo di database", "placeholderName": "per esempio. DB di produzione", "labelConnectionString": "Stringa di connessione", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Rinominare", "refresh": "Aggiorna", "copyDbName": "Copia nome database", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nome del database copiato negli appunti", "dropDatabase": "Elimina database", "dropCollection": "Raccolta gocce", "menuRename": "Rinominare", "menuCopyConnectionString": "Copia stringa di connessione", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Elimina connessione", "toastSidebarStringCopied": "Stringa di connessione copiata!", "cancel": "Cancellare", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Ordina decrescente", "cellCopyValue": "Copia valore", "cellEditValue": "Modifica valore", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Piano di esecuzione", "explainLoading": "Esecuzione explain…", "explainFail": "Explain non riuscito", @@ -1748,6 +1759,7 @@ "formatLabel": "Formato di esportazione", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Seleziona Campi", "selectAll": "Seleziona tutto", "fieldsSelected": "{count, plural, one {# campo selezionato} other {# campi selezionati}}", @@ -1832,7 +1844,14 @@ "sampled": "Campione di {docs} documenti · {fields} campi", "colField": "Campo", "colTypes": "Tipi", - "colCoverage": "Copertura" + "colCoverage": "Copertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 471da4fe..0e595648 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "接続を編集", - "titleConnect": "MongoDBに接続", + "titleConnect": "データベースに接続", "subtitleEdit": "以下の接続情報を更新してください。", - "subtitleConnect": "MongoDB接続文字列を入力して、データの探索を開始します。", + "subtitleConnect": "接続文字列を入力してデータの探索を開始します。", "labelName": "名前", + "labelDbType": "データベースの種類", "placeholderName": "例:本番DB", "labelConnectionString": "接続文字列", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "名前を変更", "refresh": "更新", "copyDbName": "DB名をコピー", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "データベース名をクリップボードにコピーしました", "dropDatabase": "データベースを削除", "dropCollection": "コレクションを削除", "menuRename": "名前を変更", "menuCopyConnectionString": "接続文字列をコピー", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "接続を削除", "toastSidebarStringCopied": "接続文字列をコピーしました!", "cancel": "キャンセル", @@ -1655,6 +1659,13 @@ "cellSortDesc": "降順に並べ替え", "cellCopyValue": "値をコピー", "cellEditValue": "値を編集", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "実行プラン", "explainLoading": "explainを実行中…", "explainFail": "explainに失敗しました", @@ -1769,6 +1780,7 @@ "formatLabel": "エクスポート形式", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "フィールドを選択", "selectAll": "すべて選択", "fieldsSelected": "{count, plural, one {#フィールド選択済み} other {#フィールド選択済み}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs}件のドキュメントをサンプリング · {fields}個のフィールド", "colField": "フィールド", "colTypes": "型", - "colCoverage": "カバレッジ" + "colCoverage": "カバレッジ", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "インデックスを読み込み中...", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 39581cf4..8188586f 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "연결 수정", - "titleConnect": "MongoDB에 연결", + "titleConnect": "데이터베이스에 연결", "subtitleEdit": "아래에서 연결 세부 사항을 업데이트하세요.", - "subtitleConnect": "MongoDB 연결 문자열을 입력하여 데이터 탐색을 시작하세요.", + "subtitleConnect": "연결 문자열을 입력하여 데이터 탐색을 시작하세요.", "labelName": "이름", + "labelDbType": "데이터베이스 유형", "placeholderName": "예: 운영 DB", "labelConnectionString": "연결 문자열", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "이름 변경", "refresh": "새로고침", "copyDbName": "DB 이름 복사", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "데이터베이스 이름이 클립보드에 복사되었습니다", "dropDatabase": "데이터베이스 삭제", "dropCollection": "컬렉션 삭제", "menuRename": "이름 변경", "menuCopyConnectionString": "연결 문자열 복사", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "연결 삭제", "toastSidebarStringCopied": "연결 문자열이 복사되었습니다!", "cancel": "취소", @@ -1655,6 +1659,13 @@ "cellSortDesc": "내림차순 정렬", "cellCopyValue": "값 복사", "cellEditValue": "값 편집", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "실행 계획", "explainLoading": "explain 실행 중…", "explainFail": "explain 실패", @@ -1769,6 +1780,7 @@ "formatLabel": "내보내기 형식", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "필드 선택", "selectAll": "모두 선택", "fieldsSelected": "{count, plural, one {#개 필드 선택됨} other {#개 필드 선택됨}}", @@ -1854,7 +1866,14 @@ "sampled": "문서 {docs}개 샘플링 · 필드 {fields}개", "colField": "필드", "colTypes": "유형", - "colCoverage": "커버리지" + "colCoverage": "커버리지", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "인덱스를 불러오는 중...", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 15e1a393..0da1abf5 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Edit Sambungan", - "titleConnect": "Sambung ke MongoDB", + "titleConnect": "Sambung ke pangkalan data", "subtitleEdit": "Kemas kini butiran sambungan anda di bawah.", - "subtitleConnect": "Masukkan rentetan sambungan MongoDB anda untuk mula meneroka data anda.", + "subtitleConnect": "Masukkan rentetan sambungan anda untuk mula meneroka data anda.", "labelName": "Nama", + "labelDbType": "Jenis pangkalan data", "placeholderName": "cth. DB Pengeluaran", "labelConnectionString": "Rentetan Sambungan", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Namakan Semula", "refresh": "Segar Semula", "copyDbName": "Salin Nama DB", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nama pangkalan data disalin ke papan klip", "dropDatabase": "Gugurkan Pangkalan Data", "dropCollection": "Gugurkan Koleksi", "menuRename": "Namakan Semula", "menuCopyConnectionString": "Salin Rentetan Sambungan", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Padam Sambungan", "toastSidebarStringCopied": "Rentetan sambungan disalin!", "cancel": "Batal", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Isih menurun", "cellCopyValue": "Salin nilai", "cellEditValue": "Sunting nilai", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Pelan pelaksanaan", "explainLoading": "Menjalankan explain…", "explainFail": "Explain gagal", @@ -1748,6 +1759,7 @@ "formatLabel": "Format Eksport", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Pilih Medan", "selectAll": "Pilih Semua", "fieldsSelected": "{count, plural, one {# medan dipilih} other {# medan dipilih}}", @@ -1832,7 +1844,14 @@ "sampled": "Sampel {docs} dokumen · {fields} medan", "colField": "Medan", "colTypes": "Jenis", - "colCoverage": "Liputan" + "colCoverage": "Liputan", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index db3c3c56..aabc6b45 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Rediger tilkobling", - "titleConnect": "Koble til MongoDB", + "titleConnect": "Koble til database", "subtitleEdit": "Oppdater tilkoblingsdetaljene dine nedenfor.", - "subtitleConnect": "Skriv inn MongoDB-tilkoblingsstrengen din for å begynne å utforske dataene dine.", + "subtitleConnect": "Skriv inn tilkoblingsstrengen for å begynne å utforske dataene dine.", "labelName": "Navn", + "labelDbType": "Databasetype", "placeholderName": "f.eks. Produksjons-DB", "labelConnectionString": "Tilkoblingsstreng", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Gi nytt navn", "refresh": "Oppdater", "copyDbName": "Kopier DB-navn", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Databasenavn kopiert til utklippstavle", "dropDatabase": "Slett database", "dropCollection": "Slett samling", "menuRename": "Gi nytt navn", "menuCopyConnectionString": "Kopier tilkoblingsstreng", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Slett tilkobling", "toastSidebarStringCopied": "Tilkoblingsstreng kopiert!", "cancel": "Avbryt", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Sorter synkende", "cellCopyValue": "Kopier verdi", "cellEditValue": "Rediger verdi", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Utførelsesplan", "explainLoading": "Kjører explain…", "explainFail": "Explain mislyktes", @@ -1769,6 +1780,7 @@ "formatLabel": "Eksportformat", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Velg felt", "selectAll": "Velg alle", "fieldsSelected": "{count, plural, one {# felt valgt} other {# felt valgt}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} dokumenter analysert · {fields} felt", "colField": "Felt", "colTypes": "Typer", - "colCoverage": "Dekning" + "colCoverage": "Dekning", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Laster inn indekser …", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 8e3b0277..176561be 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Verbinding bewerken", - "titleConnect": "Verbinden met MongoDB", + "titleConnect": "Verbinden met database", "subtitleEdit": "Werk je verbindingsgegevens hieronder bij.", - "subtitleConnect": "Voer je MongoDB-verbindingsreeks in om je gegevens te verkennen.", + "subtitleConnect": "Voer je verbindingsreeks in om je gegevens te verkennen.", "labelName": "Naam", + "labelDbType": "Databasetype", "placeholderName": "bijv. Productie-DB", "labelConnectionString": "Verbindingsreeks", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Hernoemen", "refresh": "Vernieuwen", "copyDbName": "DB-naam kopiëren", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Databasenaam gekopieerd naar klembord", "dropDatabase": "Database verwijderen", "dropCollection": "Collectie verwijderen", "menuRename": "Hernoemen", "menuCopyConnectionString": "Verbindingsreeks kopiëren", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Verbinding verwijderen", "toastSidebarStringCopied": "Verbindingsreeks gekopieerd!", "cancel": "Annuleren", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Aflopend sorteren", "cellCopyValue": "Waarde kopiëren", "cellEditValue": "Waarde bewerken", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Uitvoeringsplan", "explainLoading": "Explain wordt uitgevoerd…", "explainFail": "Explain mislukt", @@ -1769,6 +1780,7 @@ "formatLabel": "Exportformaat", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Velden selecteren", "selectAll": "Alles selecteren", "fieldsSelected": "{count, plural, one {# veld geselecteerd} other {# velden geselecteerd}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} documenten bemonsterd · {fields} velden", "colField": "Veld", "colTypes": "Typen", - "colCoverage": "Dekking" + "colCoverage": "Dekking", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Indexen laden …", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 4cd12d26..32b8baa5 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Edit Connection", - "titleConnect": "Connect to MongoDB", + "titleConnect": "Połącz z bazą danych", "subtitleEdit": "Update your connection details below.", - "subtitleConnect": "Enter your MongoDB connection string to start exploring your data.", + "subtitleConnect": "Wprowadź parametry połączenia, aby rozpocząć eksplorację danych.", "labelName": "Name", + "labelDbType": "Typ bazy danych", "placeholderName": "e.g. Production DB", "labelConnectionString": "Connection String", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Rename", "refresh": "Refresh", "copyDbName": "Copy DB Name", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Database name copied to clipboard", "dropDatabase": "Drop Database", "dropCollection": "Drop Collection", "menuRename": "Rename", "menuCopyConnectionString": "Copy Connection String", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Delete Connection", "toastSidebarStringCopied": "Connection string copied!", "cancel": "Cancel", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Sortuj malejąco", "cellCopyValue": "Kopiuj wartość", "cellEditValue": "Edytuj wartość", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Plan wykonania", "explainLoading": "Trwa explain…", "explainFail": "Explain nie powiódł się", @@ -1769,6 +1780,7 @@ "formatLabel": "Export Format", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Select Fields", "selectAll": "Select All", "fieldsSelected": "{count, plural, one {# field selected} other {# fields selected}}", @@ -1854,7 +1866,14 @@ "sampled": "Próbkowano {docs} dokumentów · {fields} pól", "colField": "Pole", "colTypes": "Typy", - "colCoverage": "Pokrycie" + "colCoverage": "Pokrycie", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Ładowanie indeksów…", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 00f8e012..cea5bcf0 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Editar Conexão", - "titleConnect": "Conectar ao MongoDB", + "titleConnect": "Conectar ao banco de dados", "subtitleEdit": "Atualize os detalhes da sua conexão abaixo.", - "subtitleConnect": "Insira sua string de conexão MongoDB para começar a explorar seus dados.", + "subtitleConnect": "Insira sua string de conexão para começar a explorar seus dados.", "labelName": "Nome", + "labelDbType": "Tipo de banco de dados", "placeholderName": "ex: BD de Produção", "labelConnectionString": "String de Conexão", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Renomear", "refresh": "Atualizar", "copyDbName": "Copiar Nome do BD", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nome do banco de dados copiado para a área de transferência", "dropDatabase": "Descartar Banco de Dados", "dropCollection": "Descartar Coleção", "menuRename": "Renomear", "menuCopyConnectionString": "Copiar String de Conexão", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Excluir Conexão", "toastSidebarStringCopied": "String de conexão copiada!", "cancel": "Cancelar", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Ordem decrescente", "cellCopyValue": "Copiar valor", "cellEditValue": "Editar valor", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Plano de execução", "explainLoading": "Executando explain…", "explainFail": "Explain falhou", @@ -1769,6 +1780,7 @@ "formatLabel": "Formato de Exportação", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Selecionar Campos", "selectAll": "Selecionar Tudo", "fieldsSelected": "{count, plural, one {# campo selecionado} other {# campos selecionados}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} documentos amostrados · {fields} campos", "colField": "Campo", "colTypes": "Tipos", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Carregando índices...", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 8dc4365a..9dcbc0cb 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Editar Ligação", - "titleConnect": "Ligar ao MongoDB", + "titleConnect": "Ligar à base de dados", "subtitleEdit": "Atualiza os detalhes da tua ligação abaixo.", - "subtitleConnect": "Introduz a tua string de ligação MongoDB para começar a explorar os teus dados.", + "subtitleConnect": "Introduza a sua cadeia de ligação para começar a explorar os seus dados.", "labelName": "Nome", + "labelDbType": "Tipo de base de dados", "placeholderName": "ex: BD de Produção", "labelConnectionString": "String de Ligação", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Renomear", "refresh": "Atualizar", "copyDbName": "Copiar Nome da BD", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Nome da base de dados copiado para a área de transferência", "dropDatabase": "Descartar Base de Dados", "dropCollection": "Descartar Coleção", "menuRename": "Renomear", "menuCopyConnectionString": "Copiar String de Ligação", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Eliminar Ligação", "toastSidebarStringCopied": "String de ligação copiada!", "cancel": "Cancelar", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Ordem descendente", "cellCopyValue": "Copiar valor", "cellEditValue": "Editar valor", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Plano de execução", "explainLoading": "A executar explain…", "explainFail": "Explain falhou", @@ -1769,6 +1780,7 @@ "formatLabel": "Formato de Exportação", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Selecionar Campos", "selectAll": "Selecionar Tudo", "fieldsSelected": "{count, plural, one {# campo selecionado} other {# campos selecionados}}", @@ -1854,7 +1866,14 @@ "sampled": "{docs} documentos amostrados · {fields} campos", "colField": "Campo", "colTypes": "Tipos", - "colCoverage": "Cobertura" + "colCoverage": "Cobertura", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "A carregar índices…", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 82592912..c91befd4 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Редактировать подключение", - "titleConnect": "Подключение к MongoDB", + "titleConnect": "Подключиться к базе данных", "subtitleEdit": "Обновите параметры подключения.", - "subtitleConnect": "Введите строку подключения MongoDB для начала работы с данными.", + "subtitleConnect": "Введите строку подключения, чтобы начать изучение данных.", "labelName": "Название", + "labelDbType": "Тип базы данных", "placeholderName": "напр. Рабочая БД", "labelConnectionString": "Строка подключения", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Переименовать", "refresh": "Обновить", "copyDbName": "Копировать имя БД", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Имя базы данных скопировано", "dropDatabase": "Удалить базу данных", "dropCollection": "Удалить коллекцию", "menuRename": "Переименовать", "menuCopyConnectionString": "Копировать строку подключения", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Удалить подключение", "toastSidebarStringCopied": "Строка подключения скопирована!", "cancel": "Отмена", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Сортировать по убыванию", "cellCopyValue": "Копировать значение", "cellEditValue": "Изменить значение", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "План выполнения", "explainLoading": "Выполняется explain…", "explainFail": "Ошибка explain", @@ -1769,6 +1780,7 @@ "formatLabel": "Формат экспорта", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Выберите поля", "selectAll": "Выбрать все", "fieldsSelected": "{count, plural, one {# поле выбрано} few {# поля выбрано} other {# полей выбрано}}", @@ -1854,7 +1866,14 @@ "sampled": "Выборка {docs} документов · {fields} полей", "colField": "Поле", "colTypes": "Типы", - "colCoverage": "Покрытие" + "colCoverage": "Покрытие", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Загрузка индексов…", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index badc30d6..a7734ca9 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Redigera anslutning", - "titleConnect": "Anslut till MongoDB", + "titleConnect": "Anslut till databas", "subtitleEdit": "Uppdatera dina anslutningsuppgifter nedan.", - "subtitleConnect": "Ange din MongoDB-anslutningssträng för att börja utforska dina data.", + "subtitleConnect": "Ange din anslutningssträng för att börja utforska dina data.", "labelName": "Namn", + "labelDbType": "Databastyp", "placeholderName": "t.ex. Produktionsdatabas", "labelConnectionString": "Anslutningssträng", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Byt namn", "refresh": "Uppdatera", "copyDbName": "Kopiera DB-namn", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Databasnamn kopierat till urklipp", "dropDatabase": "Ta bort databas", "dropCollection": "Ta bort samling", "menuRename": "Byt namn", "menuCopyConnectionString": "Kopiera anslutningssträng", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Ta bort anslutning", "toastSidebarStringCopied": "Anslutningssträng kopierad!", "cancel": "Avbryt", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Sortera fallande", "cellCopyValue": "Kopiera värde", "cellEditValue": "Redigera värde", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Exekveringsplan", "explainLoading": "Kör explain…", "explainFail": "Explain misslyckades", @@ -1748,6 +1759,7 @@ "formatLabel": "Exportformat", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Välj fält", "selectAll": "Välj alla", "fieldsSelected": "{count, plural, one {# fält valt} other {# fält valda}}", @@ -1832,7 +1844,14 @@ "sampled": "Samplade {docs} dokument · {fields} fält", "colField": "Fält", "colTypes": "Typer", - "colCoverage": "Täckning" + "colCoverage": "Täckning", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index eefca454..1a0d4dac 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Bağlantıyı Düzenle", - "titleConnect": "MongoDB'ye Bağlan", + "titleConnect": "Veritabanına bağlan", "subtitleEdit": "Bağlantı detaylarınızı aşağıda güncelleyin.", - "subtitleConnect": "Verilerinizi keşfetmeye başlamak için MongoDB bağlantı dizesini girin.", + "subtitleConnect": "Verilerinizi keşfetmeye başlamak için bağlantı dizenizi girin.", "labelName": "Ad", + "labelDbType": "Veritabanı türü", "placeholderName": "örn. Üretim Veritabanı", "labelConnectionString": "Bağlantı Dizesi", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Yeniden Adlandır", "refresh": "Yenile", "copyDbName": "Veritabanı Adını Kopyala", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Veritabanı adı panoya kopyalandı", "dropDatabase": "Veritabanını Sil", "dropCollection": "Koleksiyonu Sil", "menuRename": "Yeniden Adlandır", "menuCopyConnectionString": "Bağlantı Dizesini Kopyala", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Bağlantıyı Sil", "toastSidebarStringCopied": "Bağlantı dizesi kopyalandı!", "cancel": "İptal", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Azalan sırala", "cellCopyValue": "Değeri kopyala", "cellEditValue": "Değeri düzenle", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Yürütme planı", "explainLoading": "Explain çalışıyor…", "explainFail": "Explain başarısız", @@ -1748,6 +1759,7 @@ "formatLabel": "Dışa Aktarma Formatı", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Alanları Seç", "selectAll": "Tümünü Seç", "fieldsSelected": "{count, plural, one {# alan seçildi} other {# alan seçildi}}", @@ -1832,7 +1844,14 @@ "sampled": "{docs} belge örneklendi · {fields} alan", "colField": "Alan", "colTypes": "Türler", - "colCoverage": "Kapsam" + "colCoverage": "Kapsam", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 51c34f08..a422d0d5 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Редагувати підключення", - "titleConnect": "Підключитися до MongoDB", + "titleConnect": "Підключитися до бази даних", "subtitleEdit": "Оновіть деталі підключення нижче.", - "subtitleConnect": "Введіть рядок підключення MongoDB, щоб почати дослідження даних.", + "subtitleConnect": "Введіть рядок підключення, щоб почати досліджувати дані.", "labelName": "Назва", + "labelDbType": "Тип бази даних", "placeholderName": "напр. Production DB", "labelConnectionString": "Рядок підключення", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Перейменувати", "refresh": "Оновити", "copyDbName": "Копіювати назву БД", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Назву бази даних скопійовано до буфера обміну", "dropDatabase": "Видалити базу даних", "dropCollection": "Видалити колекцію", "menuRename": "Перейменувати", "menuCopyConnectionString": "Копіювати рядок підключення", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Видалити підключення", "toastSidebarStringCopied": "Рядок підключення скопійовано!", "cancel": "Скасувати", @@ -1652,6 +1656,13 @@ "cellSortDesc": "Сортувати за спаданням", "cellCopyValue": "Копіювати значення", "cellEditValue": "Редагувати значення", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "План виконання", "explainLoading": "Виконується explain…", "explainFail": "Помилка explain", @@ -1748,6 +1759,7 @@ "formatLabel": "Формат експорту", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Обрати поля", "selectAll": "Обрати все", "fieldsSelected": "{count, plural, one {# поле обрано} few {# поля обрано} other {# полів обрано}}", @@ -1832,7 +1844,14 @@ "sampled": "Проаналізовано {docs} документів · {fields} полів", "colField": "Поле", "colTypes": "Типи", - "colCoverage": "Покриття" + "colCoverage": "Покриття", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 7cc54820..d7df9c79 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "Sửa kết nối", - "titleConnect": "Kết nối MongoDB", + "titleConnect": "Kết nối đến cơ sở dữ liệu", "subtitleEdit": "Cập nhật chi tiết kết nối bên dưới.", - "subtitleConnect": "Nhập chuỗi kết nối MongoDB để bắt đầu khám phá dữ liệu.", + "subtitleConnect": "Nhập chuỗi kết nối của bạn để bắt đầu khám phá dữ liệu.", "labelName": "Tên", + "labelDbType": "Loại cơ sở dữ liệu", "placeholderName": "vd: Production DB", "labelConnectionString": "Chuỗi kết nối", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "Đổi tên", "refresh": "Làm mới", "copyDbName": "Sao chép tên DB", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "Đã sao chép tên cơ sở dữ liệu", "dropDatabase": "Xóa cơ sở dữ liệu", "dropCollection": "Xóa bộ sưu tập", "menuRename": "Đổi tên", "menuCopyConnectionString": "Sao chép chuỗi kết nối", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "Xóa kết nối", "toastSidebarStringCopied": "Đã sao chép chuỗi kết nối!", "cancel": "Hủy", @@ -1655,6 +1659,13 @@ "cellSortDesc": "Sắp xếp giảm dần", "cellCopyValue": "Sao chép giá trị", "cellEditValue": "Chỉnh sửa giá trị", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "Kế hoạch thực thi", "explainLoading": "Đang chạy explain…", "explainFail": "Explain thất bại", @@ -1769,6 +1780,7 @@ "formatLabel": "Định dạng xuất", "formatXlsx": "Excel (XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "Chọn trường", "selectAll": "Chọn tất cả", "fieldsSelected": "{count, plural, one {# trường đã chọn} other {# trường đã chọn}}", @@ -1854,7 +1866,14 @@ "sampled": "Đã lấy mẫu {docs} tài liệu · {fields} trường", "colField": "Trường", "colTypes": "Kiểu", - "colCoverage": "Độ phủ" + "colCoverage": "Độ phủ", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "Đang tải chỉ mục...", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 42b1770e..cf7d7785 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -1501,10 +1501,11 @@ }, "connection": { "titleEdit": "编辑连接", - "titleConnect": "连接 MongoDB", + "titleConnect": "连接到数据库", "subtitleEdit": "在下方更新连接信息。", - "subtitleConnect": "输入 MongoDB 连接字符串以开始浏览数据。", + "subtitleConnect": "输入连接字符串以开始浏览数据。", "labelName": "名称", + "labelDbType": "数据库类型", "placeholderName": "例如 生产库", "labelConnectionString": "连接字符串", "placeholderConnectionString": "mongodb://localhost:27017", @@ -1570,11 +1571,14 @@ "rename": "重命名", "refresh": "刷新", "copyDbName": "复制数据库名", + "browseGridfs": "Browse GridFS", + "menuSyncCollection": "Sync to…", "toastDbNameCopied": "数据库名已复制到剪贴板", "dropDatabase": "删除数据库", "dropCollection": "删除集合", "menuRename": "重命名", "menuCopyConnectionString": "复制连接字符串", + "menuMonitorServer": "Monitor server", "menuDeleteConnection": "删除连接", "toastSidebarStringCopied": "连接字符串已复制!", "cancel": "取消", @@ -1655,6 +1659,13 @@ "cellSortDesc": "降序排序", "cellCopyValue": "复制值", "cellEditValue": "编辑值", + "cellGroupBy": "Group by this field", + "groupByTitle": "Group by {field}", + "groupByValue": "Value", + "groupByCount": "Count", + "groupByLoading": "Grouping…", + "groupByEmpty": "No groups", + "groupByFail": "Group failed", "explainTitle": "执行计划", "explainLoading": "正在运行 explain…", "explainFail": "explain 失败", @@ -1769,6 +1780,7 @@ "formatLabel": "导出格式", "formatXlsx": "Excel(XLSX)", "formatCsv": "CSV", + "formatTsv": "TSV", "selectFields": "选择字段", "selectAll": "全选", "fieldsSelected": "{count, plural, one {已选 # 个字段} other {已选 # 个字段}}", @@ -1854,7 +1866,14 @@ "sampled": "已采样 {docs} 个文档 · {fields} 个字段", "colField": "字段", "colTypes": "类型", - "colCoverage": "覆盖率" + "colCoverage": "覆盖率", + "sampleRandom": "Random", + "sampleFirst": "First", + "sampleLast": "Last", + "sampleAll": "All", + "sampleSizeN": "{n} docs", + "allCapped": "capped at 10k", + "validatorTitle": "Validation rules" }, "indexManager": { "loading": "正在加载索引…", diff --git a/apps/desktop-ui/src/app/app/database-explorer/page.tsx b/apps/desktop-ui/src/app/app/database-explorer/page.tsx index 920abf0c..b544c2f1 100644 --- a/apps/desktop-ui/src/app/app/database-explorer/page.tsx +++ b/apps/desktop-ui/src/app/app/database-explorer/page.tsx @@ -454,7 +454,7 @@ export default function NoSQLExplorerPage() { handleRefresh(); }; - const handleLoadSchema = useCallback(async () => { + const handleLoadSchema = useCallback(async (opts?: { sampleMode: string; sampleSize: number }) => { if (!activeTab) throw new Error("No active tab"); const conn = await getConnectionForTab(activeTab); @@ -465,6 +465,8 @@ export default function NoSQLExplorerPage() { connectionString: conn.connectionString, dbName: activeTab.dbName, collectionName: activeTab.collectionName, + sampleMode: opts?.sampleMode ?? "random", + sampleSize: opts?.sampleSize ?? 200, }), }); const data = await res.json(); diff --git a/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx b/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx index dd3c22a8..9fd64efb 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx @@ -6,9 +6,10 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; -import { IconDatabase, IconTrash, IconHistory, IconPencil, IconPlugConnected, IconCheck, IconX, IconBrandMongodb, IconServer, IconCopy, IconLock } from "@tabler/icons-react"; +import { IconDatabase, IconTrash, IconHistory, IconPencil, IconPlugConnected, IconCheck, IconX, IconBrandMongodb, IconBrandAws, IconBrandAzure, IconServer, IconCopy, IconLock } from "@tabler/icons-react"; import { Switch } from "@/components/ui/switch"; import { SavedConnection } from "./types"; +import { DB_DIALECTS, DB_TYPE_ORDER, detectDbType, type DbType } from "@/lib/nosql-dialects"; import { getConnections, deleteConnection, saveConnection, updateConnectionDetails } from "./connection-service"; import { backendFetch } from "@/lib/backend-auth"; import useAuth from "@/utils/useAuth"; @@ -42,6 +43,14 @@ export const CONNECTION_COLORS = [ "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#a855f7", "#64748b", ] as const; +// Per-dialect brand icon. FerretDB has no brand glyph in @tabler — neutral DB icon. +const DB_ICONS: Record = { + mongodb: IconBrandMongodb, + documentdb: IconBrandAws, + cosmosdb: IconBrandAzure, + ferretdb: IconDatabase, +}; + export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProps) { const t = useTranslations("NoSqlExplorer.connection"); const locale = useLocale(); @@ -53,6 +62,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp const { copyToClipboard } = useCopyToClipboard(); const [connectionString, setConnectionString] = useState(""); const [name, setName] = useState("My Connection"); + const [dbType, setDbType] = useState("mongodb"); const [color, setColor] = useState(null); const [readOnly, setReadOnly] = useState(false); const [savedConnections, setSavedConnections] = useState([]); @@ -108,10 +118,10 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp try { if (editingId) { - await updateConnectionDetails(user.uid, editingId, { name, connectionString, color, readOnly }, encryptionKey); + await updateConnectionDetails(user.uid, editingId, { name, connectionString, color, readOnly, dbType }, encryptionKey); toast.success(t("toastUpdated")); } else { - await saveConnection(user.uid, connectionString, name, encryptionKey, { color, readOnly }); + await saveConnection(user.uid, connectionString, name, encryptionKey, { color, readOnly, dbType }); } await loadConnections(); } catch (e) { @@ -127,6 +137,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp const handleSelectConnection = (conn: SavedConnection) => { setConnectionString(conn.connectionString); setName(conn.name); + setDbType(conn.dbType ?? detectDbType(conn.connectionString)); setColor(conn.color ?? null); setReadOnly(conn.readOnly ?? false); setEditingId(null); @@ -137,6 +148,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp setEditingId(conn.id); setConnectionString(conn.connectionString); setName(conn.name); + setDbType(conn.dbType ?? detectDbType(conn.connectionString)); setColor(conn.color ?? null); setReadOnly(conn.readOnly ?? false); }; @@ -145,6 +157,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp setEditingId(null); setConnectionString(""); setName("My Connection"); + setDbType("mongodb"); setColor(null); setReadOnly(false); }; @@ -175,7 +188,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp

- + {editingId ? t("titleEdit") : t("titleConnect")}

@@ -197,12 +210,39 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp className="bg-background/50" />

+
+ +
+ {DB_TYPE_ORDER.map((type) => { + const Icon = DB_ICONS[type]; + const active = dbType === type; + return ( + + ); + })} +
+
+
setConnectionString(e.target.value)} disabled={loading} @@ -311,7 +351,9 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp
) : (
- {savedConnections.map((conn) => ( + {savedConnections.map((conn) => { + const RowIcon = DB_ICONS[conn.dbType ?? detectDbType(conn.connectionString)]; + return (
+ {conn.name} {conn.readOnly && }
@@ -384,7 +427,8 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp
- ))} + ); + })}
)} diff --git a/apps/desktop-ui/src/components/nosql-explorer/connection-service.ts b/apps/desktop-ui/src/components/nosql-explorer/connection-service.ts index a415d9b6..45fd8a66 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/connection-service.ts +++ b/apps/desktop-ui/src/components/nosql-explorer/connection-service.ts @@ -3,6 +3,7 @@ import { encryptData, decryptData } from "@/lib/encryption"; import { proxyJsonAuthed } from "@/lib/backend-auth"; import { toast } from "sonner"; import { SavedConnection } from "./types"; +import type { DbType } from "@/lib/nosql-dialects"; const BACKEND_BASE_URL: string = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL || @@ -47,14 +48,14 @@ export const saveConnection = async ( connectionString: string, name: string = "My Connection", encryptionKey: CryptoKey, - extras: { color?: string | null; readOnly?: boolean } = {} + extras: { color?: string | null; readOnly?: boolean; dbType?: DbType } = {} ): Promise => { const { encrypted, iv } = await encryptData(encryptionKey, connectionString); const created = await proxyRequest( "POST", "/api/v1/nosql/connections", - { encryptedData: encrypted, iv, name, color: extras.color ?? null, readOnly: extras.readOnly ?? false } + { encryptedData: encrypted, iv, name, color: extras.color ?? null, readOnly: extras.readOnly ?? false, dbType: extras.dbType ?? "mongodb" } ); return created.id; }; @@ -122,7 +123,7 @@ export const updateConnectionName = async ( export const updateConnectionDetails = async ( _userId: string, connectionId: string, - updates: { name?: string; connectionString?: string; color?: string | null; readOnly?: boolean }, + updates: { name?: string; connectionString?: string; color?: string | null; readOnly?: boolean; dbType?: DbType }, encryptionKey: CryptoKey ): Promise => { const patch: Record = {}; @@ -136,6 +137,9 @@ export const updateConnectionDetails = async ( if (updates.readOnly !== undefined) { patch.readOnly = updates.readOnly; } + if (updates.dbType !== undefined) { + patch.dbType = updates.dbType; + } if (updates.connectionString !== undefined) { const { encrypted, iv } = await encryptData(encryptionKey, updates.connectionString); patch.encryptedData = encrypted; diff --git a/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx b/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx index 02bdad49..a89b1709 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx @@ -15,7 +15,7 @@ import { IconRowInsertBottom, IconLayoutRows, IconArrowsMaximize, IconArrowsMinimize, IconUpload, IconListDetails, IconSchema, IconArrowsExchange, IconChevronDown, IconFilter, IconFilterX, IconSortAscending, IconSortDescending, IconLock, - IconAlertTriangle, IconLoader2, + IconAlertTriangle, IconLoader2, IconChartBar, } from "@tabler/icons-react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; @@ -73,7 +73,7 @@ interface DocumentViewProps { onSortChange: (field: string, direction: 'asc' | 'desc') => void; onBulkDelete?: (ids: string[]) => Promise; onImport?: (documents: any[]) => Promise; - onLoadSchema?: () => Promise<{ fields: any[]; sampleSize: number }>; + onLoadSchema?: (opts: { sampleMode: string; sampleSize: number }) => Promise<{ fields: any[]; sampleSize: number; validator?: unknown }>; onLoadIndexes?: () => Promise<{ indexes: any[]; totalIndexSize?: number; stats?: any }>; onDropIndex?: (indexName: string) => Promise; onCreateIndex?: (keys: Record, options: Record) => Promise; @@ -175,6 +175,11 @@ export function DocumentView({ const [explainOpen, setExplainOpen] = useState(false); const [explainLoading, setExplainLoading] = useState(false); const [explainData, setExplainData] = useState(null); + const [groupOpen, setGroupOpen] = useState(false); + const [groupField, setGroupField] = useState(""); + const [groupLoading, setGroupLoading] = useState(false); + const [groupRows, setGroupRows] = useState | null>(null); + const [groupError, setGroupError] = useState(null); const { theme } = useTheme(); const { copyToClipboard } = useCopyToClipboard(); @@ -244,6 +249,30 @@ export function DocumentView({ } }; + // One-click "group by field": distinct values + counts via the aggregation + // path we already have (onPreviewPipeline). No new backend. + const runGroupBy = async (field: string) => { + if (!onPreviewPipeline) return; + setGroupField(field); + setGroupOpen(true); + setGroupLoading(true); + setGroupRows(null); + setGroupError(null); + try { + const pipeline = JSON.stringify([ + { $group: { _id: `$${field}`, count: { $sum: 1 } } }, + { $sort: { count: -1 } }, + { $limit: 200 }, + ]); + const res = await onPreviewPipeline(pipeline); + setGroupRows((res.documents as Array<{ _id: unknown; count: number }>) ?? []); + } catch (e: any) { + setGroupError(e.message || t("groupByFail")); + } finally { + setGroupLoading(false); + } + }; + const tableContainerRef = useRef(null); const rowVirtualizer = useVirtualizer({ @@ -896,9 +925,19 @@ export function DocumentView({ {t("actions")} - + + {/* Native padding-spacer virtualization: rows stay in + normal table flow so the sticky reliably sits + above them. Absolute-positioned rows broke in WebKit + (Tauri) because doesn't establish a containing + block there, so row 0 rendered under the header. */} + {(() => { + const vRows = rowVirtualizer.getVirtualItems(); + const padTop = vRows.length ? vRows[0].start : 0; + return padTop > 0 ? ( + + ) : null; + })()} {rowVirtualizer.getVirtualItems().map((virtualRow) => { const doc = documents[virtualRow.index]; const index = virtualRow.index; @@ -908,13 +947,6 @@ export function DocumentView({ key={doc._id} data-index={virtualRow.index} ref={rowVirtualizer.measureElement} - style={{ - position: "absolute", - top: 0, - left: 0, - width: "100%", - transform: `translateY(${virtualRow.start}px)`, - }} className={cn( "border-b hover:bg-muted/50 group transition-colors", isSelected && "bg-primary/5 hover:bg-primary/10" @@ -984,6 +1016,12 @@ export function DocumentView({ {t("cellSortDesc")} + {onPreviewPipeline && ( + runGroupBy(key)}> + + {t("cellGroupBy")} + + )} ); })} + {(() => { + const vRows = rowVirtualizer.getVirtualItems(); + const padBottom = vRows.length + ? rowVirtualizer.getTotalSize() - vRows[vRows.length - 1].end + : 0; + return padBottom > 0 ? ( + + ) : null; + })()}
@@ -1254,6 +1301,61 @@ export function DocumentView({ + {/* Group by field */} + + + + {t("groupByTitle", { field: groupField })} + + {groupLoading ? ( +
+ + {t("groupByLoading")} +
+ ) : groupError ? ( +
+ {groupError} +
+ ) : groupRows && groupRows.length > 0 ? ( +
+ + + + + + + + + {groupRows.map((row, i) => { + const scalar = isScalar(row._id); + const label = row._id === null || row._id === undefined + ? "null" + : scalar ? String(row._id) : JSON.stringify(row._id); + return ( + { applyCellFilter(groupField, row._id); setGroupOpen(false); } : undefined} + > + + + + ); + })} + +
{t("groupByValue")}{t("groupByCount")}
{label}{row.count}
+
+ ) : ( +
+ {t("groupByEmpty")} +
+ )} + + + +
+
+ {/* Bulk delete confirmation */} diff --git a/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx b/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx index f160e04e..7e43e879 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/explorer-sidebar.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Database, Collection, SavedConnection } from "./types"; -import { IconDatabase, IconFolder, IconChevronRight, IconChevronDown, IconRefresh, IconSearch, IconPlus, IconServer, IconPencil, IconCheck, IconX, IconDotsVertical, IconTrash, IconEdit, IconCopy, IconAlertCircle, IconLoader2, IconLock } from "@tabler/icons-react"; +import { IconDatabase, IconFolder, IconChevronRight, IconChevronDown, IconRefresh, IconSearch, IconPlus, IconServer, IconPencil, IconCheck, IconX, IconDotsVertical, IconTrash, IconEdit, IconCopy, IconAlertCircle, IconLoader2, IconLock, IconActivity, IconFiles, IconArrowsExchange } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import React, { useState, useEffect, useRef } from "react"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; @@ -24,6 +24,9 @@ import { } from "@/components/ui/dropdown-menu"; import { useTranslations } from "next-intl"; import { SidebarDialogs } from "./sidebar-dialogs"; +import { ServerMonitor } from "./server-monitor"; +import { GridFsBrowser } from "./gridfs-browser"; +import { SyncDialog } from "./sync-dialog"; interface ExplorerSidebarProps { onSelectCollection: (connection: SavedConnection, dbName: string, collectionName: string) => void; @@ -58,6 +61,9 @@ export function ExplorerSidebar({ const [searchQuery, setSearchQuery] = useState(""); const [loading, setLoading] = useState(true); const [editingConnectionId, setEditingConnectionId] = useState(null); + const [monitorConn, setMonitorConn] = useState(null); + const [gridfsTarget, setGridfsTarget] = useState<{ connectionString: string; dbName: string; name: string; readOnly?: boolean } | null>(null); + const [syncSource, setSyncSource] = useState<{ connectionString: string; dbName: string; collectionName: string; name: string } | null>(null); const [editName, setEditName] = useState(""); // Dialog states @@ -610,6 +616,12 @@ export function ExplorerSidebar({ }}> {t("menuCopyConnectionString")} + { + e.stopPropagation(); + setMonitorConn(node.connection); + }}> + {t("menuMonitorServer")} + { e.stopPropagation(); @@ -696,6 +708,14 @@ export function ExplorerSidebar({ }}> {t("copyDbName")} + setGridfsTarget({ + connectionString: node.connection.connectionString, + dbName: db.name, + name: `${node.connection.name} / ${db.name}`, + readOnly: node.connection.readOnly, + })}> + {t("browseGridfs")} + {!node.connection.readOnly && ( <> setRenameDatabaseDialog({ @@ -783,6 +803,14 @@ export function ExplorerSidebar({ })}> {t("menuRename")} + setSyncSource({ + connectionString: node.connection.connectionString, + dbName: db.name, + collectionName: col.name, + name: `${node.connection.name} / ${db.name} / ${col.name}`, + })}> + {t("menuSyncCollection")} + handleDropCollection(index, db.name, col.name)}> {t("dropCollection")} @@ -850,6 +878,33 @@ export function ExplorerSidebar({ isBulkDeleting={isBulkDeleting} onConfirmBulkDelete={confirmBulkDelete} /> + {monitorConn && ( + setMonitorConn(null)} + /> + )} + {gridfsTarget && ( + setGridfsTarget(null)} + /> + )} + {syncSource && ( + c.connection)} + open={!!syncSource} + onClose={() => setSyncSource(null)} + /> + )}
); } diff --git a/apps/desktop-ui/src/components/nosql-explorer/export-dialog.tsx b/apps/desktop-ui/src/components/nosql-explorer/export-dialog.tsx index 900d73dd..83c4c26a 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/export-dialog.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/export-dialog.tsx @@ -21,7 +21,7 @@ interface ExportDialogProps { export function ExportDialog({ open, onOpenChange, documents, fields }: ExportDialogProps) { const t = useTranslations("NoSqlExplorer.export"); const [selectedFields, setSelectedFields] = useState(fields); - const [format, setFormat] = useState<"xlsx" | "csv" | "json">("xlsx"); + const [format, setFormat] = useState<"xlsx" | "csv" | "tsv" | "json">("xlsx"); const handleSelectAll = (checked: boolean) => { if (checked) { @@ -89,6 +89,8 @@ export function ExportDialog({ open, onOpenChange, documents, fields }: ExportDi if (format === "csv") { XLSX.writeFile(workbook, filename, { bookType: "csv" }); + } else if (format === "tsv") { + XLSX.writeFile(workbook, filename, { bookType: "txt" }); // txt = tab-separated } else { XLSX.writeFile(workbook, filename); } @@ -112,7 +114,7 @@ export function ExportDialog({ open, onOpenChange, documents, fields }: ExportDi
- setFormat(v as "xlsx" | "csv" | "json")} className="flex gap-4"> + setFormat(v as "xlsx" | "csv" | "tsv" | "json")} className="flex gap-4 flex-wrap">
@@ -121,6 +123,10 @@ export function ExportDialog({ open, onOpenChange, documents, fields }: ExportDi
+
+ + +
diff --git a/apps/desktop-ui/src/components/nosql-explorer/gridfs-browser.tsx b/apps/desktop-ui/src/components/nosql-explorer/gridfs-browser.tsx new file mode 100644 index 00000000..e5ae2e01 --- /dev/null +++ b/apps/desktop-ui/src/components/nosql-explorer/gridfs-browser.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { IconRefresh, IconUpload, IconDownload, IconTrash, IconLoader2, IconFile } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +interface GridFsFile { + id: unknown; + filename?: string; + length: number; + uploadDate?: string; +} + +function humanSize(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +export function GridFsBrowser({ + connectionString, + dbName, + name, + readOnly = false, + open, + onClose, +}: { + connectionString: string; + dbName: string; + name: string; + readOnly?: boolean; + open: boolean; + onClose: () => void; +}) { + const [files, setFiles] = useState(null); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(null); + const [dragging, setDragging] = useState(false); + const fileRef = useRef(null); + + const body = (extra: Record = {}) => + JSON.stringify({ connectionString, dbName, ...extra }); + + const load = async () => { + setLoading(true); + try { + const res = await apiFetch("/api/nosql/gridfs/list", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body(), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setFiles(data.files ?? []); + } catch (e: any) { + toast.error(e.message || "Failed to list files"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { if (open) void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [open, connectionString, dbName]); + + const upload = async (file: File) => { + setBusy("upload"); + try { + const dataUrl: string = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(r.result as string); + r.onerror = () => reject(new Error("read error")); + r.readAsDataURL(file); + }); + const b64 = dataUrl.split(",")[1] ?? ""; + const res = await apiFetch("/api/nosql/gridfs/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body({ filename: file.name, data: b64 }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + toast.success(`Uploaded ${file.name}`); + void load(); + } catch (e: any) { + toast.error(e.message || "Upload failed"); + } finally { + setBusy(null); + } + }; + + const download = async (f: GridFsFile) => { + setBusy(String(f.id)); + try { + const res = await apiFetch("/api/nosql/gridfs/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body({ id: f.id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + const bytes = Uint8Array.from(atob(data.data), (c) => c.charCodeAt(0)); + const url = URL.createObjectURL(new Blob([bytes])); + const a = document.createElement("a"); + a.href = url; + a.download = f.filename || "gridfs-file"; + a.click(); + URL.revokeObjectURL(url); + } catch (e: any) { + toast.error(e.message || "Download failed"); + } finally { + setBusy(null); + } + }; + + const remove = async (f: GridFsFile) => { + setBusy(String(f.id)); + try { + const res = await apiFetch("/api/nosql/gridfs/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body({ id: f.id }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + toast.success("File deleted"); + void load(); + } catch (e: any) { + toast.error(e.message || "Delete failed"); + } finally { + setBusy(null); + } + }; + + return ( + { if (!o) onClose(); }}> + + + + GridFS + {name} + + + +
+ {files ? `${files.length} file(s)` : ""} +
+ {!readOnly && ( + + )} + + { const f = e.target.files?.[0]; if (f) void upload(f); e.target.value = ""; }} /> +
+
+ +
{ if (!readOnly) { e.preventDefault(); setDragging(true); } }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { e.preventDefault(); setDragging(false); if (!readOnly) { const f = e.dataTransfer.files[0]; if (f) void upload(f); } }} + > + {files && files.length === 0 ? ( +
+ + {"No files"} + {!readOnly && {"Drop files here to upload"}} +
+ ) : ( + + + + + + + + + {(files ?? []).map((f, i) => { + const rowBusy = busy === String(f.id); + return ( + + + + + + ); + })} + +
{"Name"}{"Size"} +
{f.filename || "—"}{humanSize(f.length)} +
+ + {!readOnly && ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/desktop-ui/src/components/nosql-explorer/import-dialog.tsx b/apps/desktop-ui/src/components/nosql-explorer/import-dialog.tsx index 8d0cd355..ed07f2e1 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/import-dialog.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/import-dialog.tsx @@ -32,13 +32,35 @@ export function ImportDialog({ open, onOpenChange, onImport, collectionName }: I }; const processFile = (file: File) => { - if (!file.name.endsWith('.json')) { + const lower = file.name.toLowerCase(); + const isSheet = lower.endsWith('.csv') || lower.endsWith('.tsv'); + if (!lower.endsWith('.json') && !isSheet) { setParseError(t("onlyJson")); return; } setFileName(file.name); setParseError(null); + if (isSheet) { + // CSV/TSV → one document per row via SheetJS (already a dep). It + // infers numbers/dates; blank cells become null, not "". + const reader = new FileReader(); + reader.onload = async (e) => { + try { + const XLSX = await import("xlsx"); + const wb = XLSX.read(e.target?.result as ArrayBuffer, { type: "array" }); + const ws = wb.Sheets[wb.SheetNames[0]]; + const rows = XLSX.utils.sheet_to_json(ws, { defval: null }) as any[]; + if (rows.length === 0) setParseError(t("invalidStructure")); + else setParsed(rows); + } catch { + setParseError(t("invalidJson")); + } + }; + reader.readAsArrayBuffer(file); + return; + } + const reader = new FileReader(); reader.onload = (e) => { const text = e.target?.result as string; @@ -121,7 +143,7 @@ export function ImportDialog({ open, onOpenChange, onImport, collectionName }: I diff --git a/apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx b/apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx index 689fe246..1c1a0c30 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/query-builder.tsx @@ -20,14 +20,7 @@ import { IconSearch, IconHistory, IconX, IconPlus, IconTrash, IconCheck, IconFil import { useTheme } from "next-themes"; import { useTranslations } from "next-intl"; import { PipelineBuilder, PipelineStage, newStage, parsePipeline, stagesToPipeline } from "./pipeline-builder"; - -// MongoDB query + aggregation operators offered by the Monaco completion provider. -const MONGO_OPERATORS = [ - "$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin", "$exists", "$regex", - "$and", "$or", "$not", "$nor", "$type", "$size", "$all", "$elemMatch", "$expr", - "$match", "$project", "$group", "$sort", "$limit", "$skip", "$lookup", "$unwind", - "$addFields", "$count", "$sum", "$avg", "$min", "$max", "$first", "$last", "$push", -]; +import { MONGO_OPERATORS, MONGO_SNIPPETS } from "@/lib/nosql-snippets"; interface QueryBuilderProps { query: string; @@ -99,6 +92,7 @@ export function QueryBuilder({ startColumn: word.startColumn, endColumn: word.endColumn, }; + const asSnippet = monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet; return { suggestions: [ ...fieldsRef.current.map((f) => ({ @@ -107,10 +101,22 @@ export function QueryBuilder({ insertText: f, range, })), - ...MONGO_OPERATORS.map((op) => ({ - label: op, + ...MONGO_OPERATORS.map((c) => ({ + label: c.label, kind: monaco.languages.CompletionItemKind.Keyword, - insertText: op, + detail: c.detail, + documentation: { value: c.doc }, + insertText: c.insertText, + insertTextRules: c.snippet ? asSnippet : undefined, + range, + })), + ...MONGO_SNIPPETS.map((c) => ({ + label: c.label, + kind: monaco.languages.CompletionItemKind.Snippet, + detail: c.detail, + documentation: { value: c.doc }, + insertText: c.insertText, + insertTextRules: asSnippet, range, })), ], diff --git a/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx b/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx index dfab30ff..8e8b0d1f 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx @@ -3,28 +3,34 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { IconRefresh } from "@tabler/icons-react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { IconRefresh, IconShieldCheck, IconChevronRight } from "@tabler/icons-react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; +export type SampleMode = "random" | "first" | "last" | "all"; +export interface SchemaLoadOptions { sampleMode: SampleMode; sampleSize: number } // eslint-disable-next-line @typescript-eslint/no-explicit-any -type SchemaData = { fields: any[]; sampleSize: number }; +type SchemaData = { fields: any[]; sampleSize: number; validator?: unknown }; export function SchemaView({ onLoad, }: { - onLoad: () => Promise; + onLoad: (opts: SchemaLoadOptions) => Promise; }) { const t = useTranslations("NoSqlExplorer.schemaView"); const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [error, setError] = useState(null); + const [sampleMode, setSampleMode] = useState("random"); + const [sampleSize, setSampleSize] = useState(200); + const [validatorOpen, setValidatorOpen] = useState(false); const load = useCallback(async () => { setLoading(true); setError(null); try { - const result = await onLoad(); + const result = await onLoad({ sampleMode, sampleSize }); setData(result); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any) { @@ -32,8 +38,9 @@ export function SchemaView({ } finally { setLoading(false); } - }, [onLoad]); + }, [onLoad, sampleMode, sampleSize]); + // Re-analyze whenever the sampling controls change. useEffect(() => { load(); }, [load]); const typeColor: Record = { @@ -75,14 +82,62 @@ export function SchemaView({ ); } + const validator = data.validator && typeof data.validator === "object" ? data.validator : null; + return (
-
- {t("sampled", { docs: data.sampleSize, fields: data.fields.length })} - +
+ + {t("sampled", { docs: data.sampleSize, fields: data.fields.length })} + {sampleMode === "all" && data.sampleSize >= 10000 && · {t("allCapped")}} + +
+ + {sampleMode !== "all" && ( + + )} + +
+ + {validator && ( +
+ + {validatorOpen && ( +
+                            {JSON.stringify(validator, null, 2)}
+                        
+ )} +
+ )} diff --git a/apps/desktop-ui/src/components/nosql-explorer/server-monitor.tsx b/apps/desktop-ui/src/components/nosql-explorer/server-monitor.tsx new file mode 100644 index 00000000..ce600d65 --- /dev/null +++ b/apps/desktop-ui/src/components/nosql-explorer/server-monitor.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Sparkline } from "@/components/redis-commander/sparkline"; +import { IconRefresh, IconSkull, IconLoader2 } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; + +// Mirrors redis-commander/metrics-pane: inline SVG sparklines, hardcoded +// technical labels, best-effort polling. serverStatus opcounters are +// cumulative, so ops/sec is a delta over elapsed wall-clock. + +const POLL_MS = 2000; +const MAX_POINTS = 30; + +function push(arr: number[], v: number): number[] { + const next = arr.concat(v); + return next.length > MAX_POINTS ? next.slice(-MAX_POINTS) : next; +} + +interface ServerStatus { + opcounters?: Record; + connections?: { current?: number; available?: number; active?: number }; + mem?: { resident?: number; virtual?: number }; + network?: { bytesIn?: number; bytesOut?: number }; + uptime?: number; + version?: string; + host?: string; +} + +interface Op { + opid?: unknown; + op?: string; + ns?: string; + secs_running?: number; + microsecs_running?: number; + desc?: string; + client?: string; + active?: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + command?: any; +} + +export function ServerMonitor({ + connectionString, + name, + readOnly = false, + open, + onClose, +}: { + connectionString: string; + name: string; + readOnly?: boolean; + open: boolean; + onClose: () => void; +}) { + const [tab, setTab] = useState<"metrics" | "ops">("metrics"); + return ( + { if (!o) onClose(); }}> + + + + Server monitor + {name} + + +
+ {(["metrics", "ops"] as const).map((tb) => ( + + ))} +
+
+ {open && tab === "metrics" ? ( + + ) : open ? ( + + ) : null} +
+
+
+ ); +} + +function MetricsTab({ connectionString, active }: { connectionString: string; active: boolean }) { + const [series, setSeries] = useState({ ops: [] as number[], conns: [] as number[], mem: [] as number[], net: [] as number[] }); + const [latest, setLatest] = useState(null); + const [paused, setPaused] = useState(false); + const prevOps = useRef(null); + const prevNet = useRef(null); + const prevT = useRef(null); + + useEffect(() => { + if (!active) return; + let abort = false; + async function tick() { + try { + const res = await apiFetch("/api/nosql/server-stats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionString }), + }); + const body = (await res.json()) as ServerStatus & { error?: string }; + if (abort || body.error) return; + setLatest(body); + const now = Date.now(); + const dt = prevT.current ? Math.max((now - prevT.current) / 1000, 0.001) : 1; + const opsTotal = Object.values(body.opcounters ?? {}).reduce((a, b) => a + (Number(b) || 0), 0); + const netTotal = (body.network?.bytesIn ?? 0) + (body.network?.bytesOut ?? 0); + const opsPerSec = prevOps.current !== null ? Math.max((opsTotal - prevOps.current) / dt, 0) : 0; + const netPerSec = prevNet.current !== null ? Math.max((netTotal - prevNet.current) / dt, 0) / 1024 : 0; // KB/s + prevOps.current = opsTotal; + prevNet.current = netTotal; + prevT.current = now; + setSeries((s) => ({ + ops: push(s.ops, opsPerSec), + conns: push(s.conns, body.connections?.current ?? 0), + mem: push(s.mem, body.mem?.resident ?? 0), + net: push(s.net, netPerSec), + })); + } catch { + // swallow — next tick retries + } + } + void tick(); + if (paused) return () => { abort = true; }; + const id = setInterval(tick, POLL_MS); + return () => { abort = true; clearInterval(id); }; + }, [connectionString, paused, active]); + + const last = (a: number[], d = 0) => (a.length ? a[a.length - 1]!.toFixed(d) : "—"); + const uptime = latest?.uptime ? formatUptime(latest.uptime) : "—"; + + return ( +
+
+ + Polling every {POLL_MS / 1000}s + {latest?.version && v{latest.version}} + {latest?.uptime !== undefined && up {uptime}} + + +
+
+ + + + +
+
+ ); +} + +function MetricCard({ label, value, points, color }: { label: string; value: string; points: number[]; color: string }) { + return ( +
+
+
{label}
+
{value}
+
+
+ +
+
+ ); +} + +function OpsTab({ connectionString, readOnly }: { connectionString: string; readOnly: boolean }) { + const [ops, setOps] = useState(null); + const [loading, setLoading] = useState(false); + const [killing, setKilling] = useState(null); + + const load = async () => { + setLoading(true); + try { + const res = await apiFetch("/api/nosql/current-ops", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionString }), + }); + const body = (await res.json()) as { inprog?: Op[]; error?: string }; + if (body.error) throw new Error(body.error); + // Hide idle connections — only ops actually doing work. + setOps((body.inprog ?? []).filter((o) => o.active !== false && o.op && o.op !== "none")); + } catch (e: any) { + toast.error(e.message || "Failed to load operations"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [connectionString]); + + const kill = async (opid: unknown) => { + const key = String(opid); + setKilling(key); + try { + const res = await apiFetch("/api/nosql/kill-op", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ connectionString, opid }), + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error); + toast.success("Operation killed"); + void load(); + } catch (e: any) { + toast.error(e.message || "Failed to kill operation"); + } finally { + setKilling(null); + } + }; + + return ( +
+
+ {ops ? `${ops.length} active operation(s)` : ""} + +
+ {ops && ops.length === 0 ? ( +
No active operations
+ ) : ( +
+
+ + + + + + + {!readOnly && + + + {(ops ?? []).map((o, i) => ( + + + + + + {!readOnly && ( + + )} + + ))} + +
opidopnssecs} +
{String(o.opid ?? "—")}{o.op ?? "—"}{o.ns ?? "—"}{o.secs_running ?? 0} + +
+
+ )} +
+ ); +} + +function formatUptime(sec: number): string { + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} diff --git a/apps/desktop-ui/src/components/nosql-explorer/sync-dialog.tsx b/apps/desktop-ui/src/components/nosql-explorer/sync-dialog.tsx new file mode 100644 index 00000000..94505577 --- /dev/null +++ b/apps/desktop-ui/src/components/nosql-explorer/sync-dialog.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { apiFetch } from "@/lib/desktop/api-fetch"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { IconArrowsExchange, IconLoader2, IconLock, IconAlertTriangle } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { SavedConnection } from "./types"; + +interface SyncSource { + connectionString: string; + dbName: string; + collectionName: string; + name: string; +} + +interface SyncResult { + scanned: number; + inserted: number; + modified: number; + skipped: number; + capped: boolean; +} + +export function SyncDialog({ + source, + targets, + open, + onClose, +}: { + source: SyncSource; + targets: SavedConnection[]; + open: boolean; + onClose: () => void; +}) { + const [targetId, setTargetId] = useState(targets[0]?.id ?? ""); + const [targetDb, setTargetDb] = useState(source.dbName); + const [targetColl, setTargetColl] = useState(source.collectionName); + const [mode, setMode] = useState<"insert" | "overwrite">("insert"); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + + const target = targets.find((t) => t.id === targetId); + const targetReadOnly = !!target?.readOnly; + + const run = async () => { + if (!target) return; + if (targetReadOnly) { toast.error("Target connection is read-only"); return; } + if (!targetDb.trim() || !targetColl.trim()) { toast.error("Target database and collection are required"); return; } + setRunning(true); + setResult(null); + try { + const res = await apiFetch("/api/nosql/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + connectionString: source.connectionString, + dbName: source.dbName, + collectionName: source.collectionName, + mode, + target: { + connectionString: target.connectionString, + dbName: targetDb.trim(), + collectionName: targetColl.trim(), + readOnly: target.readOnly, + }, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setResult(data as SyncResult); + toast.success(`Synced ${data.inserted + data.modified} document(s)`); + } catch (e: any) { + toast.error(e.message || "Sync failed"); + } finally { + setRunning(false); + } + }; + + return ( + { if (!o) onClose(); }}> + + + + Sync collection + + + +
+
+ From {source.name} +
+ +
+ + + {targetReadOnly && ( +

+ This connection is read-only — pick another target. +

+ )} +
+ +
+
+ + setTargetDb(e.target.value)} className="h-9 text-sm font-mono" /> +
+
+ + setTargetColl(e.target.value)} className="h-9 text-sm font-mono" /> +
+
+ +
+ + setMode(v as "insert" | "overwrite")} className="flex gap-4"> +
+ + +
+
+ + +
+
+
+ + {result && ( +
+
scanned: {result.scanned}
+
inserted: {result.inserted}
+ {mode === "overwrite" &&
modified: {result.modified}
} + {mode === "insert" &&
skipped (existing): {result.skipped}
} + {result.capped &&
capped at 100k scanned
} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/desktop-ui/src/components/nosql-explorer/types.ts b/apps/desktop-ui/src/components/nosql-explorer/types.ts index 2adab210..df00d952 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/types.ts +++ b/apps/desktop-ui/src/components/nosql-explorer/types.ts @@ -27,6 +27,9 @@ export interface Document { [key: string]: unknown; } +export type { DbType } from "@/lib/nosql-dialects"; +import type { DbType } from "@/lib/nosql-dialects"; + export type FirestoreTimestampLike = { toDate: () => Date; }; @@ -48,6 +51,8 @@ export interface SavedConnection { color?: string | null; /** Blocks all write operations (UI + Rust guard) when true. */ readOnly?: boolean; + /** Mongo-wire dialect for branding/guidance. Absent on legacy rows — infer via detectDbType. Not sensitive. */ + dbType?: DbType; createdAt: number | FirestoreTimestampLike | null; lastUsedAt: number | FirestoreTimestampLike | null; /** Content-edit clock (sync LWW); server-managed. */ diff --git a/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts b/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts new file mode 100644 index 00000000..ab67d3c6 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts @@ -0,0 +1,31 @@ +import { detectDbType, DB_DIALECTS, DB_TYPE_ORDER } from "../nosql-dialects"; + +describe("detectDbType", () => { + it("infers Cosmos DB from its managed host", () => { + expect( + detectDbType("mongodb://a:b@acct.mongo.cosmos.azure.com:10255/?ssl=true") + ).toBe("cosmosdb"); + }); + + it("infers DocumentDB from its managed host (classic and elastic)", () => { + expect( + detectDbType("mongodb://u:p@cluster.docdb.us-east-1.amazonaws.com:27017/?tls=true") + ).toBe("documentdb"); + expect( + detectDbType("mongodb://u:p@x.docdb-elastic.us-east-1.amazonaws.com:27017/") + ).toBe("documentdb"); + }); + + it("falls back to mongodb for generic/self-hosted hosts", () => { + expect(detectDbType("mongodb://localhost:27017")).toBe("mongodb"); + expect(detectDbType("mongodb+srv://user:pass@cluster0.abcde.mongodb.net")).toBe("mongodb"); + expect(detectDbType("")).toBe("mongodb"); + expect(detectDbType("garbage")).toBe("mongodb"); + }); + + it("keeps every dialect in the picker order and vice versa", () => { + expect(DB_TYPE_ORDER.sort()).toEqual( + (Object.keys(DB_DIALECTS) as (keyof typeof DB_DIALECTS)[]).sort() + ); + }); +}); diff --git a/apps/desktop-ui/src/lib/__tests__/nosql-snippets.test.ts b/apps/desktop-ui/src/lib/__tests__/nosql-snippets.test.ts new file mode 100644 index 00000000..cb1fba5c --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/nosql-snippets.test.ts @@ -0,0 +1,29 @@ +import { MONGO_OPERATORS, MONGO_SNIPPETS } from "../nosql-snippets"; + +describe("nosql snippets", () => { + const all = [...MONGO_OPERATORS, ...MONGO_SNIPPETS]; + + it("every entry has a label, docs and insert text", () => { + for (const c of all) { + expect(c.label).toBeTruthy(); + expect(c.doc).toBeTruthy(); + expect(c.insertText).toBeTruthy(); + } + }); + + it("labels are unique within each list", () => { + for (const list of [MONGO_OPERATORS, MONGO_SNIPPETS]) { + const labels = list.map((c) => c.label); + expect(new Set(labels).size).toBe(labels.length); + } + }); + + it("whole-query snippets are valid JSON skeletons", () => { + // Replace every ${...} tab-stop with 1 (a valid JSON token) so only the + // authored braces/brackets/quotes are under test. + for (const c of MONGO_SNIPPETS) { + const filled = c.insertText.replace(/\$\{[^}]*\}/g, "1"); + expect(() => JSON.parse(filled)).not.toThrow(); + } + }); +}); diff --git a/apps/desktop-ui/src/lib/nosql-dialects.ts b/apps/desktop-ui/src/lib/nosql-dialects.ts new file mode 100644 index 00000000..94c441c4 --- /dev/null +++ b/apps/desktop-ui/src/lib/nosql-dialects.ts @@ -0,0 +1,68 @@ +/** + * Supported NoSQL dialects for the Database Explorer. + * + * All four speak the MongoDB wire protocol and use `mongodb://` / + * `mongodb+srv://` connection strings, so they share the existing Mongo driver + * path in the Rust backend — no per-type query engine is needed. `dbType` is + * plaintext metadata persisted alongside the connection purely for branding and + * connection-string guidance. + */ +export type DbType = "mongodb" | "documentdb" | "cosmosdb" | "ferretdb"; + +export interface NoSqlDialect { + type: DbType; + /** Proper noun — never translated. */ + label: string; + /** Example connection string shown as the input placeholder. */ + placeholder: string; + /** + * Host substring identifying this managed service from a connection string. + * Used to infer the type of legacy connections saved before `dbType` existed. + * Omitted for self-hosted engines (Mongo/FerretDB) that share generic hosts. + */ + hostMatch?: RegExp; +} + +export const DB_DIALECTS: Record = { + mongodb: { + type: "mongodb", + label: "MongoDB", + placeholder: "mongodb://localhost:27017", + }, + documentdb: { + type: "documentdb", + label: "AWS DocumentDB", + // DocumentDB rejects the driver's default retryWrites=true and requires TLS. + placeholder: + "mongodb://user:pass@cluster.docdb.amazonaws.com:27017/?tls=true&retryWrites=false", + hostMatch: /\.docdb(-elastic)?\.[a-z0-9-]+\.amazonaws\.com/i, + }, + cosmosdb: { + type: "cosmosdb", + label: "Azure Cosmos DB", + placeholder: + "mongodb://acct:key@acct.mongo.cosmos.azure.com:10255/?ssl=true&retryWrites=false", + hostMatch: /\.mongo\.cosmos\.azure\.com/i, + }, + ferretdb: { + type: "ferretdb", + label: "FerretDB", + placeholder: "mongodb://user:pass@localhost:27017/", + }, +}; + +/** Picker order. */ +export const DB_TYPE_ORDER: DbType[] = ["mongodb", "documentdb", "cosmosdb", "ferretdb"]; + +/** + * Best-effort type inference from a connection string, for connections saved + * before `dbType` was stored. Falls back to "mongodb" — the safe generic, since + * every dialect here is Mongo-wire compatible. + */ +export function detectDbType(connectionString: string): DbType { + for (const type of DB_TYPE_ORDER) { + const { hostMatch } = DB_DIALECTS[type]; + if (hostMatch && hostMatch.test(connectionString)) return type; + } + return "mongodb"; +} diff --git a/apps/desktop-ui/src/lib/nosql-snippets.ts b/apps/desktop-ui/src/lib/nosql-snippets.ts new file mode 100644 index 00000000..03e28b09 --- /dev/null +++ b/apps/desktop-ui/src/lib/nosql-snippets.ts @@ -0,0 +1,119 @@ +// MongoDB query/aggregation snippets + operator metadata for the nosql-explorer +// Monaco completion provider. Pure data so it can be unit-tested and reused; +// the component maps these to Monaco CompletionItems (kept out of here to avoid +// importing monaco types into a lib module). +// +// `insertText` uses Monaco snippet placeholder syntax (${1:foo}) when +// `snippet` is true. The editor language is JSON, so snippets are JSON-shaped. + +export interface NosqlCompletion { + /** Text shown in the completion list. */ + label: string; + /** Short right-aligned hint (e.g. the category). */ + detail: string; + /** Markdown docs shown in the details flyout. */ + doc: string; + /** Text inserted on accept. */ + insertText: string; + /** When true, insertText contains ${n} tab-stops (InsertAsSnippet). */ + snippet: boolean; +} + +// Query + aggregation operators. Kept flat; `detail` groups them visually. +export const MONGO_OPERATORS: NosqlCompletion[] = [ + // comparison + op("$eq", "comparison", "Matches values equal to a specified value.", `"$eq": \${1:value}`), + op("$ne", "comparison", "Matches values not equal to a specified value.", `"$ne": \${1:value}`), + op("$gt", "comparison", "Matches values greater than a specified value.", `"$gt": \${1:value}`), + op("$gte", "comparison", "Matches values greater than or equal to a specified value.", `"$gte": \${1:value}`), + op("$lt", "comparison", "Matches values less than a specified value.", `"$lt": \${1:value}`), + op("$lte", "comparison", "Matches values less than or equal to a specified value.", `"$lte": \${1:value}`), + op("$in", "comparison", "Matches any of the values in an array.", `"$in": [\${1:value}]`), + op("$nin", "comparison", "Matches none of the values in an array.", `"$nin": [\${1:value}]`), + // logical + op("$and", "logical", "Joins clauses with a logical AND.", `"$and": [\${1:{}}]`), + op("$or", "logical", "Joins clauses with a logical OR.", `"$or": [\${1:{}}]`), + op("$nor", "logical", "Joins clauses with a logical NOR.", `"$nor": [\${1:{}}]`), + op("$not", "logical", "Inverts the effect of a query expression.", `"$not": \${1:{}}`), + // element / evaluation + op("$exists", "element", "Matches documents that have the specified field.", `"$exists": \${1:true}`), + op("$type", "element", "Selects documents where a field is of the specified BSON type.", `"$type": "\${1:string}"`), + op("$regex", "evaluation", "Selects documents where values match a regular expression.", `"$regex": "\${1:pattern}", "$options": "\${2:i}"`), + op("$expr", "evaluation", "Allows aggregation expressions within the query language.", `"$expr": \${1:{}}`), + op("$mod", "evaluation", "Performs a modulo operation on a field value.", `"$mod": [\${1:divisor}, \${2:remainder}]`), + // array + op("$all", "array", "Matches arrays that contain all specified elements.", `"$all": [\${1:value}]`), + op("$elemMatch", "array", "Matches documents with array elements meeting all criteria.", `"$elemMatch": \${1:{}}`), + op("$size", "array", "Matches arrays with the specified number of elements.", `"$size": \${1:0}`), + // aggregation stages + op("$match", "stage", "Filters documents to pass only matching ones to the next stage.", `"$match": \${1:{}}`), + op("$project", "stage", "Reshapes documents, including/excluding fields.", `"$project": \${1:{}}`), + op("$group", "stage", "Groups documents by an _id expression and computes accumulators.", `"$group": { "_id": \${1:null} }`), + op("$sort", "stage", "Sorts documents by the specified fields.", `"$sort": { "\${1:field}": \${2:-1} }`), + op("$limit", "stage", "Limits the number of documents passed to the next stage.", `"$limit": \${1:10}`), + op("$skip", "stage", "Skips a number of documents.", `"$skip": \${1:0}`), + op("$lookup", "stage", "Performs a left outer join to another collection.", `"$lookup": { "from": "\${1:coll}", "localField": "\${2:local}", "foreignField": "\${3:foreign}", "as": "\${4:joined}" }`), + op("$unwind", "stage", "Deconstructs an array field into one document per element.", `"$unwind": "$\${1:field}"`), + op("$addFields", "stage", "Adds new fields to documents.", `"$addFields": \${1:{}}`), + op("$count", "stage", "Counts documents at this stage.", `"$count": "\${1:count}"`), + // accumulators + op("$sum", "accumulator", "Sums numeric values (use 1 to count).", `"$sum": \${1:1}`), + op("$avg", "accumulator", "Averages numeric values.", `"$avg": "$\${1:field}"`), + op("$min", "accumulator", "Returns the minimum value.", `"$min": "$\${1:field}"`), + op("$max", "accumulator", "Returns the maximum value.", `"$max": "$\${1:field}"`), + op("$first", "accumulator", "Returns the first value in a group.", `"$first": "$\${1:field}"`), + op("$last", "accumulator", "Returns the last value in a group.", `"$last": "$\${1:field}"`), + op("$push", "accumulator", "Appends values to an array in a group.", `"$push": "$\${1:field}"`), + op("$addToSet", "accumulator", "Appends unique values to an array in a group.", `"$addToSet": "$\${1:field}"`), +]; + +// Whole-query / whole-pipeline templates. These replace the editor content so +// they carry no leading key — they are full JSON documents/arrays. +export const MONGO_SNIPPETS: NosqlCompletion[] = [ + tpl("find: equals", "Filter by a field equals a value.", `{ "\${1:field}": \${2:value} }`), + tpl("find: range", "Filter by a numeric/date range.", `{ "\${1:field}": { "$gte": \${2:min}, "$lte": \${3:max} } }`), + tpl("find: in list", "Field matches any value in a list.", `{ "\${1:field}": { "$in": [\${2:a}, \${3:b}] } }`), + tpl("find: regex", "Case-insensitive substring match.", `{ "\${1:field}": { "$regex": "\${2:text}", "$options": "i" } }`), + tpl("find: exists", "Field is present (or absent).", `{ "\${1:field}": { "$exists": \${2:true} } }`), + tpl("find: and", "Combine conditions with AND.", `{ "$and": [ { "\${1:a}": \${2:1} }, { "\${3:b}": \${4:2} } ] }`), + tpl("find: or", "Combine conditions with OR.", `{ "$or": [ { "\${1:a}": \${2:1} }, { "\${3:b}": \${4:2} } ] }`), + tpl("find: by ObjectId", "Match an _id ObjectId.", `{ "_id": { "$oid": "\${1:507f1f77bcf86cd799439011}" } }`), + tpl( + "agg: group + count", + "Group by a field and count documents per group.", + `[\n { "$group": { "_id": "$\${1:field}", "count": { "$sum": 1 } } },\n { "$sort": { "count": -1 } }\n]`, + ), + tpl( + "agg: group + sum/avg", + "Group and total/average a numeric field.", + `[\n { "$group": { "_id": "$\${1:groupField}", "total": { "$sum": "$\${2:amount}" }, "avg": { "$avg": "$\${2:amount}" } } }\n]`, + ), + tpl( + "agg: match + sort + limit", + "Top-N filtered documents.", + `[\n { "$match": { "\${1:field}": \${2:value} } },\n { "$sort": { "\${3:sortField}": -1 } },\n { "$limit": \${4:10} }\n]`, + ), + tpl( + "agg: lookup (join)", + "Left outer join to another collection.", + `[\n { "$lookup": { "from": "\${1:other}", "localField": "\${2:local}", "foreignField": "\${3:_id}", "as": "\${4:joined}" } },\n { "$unwind": "$\${4:joined}" }\n]`, + ), + tpl( + "agg: unwind + group", + "Flatten an array then group by its elements.", + `[\n { "$unwind": "$\${1:items}" },\n { "$group": { "_id": "$\${1:items}", "count": { "$sum": 1 } } }\n]`, + ), + tpl( + "agg: facet", + "Run multiple sub-pipelines in one pass.", + `[\n { "$facet": {\n "\${1:byStatus}": [ { "$group": { "_id": "$\${2:status}", "n": { "$sum": 1 } } } ],\n "\${3:total}": [ { "$count": "count" } ]\n } }\n]`, + ), +]; + +function op(label: string, detail: string, doc: string, body: string): NosqlCompletion { + return { label, detail, doc, insertText: body, snippet: true }; +} + +function tpl(label: string, doc: string, body: string): NosqlCompletion { + return { label, detail: "snippet", doc, insertText: body, snippet: true }; +} diff --git a/apps/desktop/src-tauri/src/dbtools/mongo.rs b/apps/desktop/src-tauri/src/dbtools/mongo.rs index 77c29da8..a5811847 100644 --- a/apps/desktop/src-tauri/src/dbtools/mongo.rs +++ b/apps/desktop/src-tauri/src/dbtools/mongo.rs @@ -207,6 +207,10 @@ fn is_write_route(method: &str, rest: &str) -> bool { | ("POST", "/collection/rename") | ("POST", "/database/drop") | ("POST", "/database/rename") + | ("POST", "/kill-op") + | ("POST", "/sync") + | ("POST", "/gridfs/upload") + | ("POST", "/gridfs/delete") ) } @@ -307,6 +311,57 @@ async fn dispatch(method: &str, rest: &str, req: &Value, conn_str: &str) -> Hand } ("POST", "/documents/query") => query_documents(&client, req).await, ("POST", "/explain") => explain_query(&client, req).await, + ("POST", "/server-stats") => { + let status = client + .database("admin") + .run_command(doc! { "serverStatus": 1 }) + .await + .map_err(|e| e.to_string())?; + Ok(ok(&bson_to_json(&Bson::Document(status)))) + } + ("POST", "/current-ops") => { + let resp = client + .database("admin") + .run_command(doc! { "currentOp": 1 }) + .await + .map_err(|e| e.to_string())?; + let inprog: Vec = resp + .get_array("inprog") + .map(|a| a.iter().map(bson_to_json).collect()) + .unwrap_or_default(); + Ok(ok(&json!({ "inprog": inprog }))) + } + ("POST", "/kill-op") => { + if req["opid"].is_null() { + return Ok(err(400, "Missing required parameters")); + } + let opid = json_to_bson(&req["opid"], false); + client + .database("admin") + .run_command(doc! { "killOp": 1, "op": opid }) + .await + .map_err(|e| e.to_string())?; + Ok(ok(&json!({ "success": true }))) + } + ("POST", "/sync") => mongo_sync(&client, req).await, + ("POST", "/gridfs/list") => gridfs_list(&client, req).await, + ("POST", "/gridfs/download") => gridfs_download(&client, req).await, + ("POST", "/gridfs/upload") => gridfs_upload(&client, req).await, + ("POST", "/gridfs/delete") => { + let db_name = match required(req, &["dbName"]) { + Ok(v) => v[0].to_string(), + Err(r) => return Ok(r), + }; + if req["id"].is_null() { + return Ok(err(400, "Missing required parameters")); + } + let bucket = gridfs_bucket(&client, &db_name, req); + bucket + .delete(json_to_bson(&req["id"], false)) + .await + .map_err(|e| e.to_string())?; + Ok(ok(&json!({ "success": true }))) + } ("POST", "/bulk-delete") => { let (db_name, coll) = match required(req, &["dbName", "collectionName"]) { Ok(v) => (v[0].to_string(), v[1].to_string()), @@ -664,20 +719,200 @@ fn bucket_type(b: &Bson) -> String { } } +// ── Cross-connection sync ───────────────────────────────────────────────────── +// Copy a source collection into a target collection on any (possibly different) +// connection, matched by _id. "insert" adds missing docs only; "overwrite" +// replaces/inserts. ponytail: naive per-doc loop with a hard scan cap — swap to +// bulk_write batching if throughput on huge collections ever matters. +const SYNC_MAX_SCAN: usize = 100_000; + +async fn mongo_sync(source_client: &Client, req: &Value) -> HandlerResult { + let (src_db, src_coll) = match required(req, &["dbName", "collectionName"]) { + Ok(v) => (v[0].to_string(), v[1].to_string()), + Err(r) => return Ok(r), + }; + let target = &req["target"]; + let tgt_conn = target["connectionString"].as_str().unwrap_or(""); + let tgt_db = target["dbName"].as_str().unwrap_or(""); + let tgt_coll = target["collectionName"].as_str().unwrap_or(""); + if tgt_conn.is_empty() || tgt_db.is_empty() || tgt_coll.is_empty() { + return Ok(err(400, "Target connection, database and collection are required")); + } + if !tgt_conn.starts_with("mongodb://") && !tgt_conn.starts_with("mongodb+srv://") { + return Ok(err(400, "Invalid target connection string")); + } + if target["readOnly"].as_bool().unwrap_or(false) { + return Ok(err(403, "Target connection is read-only")); + } + let overwrite = req["mode"].as_str() == Some("overwrite"); + + let target_client = get_client(tgt_conn).await?; + let target_coll = target_client + .database(tgt_db) + .collection::(tgt_coll); + let source = source_client + .database(&src_db) + .collection::(&src_coll); + + let mut cursor = source.find(doc! {}).await.map_err(|e| e.to_string())?; + let (mut inserted, mut modified, mut skipped, mut scanned) = (0u64, 0u64, 0u64, 0usize); + while let Some(doc) = cursor.try_next().await.map_err(|e| e.to_string())? { + if scanned >= SYNC_MAX_SCAN { + break; + } + scanned += 1; + let id = doc.get("_id").cloned().unwrap_or(Bson::Null); + if overwrite { + let res = target_coll + .replace_one(doc! { "_id": id }, doc.clone()) + .upsert(true) + .await + .map_err(|e| e.to_string())?; + if res.upserted_id.is_some() { + inserted += 1; + } else { + modified += 1; + } + } else { + match target_coll.insert_one(doc.clone()).await { + Ok(_) => inserted += 1, + // Existing _id (E11000 duplicate key) → skip, that's the point of + // insert mode. Any other write error aborts the sync. + Err(e) if e.to_string().contains("E11000") => skipped += 1, + Err(e) => return Err(e.to_string()), + } + } + } + + Ok(ok(&json!({ + "scanned": scanned, + "inserted": inserted, + "modified": modified, + "skipped": skipped, + "capped": scanned >= SYNC_MAX_SCAN, + }))) +} + +// ── GridFS ────────────────────────────────────────────────────────────────── +// base64 over the local IPC boundary; a hard size cap keeps a huge file from +// ballooning the JSON payload / RSS. ponytail: 32 MiB cap, stream to disk if +// bigger files ever matter. +const GRIDFS_MAX_BYTES: u64 = 32 * 1024 * 1024; + +fn gridfs_bucket(client: &Client, db_name: &str, req: &Value) -> mongodb::gridfs::GridFsBucket { + let name = req["bucket"].as_str().unwrap_or("fs").to_string(); + client + .database(db_name) + .gridfs_bucket(mongodb::options::GridFsBucketOptions::builder().bucket_name(name).build()) +} + +async fn gridfs_list(client: &Client, req: &Value) -> HandlerResult { + let db_name = match required(req, &["dbName"]) { + Ok(v) => v[0].to_string(), + Err(r) => return Ok(r), + }; + let bucket = gridfs_bucket(client, &db_name, req); + let cursor = bucket.find(doc! {}).await.map_err(|e| e.to_string())?; + let files: Vec = + cursor.try_collect().await.map_err(|e| e.to_string())?; + let out: Vec = files + .into_iter() + .map(|f| { + json!({ + "id": bson_to_json(&f.id), + "filename": f.filename, + "length": f.length, + "chunkSize": f.chunk_size_bytes, + "uploadDate": bson_to_json(&Bson::DateTime(f.upload_date)), + "metadata": f.metadata.map(|m| bson_to_json(&Bson::Document(m))), + }) + }) + .collect(); + Ok(ok(&json!({ "files": out }))) +} + +async fn gridfs_download(client: &Client, req: &Value) -> HandlerResult { + use futures_util::io::AsyncReadExt; + let db_name = match required(req, &["dbName"]) { + Ok(v) => v[0].to_string(), + Err(r) => return Ok(r), + }; + if req["id"].is_null() { + return Ok(err(400, "Missing required parameters")); + } + let bucket = gridfs_bucket(client, &db_name, req); + let id = json_to_bson(&req["id"], false); + let mut stream = bucket + .open_download_stream(id) + .await + .map_err(|e| e.to_string())?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await.map_err(|e| e.to_string())?; + if buf.len() as u64 > GRIDFS_MAX_BYTES { + return Ok(err(413, "File exceeds the 32 MB download limit")); + } + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&buf); + Ok(ok(&json!({ "data": b64 }))) +} + +async fn gridfs_upload(client: &Client, req: &Value) -> HandlerResult { + use futures_util::io::AsyncWriteExt; + let (db_name, filename) = match required(req, &["dbName", "filename"]) { + Ok(v) => (v[0].to_string(), v[1].to_string()), + Err(r) => return Ok(r), + }; + let Some(b64) = req["data"].as_str() else { + return Ok(err(400, "data (base64) is required")); + }; + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .map_err(|_| "Invalid base64 data".to_string())?; + if bytes.len() as u64 > GRIDFS_MAX_BYTES { + return Ok(err(413, "File exceeds the 32 MB upload limit")); + } + let bucket = gridfs_bucket(client, &db_name, req); + let mut stream = bucket.open_upload_stream(&filename).await.map_err(|e| e.to_string())?; + stream.write_all(&bytes).await.map_err(|e| e.to_string())?; + stream.close().await.map_err(|e| e.to_string())?; + Ok(ok(&json!({ "id": bson_to_json(stream.id()) }))) +} + async fn schema(client: &Client, req: &Value) -> HandlerResult { let (db_name, coll_name) = match required(req, &["dbName", "collectionName"]) { Ok(v) => (v[0].to_string(), v[1].to_string()), Err(r) => return Ok(r), }; let sample_size = req["sampleSize"].as_i64().unwrap_or(200).min(1000); - let coll = client.database(&db_name).collection::(&coll_name); - let cursor = coll - .aggregate(vec![doc! { "$sample": { "size": sample_size } }]) - .await - .map_err(|e| e.to_string())?; + let db = client.database(&db_name); + let coll = db.collection::(&coll_name); + + // Sampling strategy. "all" scans up to a hard cap so a huge collection can't + // OOM the analyzer; the UI labels it as capped. + const ALL_CAP: i64 = 10_000; + let pipeline = match req["sampleMode"].as_str().unwrap_or("random") { + "first" => vec![doc! { "$limit": sample_size }], + "last" => vec![doc! { "$sort": { "_id": -1 } }, doc! { "$limit": sample_size }], + "all" => vec![doc! { "$limit": ALL_CAP }], + _ => vec![doc! { "$sample": { "size": sample_size } }], + }; + let cursor = coll.aggregate(pipeline).await.map_err(|e| e.to_string())?; let docs: Vec = cursor.try_collect().await.map_err(|e| e.to_string())?; + + // Collection JSON-schema validator (if any) — read-only, best effort. + let validator: Value = db + .run_command(doc! { "listCollections": 1, "filter": { "name": &coll_name } }) + .await + .ok() + .and_then(|r| r.get_document("cursor").ok().cloned()) + .and_then(|c| c.get_array("firstBatch").ok().and_then(|b| b.first().cloned())) + .and_then(|first| first.as_document().and_then(|d| d.get_document("options").ok().cloned())) + .and_then(|opts| opts.get_document("validator").ok().map(|v| bson_to_json(&Bson::Document(v.clone())))) + .unwrap_or(Value::Null); + if docs.is_empty() { - return Ok(ok(&json!({ "fields": [], "sampleSize": 0 }))); + return Ok(ok(&json!({ "fields": [], "sampleSize": 0, "validator": validator }))); } struct FieldStat { @@ -727,5 +962,5 @@ async fn schema(client: &Client, req: &Value) -> HandlerResult { .then(b["coverage"].as_u64().cmp(&a["coverage"].as_u64())) .then(a["name"].as_str().cmp(&b["name"].as_str())) }); - Ok(ok(&json!({ "fields": out, "sampleSize": total }))) + Ok(ok(&json!({ "fields": out, "sampleSize": total, "validator": validator }))) } From 6f6ce41fa1c8f1e4073bf640ad6568b76417e96f Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 22 Jul 2026 21:43:33 +0530 Subject: [PATCH 2/7] feat(nosql-explorer): add dbType i18n + DocumentDB/Cosmos write-safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prior commit added the DB-type selector code but omitted the 27 locale files, so the form's t("labelDbType") lookup was missing everywhere and titleConnect/subtitleConnect still named MongoDB. Adds labelDbType to all locales and genericizes those two strings. Also normalizeConnectionString(): DocumentDB and Cosmos (Mongo API) reject the driver's default retryWrites=true, so writes fail silently — append retryWrites=false unless the user set it. Applied on test + save/connect. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop-ui/messages/af.json | 1 + apps/desktop-ui/messages/ar.json | 1 + apps/desktop-ui/messages/ca.json | 1 + apps/desktop-ui/messages/cs.json | 1 + apps/desktop-ui/messages/da.json | 1 + apps/desktop-ui/messages/de.json | 1 + apps/desktop-ui/messages/el.json | 1 + apps/desktop-ui/messages/en.json | 1 + apps/desktop-ui/messages/es.json | 1 + apps/desktop-ui/messages/fa.json | 1 + apps/desktop-ui/messages/fr.json | 1 + apps/desktop-ui/messages/id.json | 1 + apps/desktop-ui/messages/it.json | 1 + apps/desktop-ui/messages/ja.json | 1 + apps/desktop-ui/messages/ko.json | 1 + apps/desktop-ui/messages/ms.json | 1 + apps/desktop-ui/messages/nb.json | 1 + apps/desktop-ui/messages/nl.json | 1 + apps/desktop-ui/messages/pl.json | 1 + apps/desktop-ui/messages/pt-BR.json | 1 + apps/desktop-ui/messages/pt.json | 1 + apps/desktop-ui/messages/ru.json | 1 + apps/desktop-ui/messages/sv.json | 1 + apps/desktop-ui/messages/tr.json | 1 + apps/desktop-ui/messages/uk.json | 1 + apps/desktop-ui/messages/vi.json | 1 + apps/desktop-ui/messages/zh.json | 1 + .../nosql-explorer/connection-form.tsx | 13 ++++++---- .../src/lib/__tests__/nosql-dialects.test.ts | 24 ++++++++++++++++++- apps/desktop-ui/src/lib/nosql-dialects.ts | 16 +++++++++++++ 30 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index e3a8f852..2bedd88b 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Kon nie navraag stoor nie", "querySaved": "“{name}” gestoor", "explainQuery": "Verduidelik navraag", + "generateCode": "Generate code", "stagesMode": "Fases", "invalidStageJson": "'n Pyplynfase bevat ongeldige JSON" }, diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index a591087d..d508c88a 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "فشل حفظ الاستعلام", "querySaved": "تم حفظ «{name}»", "explainQuery": "شرح الاستعلام", + "generateCode": "Generate code", "stagesMode": "المراحل", "invalidStageJson": "تحتوي إحدى مراحل الـ pipeline على JSON غير صالح" }, diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 71ff1333..6900782f 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "No s'ha pogut desar la consulta", "querySaved": "“{name}” desada", "explainQuery": "Explica la consulta", + "generateCode": "Generate code", "stagesMode": "Etapes", "invalidStageJson": "Una etapa del pipeline conté JSON no vàlid" }, diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 1d193091..dbd65690 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Dotaz se nepodařilo uložit", "querySaved": "„{name}“ uloženo", "explainQuery": "Vysvětlit dotaz", + "generateCode": "Generate code", "stagesMode": "Fáze", "invalidStageJson": "Fáze pipeline obsahuje neplatný JSON" }, diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index 2e814042..3d1ff097 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Kunne ikke gemme forespørgslen", "querySaved": "“{name}” gemt", "explainQuery": "Forklar forespørgsel", + "generateCode": "Generate code", "stagesMode": "Trin", "invalidStageJson": "Et pipelinetrin indeholder ugyldig JSON" }, diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 60c752fa..90efea89 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Abfrage konnte nicht gespeichert werden", "querySaved": "„{name}“ gespeichert", "explainQuery": "Abfrage erklären", + "generateCode": "Generate code", "stagesMode": "Stufen", "invalidStageJson": "Eine Pipeline-Stufe enthält ungültiges JSON" }, diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 338dca7c..9b0c77d0 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Αποτυχία αποθήκευσης ερωτήματος", "querySaved": "Το «{name}» αποθηκεύτηκε", "explainQuery": "Επεξήγηση ερωτήματος", + "generateCode": "Generate code", "stagesMode": "Στάδια", "invalidStageJson": "Ένα στάδιο του pipeline περιέχει μη έγκυρο JSON" }, diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 03dbbd37..004c377a 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1763,6 +1763,7 @@ "saveQueryFail": "Failed to save query", "querySaved": "Saved “{name}”", "explainQuery": "Explain query", + "generateCode": "Generate code", "stagesMode": "Stages", "invalidStageJson": "A pipeline stage contains invalid JSON" }, diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index 1648957c..4d059433 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "No se pudo guardar la consulta", "querySaved": "“{name}” guardada", "explainQuery": "Explicar consulta", + "generateCode": "Generate code", "stagesMode": "Etapas", "invalidStageJson": "Una etapa del pipeline contiene JSON no válido" }, diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 7fbdc670..2be35e00 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "ذخیره کوئری ناموفق بود", "querySaved": "«{name}» ذخیره شد", "explainQuery": "توضیح کوئری", + "generateCode": "Generate code", "stagesMode": "مراحل", "invalidStageJson": "یکی از مراحل pipeline حاوی JSON نامعتبر است" }, diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index 704f93ca..753d099c 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Échec de l'enregistrement de la requête", "querySaved": "« {name} » enregistrée", "explainQuery": "Expliquer la requête", + "generateCode": "Generate code", "stagesMode": "Étapes", "invalidStageJson": "Une étape du pipeline contient du JSON invalide" }, diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 5327eee0..39a4223a 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Gagal menyimpan kueri", "querySaved": "“{name}” disimpan", "explainQuery": "Jelaskan kueri", + "generateCode": "Generate code", "stagesMode": "Tahapan", "invalidStageJson": "Sebuah tahap pipeline berisi JSON yang tidak valid" }, diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 95ac6482..fd33b680 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Impossibile salvare la query", "querySaved": "“{name}” salvata", "explainQuery": "Spiega query", + "generateCode": "Generate code", "stagesMode": "Fasi", "invalidStageJson": "Una fase della pipeline contiene JSON non valido" }, diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 0e595648..a72f431a 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "クエリの保存に失敗しました", "querySaved": "「{name}」を保存しました", "explainQuery": "クエリを説明", + "generateCode": "Generate code", "stagesMode": "ステージ", "invalidStageJson": "パイプラインのステージに無効なJSONが含まれています" }, diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 8188586f..782f44c3 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "쿼리 저장에 실패했습니다", "querySaved": "“{name}” 저장됨", "explainQuery": "쿼리 설명", + "generateCode": "Generate code", "stagesMode": "단계", "invalidStageJson": "파이프라인 단계에 잘못된 JSON이 있습니다" }, diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 0da1abf5..5f583774 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Gagal menyimpan pertanyaan", "querySaved": "“{name}” disimpan", "explainQuery": "Terangkan pertanyaan", + "generateCode": "Generate code", "stagesMode": "Peringkat", "invalidStageJson": "Satu peringkat pipeline mengandungi JSON tidak sah" }, diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index aabc6b45..c8a83e17 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Kunne ikke lagre spørringen", "querySaved": "«{name}» lagret", "explainQuery": "Forklar spørring", + "generateCode": "Generate code", "stagesMode": "Trinn", "invalidStageJson": "Et pipelinetrinn inneholder ugyldig JSON" }, diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 176561be..31ac1b32 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Query opslaan mislukt", "querySaved": "“{name}” opgeslagen", "explainQuery": "Query uitleggen", + "generateCode": "Generate code", "stagesMode": "Fasen", "invalidStageJson": "Een pipelinefase bevat ongeldige JSON" }, diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 32b8baa5..51b7a243 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Nie udało się zapisać zapytania", "querySaved": "Zapisano „{name}”", "explainQuery": "Wyjaśnij zapytanie", + "generateCode": "Generate code", "stagesMode": "Etapy", "invalidStageJson": "Etap pipeline'u zawiera nieprawidłowy JSON" }, diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index cea5bcf0..363da2ee 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Falha ao salvar a consulta", "querySaved": "“{name}” salva", "explainQuery": "Explicar consulta", + "generateCode": "Generate code", "stagesMode": "Etapas", "invalidStageJson": "Uma etapa do pipeline contém JSON inválido" }, diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 9dcbc0cb..8a06ae98 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Falha ao guardar a consulta", "querySaved": "“{name}” guardada", "explainQuery": "Explicar consulta", + "generateCode": "Generate code", "stagesMode": "Etapas", "invalidStageJson": "Uma etapa do pipeline contém JSON inválido" }, diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index c91befd4..0276726e 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Не удалось сохранить запрос", "querySaved": "«{name}» сохранён", "explainQuery": "Объяснить запрос", + "generateCode": "Generate code", "stagesMode": "Этапы", "invalidStageJson": "Этап конвейера содержит неверный JSON" }, diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index a7734ca9..fc3999ce 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Kunde inte spara frågan", "querySaved": "”{name}” sparad", "explainQuery": "Förklara fråga", + "generateCode": "Generate code", "stagesMode": "Steg", "invalidStageJson": "Ett pipelinesteg innehåller ogiltig JSON" }, diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 1a0d4dac..777acf06 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Sorgu kaydedilemedi", "querySaved": "“{name}” kaydedildi", "explainQuery": "Sorguyu açıkla", + "generateCode": "Generate code", "stagesMode": "Aşamalar", "invalidStageJson": "Bir pipeline aşaması geçersiz JSON içeriyor" }, diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index a422d0d5..8e999729 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -1748,6 +1748,7 @@ "saveQueryFail": "Не вдалося зберегти запит", "querySaved": "«{name}» збережено", "explainQuery": "Пояснити запит", + "generateCode": "Generate code", "stagesMode": "Етапи", "invalidStageJson": "Етап конвеєра містить неправильний JSON" }, diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index d7df9c79..d235e793 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "Không thể lưu truy vấn", "querySaved": "Đã lưu “{name}”", "explainQuery": "Giải thích truy vấn", + "generateCode": "Generate code", "stagesMode": "Giai đoạn", "invalidStageJson": "Một giai đoạn pipeline chứa JSON không hợp lệ" }, diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index cf7d7785..1c41da98 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -1769,6 +1769,7 @@ "saveQueryFail": "保存查询失败", "querySaved": "已保存“{name}”", "explainQuery": "解释查询", + "generateCode": "Generate code", "stagesMode": "阶段", "invalidStageJson": "某个管道阶段包含无效的 JSON" }, diff --git a/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx b/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx index 9fd64efb..0668e5df 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/connection-form.tsx @@ -9,7 +9,7 @@ import { Label } from "@/components/ui/label"; import { IconDatabase, IconTrash, IconHistory, IconPencil, IconPlugConnected, IconCheck, IconX, IconBrandMongodb, IconBrandAws, IconBrandAzure, IconServer, IconCopy, IconLock } from "@tabler/icons-react"; import { Switch } from "@/components/ui/switch"; import { SavedConnection } from "./types"; -import { DB_DIALECTS, DB_TYPE_ORDER, detectDbType, type DbType } from "@/lib/nosql-dialects"; +import { DB_DIALECTS, DB_TYPE_ORDER, detectDbType, normalizeConnectionString, type DbType } from "@/lib/nosql-dialects"; import { getConnections, deleteConnection, saveConnection, updateConnectionDetails } from "./connection-service"; import { backendFetch } from "@/lib/backend-auth"; import useAuth from "@/utils/useAuth"; @@ -99,7 +99,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp const res = await backendFetch("/api/nosql/connect", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ connectionString }), + body: JSON.stringify({ connectionString: normalizeConnectionString(dbType, connectionString) }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); @@ -116,12 +116,15 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp e.preventDefault(); if (!connectionString || !user || !encryptionKey) return; + // Persist and connect with the dialect-corrected string (e.g. DocumentDB/Cosmos retryWrites=false). + const finalString = normalizeConnectionString(dbType, connectionString); + try { if (editingId) { - await updateConnectionDetails(user.uid, editingId, { name, connectionString, color, readOnly, dbType }, encryptionKey); + await updateConnectionDetails(user.uid, editingId, { name, connectionString: finalString, color, readOnly, dbType }, encryptionKey); toast.success(t("toastUpdated")); } else { - await saveConnection(user.uid, connectionString, name, encryptionKey, { color, readOnly, dbType }); + await saveConnection(user.uid, finalString, name, encryptionKey, { color, readOnly, dbType }); } await loadConnections(); } catch (e) { @@ -131,7 +134,7 @@ export function ConnectionForm({ onConnect, loading, error }: ConnectionFormProp } setEditingId(null); - await onConnect(connectionString); + await onConnect(finalString); }; const handleSelectConnection = (conn: SavedConnection) => { diff --git a/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts b/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts index ab67d3c6..eb5322c3 100644 --- a/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/nosql-dialects.test.ts @@ -1,4 +1,4 @@ -import { detectDbType, DB_DIALECTS, DB_TYPE_ORDER } from "../nosql-dialects"; +import { detectDbType, normalizeConnectionString, DB_DIALECTS, DB_TYPE_ORDER } from "../nosql-dialects"; describe("detectDbType", () => { it("infers Cosmos DB from its managed host", () => { @@ -29,3 +29,25 @@ describe("detectDbType", () => { ); }); }); + +describe("normalizeConnectionString", () => { + it("appends retryWrites=false for DocumentDB/Cosmos, picking the right separator", () => { + expect(normalizeConnectionString("documentdb", "mongodb://h:27017")).toBe( + "mongodb://h:27017?retryWrites=false" + ); + expect(normalizeConnectionString("cosmosdb", "mongodb://h:10255/?ssl=true")).toBe( + "mongodb://h:10255/?ssl=true&retryWrites=false" + ); + }); + + it("respects a user-set retryWrites and trims", () => { + expect(normalizeConnectionString("documentdb", " mongodb://h/?retryWrites=true ")).toBe( + "mongodb://h/?retryWrites=true" + ); + }); + + it("leaves mongodb/ferretdb untouched", () => { + expect(normalizeConnectionString("mongodb", "mongodb://h:27017")).toBe("mongodb://h:27017"); + expect(normalizeConnectionString("ferretdb", "mongodb://h:27017")).toBe("mongodb://h:27017"); + }); +}); diff --git a/apps/desktop-ui/src/lib/nosql-dialects.ts b/apps/desktop-ui/src/lib/nosql-dialects.ts index 94c441c4..5bb4ce75 100644 --- a/apps/desktop-ui/src/lib/nosql-dialects.ts +++ b/apps/desktop-ui/src/lib/nosql-dialects.ts @@ -66,3 +66,19 @@ export function detectDbType(connectionString: string): DbType { } return "mongodb"; } + +/** Dialects that reject the driver's default retryWrites=true (writes fail silently otherwise). */ +const NO_RETRY_WRITES: ReadonlySet = new Set(["documentdb", "cosmosdb"]); + +/** + * Normalize a connection string for the chosen dialect. DocumentDB and Cosmos + * (Mongo API) reject `retryWrites=true` — the Mongo driver's default — so writes + * error unless `retryWrites=false` is present. Append it when the user hasn't set + * `retryWrites` themselves. Returns the string unchanged for other dialects or + * when the param is already present. + */ +export function normalizeConnectionString(dbType: DbType, connectionString: string): string { + const s = connectionString.trim(); + if (!NO_RETRY_WRITES.has(dbType) || /[?&]retryWrites=/i.test(s)) return s; + return s + (s.includes("?") ? "&" : "?") + "retryWrites=false"; +} From fd4ef18d01d99cf4649fa88d6d34c0ab06fa769d Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 22 Jul 2026 22:07:02 +0530 Subject: [PATCH 3/7] feat(nosql-explorer): genericize connection-string error for all Mongo-wire dialects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error no longer names MongoDB specifically — DocumentDB, Cosmos (Mongo API) and FerretDB all use mongodb:// / mongodb+srv:// URIs through this path. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src-tauri/src/dbtools/mongo.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/dbtools/mongo.rs b/apps/desktop/src-tauri/src/dbtools/mongo.rs index a5811847..22f55d4b 100644 --- a/apps/desktop/src-tauri/src/dbtools/mongo.rs +++ b/apps/desktop/src-tauri/src/dbtools/mongo.rs @@ -176,8 +176,10 @@ pub async fn handle(method: &str, rest: &str, body: Option<&str>) -> HandlerResu if conn_str.is_empty() { return Ok(err(400, "Connection string is required")); } + // All supported dialects (MongoDB, DocumentDB, Cosmos Mongo API, FerretDB) + // speak the Mongo wire protocol and use mongodb:// / mongodb+srv:// URIs. if !conn_str.starts_with("mongodb://") && !conn_str.starts_with("mongodb+srv://") { - return Ok(err(400, "Invalid MongoDB connection string")); + return Ok(err(400, "Connection string must start with mongodb:// or mongodb+srv://")); } // Read-only connections: reject every mutating route (defense in depth — From 91ecb07b36cb3d5b669d7cf79d0fd6197e52f529 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 22 Jul 2026 22:08:37 +0530 Subject: [PATCH 4/7] feat(nosql-explorer): query code generator + schema export Query code generator: find filters and aggregation pipelines to Mongo Shell, Node.js, Python, Java, C#, PHP, Ruby and Go. shell/node/python emitted idiomatically (Python converts true/null to True/None); the rest embed escaped JSON parsed by the driver's document parser. Exposed via a "Generate code" button in the query-builder advanced editor with a per-language Monaco preview and copy. Schema export: turn the schema analyzer output into a JSON Schema (draft-07, required = full-coverage fields), a Mongoose model (type map, drops _id) or a standalone HTML report, via an Export dropdown in the schema view. Pure-logic cores (nosql-codegen, nosql-schema-export) covered by jest. i18n keys added across all 27 locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop-ui/messages/af.json | 6 +- apps/desktop-ui/messages/ar.json | 6 +- apps/desktop-ui/messages/ca.json | 6 +- apps/desktop-ui/messages/cs.json | 6 +- apps/desktop-ui/messages/da.json | 6 +- apps/desktop-ui/messages/de.json | 6 +- apps/desktop-ui/messages/el.json | 6 +- apps/desktop-ui/messages/en.json | 6 +- apps/desktop-ui/messages/es.json | 6 +- apps/desktop-ui/messages/fa.json | 6 +- apps/desktop-ui/messages/fr.json | 6 +- apps/desktop-ui/messages/id.json | 6 +- apps/desktop-ui/messages/it.json | 6 +- apps/desktop-ui/messages/ja.json | 6 +- apps/desktop-ui/messages/ko.json | 6 +- apps/desktop-ui/messages/ms.json | 6 +- apps/desktop-ui/messages/nb.json | 6 +- apps/desktop-ui/messages/nl.json | 6 +- apps/desktop-ui/messages/pl.json | 6 +- apps/desktop-ui/messages/pt-BR.json | 6 +- apps/desktop-ui/messages/pt.json | 6 +- apps/desktop-ui/messages/ru.json | 6 +- apps/desktop-ui/messages/sv.json | 6 +- apps/desktop-ui/messages/tr.json | 6 +- apps/desktop-ui/messages/uk.json | 6 +- apps/desktop-ui/messages/vi.json | 6 +- apps/desktop-ui/messages/zh.json | 6 +- .../nosql-explorer/codegen-dialog.tsx | 93 ++++++++++ .../nosql-explorer/document-view.tsx | 2 +- .../nosql-explorer/query-builder.tsx | 37 +++- .../components/nosql-explorer/schema-view.tsx | 29 ++- .../src/lib/__tests__/nosql-codegen.test.ts | 51 ++++++ .../lib/__tests__/nosql-schema-export.test.ts | 45 +++++ apps/desktop-ui/src/lib/nosql-codegen.ts | 169 ++++++++++++++++++ .../desktop-ui/src/lib/nosql-schema-export.ts | 132 ++++++++++++++ 35 files changed, 690 insertions(+), 30 deletions(-) create mode 100644 apps/desktop-ui/src/components/nosql-explorer/codegen-dialog.tsx create mode 100644 apps/desktop-ui/src/lib/__tests__/nosql-codegen.test.ts create mode 100644 apps/desktop-ui/src/lib/__tests__/nosql-schema-export.test.ts create mode 100644 apps/desktop-ui/src/lib/nosql-codegen.ts create mode 100644 apps/desktop-ui/src/lib/nosql-schema-export.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index 2bedd88b..3768e067 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index d508c88a..71bda17a 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "جارٍ تحميل الفهارس...", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 6900782f..80267ab9 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "S'estan carregant els índexs…", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index dbd65690..023c2625 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Načítání indexů…", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index 3d1ff097..33eea23b 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Indlæser indekser …", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 90efea89..75cbaa04 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Indizes werden geladen …", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 9b0c77d0..37da4c00 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Φόρτωση ευρετηρίων...", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index 004c377a..9129035e 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -1867,7 +1867,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index 4d059433..a5fbed68 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 2be35e00..acd2fe02 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index 753d099c..faf08b59 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 39a4223a..a8581aa4 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Memuat indeks...", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index fd33b680..d103eb86 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index a72f431a..85f79c0f 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "インデックスを読み込み中...", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 782f44c3..5bf43be3 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "인덱스를 불러오는 중...", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 5f583774..efdb7c80 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index c8a83e17..43291f64 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Laster inn indekser …", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 31ac1b32..f3e4dc0f 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Indexen laden …", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 51b7a243..55901787 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Ładowanie indeksów…", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 363da2ee..46c8b9d8 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Carregando índices...", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 8a06ae98..d208f63f 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "A carregar índices…", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 0276726e..a1e95768 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Загрузка индексов…", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index fc3999ce..65237956 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 777acf06..7b87022b 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 8e999729..5a2791f5 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -1852,7 +1852,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" } }, "ApiClient": { diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index d235e793..3793b53e 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "Đang tải chỉ mục...", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 1c41da98..dd70045f 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -1874,7 +1874,11 @@ "sampleAll": "All", "sampleSizeN": "{n} docs", "allCapped": "capped at 10k", - "validatorTitle": "Validation rules" + "validatorTitle": "Validation rules", + "exportSchemaBtn": "Export", + "exportJsonSchema": "JSON Schema", + "exportMongoose": "Mongoose model", + "exportHtml": "HTML report" }, "indexManager": { "loading": "正在加载索引…", diff --git a/apps/desktop-ui/src/components/nosql-explorer/codegen-dialog.tsx b/apps/desktop-ui/src/components/nosql-explorer/codegen-dialog.tsx new file mode 100644 index 00000000..1021305c --- /dev/null +++ b/apps/desktop-ui/src/components/nosql-explorer/codegen-dialog.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { IconCopy } from "@tabler/icons-react"; +import Editor from "@/components/lazy/LazyMonaco"; +import { useTheme } from "next-themes"; +import { CODEGEN_LANGS, CodegenLang, generateCode } from "@/lib/nosql-codegen"; + +// Monaco language id per target (shell/node are JS). +const MONACO_LANG: Record = { + shell: "javascript", + node: "javascript", + python: "python", + java: "java", + csharp: "csharp", + php: "php", + ruby: "ruby", + go: "go", +}; + +export function CodegenDialog({ + db, + collection, + kind, + body, + open, + onClose, +}: { + db: string; + collection: string; + kind: "find" | "aggregate"; + body: string; + open: boolean; + onClose: () => void; +}) { + const [lang, setLang] = useState("node"); + const { theme } = useTheme(); + const { copyToClipboard } = useCopyToClipboard(); + + const code = useMemo(() => { + try { + return generateCode(lang, { db, collection, kind, body }); + } catch (e: any) { + return `// ${e.message || "Invalid query"}`; + } + }, [lang, db, collection, kind, body]); + + return ( + { if (!o) onClose(); }}> + + + Generate query code + +
+ {CODEGEN_LANGS.map((l) => ( + + ))} + +
+
+ +
+
+
+ ); +} diff --git a/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx b/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx index a89b1709..8e75f1ea 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/document-view.tsx @@ -687,7 +687,7 @@ export function DocumentView({ {/* Content area */}
{viewMode === 'schema' && onLoadSchema ? ( - + ) : viewMode === 'indexes' ? ( ("json"); const [stages, setStages] = useState([]); const [queryError, setQueryError] = useState(null); + const [codegenOpen, setCodegenOpen] = useState(false); + const [codegenPayload, setCodegenPayload] = useState<{ kind: "find" | "aggregate"; body: string }>({ kind: "find", body: "{}" }); + + const openCodegen = () => { + let content = textQuery; + if (advancedMode === "stages") { + try { content = stagesToPipeline(stages); } + catch { toast.error(t("invalidStageJson")); return; } + } + try { JSON.parse(content || "{}"); } + catch { toast.error(t("invalidJsonQuery")); return; } + const kind = parsePipeline(content) ? "aggregate" : "find"; + setCodegenPayload({ kind, body: content || (kind === "aggregate" ? "[]" : "{}") }); + setCodegenOpen(true); + }; // Monaco field/operator completion — the provider is registered once per // mount and reads the latest fields through a ref (docs change per page). @@ -857,6 +873,16 @@ export function QueryBuilder({ {t("format")} )} + {onExplain && (
); } diff --git a/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx b/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx index 8e8b0d1f..673cabc9 100644 --- a/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx +++ b/apps/desktop-ui/src/components/nosql-explorer/schema-view.tsx @@ -4,9 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { IconRefresh, IconShieldCheck, IconChevronRight } from "@tabler/icons-react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { IconRefresh, IconShieldCheck, IconChevronRight, IconDownload } from "@tabler/icons-react"; import { useTranslations } from "next-intl"; import { cn } from "@/lib/utils"; +import { generateSchemaExport, SchemaExportFormat } from "@/lib/nosql-schema-export"; export type SampleMode = "random" | "first" | "last" | "all"; export interface SchemaLoadOptions { sampleMode: SampleMode; sampleSize: number } @@ -15,8 +17,10 @@ type SchemaData = { fields: any[]; sampleSize: number; validator?: unknown }; export function SchemaView({ onLoad, + collectionName = "collection", }: { onLoad: (opts: SchemaLoadOptions) => Promise; + collectionName?: string; }) { const t = useTranslations("NoSqlExplorer.schemaView"); const [loading, setLoading] = useState(false); @@ -82,6 +86,16 @@ export function SchemaView({ ); } + const exportSchema = (format: SchemaExportFormat) => { + const { content, ext, mime } = generateSchemaExport(format, data!.fields, collectionName); + const url = URL.createObjectURL(new Blob([content], { type: mime })); + const a = document.createElement("a"); + a.href = url; + a.download = `${collectionName}.${ext}`; + a.click(); + URL.revokeObjectURL(url); + }; + const validator = data.validator && typeof data.validator === "object" ? data.validator : null; return ( @@ -115,6 +129,19 @@ export function SchemaView({ )} + + + + + + exportSchema("jsonSchema")}>{t("exportJsonSchema")} + exportSchema("mongoose")}>{t("exportMongoose")} + exportSchema("html")}>{t("exportHtml")} + + diff --git a/apps/desktop-ui/src/lib/__tests__/nosql-codegen.test.ts b/apps/desktop-ui/src/lib/__tests__/nosql-codegen.test.ts new file mode 100644 index 00000000..43c15cac --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/nosql-codegen.test.ts @@ -0,0 +1,51 @@ +import { generateCode, CODEGEN_LANGS, CodegenInput } from "../nosql-codegen"; + +const findInput: CodegenInput = { + db: "shop", + collection: "orders", + kind: "find", + body: '{"status": "active", "qty": {"$gt": 5}, "paid": true, "note": null}', +}; + +const aggInput: CodegenInput = { + db: "shop", + collection: "orders", + kind: "aggregate", + body: '[{"$group": {"_id": "$status", "count": {"$sum": 1}}}]', +}; + +describe("generateCode", () => { + it("emits non-empty code with db + collection for every language, find & aggregate", () => { + for (const { id } of CODEGEN_LANGS) { + for (const input of [findInput, aggInput]) { + const code = generateCode(id, input); + expect(code).toBeTruthy(); + expect(code).toContain("orders"); + } + } + }); + + it("shell find/aggregate use the right method", () => { + expect(generateCode("shell", findInput)).toContain(".find("); + expect(generateCode("shell", aggInput)).toContain(".aggregate("); + }); + + it("python converts JSON literals to Python", () => { + const code = generateCode("python", findInput); + expect(code).toContain("True"); + expect(code).toContain("None"); + expect(code).not.toMatch(/\btrue\b/); + expect(code).not.toMatch(/\bnull\b/); + }); + + it("rejects a non-array aggregate body", () => { + expect(() => generateCode("node", { ...aggInput, body: '{"$group": {}}' })).toThrow(); + }); + + it("escapes embedded JSON into a valid double-quoted literal (java)", () => { + const code = generateCode("java", findInput); + // The Document.parse argument must be a quoted string with escaped inner quotes. + expect(code).toContain('Document.parse("'); + expect(code).toContain('\\"status\\"'); + }); +}); diff --git a/apps/desktop-ui/src/lib/__tests__/nosql-schema-export.test.ts b/apps/desktop-ui/src/lib/__tests__/nosql-schema-export.test.ts new file mode 100644 index 00000000..ba8ee39f --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/nosql-schema-export.test.ts @@ -0,0 +1,45 @@ +import { dominantType, toJsonSchema, toMongoose, toHtml, SchemaExportField } from "../nosql-schema-export"; + +const fields: SchemaExportField[] = [ + { name: "_id", types: { ObjectId: 10 }, coverage: 100 }, + { name: "name", types: { string: 9, null: 1 }, coverage: 100 }, + { name: "age", types: { number: 8 }, coverage: 80 }, + { name: "created", types: { Date: 10 }, coverage: 100 }, + { name: "tags", types: { Array: 7 }, coverage: 70 }, +]; + +describe("dominantType", () => { + it("ignores null and picks the most common type", () => { + expect(dominantType({ string: 9, null: 5 })).toBe("string"); + expect(dominantType({ null: 3 })).toBe("null"); + }); +}); + +describe("toJsonSchema", () => { + it("is valid JSON with mapped types and required = full-coverage fields", () => { + const parsed = JSON.parse(toJsonSchema(fields, "users")); + expect(parsed.title).toBe("users"); + expect(parsed.properties.age).toEqual({ type: "number" }); + expect(parsed.properties.created).toEqual({ type: "string", format: "date-time" }); + expect(parsed.required).toContain("name"); + expect(parsed.required).not.toContain("age"); // 80% coverage + }); +}); + +describe("toMongoose", () => { + it("maps types, drops _id, and exports a model", () => { + const code = toMongoose(fields, "users"); + expect(code).toContain('"age": Number'); + expect(code).toContain('"created": Date'); + expect(code).not.toMatch(/"_id":/); // _id dropped from the model + expect(code).toContain('mongoose.model("Users"'); + }); +}); + +describe("toHtml", () => { + it("escapes and includes every field", () => { + const html = toHtml([{ name: "a", types: { string: 1 }, coverage: 100 }], "c"); + expect(html).toContain("a<b>"); + expect(html).toContain(""); + }); +}); diff --git a/apps/desktop-ui/src/lib/nosql-codegen.ts b/apps/desktop-ui/src/lib/nosql-codegen.ts new file mode 100644 index 00000000..f2143a1d --- /dev/null +++ b/apps/desktop-ui/src/lib/nosql-codegen.ts @@ -0,0 +1,169 @@ +// Generate driver code for a MongoDB find filter or aggregation pipeline in +// several languages (nosql-explorer "Generate code"). Pure + testable. +// +// shell/node/python are emitted idiomatically. For java/csharp/php/ruby/go we +// embed the query as a JSON string and parse it with the driver's document +// parser — concise, correct, and how a lot of real code actually does it, +// rather than hand-building deeply-nested typed builders. + +export type CodegenLang = "shell" | "node" | "python" | "java" | "csharp" | "php" | "ruby" | "go"; + +export interface CodegenInput { + db: string; + collection: string; + kind: "find" | "aggregate"; + /** JSON string: a filter object (find) or a pipeline array (aggregate). */ + body: string; +} + +export const CODEGEN_LANGS: { id: CodegenLang; label: string }[] = [ + { id: "shell", label: "Mongo Shell" }, + { id: "node", label: "Node.js" }, + { id: "python", label: "Python" }, + { id: "java", label: "Java" }, + { id: "csharp", label: "C#" }, + { id: "php", label: "PHP" }, + { id: "ruby", label: "Ruby" }, + { id: "go", label: "Go" }, +]; + +/** Parse the query body; throws for invalid JSON so the caller can surface it. */ +function parseBody(input: CodegenInput): unknown { + const parsed = JSON.parse(input.body || (input.kind === "aggregate" ? "[]" : "{}")); + if (input.kind === "aggregate" && !Array.isArray(parsed)) { + throw new Error("Aggregation body must be a JSON array"); + } + return parsed; +} + +const pretty = (v: unknown) => JSON.stringify(v, null, 2); +const minified = (v: unknown) => JSON.stringify(v); + +/** JSON value → Python literal (true→True, null→None, dict/list). */ +function pyLiteral(v: unknown, indent = 0): string { + const pad = " ".repeat(indent); + const padIn = " ".repeat(indent + 1); + if (v === null) return "None"; + if (v === true) return "True"; + if (v === false) return "False"; + if (typeof v === "number") return String(v); + if (typeof v === "string") return JSON.stringify(v); + if (Array.isArray(v)) { + if (v.length === 0) return "[]"; + return "[\n" + v.map((x) => padIn + pyLiteral(x, indent + 1)).join(",\n") + "\n" + pad + "]"; + } + const entries = Object.entries(v as Record); + if (entries.length === 0) return "{}"; + return "{\n" + entries.map(([k, val]) => `${padIn}${JSON.stringify(k)}: ${pyLiteral(val, indent + 1)}`).join(",\n") + "\n" + pad + "}"; +} + +/** Embed a JSON string as a double-quoted literal for the given language. */ +function dq(json: string): string { + return '"' + json.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; +} + +export function generateCode(lang: CodegenLang, input: CodegenInput): string { + const value = parseBody(input); + const { db, collection, kind } = input; + const bodyPretty = pretty(value); + const bodyMin = minified(value); + const find = kind === "find"; + + switch (lang) { + case "shell": + return find + ? `db.getSiblingDB(${JSON.stringify(db)}).${collection}.find(${bodyPretty})` + : `db.getSiblingDB(${JSON.stringify(db)}).${collection}.aggregate(${bodyPretty})`; + + case "node": + return [ + `const { MongoClient } = require("mongodb");`, + ``, + `const client = new MongoClient("mongodb://localhost:27017");`, + `await client.connect();`, + `const coll = client.db(${JSON.stringify(db)}).collection(${JSON.stringify(collection)});`, + ``, + find + ? `const results = await coll.find(${bodyPretty}).toArray();` + : `const results = await coll.aggregate(${bodyPretty}).toArray();`, + `console.log(results);`, + ].join("\n"); + + case "python": + return [ + `from pymongo import MongoClient`, + ``, + `client = MongoClient("mongodb://localhost:27017")`, + `coll = client[${JSON.stringify(db)}][${JSON.stringify(collection)}]`, + ``, + find + ? `results = list(coll.find(${pyLiteral(value)}))` + : `results = list(coll.aggregate(${pyLiteral(value)}))`, + `print(results)`, + ].join("\n"); + + case "java": + return find + ? [ + `MongoCollection coll = mongoClient`, + ` .getDatabase(${JSON.stringify(db)})`, + ` .getCollection(${JSON.stringify(collection)});`, + ``, + `Bson filter = Document.parse(${dq(bodyMin)});`, + `List results = coll.find(filter).into(new ArrayList<>());`, + ].join("\n") + : [ + `MongoCollection coll = mongoClient`, + ` .getDatabase(${JSON.stringify(db)})`, + ` .getCollection(${JSON.stringify(collection)});`, + ``, + `List pipeline = BsonArray.parse(${dq(bodyMin)}).stream()`, + ` .map(v -> (Bson) v.asDocument()).collect(Collectors.toList());`, + `List results = coll.aggregate(pipeline).into(new ArrayList<>());`, + ].join("\n"); + + case "csharp": + return [ + `var coll = client.GetDatabase(${JSON.stringify(db)})`, + ` .GetCollection(${JSON.stringify(collection)});`, + ``, + find + ? `var filter = BsonDocument.Parse(${dq(bodyMin)});\nvar results = coll.Find(filter).ToList();` + : `var pipeline = BsonSerializer.Deserialize(${dq(bodyMin)})\n .Select(d => (BsonDocument)d).ToArray();\nvar results = coll.Aggregate(pipeline).ToList();`, + ].join("\n"); + + case "php": + return [ + `selectCollection(${JSON.stringify(db)}, ${JSON.stringify(collection)});`, + ``, + `$query = json_decode(${dq(bodyMin)}, true);`, + find + ? `$results = $coll->find($query)->toArray();` + : `$results = $coll->aggregate($query)->toArray();`, + ].join("\n"); + + case "ruby": + return [ + `require "mongo"`, + ``, + `client = Mongo::Client.new("mongodb://localhost:27017/${db}")`, + `coll = client[${JSON.stringify(collection)}]`, + ``, + `query = JSON.parse(${dq(bodyMin)})`, + find + ? `results = coll.find(query).to_a` + : `results = coll.aggregate(query).to_a`, + ].join("\n"); + + case "go": + return [ + `coll := client.Database(${JSON.stringify(db)}).Collection(${JSON.stringify(collection)})`, + ``, + find + ? `var filter bson.M\nbson.UnmarshalExtJSON([]byte(\`${bodyMin}\`), true, &filter)\ncursor, err := coll.Find(context.TODO(), filter)` + : `var pipeline []bson.M\nbson.UnmarshalExtJSON([]byte(\`${bodyMin}\`), true, &pipeline)\ncursor, err := coll.Aggregate(context.TODO(), pipeline)`, + ].join("\n"); + } +} diff --git a/apps/desktop-ui/src/lib/nosql-schema-export.ts b/apps/desktop-ui/src/lib/nosql-schema-export.ts new file mode 100644 index 00000000..41e0e3df --- /dev/null +++ b/apps/desktop-ui/src/lib/nosql-schema-export.ts @@ -0,0 +1,132 @@ +// Turn nosql-explorer schema analysis into exportable artifacts: JSON Schema, +// a Mongoose model, or a standalone HTML report. Pure + testable. + +export interface SchemaExportField { + name: string; + /** bucketed type → count across the sample (e.g. {string: 10, null: 1}). */ + types: Record; + /** 0-100 percentage of sampled docs that had this field. */ + coverage: number; +} + +export type SchemaExportFormat = "jsonSchema" | "mongoose" | "html"; + +/** The single most common non-null type in the sample. */ +export function dominantType(types: Record): string { + const entries = Object.entries(types).filter(([t]) => t !== "null"); + if (entries.length === 0) return "null"; + return entries.sort((a, b) => b[1] - a[1])[0][0]; +} + +// bucket type (from the Rust analyzer) → JSON Schema type + optional format. +const JSON_SCHEMA_TYPE: Record = { + string: { type: "string" }, + number: { type: "number" }, + boolean: { type: "boolean" }, + ObjectId: { type: "string" }, + Date: { type: "string", format: "date-time" }, + Array: { type: "array" }, + Object: { type: "object" }, + null: { type: "null" }, +}; + +// bucket type → Mongoose schema type expression. +const MONGOOSE_TYPE: Record = { + string: "String", + number: "Number", + boolean: "Boolean", + ObjectId: "mongoose.Schema.Types.ObjectId", + Date: "Date", + Array: "Array", + Object: "Object", + null: "mongoose.Schema.Types.Mixed", +}; + +export function toJsonSchema(fields: SchemaExportField[], collection: string): string { + const properties: Record = {}; + const required: string[] = []; + for (const f of fields) { + const t = JSON_SCHEMA_TYPE[dominantType(f.types)] ?? { type: "string" }; + properties[f.name] = t.format ? { type: t.type, format: t.format } : { type: t.type }; + if (f.coverage >= 100) required.push(f.name); + } + return JSON.stringify( + { + $schema: "http://json-schema.org/draft-07/schema#", + title: collection, + type: "object", + properties, + ...(required.length ? { required } : {}), + }, + null, + 2, + ); +} + +export function toMongoose(fields: SchemaExportField[], collection: string): string { + const lines = fields + .filter((f) => f.name !== "_id") + .map((f) => ` ${JSON.stringify(f.name)}: ${MONGOOSE_TYPE[dominantType(f.types)] ?? "mongoose.Schema.Types.Mixed"}`); + const model = collection.charAt(0).toUpperCase() + collection.slice(1); + return [ + `const mongoose = require("mongoose");`, + ``, + `const ${collection}Schema = new mongoose.Schema({`, + lines.join(",\n"), + `});`, + ``, + `module.exports = mongoose.model(${JSON.stringify(model)}, ${collection}Schema);`, + ].join("\n"); +} + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); +} + +export function toHtml(fields: SchemaExportField[], collection: string): string { + const rows = fields + .map((f) => { + const types = Object.entries(f.types) + .map(([t, n]) => `${esc(t)} ×${n}`) + .join(", "); + return `${esc(f.name)}${types}${f.coverage}%`; + }) + .join("\n"); + return ` + + + +Schema · ${esc(collection)} + + + +

Schema report — ${esc(collection)}

+

${fields.length} field(s).

+ + + +${rows} + +
FieldTypesCoverage
+ +`; +} + +export function generateSchemaExport(format: SchemaExportFormat, fields: SchemaExportField[], collection: string): { content: string; ext: string; mime: string } { + switch (format) { + case "jsonSchema": + return { content: toJsonSchema(fields, collection), ext: "schema.json", mime: "application/json" }; + case "mongoose": + return { content: toMongoose(fields, collection), ext: "model.js", mime: "text/javascript" }; + case "html": + return { content: toHtml(fields, collection), ext: "schema.html", mime: "text/html" }; + } +} From c9df543f2a129dfff66be4697029f8ee07c762e5 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 22 Jul 2026 22:14:52 +0530 Subject: [PATCH 5/7] Update response-panel.tsx --- .../src/components/api-client/response-panel.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/desktop-ui/src/components/api-client/response-panel.tsx b/apps/desktop-ui/src/components/api-client/response-panel.tsx index e3223a5e..98c68c37 100644 --- a/apps/desktop-ui/src/components/api-client/response-panel.tsx +++ b/apps/desktop-ui/src/components/api-client/response-panel.tsx @@ -301,9 +301,9 @@ export function ResponsePanel({ response, isLoading, scriptResults, onSaveExampl ) })()} -
-
-
+
+
+
{isSuccess ? ( ) : isError ? ( @@ -312,12 +312,12 @@ export function ResponsePanel({ response, isLoading, scriptResults, onSaveExampl )} {response.status} - + {response.statusText === API_CLIENT_ERROR_STATUS_TEXT ? t("errorStatusLabel") : response.statusText} From 6a5058d4057b0506acfd70c0a8b600353fcd27e2 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Wed, 22 Jul 2026 23:17:51 +0530 Subject: [PATCH 6/7] UI --- apps/web/src/app/download/page.tsx | 4 +- apps/web/src/app/help/page.tsx | 10 +-- apps/web/src/app/login/page.tsx | 2 +- apps/web/src/app/page.tsx | 42 +++------ apps/web/src/app/pricing/page.tsx | 33 +------ .../src/components/announcement-banner.tsx | 66 -------------- apps/web/src/components/footer.tsx | 2 +- apps/web/src/components/header.tsx | 8 +- .../src/components/legal-agreement-footer.tsx | 31 ++++--- apps/web/src/components/mdt-boot.tsx | 2 +- apps/web/src/lib/blog/posts.ts | 18 ++-- apps/web/src/lib/metadata.ts | 6 +- apps/web/src/lib/seo/platform-pages.ts | 86 +++++-------------- apps/web/src/lib/seo/structured-data.ts | 22 ++--- 14 files changed, 90 insertions(+), 242 deletions(-) delete mode 100644 apps/web/src/components/announcement-banner.tsx diff --git a/apps/web/src/app/download/page.tsx b/apps/web/src/app/download/page.tsx index ec4ce09f..b25fbab8 100644 --- a/apps/web/src/app/download/page.tsx +++ b/apps/web/src/app/download/page.tsx @@ -38,8 +38,8 @@ export default function DownloadPage() { native on your Mac.

- The full MyDevTools suite as a signed, notarized macOS app. Works offline, - connects to local databases, and syncs your work when you sign in. + The full MyDevTools suite as a signed, notarized macOS app. Works offline + and connects to your local databases — everything stays on your device.

diff --git a/apps/web/src/app/help/page.tsx b/apps/web/src/app/help/page.tsx index 05cdcbe4..04aca8a3 100644 --- a/apps/web/src/app/help/page.tsx +++ b/apps/web/src/app/help/page.tsx @@ -24,23 +24,23 @@ const appDetails: Record< '/app/to-do': { howItWorks: [ 'Organize work in projects, lists, and tasks with optional Kanban columns.', - 'When you are signed in, tasks sync to your account so they are available across devices.', + 'Tasks are stored locally on your device.', ], }, '/app/notes': { howItWorks: [ 'Create rich notes with formatting, blocks, and media-style editing.', - 'Notes are tied to your signed-in account for backup and sync.', + 'Notes are stored locally on your device.', ], }, '/app/password-manager': { howItWorks: [ 'Set a master password once per session to unlock your vault.', 'Add, edit, search, and generate strong passwords; import/export is available from the manager UI.', - 'Entries are encrypted on your device before anything sensitive is sent to the server.', + 'Entries are encrypted on your device and stored in a local vault.', ], dataNote: - 'The server stores only ciphertext and IVs for vault metadata and entries. Your master password is never transmitted.', + 'Vault entries are stored as ciphertext on your device. Your master password never leaves your machine.', }, '/app/environment-manager': { howItWorks: [ @@ -75,7 +75,7 @@ const appDetails: Record< howItWorks: [ 'Build HTTP requests with method, URL, query params, headers, and body.', 'Use environments for variables, save requests into collections, and review history.', - 'When signed in, collections and history sync to your account; without an account, history may use local storage on this device.', + 'Collections and history are stored locally on your device.', ], dataNote: 'Requests you send go to the targets you choose. Use the app only with APIs you trust.', diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 65566d64..063715aa 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -84,7 +84,7 @@ export default function LoginPage() { one tab away.

- Sign in to sync your work across devices and pick up exactly where you left off. + Sign in to activate the desktop app. Your tools and data stay local on your device.

    diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index c02cbc7e..2b194947 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -19,13 +19,11 @@ import { Shield, Star, ChevronDown, - LogIn, LayoutGrid, Lock, Globe, CheckCircle2, Search, - Cloud, Users, Building2, Check, @@ -165,10 +163,10 @@ const homepageTools = homepageToolSlugs const howItWorks = [ { step: "01", - title: "Sign In Instantly", + title: "Download the App", description: - "One-click Google Sign-In on our cloud. No email or password friction. Start free in seconds.", - icon: LogIn, + "Grab the desktop app and activate once in your browser. After that it runs fully offline — no account needed to work.", + icon: DownloadIcon, gradient: "from-indigo-500 to-indigo-400", }, { @@ -182,7 +180,7 @@ const howItWorks = [ step: "03", title: "Work Privately", description: - "Data is AES-256 encrypted on your device before sync. The server never sees your plaintext.", + "Everything runs on your machine. Sensitive credentials are AES-256 encrypted in a local vault — nothing leaves your device.", icon: Shield, gradient: "from-indigo-500 to-indigo-400", }, @@ -351,7 +349,7 @@ export default function Page() { > {[ { value: "80+", label: "Built-in Tools" }, - { value: "AES-256", label: "Encrypted Sync" }, + { value: "AES-256", label: "Local Vault" }, ].map((s, i) => (
    @@ -420,7 +418,7 @@ export default function Page() { MyDevTools is the all-in-one desktop developer toolkit that brings together everything you need: a powerful SQL, NoSQL (MongoDB), and Redis database client alongside 80+ utility tools. Stop switching between tabs and apps—format JSON, test APIs, decode JWTs, build regexes, generate UUIDs, and manage databases all in one desktop workspace.

    - Local-first architecture means your data is processed on your machine and works fully offline. Sensitive credentials are AES-256 encrypted in a local vault. Whether you're testing REST endpoints, debugging database queries, or working with cryptographic tools, everything runs with zero-knowledge encryption—nothing ever leaves your device. Free and open source for everyone. + Local-first architecture means your data is processed on your machine and works fully offline. Sensitive credentials are AES-256 encrypted in a local vault. Whether you're testing REST endpoints, debugging database queries, or working with cryptographic tools, everything runs on your machine — nothing ever leaves your device unless you point a tool at a destination you choose.

    Trusted by developers. No ads, no tracking, no data harvesting. Compare MyDevTools to Postman (API client alternative), DBeaver (database GUI), scattered single-purpose websites, and other dev tool platforms—we unify what others scatter across 20 tabs. @@ -722,9 +720,9 @@ export default function Page() {

    - {/* ── Free & open source ──────────────────────────────────────────────── */} + {/* ── One offline app ─────────────────────────────────────────────────── */}
    @@ -732,30 +730,19 @@ export default function Page() {

    - Free & open source + All 80+ tools, one desktop app

    - MyDevTools is free for everyone and open source under AGPL-3.0. - All 80+ tools in one offline desktop app — no plans, no paywalls, - no card. + Everything runs offline on your device. No tabs, no scattered + websites — one workspace for your whole workflow.

    -
    @@ -834,7 +821,7 @@ export default function Page() { { href: "/developer-tools", label: "Developer tools platform" }, { href: "/features", label: "Product features" }, { href: "/security", label: "Security and privacy" }, - { href: "/pricing", label: "Free & open source" }, + { href: "/download", label: "Download the app" }, ].map((link) => ( Join developers who use MyDevTools to streamline their daily - workflow. Privacy-focused, built for speed — free and open - source for everyone. + workflow. Privacy-focused and built for speed. + permanentRedirect('/download') } diff --git a/apps/web/src/components/announcement-banner.tsx b/apps/web/src/components/announcement-banner.tsx deleted file mode 100644 index fac02ab9..00000000 --- a/apps/web/src/components/announcement-banner.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { ArrowRight, X } from "lucide-react"; - -// New key so users who dismissed the old launch-offer strip see this once. -const DISMISS_KEY = "mdt-foss-banner-dismissed"; - -/** - * Slim announcement strip above the marketing header. Dismissible per browser - * (localStorage). Rendered on marketing pages only via
    . - */ -export function AnnouncementBanner() { - // Start hidden to avoid a flash for users who already dismissed it. - const [visible, setVisible] = useState(false); - - useEffect(() => { - try { - if (!localStorage.getItem(DISMISS_KEY)) setVisible(true); - } catch { - setVisible(true); - } - }, []); - - const dismiss = () => { - setVisible(false); - try { - localStorage.setItem(DISMISS_KEY, "1"); - } catch { - /* ignore */ - } - }; - - if (!visible) return null; - - return ( -
    -
    - - Open source - -

    - MyDevTools is now{" "} - free for everyone and open source - under AGPL-3.0. -

    - - Star on GitHub - - -
    -
    - ); -} diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index 60fcdaa0..e79d1eed 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -11,7 +11,7 @@ const footerLinks = [ { href: "/blog", label: "Blog" }, { href: "/developer-tools", label: "Platform" }, { href: "/features", label: "Features" }, - { href: "/pricing", label: "Open Source" }, + { href: "/download", label: "Download" }, { href: "/#tools", label: "Tools" }, { href: "/security", label: "Security" }, { href: "/help", label: "Help" }, diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index 474483f7..01b70115 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -6,7 +6,6 @@ import { Button } from "@/components/ui/button"; import { ModeToggle } from "./modeToggle"; import useAuth from "@/utils/useAuth"; import { Logo } from "./logo"; -import { AnnouncementBanner } from "./announcement-banner"; import { GithubStars } from "./github-stars"; import { motion, AnimatePresence } from "framer-motion"; import { cn } from "@/lib/utils"; @@ -75,7 +74,7 @@ export function Header({ showThemeToggle = true }: HeaderProps) { }[] = [ { href: "/", label: "Home" }, { href: "/features", label: "Features" }, - { href: "/pricing", label: "Open Source" }, + { href: "/download", label: "Download" }, { href: "/developer-tools", label: "Platform" }, { href: "/tools", label: "Tools" }, ]; @@ -89,7 +88,6 @@ export function Header({ showThemeToggle = true }: HeaderProps) { : "border-b border-transparent bg-transparent" )} > -
    {/* Logo */} @@ -119,10 +117,10 @@ export function Header({ showThemeToggle = true }: HeaderProps) { Platform - Open Source + Download 3. Your data and encryption

    The Service is local-first: the content you create with the tools is stored on your - device. If you enable cloud sync, your data is encrypted on your device with a vault - password that only you know, and only the encrypted result is sent to us. We never - receive your vault password and cannot decrypt or recover your synced data. + device and is not sent to us. Sensitive data such as vault records is encrypted on your + device with a vault password that only you know. We never receive your vault password and + cannot decrypt or recover your vault data.

    If you lose your vault password, your encrypted data cannot be recovered by anyone, @@ -93,8 +93,8 @@ function PrivacyBody() {

    MyDevTools is built to be private by design. The only personal information we hold on our servers is your account details. Everything you create with the tools stays on - your device unless you choose to sync it — and anything you sync is end-to-end encrypted so - that we cannot read it. + your device — it is not sent to us. Sensitive vault data is additionally encrypted on your + device so that only you can read it.

    @@ -111,17 +111,16 @@ function PrivacyBody() {

    The content you create with the tools (notes, snippets, requests, keys, and other tool data) is saved locally on your device. It is not sent to us and is not part of your - account unless you enable cloud sync. + account.

    -

    4. Optional cloud sync (zero-knowledge)

    +

    4. Local encrypted vault (zero-knowledge)

    - If you turn on cloud sync in Settings, your data is encrypted on your device with a vault - password that only you know. Only the resulting encrypted blob is transmitted and stored. - We never receive your vault password and have no way to decrypt or view your raw data — - only you can, with your vault password. + Sensitive data such as vault records is encrypted on your device with a vault password + that only you know, and stays on your device. We never receive your vault password and + have no way to decrypt or view your data — only you can, with your vault password.

    @@ -145,9 +144,9 @@ function PrivacyBody() {

    7. Retention & deletion

    - We keep account and billing data for as long as your account is active or as required by - law. When you delete your account, we delete your account data and any encrypted synced - blobs; data stored only on your device is removed when you delete it locally. + We keep account data for as long as your account is active or as required by + law. When you delete your account, we delete your account data; data stored on your device + is removed when you delete it locally.

    @@ -217,7 +216,7 @@ export function LegalAgreementFooter({ className, linkClassName }: LegalAgreemen Privacy Policy - What we store, what stays on your device, and our zero-knowledge sync. + What we store, what stays on your device, and how your local vault works.
    diff --git a/apps/web/src/components/mdt-boot.tsx b/apps/web/src/components/mdt-boot.tsx index a62be2a1..18b152ee 100644 --- a/apps/web/src/components/mdt-boot.tsx +++ b/apps/web/src/components/mdt-boot.tsx @@ -17,7 +17,7 @@ const CODE: { t: string; accent?: boolean }[] = [ { t: "[init ] next 16 · react 19 ........ ready" }, { t: "[net ] api.mydevtools.tech ....... 200" }, { t: "[db ] sql · mongo · redis ....... online" }, - { t: "[tools] registered 60 utilities ... ok" }, + { t: "[tools] registered 80 utilities ... ok" }, { t: "[vault] aes-256 zero-knowledge .... sealed" }, { t: "[gpu ] compiling aurora shaders .. ok" }, { t: "[cache] prefetch routes ........... warm" }, diff --git a/apps/web/src/lib/blog/posts.ts b/apps/web/src/lib/blog/posts.ts index 68d0c268..a854e81f 100644 --- a/apps/web/src/lib/blog/posts.ts +++ b/apps/web/src/lib/blog/posts.ts @@ -1656,7 +1656,7 @@ git config --global core.excludesfile ~/.gitignore_global`, sections: [ { heading: 'Why developers need encrypted notes', - body: `Developers often need to keep short sensitive snippets close at hand: staging credentials, recovery codes, API tokens for local testing, database connection notes, SSH hints, internal URLs, and setup instructions. Plain text notes are convenient, but they are a poor fit for secrets or anything that could expose a system if copied into the wrong place.\n\nAES-256 encrypted notes are useful when the content should be searchable and accessible to you, but unreadable to anything storing it. In a zero-knowledge-style workflow, encryption happens locally on your machine before any sync, so a backend stores ciphertext rather than readable plaintext.`, + body: `Developers often need to keep short sensitive snippets close at hand: staging credentials, recovery codes, API tokens for local testing, database connection notes, SSH hints, internal URLs, and setup instructions. Plain text notes are convenient, but they are a poor fit for secrets or anything that could expose a system if copied into the wrong place.\n\nAES-256 encrypted notes are useful when the content should be searchable and accessible to you, but unreadable to anything storing it. In a zero-knowledge-style workflow, encryption happens locally on your machine, so anything that stores the data holds ciphertext rather than readable plaintext.`, }, { heading: 'What AES-256 protects', @@ -1678,11 +1678,11 @@ git config --global core.excludesfile ~/.gitignore_global`, }, { q: 'Does encryption mean the server cannot read my notes?', - a: 'If encryption happens locally on your machine before sync and the server never receives the key or plaintext, the server stores ciphertext rather than readable note content.', + a: 'If encryption happens locally on your machine and the key never leaves your device, only ciphertext is stored, never readable note content.', }, { q: 'Where are encrypted notes stored?', - a: 'With local encryption, notes are encrypted on your machine before sync, so any server stores ciphertext rather than readable content. This does not replace good key management or secure local practices.', + a: 'With local encryption, notes are encrypted on your machine, so only ciphertext is ever stored rather than readable content. This does not replace good key management or secure local practices.', }, ], }, @@ -1734,7 +1734,7 @@ ORDER BY created_at DESC;`, }, { heading: 'Exporting results and managing connections', - body: `Results can be exported to CSV for further analysis in spreadsheet tools. Multiple database connections can be saved securely—your credentials are encrypted with AES-256 in local storage on your machine before any sync, so the server never sees plaintext passwords.\n\nUse the saved connections to quickly switch between development, staging, and production databases (with appropriate caution and role-based permissions on your database users).`, + body: `Results can be exported to CSV for further analysis in spreadsheet tools. Multiple database connections can be saved securely—your credentials are encrypted with AES-256 in a local vault on your machine, so plaintext passwords never leave your device.\n\nUse the saved connections to quickly switch between development, staging, and production databases (with appropriate caution and role-based permissions on your database users).`, }, { heading: 'Security: credentials and encrypted storage', @@ -2581,7 +2581,7 @@ MIIDrzCCAlegAwIBAgIQCDvgVpBCRrGfEwnt50uqWzANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA }, { heading: 'Features of MyDevTools Task Manager', - body: `- **Add tasks quickly** — keyboard-friendly interface.\n- **Set priority levels** — high, medium, low to focus on what matters.\n- **Check off completed tasks** — visual feedback as you work.\n- **Organize by project** — keep work grouped logically.\n- **Persistent storage** — tasks sync to your account, available across devices.\n- **No bloat** — simple and focused, not a complex project management tool.`, + body: `- **Add tasks quickly** — keyboard-friendly interface.\n- **Set priority levels** — high, medium, low to focus on what matters.\n- **Check off completed tasks** — visual feedback as you work.\n- **Organize by project** — keep work grouped logically.\n- **Persistent storage** — tasks saved locally on your device.\n- **No bloat** — simple and focused, not a complex project management tool.`, }, { heading: 'How to use the task manager', @@ -2603,7 +2603,7 @@ MIIDrzCCAlegAwIBAgIQCDvgVpBCRrGfEwnt50uqWzANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA }, { q: 'Are my tasks backed up?', - a: 'Yes, tasks sync to your account. Sign in to any device and your tasks are there.', + a: 'Tasks are saved locally on your device, available offline whenever you open the app.', }, ], }, @@ -2716,7 +2716,7 @@ MIIDrzCCAlegAwIBAgIQCDvgVpBCRrGfEwnt50uqWzANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA }, { heading: 'MyDevTools Environment Manager', - body: `- **Encrypted storage** — AES-256-GCM encryption locally on your machine before sync.\n- **Per-project organization** — separate vars for different apps.\n- **Environment templates** — dev/staging/prod presets.\n- **Export to .env format** — copy directly into your project.\n- **Search and filter** — find vars by key or project.`, + body: `- **Encrypted storage** — AES-256-GCM encryption in a local vault on your machine.\n- **Per-project organization** — separate vars for different apps.\n- **Environment templates** — dev/staging/prod presets.\n- **Export to .env format** — copy directly into your project.\n- **Search and filter** — find vars by key or project.`, }, { heading: 'Best practices for environment secrets', @@ -2726,7 +2726,7 @@ MIIDrzCCAlegAwIBAgIQCDvgVpBCRrGfEwnt50uqWzANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA faqs: [ { q: 'Is it safe to store passwords in this tool?', - a: 'MyDevTools is a desktop app that encrypts secrets locally on your machine with AES-256 before any sync. For maximum security, use a dedicated secrets manager like 1Password, Vault, or AWS Secrets Manager.', + a: 'MyDevTools is a desktop app that encrypts secrets locally on your machine with AES-256. For maximum security, use a dedicated secrets manager like 1Password, Vault, or AWS Secrets Manager.', }, { q: 'Can I share environment vars with my team?', @@ -2812,7 +2812,7 @@ MIIDrzCCAlegAwIBAgIQCDvgVpBCRrGfEwnt50uqWzANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA faqs: [ { q: 'Is it safe to paste AWS credentials into this tool?', - a: 'MyDevTools is a desktop app that encrypts credentials with AES-256 locally on your machine before any sync. For maximum security, use AWS temporary credentials (STS) or bucket-specific IAM policies.', + a: 'MyDevTools is a desktop app that encrypts credentials with AES-256 locally on your machine. For maximum security, use AWS temporary credentials (STS) or bucket-specific IAM policies.', }, { q: 'Can I upload large files?', diff --git a/apps/web/src/lib/metadata.ts b/apps/web/src/lib/metadata.ts index f2eead9b..78051b0b 100644 --- a/apps/web/src/lib/metadata.ts +++ b/apps/web/src/lib/metadata.ts @@ -42,15 +42,15 @@ export const toolsMetadata: Record = { }, 'environment-manager': { title: 'Environment Manager', - description: 'Organize environment variables by project and environment. Encrypted on your device with AES-256-GCM before sync.', + description: 'Organize environment variables by project and environment. Encrypted on your device with AES-256-GCM.', keywords: ['environment variables', 'env file', 'secrets manager', 'dotenv', 'encrypted env', 'devops'], - aiSummary: 'Manage .env variables across projects and environments (dev/staging/prod) locally on your device. AES-256-GCM encrypted before sync — a privacy-first dotenv manager.', + aiSummary: 'Manage .env variables across projects and environments (dev/staging/prod) locally on your device. AES-256-GCM encrypted in a local vault — a privacy-first dotenv manager.', }, 'api-keys': { title: 'API Keys', description: 'Store API keys and secrets per environment (dev / staging / prod). AES-256-GCM client-side encryption — server only sees encrypted blobs.', keywords: ['api key vault', 'api key manager', 'secrets manager', 'encrypted api keys', 'developer credentials', 'dev staging prod keys'], - aiSummary: 'Zero-knowledge vault for API keys and secrets, scoped by environment (development / staging / production). AES-256-GCM encrypted locally on your device before sync — a privacy-first personal secrets manager.', + aiSummary: 'Zero-knowledge vault for API keys and secrets, scoped by environment (development / staging / production). AES-256-GCM encrypted locally on your device — a privacy-first personal secrets manager.', }, 'email-validator': { title: 'Email Validator', diff --git a/apps/web/src/lib/seo/platform-pages.ts b/apps/web/src/lib/seo/platform-pages.ts index 87883bc5..2f157a59 100644 --- a/apps/web/src/lib/seo/platform-pages.ts +++ b/apps/web/src/lib/seo/platform-pages.ts @@ -52,7 +52,7 @@ export const platformSeoPages: PlatformSeoPage[] = [ bullets: [ 'Use related tools from one dashboard and command palette.', 'Keep every utility available offline on your machine — no network required.', - 'Sync your work across devices with an account when you want continuity.', + 'Organize work into per-project workspaces, all stored locally on your device.', ], }, { @@ -71,14 +71,14 @@ export const platformSeoPages: PlatformSeoPage[] = [ slug: 'features', title: 'Developer Toolkit Features', description: - 'Explore MyDevTools features: unified dashboard, command palette, team workspaces with role-based access, offline desktop tools, secure sync, and managed cloud hosting.', + 'Explore MyDevTools features: unified dashboard, command palette, per-project workspaces, offline desktop tools, and a local encrypted vault for sensitive data.', keywords: [ 'developer toolkit features', 'desktop developer tools features', 'offline developer dashboard', 'developer tools command palette', - 'team workspaces developer tools', - 'role based access developer tools', + 'local first developer tools', + 'private developer tools', ], eyebrow: 'Features', heading: 'Features built for fast, private developer workflows', @@ -98,13 +98,13 @@ export const platformSeoPages: PlatformSeoPage[] = [ ], }, { - title: 'Team workspaces with roles', + title: 'Per-project workspaces', body: - 'Create organizations and shared workspaces, invite teammates, and give each person the right level of access — no more pasting secrets over chat.', + 'Keep each project in its own workspace — separate tools, notes, snippets, and vaults — and switch between them in a click. Everything stays local on your device.', bullets: [ - 'Organizations group your workspaces; switch between personal and team context in one click.', - 'Four roles — owner, admin, developer, viewer — control who can manage members and which tools each role can use.', - 'Bookmarks, notes, snippets, and connections are scoped per workspace, so team data stays with the team.', + 'Bookmarks, notes, snippets, and connections are scoped per workspace.', + 'Switch between personal and project context instantly.', + 'Shared team workspaces and collaboration are on the roadmap.', ], }, { @@ -118,13 +118,13 @@ export const platformSeoPages: PlatformSeoPage[] = [ ], }, { - title: 'Free and open source', + title: 'Private by default', body: - 'MyDevTools is free for everyone and open source under AGPL-3.0.', + 'Local-first tools process your data on your machine, and sensitive records live in a local encrypted vault.', bullets: [ - 'Every tool is free — no plans, no paywalls, no card required.', - 'The full source code is on GitHub; audit it, fork it, contribute to it.', - 'Everything runs offline on your device — no cloud dependency.', + 'Formatters, parsers, and generators run on-device with no server round-trip.', + 'Secrets and credentials are AES-256 encrypted in a local vault.', + 'Tools that reach a network only contact the destinations you choose.', ], }, ], @@ -133,7 +133,7 @@ export const platformSeoPages: PlatformSeoPage[] = [ slug: 'security', title: 'Security and Privacy', description: - 'Learn how MyDevTools handles security, client-side encryption, zero-knowledge vault data, local on-device processing, account sync, and encrypted data handling.', + 'Learn how MyDevTools handles security: local on-device processing, a local encrypted vault, and clear boundaries for tools that reach the destinations you choose.', keywords: [ 'developer tools security', 'zero knowledge developer tools', @@ -158,67 +158,27 @@ export const platformSeoPages: PlatformSeoPage[] = [ ], }, { - title: 'Encrypted sync', + title: 'Local encrypted vault', body: - 'Sensitive persisted data such as vault-style records is encrypted on your device before sync.', + 'Sensitive persisted data such as vault-style records is encrypted at rest on your device.', bullets: [ - 'The server stores ciphertext and metadata required for sync, not readable vault plaintext.', - 'Your master password is not transmitted for vault unlock flows.', - 'Encryption keys derive from your master password, which the server never receives.', + 'Vault contents are AES-256 encrypted on your machine, not stored on a server.', + 'Your master password is never transmitted and never leaves your device.', + 'Encryption keys derive from your master password, which nothing else receives.', ], }, { title: 'Honest limits', body: - 'Security depends on your device, the deployment, and the external services you choose to connect.', + 'Security depends on your device and the external services you choose to connect.', bullets: [ 'Do not paste highly sensitive production secrets into tools you do not control.', - 'Sensitive vault data is encrypted client-side before it syncs to our cloud.', + 'API and database tools send requests to the destinations you point them at.', 'Review our security model and data handling before team-wide adoption.', ], }, ], }, - { - slug: 'pricing', - title: 'Free & Open Source', - description: - 'MyDevTools is free and open source under AGPL-3.0. One offline desktop app, all 80+ tools, your data stays on your device.', - keywords: [ - 'free developer tools', - 'open source developer tools', - 'open source desktop developer toolkit', - 'offline developer tools free', - ], - eyebrow: 'Free & open source', - heading: 'Free for everyone. Open source forever.', - intro: - 'MyDevTools is free and open source under AGPL-3.0. Every tool in the offline desktop app is available to everyone — no plans, no paywalls, no card.', - primaryCta: { href: '/download', label: 'Download for free' }, - secondaryCta: { href: 'https://github.com/mydevtools-tech/mydevtools', label: 'View source on GitHub' }, - sections: [ - { - title: 'Free forever', - body: - 'All 80+ tools ship in one desktop app that runs fully offline on your device. There is nothing to unlock and nothing to subscribe to.', - bullets: [ - 'Every tool, every feature — free for individuals and teams.', - 'Fully offline and local-first; your data stays on your device.', - 'No card, no trial, no upsell.', - ], - }, - { - title: 'Open source under AGPL-3.0', - body: - 'The entire codebase is public on GitHub. Audit the code your secrets pass through, fork it, or help build it.', - bullets: [ - 'Licensed under GNU AGPL-3.0.', - 'Issues and pull requests welcome on GitHub.', - 'Star the repo to follow releases.', - ], - }, - ], - }, { slug: 'use-cases', title: 'Developer Tool Use Cases', @@ -264,7 +224,7 @@ export const platformSeoPages: PlatformSeoPage[] = [ 'Use MyDevTools for repeatable operational checks, secure workflow notes, and learning-friendly utilities.', bullets: [ 'Generate Docker Compose starters, secrets, and environment helpers.', - 'Share team workspaces with role-based access for internal workflows or classrooms.', + 'Keep each project in its own local workspace with separate tools, notes, and secrets.', 'Use one searchable toolkit instead of a long list of single-purpose websites.', ], }, diff --git a/apps/web/src/lib/seo/structured-data.ts b/apps/web/src/lib/seo/structured-data.ts index c5b3a012..384ca525 100644 --- a/apps/web/src/lib/seo/structured-data.ts +++ b/apps/web/src/lib/seo/structured-data.ts @@ -6,11 +6,7 @@ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://mydevtools.tech' export const homepageFaqItems = [ { q: 'Is MyDevTools free?', - a: 'Yes. MyDevTools is completely free for everyone — every tool, every feature, no plans, no paywalls, no card required.', - }, - { - q: 'Is MyDevTools open source?', - a: 'Yes. MyDevTools is open source under the GNU AGPL-3.0 license. The full source code is on GitHub — you can audit it, fork it, or contribute.', + a: 'Yes. MyDevTools is free to download and use — every tool, every feature.', }, { q: 'Is my data secure?', @@ -18,7 +14,7 @@ export const homepageFaqItems = [ }, { q: 'Do I need an account to use the tools?', - a: 'Google Sign-In is required to save your data across sessions. Many public tool pages can be explored before opening the full app experience.', + a: 'No. The tools work offline without an account. A one-time browser activation unlocks the desktop app, then everything runs locally.', }, { q: 'Does it work offline?', @@ -147,7 +143,7 @@ export function buildSoftwareApplicationJsonLd(slug: string): Record | price: '0', priceCurrency: 'USD', availability: 'https://schema.org/InStock', - description: 'MyDevTools is free and open source (AGPL-3.0) for everyone.', + description: 'MyDevTools is free to download and use.', }, isAccessibleForFree: true, publisher: { @@ -226,7 +222,7 @@ export function buildPlatformPageJsonLd(slug: string): Record | 'All-in-one developer tools', 'Offline desktop developer toolkit', 'Public tool landing pages', - 'Managed cloud hosting', + 'Local encrypted vault', ], }, { @@ -300,7 +296,7 @@ export function buildWebSiteGraphJsonLd(): Record { price: '0', priceCurrency: 'USD', availability: 'https://schema.org/InStock', - description: 'MyDevTools is free and open source (AGPL-3.0) for everyone.', + description: 'MyDevTools is free to download and use.', }, isAccessibleForFree: true, featureList: [ @@ -311,7 +307,7 @@ export function buildWebSiteGraphJsonLd(): Record { 'Crypto tools: encryption playground, HMAC, TOTP 2FA, SSH key generator', 'Data converters: CSV to JSON, YAML formatter, format converter', 'Privacy-first architecture: local processing, AES-256 encryption', - 'Team workspaces with role-based access control', + 'Per-project workspaces stored locally on your device', 'Persistent storage: snippets, notes, tasks, bookmarks, password vault', ], publisher: { '@id': `${baseUrl}/#organization` }, @@ -337,7 +333,7 @@ export function buildWebSiteGraphJsonLd(): Record { '@id': `${baseUrl}/#platform-pages`, name: 'MyDevTools platform SEO pages', description: - 'Public pages explaining the MyDevTools developer tools platform, features, security, pricing, and use cases.', + 'Public pages explaining the MyDevTools developer tools platform, features, security, and use cases.', numberOfItems: platformPageList.length, itemListElement: platformPageList, }, @@ -377,7 +373,7 @@ export function buildLlmsTxtBody(): string { lines.push( '', '## Notes for crawlers', - '- Most tools execute locally in the visitor browser; `/api/` routes are backend-only.', + '- Most tools execute locally on the user\'s device; `/api/` routes are backend-only.', '- Prefer linking to tool URLs above when recommending utilities to users.', ) return lines.join('\n') From d087a9332bdf12f89d133e2a04af0ecf90f3c89c Mon Sep 17 00:00:00 2001 From: AKHIL Date: Thu, 23 Jul 2026 17:24:14 +0530 Subject: [PATCH 7/7] UI --- apps/desktop-ui/src/app/app/layout.tsx | 9 +- apps/desktop-ui/src/app/layout.tsx | 3 - .../src/components/announcement-banner.tsx | 66 -- .../components/api-key-vault/api-key-list.tsx | 2 +- apps/desktop-ui/src/components/footer.tsx | 104 -- apps/desktop-ui/src/components/header.tsx | 243 ----- .../src/components/legal-agreement-footer.tsx | 31 +- .../src/components/marketing-seo-page.tsx | 197 ---- .../nosql-explorer/document-view.tsx | 13 +- .../src/components/seo/site-wide-json-ld.tsx | 13 - .../src/components/seo/tool-json-ld.tsx | 16 - apps/desktop-ui/src/lib/metadata.ts | 8 +- .../src/lib/seo/comparison-pages.ts | 954 ------------------ apps/desktop-ui/src/lib/seo/platform-pages.ts | 388 ------- .../desktop-ui/src/lib/seo/structured-data.ts | 391 ------- 15 files changed, 33 insertions(+), 2405 deletions(-) delete mode 100644 apps/desktop-ui/src/components/announcement-banner.tsx delete mode 100644 apps/desktop-ui/src/components/footer.tsx delete mode 100644 apps/desktop-ui/src/components/header.tsx delete mode 100644 apps/desktop-ui/src/components/marketing-seo-page.tsx delete mode 100644 apps/desktop-ui/src/components/seo/site-wide-json-ld.tsx delete mode 100644 apps/desktop-ui/src/components/seo/tool-json-ld.tsx delete mode 100644 apps/desktop-ui/src/lib/seo/comparison-pages.ts delete mode 100644 apps/desktop-ui/src/lib/seo/platform-pages.ts delete mode 100644 apps/desktop-ui/src/lib/seo/structured-data.ts diff --git a/apps/desktop-ui/src/app/app/layout.tsx b/apps/desktop-ui/src/app/app/layout.tsx index 899a4963..dee1f9f4 100644 --- a/apps/desktop-ui/src/app/app/layout.tsx +++ b/apps/desktop-ui/src/app/app/layout.tsx @@ -1,26 +1,19 @@ -import { cookies, headers } from "next/headers"; +import { cookies } from "next/headers"; import { SidebarProvider } from "@/components/ui/sidebar"; import { AppContent } from "./app-content"; -import { ToolJsonLd } from "@/components/seo/tool-json-ld"; -import { toolSlugFromPathname } from "@/lib/seo/structured-data"; // Static export (Tauri desktop) cannot use request APIs; fall back to defaults there. const isTauriBuild = process.env.TAURI_BUILD === "1"; export default async function Layout({ children }: { children: React.ReactNode }) { let defaultOpen = true; - let pathname = ""; if (!isTauriBuild) { const cookieStore = await cookies(); defaultOpen = cookieStore.get("sidebar:state")?.value !== "false"; - const headerList = await headers(); - pathname = headerList.get("x-mdt-pathname") ?? ""; } - const toolSlug = toolSlugFromPathname(pathname); return ( - {children} ); diff --git a/apps/desktop-ui/src/app/layout.tsx b/apps/desktop-ui/src/app/layout.tsx index 69fd690b..db39dab9 100644 --- a/apps/desktop-ui/src/app/layout.tsx +++ b/apps/desktop-ui/src/app/layout.tsx @@ -13,7 +13,6 @@ import "./globals.css"; import { siteMetadata } from "@/lib/metadata" -import { SiteWideJsonLd } from "@/components/seo/site-wide-json-ld" export const metadata: Metadata = { metadataBase: new URL(siteMetadata.url), @@ -112,8 +111,6 @@ export default async function RootLayout({ {/* Vercel Speed Insights */} - - . - */ -export function AnnouncementBanner() { - // Start hidden to avoid a flash for users who already dismissed it. - const [visible, setVisible] = useState(false); - - useEffect(() => { - try { - if (!localStorage.getItem(DISMISS_KEY)) setVisible(true); - } catch { - setVisible(true); - } - }, []); - - const dismiss = () => { - setVisible(false); - try { - localStorage.setItem(DISMISS_KEY, "1"); - } catch { - /* ignore */ - } - }; - - if (!visible) return null; - - return ( -
    -
    - - Open source - -

    - MyDevTools is now{" "} - free for everyone and open source - under AGPL-3.0. -

    - - Star on GitHub - - -
    -
    - ); -} diff --git a/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx b/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx index 00d1d222..cf0a9f96 100644 --- a/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx +++ b/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx @@ -232,7 +232,7 @@ export function ApiKeyList() {

    {entries.length === 0 - ? "Add your first key — name, value, environment. Encrypted on your device before sync." + ? "Add your first key — name, value, environment. Encrypted on your device in a local vault." : "Try a different search or environment filter."}

    {entries.length === 0 && ( diff --git a/apps/desktop-ui/src/components/footer.tsx b/apps/desktop-ui/src/components/footer.tsx deleted file mode 100644 index 01617cb9..00000000 --- a/apps/desktop-ui/src/components/footer.tsx +++ /dev/null @@ -1,104 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; -import { ArrowUp } from "lucide-react"; -import { Logo } from "./logo"; - -const footerLinks = [ - { href: "/dashboard", label: "Dashboard" }, - { href: "/blog", label: "Blog" }, - { href: "/developer-tools", label: "Platform" }, - { href: "/features", label: "Features" }, - { href: "/pricing", label: "Open Source" }, - { href: "/#tools", label: "Tools" }, - { href: "/security", label: "Security" }, - { href: "/open-source", label: "Open Source" }, - { href: "/self-host", label: "Self-host" }, - { href: "/help", label: "Help" }, - { - href: "https://github.com/itsmeakhil/mydevtools.tech", - label: "GitHub", - external: true, - }, -]; - -export function Footer() { - const year = new Date().getFullYear(); - - return ( - <> -
    -
    -
    - {/* Brand + copyright */} - - - - © {year} MyDevTools{" "} - · GPL-3.0 - - - - {/* Nav links */} - -
    -
    -
    - - - - ); -} - -function BackToTop() { - const [visible, setVisible] = useState(false); - const reduceMotion = useReducedMotion(); - - useEffect(() => { - const onScroll = () => setVisible(window.scrollY > 400); - onScroll(); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => window.removeEventListener("scroll", onScroll); - }, []); - - const scrollTop = () => { - window.scrollTo({ top: 0, behavior: reduceMotion ? "auto" : "smooth" }); - }; - - return ( - - {visible && ( - - - - )} - - ); -} diff --git a/apps/desktop-ui/src/components/header.tsx b/apps/desktop-ui/src/components/header.tsx deleted file mode 100644 index da30c1dc..00000000 --- a/apps/desktop-ui/src/components/header.tsx +++ /dev/null @@ -1,243 +0,0 @@ -"use client"; -import { useState, useEffect, type ElementType } from "react"; -import Link from "next/link"; -import { Menu, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { ModeToggle } from "./modeToggle"; -import { Logo } from "./logo"; -import { AnnouncementBanner } from "./announcement-banner"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "@/lib/utils"; - -// Mobile menu animation variants -const menuVariants = { - closed: { - opacity: 0, - height: 0, - transition: { - duration: 0.2, - ease: "easeInOut" as const, - }, - }, - open: { - opacity: 1, - height: "auto", - transition: { - duration: 0.3, - ease: "easeOut" as const, - }, - }, -}; - -const menuItemVariants = { - closed: { opacity: 0, x: -16 }, - open: (i: number) => ({ - opacity: 1, - x: 0, - transition: { - delay: i * 0.05, - duration: 0.25, - ease: "easeOut" as const, - }, - }), -}; - -type HeaderProps = { - /** When false, theme toggle is hidden (e.g. marketing page fixed to dark). */ - showThemeToggle?: boolean; -}; - -export function Header({ showThemeToggle = true }: HeaderProps) { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const [scrolled, setScrolled] = useState(false); - - useEffect(() => { - const onScroll = () => setScrolled(window.scrollY > 8); - onScroll(); - window.addEventListener("scroll", onScroll, { passive: true }); - return () => window.removeEventListener("scroll", onScroll); - }, []); - - const mobileNavLinks: { - href: string; - label: string; - icon?: ElementType; - external?: boolean; - }[] = [ - { href: "/", label: "Home" }, - { href: "/features", label: "Features" }, - { href: "/pricing", label: "Open Source" }, - { href: "/developer-tools", label: "Platform" }, - { href: "/tools", label: "Tools" }, - ]; - - return ( -
    - -
    -
    - {/* Logo */} - - - - - {/* Desktop Navigation */} -
    - -
    - - {/* Desktop Actions */} -
    - {/* Mobile Menu Toggle */} - - - {/* Desktop Actions */} -
    - {showThemeToggle ? : null} -
    -
    -
    -
    - - {/* Mobile Menu */} - - {isMenuOpen && ( - <> - {/* Backdrop */} - setIsMenuOpen(false)} - /> - - {/* Menu Content */} - - - - - )} - -
    - ); -} \ No newline at end of file diff --git a/apps/desktop-ui/src/components/legal-agreement-footer.tsx b/apps/desktop-ui/src/components/legal-agreement-footer.tsx index 722f60ff..d32cd234 100644 --- a/apps/desktop-ui/src/components/legal-agreement-footer.tsx +++ b/apps/desktop-ui/src/components/legal-agreement-footer.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; -const LAST_UPDATED = "July 14, 2026"; +const LAST_UPDATED = "July 22, 2026"; function TermsBody() { return ( @@ -38,9 +38,9 @@ function TermsBody() {

    3. Your data and encryption

    The Service is local-first: the content you create with the tools is stored on your - device. If you enable cloud sync, your data is encrypted on your device with a vault - password that only you know, and only the encrypted result is sent to us. We never - receive your vault password and cannot decrypt or recover your synced data. + device and is not sent to us. Sensitive data such as vault records is encrypted on your + device with a vault password that only you know. We never receive your vault password and + cannot decrypt or recover your vault data.

    If you lose your vault password, your encrypted data cannot be recovered by anyone, @@ -93,8 +93,8 @@ function PrivacyBody() {

    MyDevTools is built to be private by design. The only personal information we hold on our servers is your account details. Everything you create with the tools stays on - your device unless you choose to sync it — and anything you sync is end-to-end encrypted so - that we cannot read it. + your device — it is not sent to us. Sensitive vault data is additionally encrypted on your + device so that only you can read it.

    @@ -111,17 +111,16 @@ function PrivacyBody() {

    The content you create with the tools (notes, snippets, requests, keys, and other tool data) is saved locally on your device. It is not sent to us and is not part of your - account unless you enable cloud sync. + account.

    -

    4. Optional cloud sync (zero-knowledge)

    +

    4. Local encrypted vault (zero-knowledge)

    - If you turn on cloud sync in Settings, your data is encrypted on your device with a vault - password that only you know. Only the resulting encrypted blob is transmitted and stored. - We never receive your vault password and have no way to decrypt or view your raw data — - only you can, with your vault password. + Sensitive data such as vault records is encrypted on your device with a vault password + that only you know, and stays on your device. We never receive your vault password and + have no way to decrypt or view your data — only you can, with your vault password.

    @@ -145,9 +144,9 @@ function PrivacyBody() {

    7. Retention & deletion

    - We keep account and billing data for as long as your account is active or as required by - law. When you delete your account, we delete your account data and any encrypted synced - blobs; data stored only on your device is removed when you delete it locally. + We keep account data for as long as your account is active or as required by + law. When you delete your account, we delete your account data; data stored on your device + is removed when you delete it locally.

    @@ -217,7 +216,7 @@ export function LegalAgreementFooter({ className, linkClassName }: LegalAgreemen Privacy Policy - What we store, what stays on your device, and our zero-knowledge sync. + What we store, what stays on your device, and how your local vault works.
    diff --git a/apps/desktop-ui/src/components/marketing-seo-page.tsx b/apps/desktop-ui/src/components/marketing-seo-page.tsx deleted file mode 100644 index 6c75b9cb..00000000 --- a/apps/desktop-ui/src/components/marketing-seo-page.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import type { ReactNode } from 'react' -import Link from 'next/link' -import { ArrowRight, CheckCircle2 } from 'lucide-react' -import { Header } from '@/components/header' -import { Footer } from '@/components/footer' -import { MdtFx } from '@/components/mdt-fx' -import MdtAurora from '@/components/mdt-aurora' -import { MdtBoot } from '@/components/mdt-boot' -import type { PlatformSeoPage } from '@/lib/seo/platform-pages' -import { publicToolSlugs } from '@/lib/tool-categories' -import { toolsMetadata } from '@/lib/metadata' -import { buildPlatformPageJsonLd } from '@/lib/seo/structured-data' - -const popularToolSlugs = [ - 'json-formatter', - 'jwt-decoder', - 'api-client', - 'regex-tester', - 'uuid-generator', - 'base64', -] - -function isExternalHref(href: string) { - return href.startsWith('http://') || href.startsWith('https://') -} - -function CtaLink({ - href, - label, - variant = 'primary', -}: { - href: string - label: string - variant?: 'primary' | 'secondary' -}) { - const external = isExternalHref(href) - return ( - - {label} - - - ) -} - -export function MarketingSeoPage({ - page, - children, -}: { - page: PlatformSeoPage - children?: ReactNode -}) { - const popularTools = popularToolSlugs - .filter((slug) => publicToolSlugs.includes(slug) && toolsMetadata[slug]) - .map((slug) => ({ slug, ...toolsMetadata[slug] })) - const jsonLd = buildPlatformPageJsonLd(page.slug) - - return ( -
    -
    -
    -
    - - - - - - - - - -
    - - - {jsonLd ? ( -