diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index aa61f875..d93c05e2 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Koppelvlakvoorkeure", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Spasie)", - "keyLabel": "event.key", + "keyLabel": "Sleutel", "repeat": "herhaal" }, "copyJson": "Kopieer JSON", "copiedJson": "Gebeurtenis-JSON na knipbord gekopieer", "deprecated": "verouderd", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kode", + "keyCode": "Sleutelkode", + "which": "Which", + "location": "Ligging" }, "location": { "standard": "Standaard", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 4e1ac6d2..427d3361 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "تفضيلات الواجهة", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(مسافة)", - "keyLabel": "event.key", + "keyLabel": "المفتاح", "repeat": "تكرار" }, "copyJson": "نسخ JSON", "copiedJson": "تم نسخ JSON الحدث إلى الحافظة", "deprecated": "مهمَل", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "الرمز", + "keyCode": "رمز المفتاح", + "which": "Which", + "location": "الموقع" }, "location": { "standard": "قياسي", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 0bbbc2fb..f59814c7 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferències d’interfície", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Espai)", - "keyLabel": "event.key", + "keyLabel": "Tecla", "repeat": "repetició" }, "copyJson": "Copia el JSON", "copiedJson": "JSON de l'esdeveniment copiat al porta-retalls", "deprecated": "obsolet", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Codi", + "keyCode": "Codi de tecla", + "which": "Which", + "location": "Ubicació" }, "location": { "standard": "Estàndard", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index ae84c5af..9c9c9bd2 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Předvolby rozhraní", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Mezerník)", - "keyLabel": "event.key", + "keyLabel": "Klávesa", "repeat": "opakování" }, "copyJson": "Kopírovat JSON", "copiedJson": "JSON události zkopírován do schránky", "deprecated": "zastaralé", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kód", + "keyCode": "Kód klávesy", + "which": "Which", + "location": "Umístění" }, "location": { "standard": "Standardní", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index c348e15d..7c826648 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Brugerfladeindstillinger", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Mellemrum)", - "keyLabel": "event.key", + "keyLabel": "Tast", "repeat": "gentagelse" }, "copyJson": "Kopiér JSON", "copiedJson": "Hændelses-JSON kopieret til udklipsholder", "deprecated": "forældet", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kode", + "keyCode": "Tastkode", + "which": "Which", + "location": "Placering" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index b3af3829..a5b46a22 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Oberflächeneinstellungen", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Leertaste)", - "keyLabel": "event.key", + "keyLabel": "Taste", "repeat": "Wiederholung" }, "copyJson": "JSON kopieren", "copiedJson": "Event-JSON in die Zwischenablage kopiert", "deprecated": "veraltet", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Code", + "keyCode": "Tastencode", + "which": "Which", + "location": "Position" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index d441276c..82b1be61 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Προτιμήσεις διεπαφής", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Διάστημα)", - "keyLabel": "event.key", + "keyLabel": "Πλήκτρο", "repeat": "επανάληψη" }, "copyJson": "Αντιγραφή JSON", "copiedJson": "Το JSON του συμβάντος αντιγράφηκε στο πρόχειρο", "deprecated": "καταργημένο", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Κωδικός", + "keyCode": "Κωδικός πλήκτρου", + "which": "Which", + "location": "Θέση" }, "location": { "standard": "Τυπική", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index a430419d..77f507fb 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -393,6 +393,13 @@ "apiClientHistory": "API history entries", "jsonFormatterDocuments": "JSON formatter documents", "codeSnippets": "Code snippets", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files", "activityTitle": "Activity", "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", @@ -5063,17 +5070,17 @@ }, "hero": { "spaceKey": "(Space)", - "keyLabel": "event.key", + "keyLabel": "Key", "repeat": "repeat" }, "copyJson": "Copy JSON", "copiedJson": "Event JSON copied to clipboard", "deprecated": "deprecated", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Code", + "keyCode": "Key code", + "which": "Which", + "location": "Location" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index b5bd3aa9..b7b69a83 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferencias de interfaz", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Espacio)", - "keyLabel": "event.key", + "keyLabel": "Tecla", "repeat": "repetición" }, "copyJson": "Copiar JSON", "copiedJson": "JSON del evento copiado al portapapeles", "deprecated": "obsoleto", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Código", + "keyCode": "Código de tecla", + "which": "Which", + "location": "Ubicación" }, "location": { "standard": "Estándar", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index 10b2a386..95c50deb 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "ترجیحات رابط", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(فاصله)", - "keyLabel": "event.key", + "keyLabel": "کلید", "repeat": "تکرار" }, "copyJson": "کپی JSON", "copiedJson": "JSON رویداد در کلیپ‌بورد کپی شد", "deprecated": "منسوخ", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "کد", + "keyCode": "کد کلید", + "which": "Which", + "location": "موقعیت" }, "location": { "standard": "استاندارد", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index e4420437..1dfb46da 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Préférences d’interface", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Espace)", - "keyLabel": "event.key", + "keyLabel": "Touche", "repeat": "répétition" }, "copyJson": "Copier le JSON", "copiedJson": "JSON de l'événement copié dans le presse-papiers", "deprecated": "obsolète", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Code", + "keyCode": "Code de touche", + "which": "Which", + "location": "Emplacement" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index ba78f61d..51d77f78 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferensi antarmuka", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Spasi)", - "keyLabel": "event.key", + "keyLabel": "Tombol", "repeat": "ulang" }, "copyJson": "Salin JSON", "copiedJson": "JSON event disalin ke clipboard", "deprecated": "usang", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kode", + "keyCode": "Kode tombol", + "which": "Which", + "location": "Lokasi" }, "location": { "standard": "Standar", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 5cf8185b..e90f57aa 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferenze interfaccia", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Spazio)", - "keyLabel": "event.key", + "keyLabel": "Tasto", "repeat": "ripetizione" }, "copyJson": "Copia JSON", "copiedJson": "JSON dell'evento copiato negli appunti", "deprecated": "deprecato", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Codice", + "keyCode": "Codice tasto", + "which": "Which", + "location": "Posizione" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 2fe81b64..fec04e44 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "インターフェース設定", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(スペース)", - "keyLabel": "event.key", + "keyLabel": "キー", "repeat": "リピート" }, "copyJson": "JSON をコピー", "copiedJson": "イベント JSON をクリップボードにコピーしました", "deprecated": "非推奨", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "コード", + "keyCode": "キーコード", + "which": "Which", + "location": "位置" }, "location": { "standard": "標準", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 69e705ba..f47729a7 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "인터페이스 설정", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(스페이스)", - "keyLabel": "event.key", + "keyLabel": "키", "repeat": "반복" }, "copyJson": "JSON 복사", "copiedJson": "이벤트 JSON을 클립보드에 복사했습니다", "deprecated": "더 이상 사용되지 않음", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "코드", + "keyCode": "키 코드", + "which": "Which", + "location": "위치" }, "location": { "standard": "표준", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index 86646f8c..157d9d93 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -700,7 +700,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Pilihan antara muka", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Space)", - "keyLabel": "event.key", + "keyLabel": "Kekunci", "repeat": "ulang" }, "copyJson": "Salin JSON", "copiedJson": "JSON peristiwa disalin ke papan keratan", "deprecated": "usang", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kod", + "keyCode": "Kod kekunci", + "which": "Which", + "location": "Lokasi" }, "location": { "standard": "Piawai", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index a958af60..ce4809a7 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Grensesnittpreferanser", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Mellomrom)", - "keyLabel": "event.key", + "keyLabel": "Tast", "repeat": "gjentakelse" }, "copyJson": "Kopier JSON", "copiedJson": "Hendelses-JSON kopiert til utklippstavlen", "deprecated": "utdatert", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kode", + "keyCode": "Tastkode", + "which": "Which", + "location": "Plassering" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index b93207a8..c7c8f031 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Interfacevoorkeuren", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Spatie)", - "keyLabel": "event.key", + "keyLabel": "Toets", "repeat": "herhaling" }, "copyJson": "JSON kopiëren", "copiedJson": "Event-JSON naar het klembord gekopieerd", "deprecated": "verouderd", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Code", + "keyCode": "Toetscode", + "which": "Which", + "location": "Locatie" }, "location": { "standard": "Standaard", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index bdec4d87..96e9d694 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferencje interfejsu", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Spacja)", - "keyLabel": "event.key", + "keyLabel": "Klawisz", "repeat": "powtórzenie" }, "copyJson": "Kopiuj JSON", "copiedJson": "JSON zdarzenia skopiowano do schowka", "deprecated": "przestarzałe", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kod", + "keyCode": "Kod klawisza", + "which": "Which", + "location": "Lokalizacja" }, "location": { "standard": "Standardowa", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index d0afff29..3c46f30e 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferências de interface", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Espaço)", - "keyLabel": "event.key", + "keyLabel": "Tecla", "repeat": "repetição" }, "copyJson": "Copiar JSON", "copiedJson": "JSON do evento copiado para a área de transferência", "deprecated": "obsoleto", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Código", + "keyCode": "Código da tecla", + "which": "Which", + "location": "Localização" }, "location": { "standard": "Padrão", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 58a83485..f1b818a6 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Preferências de interface", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Espaço)", - "keyLabel": "event.key", + "keyLabel": "Tecla", "repeat": "repetição" }, "copyJson": "Copiar JSON", "copiedJson": "JSON do evento copiado para a área de transferência", "deprecated": "obsoleto", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Código", + "keyCode": "Código da tecla", + "which": "Which", + "location": "Localização" }, "location": { "standard": "Padrão", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index d7459120..ead5610a 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Настройки интерфейса", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Пробел)", - "keyLabel": "event.key", + "keyLabel": "Клавиша", "repeat": "повтор" }, "copyJson": "Копировать JSON", "copiedJson": "JSON события скопирован в буфер обмена", "deprecated": "устарело", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Код", + "keyCode": "Код клавиши", + "which": "Which", + "location": "Расположение" }, "location": { "standard": "Стандартная", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 1753ab49..e2d948ec 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Gränssnittsinställningar", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Mellanslag)", - "keyLabel": "event.key", + "keyLabel": "Tangent", "repeat": "upprepning" }, "copyJson": "Kopiera JSON", "copiedJson": "Händelse-JSON kopierad till urklipp", "deprecated": "föråldrad", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kod", + "keyCode": "Tangentkod", + "which": "Which", + "location": "Plats" }, "location": { "standard": "Standard", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index a4c0333b..2c6b8c55 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Arayüz tercihleri", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Boşluk)", - "keyLabel": "event.key", + "keyLabel": "Tuş", "repeat": "yineleme" }, "copyJson": "JSON kopyala", "copiedJson": "Olay JSON'ı panoya kopyalandı", "deprecated": "kullanımdan kaldırıldı", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Kod", + "keyCode": "Tuş kodu", + "which": "Which", + "location": "Konum" }, "location": { "standard": "Standart", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 2820a96c..92405431 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Налаштування інтерфейсу", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Пробіл)", - "keyLabel": "event.key", + "keyLabel": "Клавіша", "repeat": "повтор" }, "copyJson": "Копіювати JSON", "copiedJson": "JSON події скопійовано до буфера обміну", "deprecated": "застаріле", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Код", + "keyCode": "Код клавіші", + "which": "Which", + "location": "Розташування" }, "location": { "standard": "Стандартне", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 344f364c..41db7da6 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "Tùy chọn giao diện", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(Dấu cách)", - "keyLabel": "event.key", + "keyLabel": "Phím", "repeat": "lặp lại" }, "copyJson": "Sao chép JSON", "copiedJson": "Đã sao chép JSON sự kiện vào bộ nhớ tạm", "deprecated": "không dùng nữa", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "Mã", + "keyCode": "Mã phím", + "which": "Which", + "location": "Vị trí" }, "location": { "standard": "Chuẩn", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 461e2643..6c887329 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -697,7 +697,14 @@ "activityEmpty": "Activity builds as you use tools", "distributionTitle": "Data distribution", "topToolsTitle": "Top tools", - "taskDonutTitle": "Task completion" + "taskDonutTitle": "Task completion", + "groupFiles": "Files", + "filesCount": "Encrypted files", + "filesOnDisk": "Size on disk", + "filesLastChange": "Last change", + "filesEmpty": "No encrypted files yet — import files to see storage stats.", + "filesNoFolder": "No storage folder yet — pick one to start encrypting files.", + "filesCta": "Open Files" }, "ui": { "title": "界面偏好", @@ -4976,17 +4983,17 @@ }, "hero": { "spaceKey": "(空格)", - "keyLabel": "event.key", + "keyLabel": "按键", "repeat": "重复" }, "copyJson": "复制 JSON", "copiedJson": "事件 JSON 已复制到剪贴板", "deprecated": "已弃用", "fields": { - "code": "event.code", - "keyCode": "event.keyCode", - "which": "event.which", - "location": "event.location" + "code": "代码", + "keyCode": "键码", + "which": "Which", + "location": "位置" }, "location": { "standard": "标准", diff --git a/apps/desktop-ui/src/app/dashboard/page.tsx b/apps/desktop-ui/src/app/dashboard/page.tsx index ceec1f1b..987b4ea4 100644 --- a/apps/desktop-ui/src/app/dashboard/page.tsx +++ b/apps/desktop-ui/src/app/dashboard/page.tsx @@ -17,7 +17,7 @@ import { type RenderToolItem, type RenderGroup, createItemId, - findItemById, + findItemByUrl, getTotalToolCount, POPULAR_TOOL_URLS, } from '@/components/dashboard/types' @@ -75,12 +75,13 @@ const DashboardPage: React.FC = () => { const totalTools = useMemo(() => getTotalToolCount(), []) - // Get recently used tools + // Get recently used tools. Usage is keyed by route path; `usage.url` is the + // fallback for entries written before that (their toolId was a bare slug). useEffect(() => { const recent = getRecentlyUsedTools(8) const items = recent .map((usage) => { - const item = findItemById(usage.toolId) + const item = findItemByUrl(usage.toolId) ?? findItemByUrl(usage.url) return item ? { id: usage.toolId, timestamp: usage.timestamp, ...item } : null }) .filter((item): item is FavoriteItem => !!item) diff --git a/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx b/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx index e04ffe43..21f1af2f 100644 --- a/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx +++ b/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx @@ -18,9 +18,20 @@ import { IconWand } from '@tabler/icons-react' import { ToolShell } from '@/components/tools/tool-shell' import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel' import { BEAUTIFY_LANGS, beautify, minify, type BeautifyLang } from '@/lib/beautify-minify' +import { highlightCode } from '@/lib/code-screenshot' +import '@/components/tools/hljs-theme.css' type Action = 'beautify' | 'minify' +/** Map a beautifier language onto the highlight.js grammar that renders it. */ +const HLJS_LANG: Record = { + html: 'xml', + xml: 'xml', + css: 'css', + js: 'javascript', + json: 'json', +} + export function BeautifyMinifyLayout() { const t = useTranslations('BeautifyMinify') const [input, setInput] = useState('') @@ -39,6 +50,11 @@ export function BeautifyMinifyLayout() { ) const inBytes = useMemo(() => (input ? new TextEncoder().encode(input).length : 0), [input]) + const highlighted = useMemo( + () => (result.error || !result.output ? '' : highlightCode(result.output, HLJS_LANG[lang]).html), + [result.error, result.output, lang], + ) + const toolbar = (
@@ -143,9 +159,16 @@ export function BeautifyMinifyLayout() { {result.error ? (
{result.error}
) : result.output ? ( -
-              {result.output}
-            
+
+
+                
+              
+
) : (
{t('outputPlaceholder')}
)} diff --git a/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx b/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx index 509b4dd7..4031d148 100644 --- a/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx +++ b/apps/desktop-ui/src/components/csv-excel-json/csv-excel-json-tool.tsx @@ -1,9 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useTranslations } from "next-intl"; -import { usePathname } from "next/navigation"; import { IconCopy, IconDownload, @@ -17,7 +16,6 @@ import { Button } from "@/components/ui/button"; import { ToolPinButton } from "@/components/tools/tool-header"; import { ToolShell } from "@/components/tools/tool-shell"; import { IOPanel, ToolTextArea } from "@/components/tools/io-panel"; -import { useToolUsage } from "@/hooks/use-tool-usage"; import { cn } from "@/lib/utils"; import { csvTextToRows, @@ -35,18 +33,12 @@ const SAMPLE_JSON = `[ export function CsvExcelJsonTool() { const t = useTranslations("CsvExcelJson"); - const pathname = usePathname(); - const { trackToolUsage } = useToolUsage(); const [jsonText, setJsonText] = useState(""); const [error, setError] = useState(null); const [dragOver, setDragOver] = useState(false); const { isCopied: copied, copyToClipboard } = useCopyToClipboard(); const fileRef = useRef(null); - useEffect(() => { - trackToolUsage("csv-excel-json", pathname); - }, [pathname, trackToolUsage]); - const applyRows = useCallback((rows: Record[]) => { setJsonText(JSON.stringify(rows, null, 2)); setError(null); diff --git a/apps/desktop-ui/src/components/dashboard/__tests__/find-item-by-url.test.ts b/apps/desktop-ui/src/components/dashboard/__tests__/find-item-by-url.test.ts new file mode 100644 index 00000000..d3839259 --- /dev/null +++ b/apps/desktop-ui/src/components/dashboard/__tests__/find-item-by-url.test.ts @@ -0,0 +1,46 @@ +import { findItemByUrl, findItemById, createItemId } from '@/components/dashboard/types' +import { sidebarData } from '@/components/sidebar/data/sidebar-data' + +describe('findItemByUrl', () => { + it('resolves a top-level tool route to its sidebar item', () => { + expect(findItemByUrl('/app/json-formatter')?.url?.toString()).toBe('/app/json-formatter') + }) + + it('resolves every sidebar tool that has a url', () => { + const urls: string[] = [] + for (const group of sidebarData.navGroups) { + for (const item of group.items) { + if (item.url) urls.push(item.url.toString()) + for (const sub of item.items ?? []) { + if (sub.url) urls.push(sub.url.toString()) + } + } + } + expect(urls.length).toBeGreaterThan(50) + const unresolved = urls.filter((url) => !findItemByUrl(url)) + expect(unresolved).toEqual([]) + }) + + it('inherits the parent icon for a sub-item that has none', () => { + const parent = sidebarData.navGroups + .flatMap((g) => g.items) + .find((item) => item.icon && item.items?.some((sub) => sub.url && !sub.icon)) + if (!parent) return // no such shape in the current sidebar data + const sub = parent.items!.find((s) => s.url && !s.icon)! + expect(findItemByUrl(sub.url!.toString())?.icon).toBe(parent.icon) + }) + + it('returns undefined for unknown, empty and non-tool paths', () => { + expect(findItemByUrl('/app/does-not-exist')).toBeUndefined() + expect(findItemByUrl('')).toBeUndefined() + expect(findItemByUrl(null)).toBeUndefined() + expect(findItemByUrl('/dashboard')).toBeUndefined() + }) + + it('is not interchangeable with the positional id lookup — the bug this replaces', () => { + // Usage history stores route paths. Feeding one to findItemById parses + // "app"/"json" as indices, yields NaN, and silently drops the entry. + expect(findItemById('/app/json-formatter')).toBeUndefined() + expect(findItemById(createItemId(0, 0))).toBeDefined() + }) +}) diff --git a/apps/desktop-ui/src/components/dashboard/analytics-cells.tsx b/apps/desktop-ui/src/components/dashboard/analytics-cells.tsx index 0df7f1d7..98234b78 100644 --- a/apps/desktop-ui/src/components/dashboard/analytics-cells.tsx +++ b/apps/desktop-ui/src/components/dashboard/analytics-cells.tsx @@ -57,17 +57,21 @@ export function SectionHeader({ export function MetricChip({ label, value, + display: displayOverride, icon: Icon, accent, href, }: { label: string value: number + /** Pre-formatted value (byte sizes, durations) — skips the count-up. */ + display?: string icon: React.ElementType accent: string href: string }) { - const display = useCountUp(value) + const counted = useCountUp(value) + const display = displayOverride ?? counted const isEmpty = value === 0 return ( ['getToolUsageCounts']> +} + +const EMPTY_USAGE: UsageSnapshot = { events: [], counts: {} } + export function DashboardAnalyticsPanel() { const t = useTranslations('Dashboard.analytics') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [refreshedAt, setRefreshedAt] = useState(null) - const [tick, setTick] = useState(0) const [showEmpty, setShowEmpty] = useState(false) - const load = useCallback(async () => { - setLoading(true) + const { getUsageEvents, getToolUsageCounts } = useToolUsage() + const [usage, setUsage] = useState(EMPTY_USAGE) + + /** + * `silent` keeps the skeleton off for background refreshes — swapping the + * whole panel for a skeleton every 30s reads as a broken page. + */ + const load = useCallback(async (silent = false) => { + if (!silent) setLoading(true) setError(null) try { const summary = await fetchDashboardAnalyticsSummary() setData(summary) + // Usage history lives in localStorage; re-read it on the same tick so the + // launch counts and activity chart never lag the stored counts. + setUsage({ events: getUsageEvents(), counts: getToolUsageCounts() }) setRefreshedAt(new Date()) } catch (e) { setError(e instanceof Error ? e.message : t('loadError')) - setData(null) + if (!silent) setData(null) } finally { - setLoading(false) + if (!silent) setLoading(false) } - }, [t]) + }, [t, getUsageEvents, getToolUsageCounts]) useEffect(() => { void load() }, [load]) - // Re-render "X ago" every 30s + // Keep the panel live: poll while the window is on screen, and refresh the + // moment it comes back — data changes in the tools, not in here, so mount-time + // numbers go stale as soon as the user does anything. useEffect(() => { - const id = setInterval(() => setTick((n) => n + 1), 30_000) - return () => clearInterval(id) - }, []) - void tick + const isVisible = () => typeof document === 'undefined' || document.visibilityState === 'visible' + const refresh = () => { if (isVisible()) void load(true) } - const { getUsageEvents, getToolUsageCounts } = useToolUsage() - const usageEvents = useMemo(() => getUsageEvents(), [getUsageEvents]) - const toolUsageCounts = useMemo(() => getToolUsageCounts(), [getToolUsageCounts]) + const id = setInterval(refresh, REFRESH_INTERVAL_MS) + window.addEventListener('focus', refresh) + document.addEventListener('visibilitychange', refresh) + return () => { + clearInterval(id) + window.removeEventListener('focus', refresh) + document.removeEventListener('visibilitychange', refresh) + } + }, [load]) + + const { events: usageEvents, counts: toolUsageCounts } = usage const topUsedTools = useMemo(() => { return Object.entries(toolUsageCounts) @@ -162,6 +194,28 @@ export function DashboardAnalyticsPanel() { { label: t('jsonFormatterDocuments'), value: data.jsonFormatterDocuments, icon: Braces, accent: 'from-cyan-500 to-blue-600', href: '/app/json-formatter' }, ] + const { files } = data + const filesActiveCount = files.count > 0 ? 1 : 0 + const fileChips = [ + { label: t('filesCount'), value: files.count, icon: FolderLock, accent: 'from-indigo-500 to-violet-600', href: '/app/secure-files' }, + { + label: t('filesOnDisk'), + value: files.physicalBytes, + display: formatBytes(files.physicalBytes), + icon: HardDrive, + accent: 'from-teal-500 to-emerald-600', + href: '/app/secure-files', + }, + { + label: t('filesLastChange'), + value: files.lastModifiedAt, + display: files.lastModifiedAt > 0 ? timeAgo(new Date(files.lastModifiedAt)) : '—', + icon: Clock, + accent: 'from-amber-500 to-orange-600', + href: '/app/secure-files', + }, + ] + const visibleVault = showEmpty ? vaultChips : vaultChips.filter((c) => c.value > 0) const visibleWorkspace = showEmpty ? workspaceChips : workspaceChips.filter((c) => c.value > 0) const visibleToolkit = showEmpty ? toolkitChips : toolkitChips.filter((c) => c.value > 0) @@ -351,6 +405,26 @@ export function DashboardAnalyticsPanel() { )}
+ {/* ── Group 4: Files ─────────────────────────────────────────────────── + Measured from the .mydt objects on disk, so the count and size stay + truthful while the vault is locked. */} +
+ + {files.count === 0 && !showEmpty ? ( + + ) : ( +
+ {fileChips.map((c) => ( + + ))} +
+ )} +
+
) } diff --git a/apps/desktop-ui/src/components/dashboard/types.ts b/apps/desktop-ui/src/components/dashboard/types.ts index 368debef..071d019b 100644 --- a/apps/desktop-ui/src/components/dashboard/types.ts +++ b/apps/desktop-ui/src/components/dashboard/types.ts @@ -84,6 +84,26 @@ export const findItemById = (id: string | undefined | null): ToolItem | undefine } } +/** + * Resolve a route path (`/app/json-formatter`) to the sidebar ToolItem it opens. + * + * Route paths are the stable identity for a tool: unlike the position-based IDs + * above they survive any reordering of `sidebar-data`, so they are what usage + * history is keyed by. + */ +export const findItemByUrl = (url: string | undefined | null): ToolItem | undefined => { + if (!url || typeof url !== 'string') return undefined + + for (const group of sidebarData.navGroups) { + for (const item of group.items) { + if (item.url?.toString() === url) return item + const sub = item.items?.find((s) => s.url?.toString() === url) + if (sub) return { ...sub, icon: sub.icon ?? item.icon } + } + } + return undefined +} + /** Human-readable relative time from a unix-ms timestamp. */ export function formatRelativeTime(ts: number): string { const diff = Date.now() - ts diff --git a/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx b/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx index d027addc..6c7b2a84 100644 --- a/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx +++ b/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useTranslations } from 'next-intl' import { useIsMobile } from '@/components/hooks/use-mobile' @@ -24,6 +24,7 @@ import { import { toast } from 'sonner' import type { Mode, Content, OnChangeStatus } from 'vanilla-jsoneditor' import { fetchAllPages } from '@/lib/fetch-all-pages' +import { docTime, formatDocDate } from '@/lib/json-formatter-docs' import { ToolPageHeader } from '@/components/tools/tool-page-header' import { ToolMobileTabs } from '@/components/tools/tool-mobile-tabs' import { RevealItem } from '@/components/dashboard/dashboard-reveal' @@ -100,8 +101,9 @@ type JsonFormatterDocumentOut = { title: string pane: PaneKey content: string - createdAt: string - updatedAt: string + // Local router stores these as epoch ms; older/remote rows may be ISO strings. + createdAt: string | number + updatedAt: string | number } const DOCS_PAGE_SIZE = 500 @@ -163,13 +165,6 @@ export function JsonFormatterLayout() { const [docsLoading, setDocsLoading] = useState(false) const [docs, setDocs] = useState([]) - const docsByPane = useMemo(() => { - return { - left: docs.filter((d) => d.pane === 'left'), - right: docs.filter((d) => d.pane === 'right'), - } - }, [docs]) - const updatePane = (pane: PaneKey, updater: (prev: PaneState) => PaneState) => { if (pane === 'left') { setLeftPane(updater) @@ -330,7 +325,12 @@ export function JsonFormatterLayout() { }, }) - setDocs(allDocs) + // Newest first — the local router lists oldest-first, and saves repeat titles. + setDocs( + [...allDocs].sort( + (a, b) => docTime(b.updatedAt || b.createdAt) - docTime(a.updatedAt || a.createdAt) + ) + ) } const openLoadDialog = async (pane: PaneKey) => { @@ -564,14 +564,14 @@ export function JsonFormatterLayout() { Load saved JSON - Pick a previously saved JSON document from your account. + Pick any previously saved JSON document — saves from both panes are listed.
- Load into {loadPane} + Load into {loadPane === 'right' ? 'Tree' : 'Text'} pane
- {d.pane} + {d.pane === 'right' ? 'Tree' : 'Text'}
diff --git a/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx b/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx index 3ebef174..5e7901ec 100644 --- a/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx +++ b/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx @@ -31,7 +31,7 @@ import { type StringFormat, } from '@/lib/json-schema-generator'; import { Badge } from '@/components/ui/badge'; -import './json-schema-highlighter.css'; +import '@/components/tools/hljs-theme.css'; import { JsonSchemaInputEditor } from './json-schema-input-editor'; const defaultSample = `{ @@ -211,7 +211,7 @@ export function JsonSchemaGeneratorLayout() { ) : ( -
+
{output ? (
                        {
+    if (!pathname || !findItemByUrl(pathname)) return;
+    trackToolUsage(pathname, pathname);
+  }, [pathname, trackToolUsage]);
+
+  return null;
+}
+
 function Layout({ children }: { children: React.ReactNode }) {
   const pathname = usePathname();
   const { tabs } = useTabStore();
@@ -69,6 +88,7 @@ function Layout({ children }: { children: React.ReactNode }) {
   return (
     
+
diff --git a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx index 248707f1..9644888d 100644 --- a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx +++ b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx @@ -834,7 +834,7 @@ export function SnippetManagerTool() { if (!selectedId && snippets.length === 0) { return ( - +
@@ -849,7 +849,7 @@ export function SnippetManagerTool() { if (isMobile) { return ( - +
{/* Mobile top bar */}
diff --git a/apps/desktop-ui/src/components/tools/hljs-theme.css b/apps/desktop-ui/src/components/tools/hljs-theme.css new file mode 100644 index 00000000..aed99d20 --- /dev/null +++ b/apps/desktop-ui/src/components/tools/hljs-theme.css @@ -0,0 +1,204 @@ +/** + * Shared highlight.js token theme for read-only tool output panels. + * Scoped to .hljs-theme so it never bleeds. Light / dark mirror GitHub / + * GitHub Dark (Primer) token colors. + */ + +.hljs-theme pre { + margin: 0; + min-height: 100%; +} + +.hljs-theme code.hljs { + display: block; + overflow-x: auto; + padding: 1rem; + font-size: 0.875rem; + line-height: 1.625; + tab-size: 2; + background: transparent !important; +} + +/* ─── Light ─── */ +html:not(.dark) .hljs-theme code.hljs { + color: #24292e; +} + +html:not(.dark) .hljs-theme .hljs-doctag, +html:not(.dark) .hljs-theme .hljs-keyword, +html:not(.dark) .hljs-theme .hljs-meta .hljs-keyword, +html:not(.dark) .hljs-theme .hljs-template-tag, +html:not(.dark) .hljs-theme .hljs-template-variable, +html:not(.dark) .hljs-theme .hljs-type, +html:not(.dark) .hljs-theme .hljs-variable.language_ { + color: #d73a49; +} + +html:not(.dark) .hljs-theme .hljs-title, +html:not(.dark) .hljs-theme .hljs-title.class_, +html:not(.dark) .hljs-theme .hljs-title.class_.inherited__, +html:not(.dark) .hljs-theme .hljs-title.function_ { + color: #6f42c1; +} + +html:not(.dark) .hljs-theme .hljs-attr, +html:not(.dark) .hljs-theme .hljs-attribute, +html:not(.dark) .hljs-theme .hljs-literal, +html:not(.dark) .hljs-theme .hljs-meta, +html:not(.dark) .hljs-theme .hljs-number, +html:not(.dark) .hljs-theme .hljs-operator, +html:not(.dark) .hljs-theme .hljs-variable, +html:not(.dark) .hljs-theme .hljs-selector-attr, +html:not(.dark) .hljs-theme .hljs-selector-class, +html:not(.dark) .hljs-theme .hljs-selector-id { + color: #005cc5; +} + +html:not(.dark) .hljs-theme .hljs-regexp, +html:not(.dark) .hljs-theme .hljs-string, +html:not(.dark) .hljs-theme .hljs-meta .hljs-string { + color: #032f62; +} + +html:not(.dark) .hljs-theme .hljs-built_in, +html:not(.dark) .hljs-theme .hljs-symbol { + color: #e36209; +} + +html:not(.dark) .hljs-theme .hljs-comment, +html:not(.dark) .hljs-theme .hljs-code, +html:not(.dark) .hljs-theme .hljs-formula { + color: #6a737d; +} + +html:not(.dark) .hljs-theme .hljs-name, +html:not(.dark) .hljs-theme .hljs-quote, +html:not(.dark) .hljs-theme .hljs-selector-tag, +html:not(.dark) .hljs-theme .hljs-selector-pseudo { + color: #22863a; +} + +html:not(.dark) .hljs-theme .hljs-subst { + color: #24292e; +} + +html:not(.dark) .hljs-theme .hljs-section { + color: #005cc5; + font-weight: bold; +} + +html:not(.dark) .hljs-theme .hljs-bullet { + color: #735c0f; +} + +html:not(.dark) .hljs-theme .hljs-emphasis { + color: #24292e; + font-style: italic; +} + +html:not(.dark) .hljs-theme .hljs-strong { + color: #24292e; + font-weight: bold; +} + +html:not(.dark) .hljs-theme .hljs-addition { + color: #22863a; + background-color: #f0fff4; +} + +html:not(.dark) .hljs-theme .hljs-deletion { + color: #b31d28; + background-color: #ffeef0; +} + +/* ─── Dark ─── */ +html.dark .hljs-theme code.hljs { + color: #c9d1d9; +} + +html.dark .hljs-theme .hljs-doctag, +html.dark .hljs-theme .hljs-keyword, +html.dark .hljs-theme .hljs-meta .hljs-keyword, +html.dark .hljs-theme .hljs-template-tag, +html.dark .hljs-theme .hljs-template-variable, +html.dark .hljs-theme .hljs-type, +html.dark .hljs-theme .hljs-variable.language_ { + color: #ff7b72; +} + +html.dark .hljs-theme .hljs-title, +html.dark .hljs-theme .hljs-title.class_, +html.dark .hljs-theme .hljs-title.class_.inherited__, +html.dark .hljs-theme .hljs-title.function_ { + color: #d2a8ff; +} + +html.dark .hljs-theme .hljs-attr, +html.dark .hljs-theme .hljs-attribute, +html.dark .hljs-theme .hljs-literal, +html.dark .hljs-theme .hljs-meta, +html.dark .hljs-theme .hljs-number, +html.dark .hljs-theme .hljs-operator, +html.dark .hljs-theme .hljs-variable, +html.dark .hljs-theme .hljs-selector-attr, +html.dark .hljs-theme .hljs-selector-class, +html.dark .hljs-theme .hljs-selector-id { + color: #79c0ff; +} + +html.dark .hljs-theme .hljs-regexp, +html.dark .hljs-theme .hljs-string, +html.dark .hljs-theme .hljs-meta .hljs-string { + color: #a5d6ff; +} + +html.dark .hljs-theme .hljs-built_in, +html.dark .hljs-theme .hljs-symbol { + color: #ffa657; +} + +html.dark .hljs-theme .hljs-comment, +html.dark .hljs-theme .hljs-code, +html.dark .hljs-theme .hljs-formula { + color: #8b949e; +} + +html.dark .hljs-theme .hljs-name, +html.dark .hljs-theme .hljs-quote, +html.dark .hljs-theme .hljs-selector-tag, +html.dark .hljs-theme .hljs-selector-pseudo { + color: #7ee787; +} + +html.dark .hljs-theme .hljs-subst { + color: #c9d1d9; +} + +html.dark .hljs-theme .hljs-section { + color: #1f6feb; + font-weight: bold; +} + +html.dark .hljs-theme .hljs-bullet { + color: #f2cc60; +} + +html.dark .hljs-theme .hljs-emphasis { + color: #c9d1d9; + font-style: italic; +} + +html.dark .hljs-theme .hljs-strong { + color: #c9d1d9; + font-weight: bold; +} + +html.dark .hljs-theme .hljs-addition { + color: #aff5b4; + background-color: #033a16; +} + +html.dark .hljs-theme .hljs-deletion { + color: #ffdcd7; + background-color: #67060c; +} diff --git a/apps/desktop-ui/src/components/tools/tool-wrapper.tsx b/apps/desktop-ui/src/components/tools/tool-wrapper.tsx index 36320660..25efaf68 100644 --- a/apps/desktop-ui/src/components/tools/tool-wrapper.tsx +++ b/apps/desktop-ui/src/components/tools/tool-wrapper.tsx @@ -1,14 +1,11 @@ 'use client'; -import { ReactNode, useEffect } from 'react'; +import { ReactNode } from 'react'; import { Card, CardContent } from '@/components/ui/card'; -import { useToolUsage } from '@/hooks/use-tool-usage'; -import { usePathname } from 'next/navigation'; import { cn } from '@/lib/utils'; interface ToolWrapperProps { children: ReactNode; - toolId: string; className?: string; maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '5xl' | 'full'; /** Fill the app main column with minimal padding (full-height tools). */ @@ -27,24 +24,18 @@ const maxWidthClasses = { }; /** - * Base wrapper component for all tools - * Provides consistent layout and tracks tool usage + * Base wrapper component for all tools — consistent layout only. + * + * Usage history is recorded centrally in the app shell (`ToolUsageTracker` in + * components/sidebar/client-layout.tsx) so that every tool is counted, not just + * the handful that happen to render this wrapper. */ export function ToolWrapper({ children, - toolId, className = '', maxWidth = '4xl', fillMain = false, }: ToolWrapperProps) { - const pathname = usePathname(); - const { trackToolUsage } = useToolUsage(); - - // Track tool usage when component mounts - useEffect(() => { - trackToolUsage(toolId, pathname); - }, [toolId, pathname, trackToolUsage]); - return (
{ expect(BACKGROUNDS.filter((b) => b.css === null)).toHaveLength(1); }); }); + +describe('highlightCode for beautify/minify languages', () => { + // The Beautify & Minify tool maps its languages onto these hljs grammars. + const cases: [string, string][] = [ + ['json', '{"a": 1}'], + ['css', 'a { color: red; }'], + ['xml', '
hi
'], + ['javascript', 'const a = 1;'], + ] + it.each(cases)('emits hljs tokens for %s', (lang, code) => { + const res = highlightCode(code, lang) + expect(res.language).toBe(lang) + expect(res.html).toContain(' = {} -const memoryLocalStorage = { - getItem: (k: string) => (k in storage ? storage[k] : null), - setItem: (k: string, v: string) => { storage[k] = String(v) }, - removeItem: (k: string) => { delete storage[k] }, - clear: () => { for (const k of Object.keys(storage)) delete storage[k] }, - key: (i: number) => Object.keys(storage)[i] ?? null, - get length() { return Object.keys(storage).length }, -} -;(globalThis as unknown as { window: object }).window = {} -;(globalThis as unknown as { localStorage: typeof memoryLocalStorage }).localStorage = memoryLocalStorage - -beforeEach(() => memoryLocalStorage.clear()) - -describe("fetchDashboardAnalyticsSummary (local)", () => { - it("returns all-zero when nothing is stored", async () => { - const s = await fetchDashboardAnalyticsSummary() - expect(s.codeSnippets).toBe(0) - expect(s.apiClientHistoryEntries).toBe(0) - expect(s.tasks).toEqual({ total: 0, completed: 0, ongoing: 0, notStarted: 0 }) - }) +import { + EMPTY_ANALYTICS_SUMMARY, + fetchDashboardAnalyticsSummary, + normalizeAnalyticsSummary, +} from "../dashboard-analytics-api" +import { apiFetch } from "@/lib/desktop/api-fetch" + +jest.mock("@/lib/desktop/api-fetch", () => ({ apiFetch: jest.fn() })) + +const mockApiFetch = apiFetch as jest.MockedFunction + +const jsonResponse = (body: unknown, ok = true) => + ({ ok, json: async () => body }) as unknown as Response + +beforeEach(() => mockApiFetch.mockReset()) - it("counts snippets and api-client arrays from localStorage", async () => { - localStorage.setItem( - "snippet-manager-storage-v1", - JSON.stringify({ state: { snippets: [{ id: "a" }, { id: "b" }] }, version: 0 }), +describe("fetchDashboardAnalyticsSummary", () => { + it("reads live counts from the local router", async () => { + mockApiFetch.mockResolvedValue( + jsonResponse({ + notes: 4, + bookmarks: 2, + tasks: { total: 3, completed: 1, ongoing: 2, notStarted: 0 }, + codeSnippets: 7, + }), ) - localStorage.setItem("api-client-history", JSON.stringify([{}, {}, {}])) - localStorage.setItem("api-client-environments", JSON.stringify([{}])) + const s = await fetchDashboardAnalyticsSummary() - expect(s.codeSnippets).toBe(2) - expect(s.apiClientHistoryEntries).toBe(3) - expect(s.apiClientEnvironments).toBe(1) + + expect(mockApiFetch).toHaveBeenCalledWith("/api/backend/dashboard/analytics") + expect(s.notes).toBe(4) + expect(s.bookmarks).toBe(2) + expect(s.codeSnippets).toBe(7) + expect(s.tasks).toEqual({ total: 3, completed: 1, ongoing: 2, notStarted: 0 }) + // Fields the router did not send fall back to 0 rather than undefined. + expect(s.jsonFormatterDocuments).toBe(0) }) - it("survives malformed JSON without throwing", async () => { - localStorage.setItem("snippet-manager-storage-v1", "{not json") - localStorage.setItem("api-client-history", "42") - const s = await fetchDashboardAnalyticsSummary() - expect(s.codeSnippets).toBe(0) - expect(s.apiClientHistoryEntries).toBe(0) + it("returns zeros when there is no local router (web build)", async () => { + mockApiFetch.mockResolvedValue(jsonResponse({ detail: "Not found" }, false)) + await expect(fetchDashboardAnalyticsSummary()).resolves.toEqual(EMPTY_ANALYTICS_SUMMARY) + }) +}) + +describe("normalizeAnalyticsSummary", () => { + it("coerces junk payloads to zeros without throwing", () => { + expect(normalizeAnalyticsSummary(null)).toEqual(EMPTY_ANALYTICS_SUMMARY) + expect(normalizeAnalyticsSummary("nope")).toEqual(EMPTY_ANALYTICS_SUMMARY) + expect( + normalizeAnalyticsSummary({ notes: "12", bookmarks: NaN, tasks: { total: "x" } }), + ).toEqual(EMPTY_ANALYTICS_SUMMARY) }) }) diff --git a/apps/desktop-ui/src/lib/__tests__/format-bytes.test.ts b/apps/desktop-ui/src/lib/__tests__/format-bytes.test.ts new file mode 100644 index 00000000..5b5c6fe3 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/format-bytes.test.ts @@ -0,0 +1,22 @@ +import { formatBytes } from '@/lib/format-bytes' + +describe('formatBytes', () => { + it('scales through the units', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1024)).toBe('1.0 KB') + expect(formatBytes(1536)).toBe('1.5 KB') + expect(formatBytes(5 * 1024 ** 2)).toBe('5.0 MB') + expect(formatBytes(3 * 1024 ** 3)).toBe('3.0 GB') + }) + + it('clamps past the largest unit instead of printing undefined', () => { + expect(formatBytes(1024 ** 7)).toBe('1048576.0 PB') + }) + + it('renders zero, negatives and junk as 0 B', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(-5)).toBe('0 B') + expect(formatBytes(NaN)).toBe('0 B') + expect(formatBytes(Infinity)).toBe('0 B') + }) +}) diff --git a/apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts b/apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts new file mode 100644 index 00000000..e2a82afb --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts @@ -0,0 +1,35 @@ +import { docTime, formatDocDate } from '@/lib/json-formatter-docs' + +describe('docTime', () => { + it('reads epoch ms from a number and from a numeric string', () => { + expect(docTime(1756800000000)).toBe(1756800000000) + expect(docTime('1756800000000')).toBe(1756800000000) + }) + + it('reads ISO strings', () => { + expect(docTime('2026-09-02T00:00:00.000Z')).toBe(Date.parse('2026-09-02T00:00:00.000Z')) + }) + + it('returns 0 for missing or unparseable values', () => { + expect(docTime(undefined)).toBe(0) + expect(docTime('')).toBe(0) + expect(docTime('not a date')).toBe(0) + }) + + it('sorts newest first', () => { + const docs = [{ updatedAt: 1 }, { updatedAt: '2026-09-02T00:00:00.000Z' }, { updatedAt: 2 }] + const sorted = [...docs].sort((a, b) => docTime(b.updatedAt) - docTime(a.updatedAt)) + expect(sorted.map((d) => d.updatedAt)).toEqual(['2026-09-02T00:00:00.000Z', 2, 1]) + }) +}) + +describe('formatDocDate', () => { + it('renders a real date instead of raw epoch ms', () => { + expect(formatDocDate(1756800000000)).toBe(new Date(1756800000000).toLocaleString()) + }) + + it('renders nothing when there is no usable timestamp', () => { + expect(formatDocDate(undefined)).toBe('') + expect(formatDocDate('nope')).toBe('') + }) +}) diff --git a/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts b/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts index 6c8d7f10..75c66924 100644 --- a/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts @@ -2,6 +2,7 @@ import { appendEvent, deriveRecents, deriveCounts, + DEDUPE_WINDOW_MS, MAX_EVENTS, type ToolUsage, } from '@/lib/tool-usage-utils' @@ -13,13 +14,25 @@ const ev = (toolId: string, timestamp: number, url = `/app/${toolId}`): ToolUsag }) describe('appendEvent', () => { - it('prepends the new event without deduping', () => { + it('keeps repeat opens of the same tool once they are outside the dedupe window', () => { const log = [ev('a', 1000)] const out = appendEvent(log, ev('a', 2000), 2000) expect(out).toHaveLength(2) expect(out[0]).toEqual(ev('a', 2000)) }) + it('drops a repeat of the newest tool inside the dedupe window', () => { + const log = [ev('a', 1000)] + const out = appendEvent(log, ev('a', 1000 + DEDUPE_WINDOW_MS - 1), 2000) + expect(out).toBe(log) + }) + + it('still records a different tool inside the dedupe window', () => { + const log = [ev('a', 1000)] + const out = appendEvent(log, ev('b', 1001), 2000) + expect(out.map((e) => e.toolId)).toEqual(['b', 'a']) + }) + it('prunes events older than 90 days', () => { const now = 90 * 24 * 60 * 60 * 1000 + 5000 const old = ev('old', 1000) // ~epoch, older than 90d before now diff --git a/apps/desktop-ui/src/lib/dashboard-analytics-api.ts b/apps/desktop-ui/src/lib/dashboard-analytics-api.ts index a3d1d5bd..316792ad 100644 --- a/apps/desktop-ui/src/lib/dashboard-analytics-api.ts +++ b/apps/desktop-ui/src/lib/dashboard-analytics-api.ts @@ -1,3 +1,5 @@ +import { apiFetch } from '@/lib/desktop/api-fetch' + export type DashboardTaskStats = { total: number completed: number @@ -5,6 +7,19 @@ export type DashboardTaskStats = { notStarted: number } +/** + * Secure Files storage, measured from the object folder itself — so the count + * and the bytes on disk are real even while the vault is locked. `unlocked` + * says whether the file names behind those bytes are readable right now. + */ +export type DashboardFileStats = { + count: number + physicalBytes: number + lastModifiedAt: number + configured: boolean + unlocked: boolean +} + export type DashboardAnalyticsSummary = { passwordEntries: number bookmarks: number @@ -18,12 +33,28 @@ export type DashboardAnalyticsSummary = { apiClientHistoryEntries: number jsonFormatterDocuments: number codeSnippets: number + files: DashboardFileStats } function num(v: unknown): number { return typeof v === 'number' && Number.isFinite(v) ? v : 0 } +function bool(v: unknown): boolean { + return v === true +} + +function normalizeFiles(f: unknown): DashboardFileStats { + const obj = (f && typeof f === 'object' ? f : {}) as Record + return { + count: num(obj.count), + physicalBytes: num(obj.physicalBytes), + lastModifiedAt: num(obj.lastModifiedAt), + configured: bool(obj.configured), + unlocked: bool(obj.unlocked), + } +} + function normalizeTasks(t: unknown): DashboardTaskStats { const obj = (t && typeof t === 'object' ? t : {}) as Record return { @@ -34,49 +65,52 @@ function normalizeTasks(t: unknown): DashboardTaskStats { } } -function parse(key: string): unknown { - try { - if (typeof window === 'undefined') return null - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - } catch { - return null - } +export const EMPTY_ANALYTICS_SUMMARY: DashboardAnalyticsSummary = { + passwordEntries: 0, + bookmarks: 0, + bookmarkFolders: 0, + tasks: normalizeTasks(null), + projects: 0, + nosqlConnections: 0, + notes: 0, + apiClientCollections: 0, + apiClientEnvironments: 0, + apiClientHistoryEntries: 0, + jsonFormatterDocuments: 0, + codeSnippets: 0, + files: normalizeFiles(null), } -function arrayLen(key: string): number { - const v = parse(key) - return Array.isArray(v) ? v.length : 0 +/** Shape the local router returns; every field is validated before use. */ +export function normalizeAnalyticsSummary(raw: unknown): DashboardAnalyticsSummary { + const o = (raw && typeof raw === 'object' ? raw : {}) as Record + return { + passwordEntries: num(o.passwordEntries), + bookmarks: num(o.bookmarks), + bookmarkFolders: num(o.bookmarkFolders), + tasks: normalizeTasks(o.tasks), + projects: num(o.projects), + nosqlConnections: num(o.nosqlConnections), + notes: num(o.notes), + apiClientCollections: num(o.apiClientCollections), + apiClientEnvironments: num(o.apiClientEnvironments), + apiClientHistoryEntries: num(o.apiClientHistoryEntries), + jsonFormatterDocuments: num(o.jsonFormatterDocuments), + codeSnippets: num(o.codeSnippets), + files: normalizeFiles(o.files), + } } /** - * Local, offline analytics — counts data straight from the browser's own - * persistence instead of a backend. Only tools that actually persist locally - * report real numbers today: code snippets (zustand persist) and the API - * client (collections / environments / history in localStorage). + * Live counts from the local store (SQLCipher via the Rust router), computed + * there with one grouped query — the UI never downloads rows just to count + * them, so this stays cheap enough to re-run on every dashboard visit. * - * ponytail: passwords, bookmarks, tasks, notes, projects, nosql and JSON docs - * still live in the (encrypted) backend vault / MongoDB, so they read 0 until - * those tools move to local storage. The panel renders 0s gracefully. + * Outside the desktop app there is no local router, so the request 404s and the + * panel renders zeros rather than an error. */ export async function fetchDashboardAnalyticsSummary(): Promise { - const snippetStore = parse('snippet-manager-storage-v1') as - | { state?: { snippets?: unknown } } - | null - const snippets = snippetStore?.state?.snippets - - return { - passwordEntries: 0, - bookmarks: 0, - bookmarkFolders: 0, - tasks: normalizeTasks(null), - projects: 0, - nosqlConnections: 0, - notes: 0, - apiClientCollections: arrayLen('api-client-collections'), - apiClientEnvironments: arrayLen('api-client-environments'), - apiClientHistoryEntries: arrayLen('api-client-history'), - jsonFormatterDocuments: 0, - codeSnippets: Array.isArray(snippets) ? snippets.length : 0, - } + const res = await apiFetch('/api/backend/dashboard/analytics') + if (!res.ok) return EMPTY_ANALYTICS_SUMMARY + return normalizeAnalyticsSummary(await res.json()) } diff --git a/apps/desktop-ui/src/lib/format-bytes.ts b/apps/desktop-ui/src/lib/format-bytes.ts new file mode 100644 index 00000000..e1b66b2b --- /dev/null +++ b/apps/desktop-ui/src/lib/format-bytes.ts @@ -0,0 +1,13 @@ +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const + +/** + * Human-readable byte size (1024-based, one decimal above bytes). + * + * The unit index is clamped, so a size past the last unit renders as "… PB" + * instead of "NaN undefined"; junk and negatives render as "0 B". + */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1) + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${UNITS[i]}` +} diff --git a/apps/desktop-ui/src/lib/json-formatter-docs.ts b/apps/desktop-ui/src/lib/json-formatter-docs.ts new file mode 100644 index 00000000..f358a18f --- /dev/null +++ b/apps/desktop-ui/src/lib/json-formatter-docs.ts @@ -0,0 +1,21 @@ +/** + * Timestamp helpers for saved JSON formatter documents. + * + * The local Rust router stores `createdAt`/`updatedAt` as epoch milliseconds, + * but rows written by older builds carry ISO strings — both shapes reach the UI. + */ + +/** Epoch ms for a timestamp in either shape; 0 when missing or unparseable. */ +export function docTime(value: string | number | undefined | null): number { + if (value === undefined || value === null || value === '') return 0 + const parsed = + typeof value === 'string' && !/^\d+$/.test(value) ? new Date(value) : new Date(Number(value)) + const ms = parsed.getTime() + return Number.isNaN(ms) ? 0 : ms +} + +/** Localized date for the load dialog; empty string when there is nothing to show. */ +export function formatDocDate(value: string | number | undefined | null): string { + const ms = docTime(value) + return ms === 0 ? '' : new Date(ms).toLocaleString() +} diff --git a/apps/desktop-ui/src/lib/tool-usage-utils.ts b/apps/desktop-ui/src/lib/tool-usage-utils.ts index b245b8c0..3dba22b3 100644 --- a/apps/desktop-ui/src/lib/tool-usage-utils.ts +++ b/apps/desktop-ui/src/lib/tool-usage-utils.ts @@ -9,12 +9,30 @@ export const MAX_AGE_DAYS = 90 const MAX_AGE_MS = MAX_AGE_DAYS * 24 * 60 * 60 * 1000 -/** Prepend `event`, then prune by age (relative to `now`) and total count. */ +/** Repeat opens of the same tool inside this window count once. */ +export const DEDUPE_WINDOW_MS = 1000 + +/** + * Prepend `event`, then prune by age (relative to `now`) and total count. + * + * A repeat of the newest event's tool within `DEDUPE_WINDOW_MS` is dropped: + * React StrictMode mounts effects twice in development, and that would + * otherwise double every launch count. + */ export function appendEvent( log: ToolUsage[], event: ToolUsage, now: number = Date.now(), ): ToolUsage[] { + const newest = log[0] + if ( + newest && + newest.toolId === event.toolId && + event.timestamp - newest.timestamp < DEDUPE_WINDOW_MS + ) { + return log + } + const cutoff = now - MAX_AGE_MS return [event, ...log].filter((e) => e.timestamp >= cutoff).slice(0, MAX_EVENTS) } diff --git a/apps/desktop/src-tauri/src/router/analytics.rs b/apps/desktop/src-tauri/src/router/analytics.rs new file mode 100644 index 00000000..f7080fe2 --- /dev/null +++ b/apps/desktop/src-tauri/src/router/analytics.rs @@ -0,0 +1,80 @@ +//! Local mirror of `/api/v1/dashboard/analytics`. +//! +//! The dashboard needs one number per tool, not the rows themselves. Counting +//! them here is a single grouped query over `entries`; the alternative — the UI +//! listing every note, bookmark and API-client history entry just to read +//! `.length` — moves megabytes across the bridge to produce a dozen integers. + +use std::collections::HashMap; + +use rusqlite::params; +use serde_json::{json, Value}; + +use crate::error::Result; +use crate::router::entries::{active_workspace, list_docs}; +use crate::router::secure_files; +use crate::router::ApiResponse; +use crate::state::AppState; + +pub fn handle(state: &AppState, method: &str, rest: &str) -> Result { + if method != "GET" || !rest.is_empty() { + return Ok(ApiResponse::detail(404, "Not found")); + } + + // Secure Files lives on disk, not in `entries`, and takes the db lock of its + // own to read its config — so it runs before this handler takes that lock. + let files = secure_files::storage_stats(state); + + let db = state.db.lock().unwrap(); + let ws = active_workspace(&db); + + let mut counts: HashMap = HashMap::new(); + { + let mut stmt = db.prepare( + "SELECT tool_kind, COUNT(*) FROM entries + WHERE workspace_id = ?1 AND deleted_at IS NULL + GROUP BY tool_kind", + )?; + let rows = stmt.query_map(params![ws], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) + })?; + for row in rows { + let (kind, n) = row?; + counts.insert(kind, n); + } + } + let count = |kind: &str| counts.get(kind).copied().unwrap_or(0); + + // Tasks: the grouped count above includes archived rows. The dashboard + // shows the same active-only breakdown as `/tasks/stats`. + let tasks = list_docs(&db, "tasks", &ws)?; + let active: Vec<&Value> = tasks + .iter() + .filter(|d| !d["archived"].as_bool().unwrap_or(false)) + .collect(); + let by_status = + |s: &str| active.iter().filter(|d| d["status"].as_str() == Some(s)).count(); + + ApiResponse::ok(&json!({ + "passwordEntries": count("password_entries"), + "bookmarks": count("bookmarks"), + "bookmarkFolders": count("bookmark_folders"), + "tasks": { + "total": active.len(), + "completed": by_status("completed"), + "ongoing": by_status("ongoing"), + "notStarted": by_status("not-started"), + }, + "projects": count("projects"), + "notes": count("notes"), + // "NoSQL connections" predates the unified data explorer; rows written + // by either generation of the tool belong to the same chip. + "nosqlConnections": count("data_explorer_connections") + count("nosql_connections"), + "apiClientCollections": count("api_client_collections"), + "apiClientEnvironments": count("api_client_environments"), + "apiClientHistoryEntries": count("api_client_history"), + "jsonFormatterDocuments": count("json_formatter_documents"), + "codeSnippets": count("code_snippets"), + "files": files, + })) +} diff --git a/apps/desktop/src-tauri/src/router/mod.rs b/apps/desktop/src-tauri/src/router/mod.rs index 1af4a32d..f58e9db1 100644 --- a/apps/desktop/src-tauri/src/router/mod.rs +++ b/apps/desktop/src-tauri/src/router/mod.rs @@ -1,3 +1,4 @@ +pub mod analytics; pub mod api_client; pub mod backup; pub mod backup_codes; @@ -102,6 +103,9 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str> } } + if let Some(rest) = rel.strip_prefix("/dashboard/analytics") { + return analytics::handle(state, method, rest); + } if let Some(rest) = rel.strip_prefix("/code-snippets") { return snippets::handle(state, method, rest, body); } @@ -461,6 +465,49 @@ mod tests { assert_eq!(r.status, 422); } + #[test] + fn dashboard_analytics_counts_live_rows() { + let state = AppState::in_memory(); + let empty = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + assert_eq!(empty["notes"], 0); + assert_eq!(empty["tasks"]["total"], 0); + + route(&state, "POST", "/api/v1/notes", Some(r#"{"title":"n1"}"#)).unwrap(); + route(&state, "POST", "/api/v1/notes", Some(r#"{"title":"n2"}"#)).unwrap(); + route(&state, "POST", "/api/v1/bookmarks", Some(r#"{"url":"https://a.dev"}"#)).unwrap(); + route(&state, "POST", "/api/v1/json-formatter/documents", Some(r#"{"title":"d"}"#)).unwrap(); + let done = body_json( + &route(&state, "POST", "/api/v1/tasks", Some(r#"{"title":"t1","status":"completed"}"#)) + .unwrap(), + ); + route(&state, "POST", "/api/v1/tasks", Some(r#"{"title":"t2","status":"ongoing"}"#)).unwrap(); + + let v = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + assert_eq!(v["notes"], 2); + assert_eq!(v["bookmarks"], 1); + assert_eq!(v["jsonFormatterDocuments"], 1); + assert_eq!(v["tasks"]["total"], 2); + assert_eq!(v["tasks"]["completed"], 1); + assert_eq!(v["tasks"]["ongoing"], 1); + + // Deleted rows drop out of the counts. + let note = body_json(&route(&state, "GET", "/api/v1/notes", None).unwrap())[0]["id"] + .as_str() + .unwrap() + .to_string(); + route(&state, "DELETE", &format!("/api/v1/notes/{note}"), None).unwrap(); + let v = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + assert_eq!(v["notes"], 1); + + // Archived tasks leave the active breakdown, matching /tasks/stats. + let id = done["id"].as_str().unwrap(); + route(&state, "PATCH", &format!("/api/v1/tasks/{id}"), Some(r#"{"archived":true}"#)).unwrap(); + let v = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + let stats = body_json(&route(&state, "GET", "/api/v1/tasks/stats", None).unwrap()); + assert_eq!(v["tasks"], stats); + assert_eq!(v["tasks"]["total"], 1); + } + #[test] fn json_formatter_documents_crud_and_paginate() { let state = AppState::in_memory(); diff --git a/apps/desktop/src-tauri/src/router/secure_files/mod.rs b/apps/desktop/src-tauri/src/router/secure_files/mod.rs index 572183e9..82da3632 100644 --- a/apps/desktop/src-tauri/src/router/secure_files/mod.rs +++ b/apps/desktop/src-tauri/src/router/secure_files/mod.rs @@ -355,6 +355,48 @@ fn encrypt_and_store(c: &Ctx, meta: &FileMeta, plaintext: &[u8], durable: bool) Ok(Entry { id, meta: meta.clone(), physical: bytes.len() as u64 }) } +/// Storage figures for the dashboard, derived from the folder listing alone. +/// +/// Nothing is decrypted, so these are available with the vault locked — the +/// object count and the bytes the folder occupies are properties of the +/// container files, not of their contents. Plaintext sizes and names stay +/// behind the KEK; `unlocked` tells the dashboard which of the two it is +/// showing. +pub fn storage_stats(state: &AppState) -> Value { + let unlocked = state.kek.lock().unwrap().is_some(); + let dir = load_cfg(&state.db.lock().unwrap()) + .ok() + .flatten() + .and_then(|c| c.dir); + + let (mut count, mut physical, mut last_modified) = (0u64, 0u64, 0i64); + if let Some(entries) = dir.as_deref().map(Path::new).and_then(|d| fs::read_dir(d).ok()) { + for ent in entries.flatten() { + let path = ent.path(); + let is_object = path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(&format!(".{EXT}"))) + .is_some_and(|id| valid_id(id).is_ok()); + if !is_object { + continue; // stray file, or a `.mydt.tmp` left by an interrupted write + } + let Ok(md) = ent.metadata() else { continue }; + count += 1; + physical += md.len(); + last_modified = last_modified.max(mtime_ms(&md)); + } + } + + json!({ + "count": count, + "physicalBytes": physical, + "lastModifiedAt": last_modified, + "configured": dir.is_some(), + "unlocked": unlocked, + }) +} + fn mtime_ms(md: &fs::Metadata) -> i64 { md.modified() .ok() @@ -767,6 +809,40 @@ mod tests { v } + #[test] + fn storage_stats_reads_the_folder_without_the_kek() { + let unset = storage_stats(&AppState::in_memory()); + assert_eq!(unset["count"], 0); + assert_eq!(unset["configured"], false); + assert_eq!(unset["unlocked"], false); + + let tmp = Tmp::new("stats"); + let state = unlocked(&tmp); + fs::write(tmp.path("a.txt"), b"hello").unwrap(); + fs::write(tmp.path("b.txt"), vec![0u8; 4096]).unwrap(); + import(&state, &[tmp.s("a.txt"), tmp.s("b.txt")], ""); + // Junk in the storage folder must not be counted as an object. + fs::write(tmp.path("store/notes.txt"), b"stray").unwrap(); + fs::write(tmp.path("store/deadbeef.mydt.tmp"), b"interrupted write").unwrap(); + + let s = storage_stats(&state); + assert_eq!(s["count"], 2); + assert_eq!(s["configured"], true); + assert_eq!(s["unlocked"], true); + assert!(s["lastModifiedAt"].as_i64().unwrap() > 0); + // Containers hold the plaintext plus per-object overhead. + let physical = s["physicalBytes"].as_u64().unwrap(); + assert!(physical > 5 + 4096, "physical {physical} should exceed the plaintext bytes"); + assert_eq!(physical, list(&state)["totals"]["physical"].as_u64().unwrap()); + + // Same numbers with the vault locked — nothing here is decrypted. + *state.kek.lock().unwrap() = None; + let locked = storage_stats(&state); + assert_eq!(locked["count"], 2); + assert_eq!(locked["physicalBytes"], physical); + assert_eq!(locked["unlocked"], false); + } + #[test] fn locked_and_unconfigured_states() { let state = AppState::in_memory();