diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 7e0f4ca2e..a26a7dfce 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -240,7 +240,14 @@ jobs: # dashboard source — exists only on `development`. Pinned to `main` the # grant lookup would fail soft to owned-only and the suite would quietly # test less than it appears to. - additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"}]' + # + # integriq is here because the Integrations page reads integriq's + # `app_connection` rows (adopt-connection-registry). Without it the page + # shows the missing-dependency screen and `tests/e2e/integrations-page.spec.ts` + # fails on every run. `app` is `integriq`, read from its appinfo/info.xml + # on `development` on 2026-09-15. Its frontend is not built here, so the + # spec only asserts that Add integration lands on integriq's URL. + additional-apps: '[{"repo":"ConductionNL/openregister","app":"openregister","ref":"development"},{"repo":"ConductionNL/integriq","app":"integriq","ref":"development"}]' enable-playwright: true # Enforce E2E skip discipline. Re-added: #354 set this and #356 removed # it 16 minutes later while rewriting this file's triggers -- not a diff --git a/l10n/be.js b/l10n/be.js index b0e434211..3089e1253 100644 --- a/l10n/be.js +++ b/l10n/be.js @@ -1114,6 +1114,19 @@ OC.L10N.register( "Kind": "Від", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Колькі ёсць панэляў і каму яны належаць.", - "Store": "Крама" + "Store": "Крама", + "Integrations" : "Інтэграцыі", + "Connection" : "Злучэнне", + "Status" : "Статус", + "Status message" : "Паведамленне пра статус", + "Last checked" : "Апошняя праверка", + "All connections" : "Усе злучэнні", + "Add integration" : "Дадаць інтэграцыю", + "Open settings" : "Адкрыць налады", + "Configured" : "Наладжана", + "Limited" : "Абмежавана", + "Not configured" : "Не наладжана", + "Simulated" : "Сімулявана", + "Error" : "Памылка" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"); diff --git a/l10n/be.json b/l10n/be.json index 28429c50b..75f704bfd 100644 --- a/l10n/be.json +++ b/l10n/be.json @@ -1091,6 +1091,19 @@ "Kind": "Від", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Колькі ёсць панэляў і каму яны належаць.", - "Store": "Крама" + "Store": "Крама", + "Integrations": "Інтэграцыі", + "Connection": "Злучэнне", + "Status": "Статус", + "Status message": "Паведамленне пра статус", + "Last checked": "Апошняя праверка", + "All connections": "Усе злучэнні", + "Add integration": "Дадаць інтэграцыю", + "Open settings": "Адкрыць налады", + "Configured": "Наладжана", + "Limited": "Абмежавана", + "Not configured": "Не наладжана", + "Simulated": "Сімулявана", + "Error": "Памылка" } } diff --git a/l10n/bg.js b/l10n/bg.js index 880d1d362..0c8268319 100644 --- a/l10n/bg.js +++ b/l10n/bg.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Колко табла има и на кого принадлежат.", - "Store": "Магазин" + "Store": "Магазин", + "Integrations" : "Интеграции", + "Connection" : "Връзка", + "Status" : "Статус", + "Status message" : "Съобщение за статус", + "Last checked" : "Последна проверка", + "All connections" : "Всички връзки", + "Add integration" : "Добавяне на интеграция", + "Open settings" : "Отваряне на настройките", + "Configured" : "Конфигурирано", + "Limited" : "Ограничено", + "Not configured" : "Не е конфигурирано", + "Simulated" : "Симулирано", + "Error" : "Грешка" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/bg.json b/l10n/bg.json index cf72a1c8f..4ff7a0816 100644 --- a/l10n/bg.json +++ b/l10n/bg.json @@ -1090,6 +1090,19 @@ "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Колко табла има и на кого принадлежат.", - "Store": "Магазин" + "Store": "Магазин", + "Integrations": "Интеграции", + "Connection": "Връзка", + "Status": "Статус", + "Status message": "Съобщение за статус", + "Last checked": "Последна проверка", + "All connections": "Всички връзки", + "Add integration": "Добавяне на интеграция", + "Open settings": "Отваряне на настройките", + "Configured": "Конфигурирано", + "Limited": "Ограничено", + "Not configured": "Не е конфигурирано", + "Simulated": "Симулирано", + "Error": "Грешка" } } diff --git a/l10n/bs.js b/l10n/bs.js index 06ceff758..dac90232a 100644 --- a/l10n/bs.js +++ b/l10n/bs.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko ima kontrolnih tabli i kome pripadaju.", - "Store": "Trgovina" + "Store": "Trgovina", + "Integrations" : "Integracije", + "Connection" : "Veza", + "Status" : "Status", + "Status message" : "Statusna poruka", + "Last checked" : "Posljednja provjera", + "All connections" : "Sve veze", + "Add integration" : "Dodaj integraciju", + "Open settings" : "Otvori postavke", + "Configured" : "Konfigurisano", + "Limited" : "Ograničeno", + "Not configured" : "Nije konfigurisano", + "Simulated" : "Simulirano", + "Error" : "Greška" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/bs.json b/l10n/bs.json index f8c906f49..5c6f0ccee 100644 --- a/l10n/bs.json +++ b/l10n/bs.json @@ -1091,6 +1091,19 @@ "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko ima kontrolnih tabli i kome pripadaju.", - "Store": "Trgovina" + "Store": "Trgovina", + "Integrations": "Integracije", + "Connection": "Veza", + "Status": "Status", + "Status message": "Statusna poruka", + "Last checked": "Posljednja provjera", + "All connections": "Sve veze", + "Add integration": "Dodaj integraciju", + "Open settings": "Otvori postavke", + "Configured": "Konfigurisano", + "Limited": "Ograničeno", + "Not configured": "Nije konfigurisano", + "Simulated": "Simulirano", + "Error": "Greška" } } diff --git a/l10n/ca.js b/l10n/ca.js index 2bd88f34e..3f5077c98 100644 --- a/l10n/ca.js +++ b/l10n/ca.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tipus", "Version": "Versió", "How many dashboards there are, and who they belong to.": "Quants taulers hi ha i a qui pertanyen.", - "Store": "Botiga" + "Store": "Botiga", + "Integrations" : "Integracions", + "Connection" : "Connexió", + "Status" : "Estat", + "Status message" : "Missatge d'estat", + "Last checked" : "Última comprovació", + "All connections" : "Totes les connexions", + "Add integration" : "Afegeix una integració", + "Open settings" : "Obre la configuració", + "Configured" : "Configurat", + "Limited" : "Limitat", + "Not configured" : "No configurat", + "Simulated" : "Simulat", + "Error" : "Error" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/ca.json b/l10n/ca.json index 577c887a7..b18241a2b 100644 --- a/l10n/ca.json +++ b/l10n/ca.json @@ -1090,6 +1090,19 @@ "Kind": "Tipus", "Version": "Versió", "How many dashboards there are, and who they belong to.": "Quants taulers hi ha i a qui pertanyen.", - "Store": "Botiga" + "Store": "Botiga", + "Integrations": "Integracions", + "Connection": "Connexió", + "Status": "Estat", + "Status message": "Missatge d'estat", + "Last checked": "Última comprovació", + "All connections": "Totes les connexions", + "Add integration": "Afegeix una integració", + "Open settings": "Obre la configuració", + "Configured": "Configurat", + "Limited": "Limitat", + "Not configured": "No configurat", + "Simulated": "Simulat", + "Error": "Error" } } diff --git a/l10n/cs.js b/l10n/cs.js index dcef39851..ce3834323 100644 --- a/l10n/cs.js +++ b/l10n/cs.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Druh", "Version": "Verze", "How many dashboards there are, and who they belong to.": "Kolik je přehledů a komu patří.", - "Store": "Obchod" + "Store": "Obchod", + "Integrations" : "Integrace", + "Connection" : "Připojení", + "Status" : "Stav", + "Status message" : "Zpráva o stavu", + "Last checked" : "Naposledy zkontrolováno", + "All connections" : "Všechna připojení", + "Add integration" : "Přidat integraci", + "Open settings" : "Otevřít nastavení", + "Configured" : "Nastaveno", + "Limited" : "Omezeno", + "Not configured" : "Nenastaveno", + "Simulated" : "Simulováno", + "Error" : "Chyba" }, "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;" ); diff --git a/l10n/cs.json b/l10n/cs.json index a5e38c6e3..3fc1026a6 100644 --- a/l10n/cs.json +++ b/l10n/cs.json @@ -1091,6 +1091,19 @@ "Kind": "Druh", "Version": "Verze", "How many dashboards there are, and who they belong to.": "Kolik je přehledů a komu patří.", - "Store": "Obchod" + "Store": "Obchod", + "Integrations": "Integrace", + "Connection": "Připojení", + "Status": "Stav", + "Status message": "Zpráva o stavu", + "Last checked": "Naposledy zkontrolováno", + "All connections": "Všechna připojení", + "Add integration": "Přidat integraci", + "Open settings": "Otevřít nastavení", + "Configured": "Nastaveno", + "Limited": "Omezeno", + "Not configured": "Nenastaveno", + "Simulated": "Simulováno", + "Error": "Chyba" } } diff --git a/l10n/da.js b/l10n/da.js index 57cebacd9..a3773d39a 100644 --- a/l10n/da.js +++ b/l10n/da.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hvor mange dashboards der er, og hvem de tilhører.", - "Store": "Butik" + "Store": "Butik", + "Integrations" : "Integrationer", + "Connection" : "Forbindelse", + "Status" : "Status", + "Status message" : "Statusbesked", + "Last checked" : "Sidst kontrolleret", + "All connections" : "Alle forbindelser", + "Add integration" : "Tilføj integration", + "Open settings" : "Åbn indstillinger", + "Configured" : "Konfigureret", + "Limited" : "Begrænset", + "Not configured" : "Ikke konfigureret", + "Simulated" : "Simuleret", + "Error" : "Fejl" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/da.json b/l10n/da.json index 7bdf63425..7bf393c12 100644 --- a/l10n/da.json +++ b/l10n/da.json @@ -1090,6 +1090,19 @@ "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hvor mange dashboards der er, og hvem de tilhører.", - "Store": "Butik" + "Store": "Butik", + "Integrations": "Integrationer", + "Connection": "Forbindelse", + "Status": "Status", + "Status message": "Statusbesked", + "Last checked": "Sidst kontrolleret", + "All connections": "Alle forbindelser", + "Add integration": "Tilføj integration", + "Open settings": "Åbn indstillinger", + "Configured": "Konfigureret", + "Limited": "Begrænset", + "Not configured": "Ikke konfigureret", + "Simulated": "Simuleret", + "Error": "Fejl" } } diff --git a/l10n/de.js b/l10n/de.js index c07f0e248..ec30f4ce1 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Art", "Version": "Version", "How many dashboards there are, and who they belong to.": "Wie viele Dashboards es gibt und wem sie gehören.", - "Store": "Store" + "Store": "Store", + "Integrations" : "Integrationen", + "Connection" : "Verbindung", + "Status" : "Status", + "Status message" : "Statusmeldung", + "Last checked" : "Zuletzt geprüft", + "All connections" : "Alle Verbindungen", + "Add integration" : "Integration hinzufügen", + "Open settings" : "Einstellungen öffnen", + "Configured" : "Konfiguriert", + "Limited" : "Eingeschränkt", + "Not configured" : "Nicht konfiguriert", + "Simulated" : "Simuliert", + "Error" : "Fehler" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/de.json b/l10n/de.json index 8132dff65..2fbb4a66b 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -1090,6 +1090,19 @@ "Kind": "Art", "Version": "Version", "How many dashboards there are, and who they belong to.": "Wie viele Dashboards es gibt und wem sie gehören.", - "Store": "Store" + "Store": "Store", + "Integrations": "Integrationen", + "Connection": "Verbindung", + "Status": "Status", + "Status message": "Statusmeldung", + "Last checked": "Zuletzt geprüft", + "All connections": "Alle Verbindungen", + "Add integration": "Integration hinzufügen", + "Open settings": "Einstellungen öffnen", + "Configured": "Konfiguriert", + "Limited": "Eingeschränkt", + "Not configured": "Nicht konfiguriert", + "Simulated": "Simuliert", + "Error": "Fehler" } } diff --git a/l10n/el.js b/l10n/el.js index 1e9ede047..fe07bbac1 100644 --- a/l10n/el.js +++ b/l10n/el.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Είδος", "Version": "Έκδοση", "How many dashboards there are, and who they belong to.": "Πόσοι πίνακες υπάρχουν και σε ποιον ανήκουν.", - "Store": "Κατάστημα" + "Store": "Κατάστημα", + "Integrations" : "Ενσωματώσεις", + "Connection" : "Σύνδεση", + "Status" : "Κατάσταση", + "Status message" : "Μήνυμα κατάστασης", + "Last checked" : "Τελευταίος έλεγχος", + "All connections" : "Όλες οι συνδέσεις", + "Add integration" : "Προσθήκη ενσωμάτωσης", + "Open settings" : "Άνοιγμα ρυθμίσεων", + "Configured" : "Ρυθμισμένο", + "Limited" : "Περιορισμένο", + "Not configured" : "Μη ρυθμισμένο", + "Simulated" : "Προσομοιωμένο", + "Error" : "Σφάλμα" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/el.json b/l10n/el.json index 2692ca19e..6d79c40fc 100644 --- a/l10n/el.json +++ b/l10n/el.json @@ -1090,6 +1090,19 @@ "Kind": "Είδος", "Version": "Έκδοση", "How many dashboards there are, and who they belong to.": "Πόσοι πίνακες υπάρχουν και σε ποιον ανήκουν.", - "Store": "Κατάστημα" + "Store": "Κατάστημα", + "Integrations": "Ενσωματώσεις", + "Connection": "Σύνδεση", + "Status": "Κατάσταση", + "Status message": "Μήνυμα κατάστασης", + "Last checked": "Τελευταίος έλεγχος", + "All connections": "Όλες οι συνδέσεις", + "Add integration": "Προσθήκη ενσωμάτωσης", + "Open settings": "Άνοιγμα ρυθμίσεων", + "Configured": "Ρυθμισμένο", + "Limited": "Περιορισμένο", + "Not configured": "Μη ρυθμισμένο", + "Simulated": "Προσομοιωμένο", + "Error": "Σφάλμα" } } diff --git a/l10n/en.js b/l10n/en.js index bb3cf1c5b..255525c5d 100644 --- a/l10n/en.js +++ b/l10n/en.js @@ -1101,7 +1101,20 @@ OC.L10N.register( "AI insight" : "AI insight", "AI insight unavailable — the local LLM source is not configured" : "AI insight unavailable — the local LLM source is not configured", "Generate insight" : "Generate insight", - "Total spend: {amount}" : "Total spend: {amount}" + "Total spend: {amount}" : "Total spend: {amount}", + "Integrations" : "Integrations", + "Connection" : "Connection", + "Status" : "Status", + "Status message" : "Status message", + "Last checked" : "Last checked", + "All connections" : "All connections", + "Add integration" : "Add integration", + "Open settings" : "Open settings", + "Configured" : "Configured", + "Limited" : "Limited", + "Not configured" : "Not configured", + "Simulated" : "Simulated", + "Error" : "Error" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/en.json b/l10n/en.json index f641b5e01..8973c4237 100644 --- a/l10n/en.json +++ b/l10n/en.json @@ -1066,6 +1066,19 @@ "Generate insight": "Generate insight", "Total spend: {amount}": "Total spend: {amount}", "Features & roadmap": "Features & roadmap", - "Admin settings": "Admin settings" + "Admin settings": "Admin settings", + "Integrations": "Integrations", + "Connection": "Connection", + "Status": "Status", + "Status message": "Status message", + "Last checked": "Last checked", + "All connections": "All connections", + "Add integration": "Add integration", + "Open settings": "Open settings", + "Configured": "Configured", + "Limited": "Limited", + "Not configured": "Not configured", + "Simulated": "Simulated", + "Error": "Error" } } diff --git a/l10n/es.js b/l10n/es.js index 57429e95e..8316996de 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tipo", "Version": "Versión", "How many dashboards there are, and who they belong to.": "Cuántos paneles hay y a quién pertenecen.", - "Store": "Tienda" + "Store": "Tienda", + "Integrations" : "Integraciones", + "Connection" : "Conexión", + "Status" : "Estado", + "Status message" : "Mensaje de estado", + "Last checked" : "Última comprobación", + "All connections" : "Todas las conexiones", + "Add integration" : "Añadir integración", + "Open settings" : "Abrir ajustes", + "Configured" : "Configurado", + "Limited" : "Limitado", + "Not configured" : "No configurado", + "Simulated" : "Simulado", + "Error" : "Error" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/es.json b/l10n/es.json index 7e5e8a942..2eff79b1f 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -1090,6 +1090,19 @@ "Kind": "Tipo", "Version": "Versión", "How many dashboards there are, and who they belong to.": "Cuántos paneles hay y a quién pertenecen.", - "Store": "Tienda" + "Store": "Tienda", + "Integrations": "Integraciones", + "Connection": "Conexión", + "Status": "Estado", + "Status message": "Mensaje de estado", + "Last checked": "Última comprobación", + "All connections": "Todas las conexiones", + "Add integration": "Añadir integración", + "Open settings": "Abrir ajustes", + "Configured": "Configurado", + "Limited": "Limitado", + "Not configured": "No configurado", + "Simulated": "Simulado", + "Error": "Error" } } diff --git a/l10n/et.js b/l10n/et.js index c72bc77cb..a1cc3512f 100644 --- a/l10n/et.js +++ b/l10n/et.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Liik", "Version": "Versioon", "How many dashboards there are, and who they belong to.": "Kui palju töölaudu on ja kellele need kuuluvad.", - "Store": "Pood" + "Store": "Pood", + "Integrations" : "Integratsioonid", + "Connection" : "Ühendus", + "Status" : "Olek", + "Status message" : "Olekuteade", + "Last checked" : "Viimati kontrollitud", + "All connections" : "Kõik ühendused", + "Add integration" : "Lisa integratsioon", + "Open settings" : "Ava seaded", + "Configured" : "Seadistatud", + "Limited" : "Piiratud", + "Not configured" : "Seadistamata", + "Simulated" : "Simuleeritud", + "Error" : "Viga" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/et.json b/l10n/et.json index 876ff0165..263367c9e 100644 --- a/l10n/et.json +++ b/l10n/et.json @@ -1090,6 +1090,19 @@ "Kind": "Liik", "Version": "Versioon", "How many dashboards there are, and who they belong to.": "Kui palju töölaudu on ja kellele need kuuluvad.", - "Store": "Pood" + "Store": "Pood", + "Integrations": "Integratsioonid", + "Connection": "Ühendus", + "Status": "Olek", + "Status message": "Olekuteade", + "Last checked": "Viimati kontrollitud", + "All connections": "Kõik ühendused", + "Add integration": "Lisa integratsioon", + "Open settings": "Ava seaded", + "Configured": "Seadistatud", + "Limited": "Piiratud", + "Not configured": "Seadistamata", + "Simulated": "Simuleeritud", + "Error": "Viga" } } diff --git a/l10n/fi.js b/l10n/fi.js index 6e355f626..33da9cf1b 100644 --- a/l10n/fi.js +++ b/l10n/fi.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Laji", "Version": "Versio", "How many dashboards there are, and who they belong to.": "Kuinka monta koontinäyttöä on ja kenelle ne kuuluvat.", - "Store": "Kauppa" + "Store": "Kauppa", + "Integrations" : "Integraatiot", + "Connection" : "Yhteys", + "Status" : "Tila", + "Status message" : "Tilaviesti", + "Last checked" : "Viimeksi tarkistettu", + "All connections" : "Kaikki yhteydet", + "Add integration" : "Lisää integraatio", + "Open settings" : "Avaa asetukset", + "Configured" : "Määritetty", + "Limited" : "Rajoitettu", + "Not configured" : "Ei määritetty", + "Simulated" : "Simuloitu", + "Error" : "Virhe" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/fi.json b/l10n/fi.json index d8a67eb5a..e84a486e2 100644 --- a/l10n/fi.json +++ b/l10n/fi.json @@ -1090,6 +1090,19 @@ "Kind": "Laji", "Version": "Versio", "How many dashboards there are, and who they belong to.": "Kuinka monta koontinäyttöä on ja kenelle ne kuuluvat.", - "Store": "Kauppa" + "Store": "Kauppa", + "Integrations": "Integraatiot", + "Connection": "Yhteys", + "Status": "Tila", + "Status message": "Tilaviesti", + "Last checked": "Viimeksi tarkistettu", + "All connections": "Kaikki yhteydet", + "Add integration": "Lisää integraatio", + "Open settings": "Avaa asetukset", + "Configured": "Määritetty", + "Limited": "Rajoitettu", + "Not configured": "Ei määritetty", + "Simulated": "Simuloitu", + "Error": "Virhe" } } diff --git a/l10n/fr.js b/l10n/fr.js index 51d66638f..f3721d724 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Combien de tableaux de bord il y a, et à qui ils appartiennent.", - "Store": "Boutique" + "Store": "Boutique", + "Integrations" : "Intégrations", + "Connection" : "Connexion", + "Status" : "Statut", + "Status message" : "Message de statut", + "Last checked" : "Dernière vérification", + "All connections" : "Toutes les connexions", + "Add integration" : "Ajouter une intégration", + "Open settings" : "Ouvrir les paramètres", + "Configured" : "Configuré", + "Limited" : "Limité", + "Not configured" : "Non configuré", + "Simulated" : "Simulé", + "Error" : "Erreur" }, "nplurals=2; plural=(n > 1);" ); diff --git a/l10n/fr.json b/l10n/fr.json index caee966ce..e1934c496 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -1090,6 +1090,19 @@ "Kind": "Type", "Version": "Version", "How many dashboards there are, and who they belong to.": "Combien de tableaux de bord il y a, et à qui ils appartiennent.", - "Store": "Boutique" + "Store": "Boutique", + "Integrations": "Intégrations", + "Connection": "Connexion", + "Status": "Statut", + "Status message": "Message de statut", + "Last checked": "Dernière vérification", + "All connections": "Toutes les connexions", + "Add integration": "Ajouter une intégration", + "Open settings": "Ouvrir les paramètres", + "Configured": "Configuré", + "Limited": "Limité", + "Not configured": "Non configuré", + "Simulated": "Simulé", + "Error": "Erreur" } } diff --git a/l10n/ga.js b/l10n/ga.js index 5ce064b10..ea3ecb9fd 100644 --- a/l10n/ga.js +++ b/l10n/ga.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Cineál", "Version": "Leagan", "How many dashboards there are, and who they belong to.": "Cé mhéad painéal atá ann, agus cé leis iad.", - "Store": "Siopa" + "Store": "Siopa", + "Integrations" : "Comhtháthuithe", + "Connection" : "Nasc", + "Status" : "Stádas", + "Status message" : "Teachtaireacht stádais", + "Last checked" : "Seiceáilte go deireanach", + "All connections" : "Gach nasc", + "Add integration" : "Cuir comhtháthú leis", + "Open settings" : "Oscail na socruithe", + "Configured" : "Cumraithe", + "Limited" : "Teoranta", + "Not configured" : "Gan chumrú", + "Simulated" : "Insamhlaithe", + "Error" : "Earráid" }, "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);" ); diff --git a/l10n/ga.json b/l10n/ga.json index ca50e5947..e4870a755 100644 --- a/l10n/ga.json +++ b/l10n/ga.json @@ -1093,6 +1093,19 @@ "Kind": "Cineál", "Version": "Leagan", "How many dashboards there are, and who they belong to.": "Cé mhéad painéal atá ann, agus cé leis iad.", - "Store": "Siopa" + "Store": "Siopa", + "Integrations": "Comhtháthuithe", + "Connection": "Nasc", + "Status": "Stádas", + "Status message": "Teachtaireacht stádais", + "Last checked": "Seiceáilte go deireanach", + "All connections": "Gach nasc", + "Add integration": "Cuir comhtháthú leis", + "Open settings": "Oscail na socruithe", + "Configured": "Cumraithe", + "Limited": "Teoranta", + "Not configured": "Gan chumrú", + "Simulated": "Insamhlaithe", + "Error": "Earráid" } } diff --git a/l10n/hr.js b/l10n/hr.js index 0e1ce400a..e230b5bc9 100644 --- a/l10n/hr.js +++ b/l10n/hr.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko nadzornih ploča ima i kome pripadaju.", - "Store": "Trgovina" + "Store": "Trgovina", + "Integrations" : "Integracije", + "Connection" : "Veza", + "Status" : "Status", + "Status message" : "Poruka o statusu", + "Last checked" : "Posljednja provjera", + "All connections" : "Sve veze", + "Add integration" : "Dodaj integraciju", + "Open settings" : "Otvori postavke", + "Configured" : "Konfigurirano", + "Limited" : "Ograničeno", + "Not configured" : "Nije konfigurirano", + "Simulated" : "Simulirano", + "Error" : "Pogreška" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/hr.json b/l10n/hr.json index 9ae1c8436..45d34920d 100644 --- a/l10n/hr.json +++ b/l10n/hr.json @@ -1091,6 +1091,19 @@ "Kind": "Vrsta", "Version": "Verzija", "How many dashboards there are, and who they belong to.": "Koliko nadzornih ploča ima i kome pripadaju.", - "Store": "Trgovina" + "Store": "Trgovina", + "Integrations": "Integracije", + "Connection": "Veza", + "Status": "Status", + "Status message": "Poruka o statusu", + "Last checked": "Posljednja provjera", + "All connections": "Sve veze", + "Add integration": "Dodaj integraciju", + "Open settings": "Otvori postavke", + "Configured": "Konfigurirano", + "Limited": "Ograničeno", + "Not configured": "Nije konfigurirano", + "Simulated": "Simulirano", + "Error": "Pogreška" } } diff --git a/l10n/hu.js b/l10n/hu.js index 373fa5bd4..4f8456ab0 100644 --- a/l10n/hu.js +++ b/l10n/hu.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Fajta", "Version": "Verzió", "How many dashboards there are, and who they belong to.": "Hány irányítópult van, és kihez tartoznak.", - "Store": "Áruház" + "Store": "Áruház", + "Integrations" : "Integrációk", + "Connection" : "Kapcsolat", + "Status" : "Állapot", + "Status message" : "Állapotüzenet", + "Last checked" : "Utolsó ellenőrzés", + "All connections" : "Minden kapcsolat", + "Add integration" : "Integráció hozzáadása", + "Open settings" : "Beállítások megnyitása", + "Configured" : "Beállítva", + "Limited" : "Korlátozott", + "Not configured" : "Nincs beállítva", + "Simulated" : "Szimulált", + "Error" : "Hiba" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/hu.json b/l10n/hu.json index 0a50f14f6..333e520bd 100644 --- a/l10n/hu.json +++ b/l10n/hu.json @@ -1090,6 +1090,19 @@ "Kind": "Fajta", "Version": "Verzió", "How many dashboards there are, and who they belong to.": "Hány irányítópult van, és kihez tartoznak.", - "Store": "Áruház" + "Store": "Áruház", + "Integrations": "Integrációk", + "Connection": "Kapcsolat", + "Status": "Állapot", + "Status message": "Állapotüzenet", + "Last checked": "Utolsó ellenőrzés", + "All connections": "Minden kapcsolat", + "Add integration": "Integráció hozzáadása", + "Open settings": "Beállítások megnyitása", + "Configured": "Beállítva", + "Limited": "Korlátozott", + "Not configured": "Nincs beállítva", + "Simulated": "Szimulált", + "Error": "Hiba" } } diff --git a/l10n/is.js b/l10n/is.js index 147f95504..bd5957fbc 100644 --- a/l10n/is.js +++ b/l10n/is.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tegund", "Version": "Útgáfa", "How many dashboards there are, and who they belong to.": "Hversu mörg mælaborð eru til og hverjum þau tilheyra.", - "Store": "Verslun" + "Store": "Verslun", + "Integrations" : "Samþættingar", + "Connection" : "Tenging", + "Status" : "Staða", + "Status message" : "Stöðuskilaboð", + "Last checked" : "Síðast athugað", + "All connections" : "Allar tengingar", + "Add integration" : "Bæta við samþættingu", + "Open settings" : "Opna stillingar", + "Configured" : "Stillt", + "Limited" : "Takmarkað", + "Not configured" : "Ekki stillt", + "Simulated" : "Hermt", + "Error" : "Villa" }, "nplurals=2; plural=(n%10!=1 || n%100==11);" ); diff --git a/l10n/is.json b/l10n/is.json index 1b99975db..3f4e39d56 100644 --- a/l10n/is.json +++ b/l10n/is.json @@ -1090,6 +1090,19 @@ "Kind": "Tegund", "Version": "Útgáfa", "How many dashboards there are, and who they belong to.": "Hversu mörg mælaborð eru til og hverjum þau tilheyra.", - "Store": "Verslun" + "Store": "Verslun", + "Integrations": "Samþættingar", + "Connection": "Tenging", + "Status": "Staða", + "Status message": "Stöðuskilaboð", + "Last checked": "Síðast athugað", + "All connections": "Allar tengingar", + "Add integration": "Bæta við samþættingu", + "Open settings": "Opna stillingar", + "Configured": "Stillt", + "Limited": "Takmarkað", + "Not configured": "Ekki stillt", + "Simulated": "Hermt", + "Error": "Villa" } } diff --git a/l10n/it.js b/l10n/it.js index 5f58b50f8..0035f8d5c 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tipo", "Version": "Versione", "How many dashboards there are, and who they belong to.": "Quante dashboard ci sono e a chi appartengono.", - "Store": "Store" + "Store": "Store", + "Integrations" : "Integrazioni", + "Connection" : "Connessione", + "Status" : "Stato", + "Status message" : "Messaggio di stato", + "Last checked" : "Ultimo controllo", + "All connections" : "Tutte le connessioni", + "Add integration" : "Aggiungi integrazione", + "Open settings" : "Apri impostazioni", + "Configured" : "Configurato", + "Limited" : "Limitato", + "Not configured" : "Non configurato", + "Simulated" : "Simulato", + "Error" : "Errore" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/it.json b/l10n/it.json index 262f00b62..dd72cf108 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -1090,6 +1090,19 @@ "Kind": "Tipo", "Version": "Versione", "How many dashboards there are, and who they belong to.": "Quante dashboard ci sono e a chi appartengono.", - "Store": "Store" + "Store": "Store", + "Integrations": "Integrazioni", + "Connection": "Connessione", + "Status": "Stato", + "Status message": "Messaggio di stato", + "Last checked": "Ultimo controllo", + "All connections": "Tutte le connessioni", + "Add integration": "Aggiungi integrazione", + "Open settings": "Apri impostazioni", + "Configured": "Configurato", + "Limited": "Limitato", + "Not configured": "Non configurato", + "Simulated": "Simulato", + "Error": "Errore" } } diff --git a/l10n/lb.js b/l10n/lb.js index 887dff034..59bc41fff 100644 --- a/l10n/lb.js +++ b/l10n/lb.js @@ -1114,7 +1114,20 @@ OC.L10N.register( "Kind": "Aart", "Version": "Versioun", "How many dashboards there are, and who they belong to.": "Wéi vill Dashboards et gëtt a wiem se gehéieren.", - "Store": "Buttek" + "Store": "Buttek", + "Integrations" : "Integratiounen", + "Connection" : "Verbindung", + "Status" : "Status", + "Status message" : "Statusmeldung", + "Last checked" : "Lescht gepréift", + "All connections" : "All Verbindungen", + "Add integration" : "Integratioun derbäisetzen", + "Open settings" : "Astellungen opmaachen", + "Configured" : "Konfiguréiert", + "Limited" : "Limitéiert", + "Not configured" : "Net konfiguréiert", + "Simulated" : "Simuléiert", + "Error" : "Feeler" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/lb.json b/l10n/lb.json index 40fe64d6c..0504909e1 100644 --- a/l10n/lb.json +++ b/l10n/lb.json @@ -1090,6 +1090,19 @@ "Kind": "Aart", "Version": "Versioun", "How many dashboards there are, and who they belong to.": "Wéi vill Dashboards et gëtt a wiem se gehéieren.", - "Store": "Buttek" + "Store": "Buttek", + "Integrations": "Integratiounen", + "Connection": "Verbindung", + "Status": "Status", + "Status message": "Statusmeldung", + "Last checked": "Lescht gepréift", + "All connections": "All Verbindungen", + "Add integration": "Integratioun derbäisetzen", + "Open settings": "Astellungen opmaachen", + "Configured": "Konfiguréiert", + "Limited": "Limitéiert", + "Not configured": "Net konfiguréiert", + "Simulated": "Simuléiert", + "Error": "Feeler" } } diff --git a/l10n/lt.js b/l10n/lt.js index d0691af5c..22ad26e27 100644 --- a/l10n/lt.js +++ b/l10n/lt.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Rūšis", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Kiek yra skydelių ir kam jie priklauso.", - "Store": "Parduotuvė" + "Store": "Parduotuvė", + "Integrations" : "Integracijos", + "Connection" : "Ryšys", + "Status" : "Būsena", + "Status message" : "Būsenos pranešimas", + "Last checked" : "Paskutinį kartą patikrinta", + "All connections" : "Visi ryšiai", + "Add integration" : "Pridėti integraciją", + "Open settings" : "Atverti nustatymus", + "Configured" : "Sukonfigūruota", + "Limited" : "Ribota", + "Not configured" : "Nesukonfigūruota", + "Simulated" : "Imituota", + "Error" : "Klaida" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/lt.json b/l10n/lt.json index 46e3713ec..4f2411130 100644 --- a/l10n/lt.json +++ b/l10n/lt.json @@ -1091,6 +1091,19 @@ "Kind": "Rūšis", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Kiek yra skydelių ir kam jie priklauso.", - "Store": "Parduotuvė" + "Store": "Parduotuvė", + "Integrations": "Integracijos", + "Connection": "Ryšys", + "Status": "Būsena", + "Status message": "Būsenos pranešimas", + "Last checked": "Paskutinį kartą patikrinta", + "All connections": "Visi ryšiai", + "Add integration": "Pridėti integraciją", + "Open settings": "Atverti nustatymus", + "Configured": "Sukonfigūruota", + "Limited": "Ribota", + "Not configured": "Nesukonfigūruota", + "Simulated": "Imituota", + "Error": "Klaida" } } diff --git a/l10n/lv.js b/l10n/lv.js index 717c0eb7a..684d36969 100644 --- a/l10n/lv.js +++ b/l10n/lv.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Veids", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Cik ir infopaneļu un kam tie pieder.", - "Store": "Veikals" + "Store": "Veikals", + "Integrations" : "Integrācijas", + "Connection" : "Savienojums", + "Status" : "Statuss", + "Status message" : "Statusa ziņojums", + "Last checked" : "Pēdējoreiz pārbaudīts", + "All connections" : "Visi savienojumi", + "Add integration" : "Pievienot integrāciju", + "Open settings" : "Atvērt iestatījumus", + "Configured" : "Konfigurēts", + "Limited" : "Ierobežots", + "Not configured" : "Nav konfigurēts", + "Simulated" : "Simulēts", + "Error" : "Kļūda" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);" ); diff --git a/l10n/lv.json b/l10n/lv.json index 9bb4873f0..9cc0d8315 100644 --- a/l10n/lv.json +++ b/l10n/lv.json @@ -1091,6 +1091,19 @@ "Kind": "Veids", "Version": "Versija", "How many dashboards there are, and who they belong to.": "Cik ir infopaneļu un kam tie pieder.", - "Store": "Veikals" + "Store": "Veikals", + "Integrations": "Integrācijas", + "Connection": "Savienojums", + "Status": "Statuss", + "Status message": "Statusa ziņojums", + "Last checked": "Pēdējoreiz pārbaudīts", + "All connections": "Visi savienojumi", + "Add integration": "Pievienot integrāciju", + "Open settings": "Atvērt iestatījumus", + "Configured": "Konfigurēts", + "Limited": "Ierobežots", + "Not configured": "Nav konfigurēts", + "Simulated": "Simulēts", + "Error": "Kļūda" } } diff --git a/l10n/mk.js b/l10n/mk.js index fa0853271..21762a208 100644 --- a/l10n/mk.js +++ b/l10n/mk.js @@ -1114,7 +1114,20 @@ OC.L10N.register( "Kind": "Вид", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колку контролни табли има и кому припаѓаат.", - "Store": "Продавница" + "Store": "Продавница", + "Integrations" : "Интеграции", + "Connection" : "Врска", + "Status" : "Статус", + "Status message" : "Порака за статус", + "Last checked" : "Последна проверка", + "All connections" : "Сите врски", + "Add integration" : "Додај интеграција", + "Open settings" : "Отвори поставки", + "Configured" : "Конфигурирано", + "Limited" : "Ограничено", + "Not configured" : "Не е конфигурирано", + "Simulated" : "Симулирано", + "Error" : "Грешка" }, "nplurals=2; plural=(n%10==1 && n%100!=11 ? 0 : 1);" ); diff --git a/l10n/mk.json b/l10n/mk.json index a36b82daa..7d9ded3d2 100644 --- a/l10n/mk.json +++ b/l10n/mk.json @@ -1090,6 +1090,19 @@ "Kind": "Вид", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колку контролни табли има и кому припаѓаат.", - "Store": "Продавница" + "Store": "Продавница", + "Integrations": "Интеграции", + "Connection": "Врска", + "Status": "Статус", + "Status message": "Порака за статус", + "Last checked": "Последна проверка", + "All connections": "Сите врски", + "Add integration": "Додај интеграција", + "Open settings": "Отвори поставки", + "Configured": "Конфигурирано", + "Limited": "Ограничено", + "Not configured": "Не е конфигурирано", + "Simulated": "Симулирано", + "Error": "Грешка" } } diff --git a/l10n/mt.js b/l10n/mt.js index 51925453c..e0c5fa27c 100644 --- a/l10n/mt.js +++ b/l10n/mt.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tip", "Version": "Verżjoni", "How many dashboards there are, and who they belong to.": "Kemm hemm dashboards, u ta' min huma.", - "Store": "Ħanut" + "Store": "Ħanut", + "Integrations" : "Integrazzjonijiet", + "Connection" : "Konnessjoni", + "Status" : "Status", + "Status message" : "Messaġġ tal-istatus", + "Last checked" : "L-aħħar verifika", + "All connections" : "Il-konnessjonijiet kollha", + "Add integration" : "Żid integrazzjoni", + "Open settings" : "Iftaħ is-settings", + "Configured" : "Ikkonfigurat", + "Limited" : "Limitat", + "Not configured" : "Mhux ikkonfigurat", + "Simulated" : "Simulat", + "Error" : "Żball" }, "nplurals=4; plural=(n==1 ? 0 : n==0 || (n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20) ? 2 : 3);" ); diff --git a/l10n/mt.json b/l10n/mt.json index c5ecfac41..a4a4e6d09 100644 --- a/l10n/mt.json +++ b/l10n/mt.json @@ -1092,6 +1092,19 @@ "Kind": "Tip", "Version": "Verżjoni", "How many dashboards there are, and who they belong to.": "Kemm hemm dashboards, u ta' min huma.", - "Store": "Ħanut" + "Store": "Ħanut", + "Integrations": "Integrazzjonijiet", + "Connection": "Konnessjoni", + "Status": "Status", + "Status message": "Messaġġ tal-istatus", + "Last checked": "L-aħħar verifika", + "All connections": "Il-konnessjonijiet kollha", + "Add integration": "Żid integrazzjoni", + "Open settings": "Iftaħ is-settings", + "Configured": "Ikkonfigurat", + "Limited": "Limitat", + "Not configured": "Mhux ikkonfigurat", + "Simulated": "Simulat", + "Error": "Żball" } } diff --git a/l10n/nb.js b/l10n/nb.js index 9e85b8e82..bd7a0b258 100644 --- a/l10n/nb.js +++ b/l10n/nb.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Type", "Version": "Versjon", "How many dashboards there are, and who they belong to.": "Hvor mange dashbord det er, og hvem de tilhører.", - "Store": "Butikk" + "Store": "Butikk", + "Integrations" : "Integrasjoner", + "Connection" : "Tilkobling", + "Status" : "Status", + "Status message" : "Statusmelding", + "Last checked" : "Sist kontrollert", + "All connections" : "Alle tilkoblinger", + "Add integration" : "Legg til integrasjon", + "Open settings" : "Åpne innstillinger", + "Configured" : "Konfigurert", + "Limited" : "Begrenset", + "Not configured" : "Ikke konfigurert", + "Simulated" : "Simulert", + "Error" : "Feil" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nb.json b/l10n/nb.json index 07254e51a..e8f7bd9c9 100644 --- a/l10n/nb.json +++ b/l10n/nb.json @@ -1090,6 +1090,19 @@ "Kind": "Type", "Version": "Versjon", "How many dashboards there are, and who they belong to.": "Hvor mange dashbord det er, og hvem de tilhører.", - "Store": "Butikk" + "Store": "Butikk", + "Integrations": "Integrasjoner", + "Connection": "Tilkobling", + "Status": "Status", + "Status message": "Statusmelding", + "Last checked": "Sist kontrollert", + "All connections": "Alle tilkoblinger", + "Add integration": "Legg til integrasjon", + "Open settings": "Åpne innstillinger", + "Configured": "Konfigurert", + "Limited": "Begrenset", + "Not configured": "Ikke konfigurert", + "Simulated": "Simulert", + "Error": "Feil" } } diff --git a/l10n/nl.js b/l10n/nl.js index 8ecad5470..c920f7dfa 100644 --- a/l10n/nl.js +++ b/l10n/nl.js @@ -1174,7 +1174,19 @@ OC.L10N.register( "None, I will set this up myself": "Geen, ik richt dit zelf in", "Nothing is imported. You start with an empty app and add your own data.": "Er wordt niets geïmporteerd. Je begint met een lege app en voegt zelf gegevens toe.", "Example data": "Voorbeeldgegevens", - "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen." + "Sample values for every schema this app supplies, generated from the schemas themselves. It shows the lists, detail pages and dashboards working rather than telling a story. Safe to run more than once, and you can delete it afterwards.": "Voorbeeldwaarden voor elk schema dat deze app levert, gegenereerd uit de schema’s zelf. Het laat de lijsten, detailpagina’s en dashboards werkend zien in plaats van een verhaal te vertellen. Veilig om vaker uit te voeren, en je kunt het daarna verwijderen.", + "Integrations" : "Koppelingen", + "Connection" : "Verbinding", + "Status message" : "Statusbericht", + "Last checked" : "Laatst gecontroleerd", + "All connections" : "Alle verbindingen", + "Add integration" : "Integratie toevoegen", + "Open settings" : "Instellingen openen", + "Configured" : "Ingericht", + "Limited" : "Beperkt", + "Not configured" : "Niet geconfigureerd", + "Simulated" : "Gesimuleerd", + "Error" : "Fout" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/nl.json b/l10n/nl.json index a4ef4783c..1e49f23bf 100644 --- a/l10n/nl.json +++ b/l10n/nl.json @@ -1151,6 +1151,18 @@ "Registry saved.": "Register opgeslagen.", "Token removed.": "Token verwijderd.", "The registry could not be saved.": "Het register kon niet worden opgeslagen.", - "Admin settings": "Beheerinstellingen" + "Admin settings": "Beheerinstellingen", + "Integrations": "Koppelingen", + "Connection": "Verbinding", + "Status message": "Statusbericht", + "Last checked": "Laatst gecontroleerd", + "All connections": "Alle verbindingen", + "Add integration": "Integratie toevoegen", + "Open settings": "Instellingen openen", + "Configured": "Ingericht", + "Limited": "Beperkt", + "Not configured": "Niet geconfigureerd", + "Simulated": "Gesimuleerd", + "Error": "Fout" } } diff --git a/l10n/pl.js b/l10n/pl.js index 72d97b692..a8e141e11 100644 --- a/l10n/pl.js +++ b/l10n/pl.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Rodzaj", "Version": "Wersja", "How many dashboards there are, and who they belong to.": "Ile jest pulpitów i do kogo należą.", - "Store": "Sklep" + "Store": "Sklep", + "Integrations" : "Integracje", + "Connection" : "Połączenie", + "Status" : "Status", + "Status message" : "Komunikat o stanie", + "Last checked" : "Ostatnio sprawdzono", + "All connections" : "Wszystkie połączenia", + "Add integration" : "Dodaj integrację", + "Open settings" : "Otwórz ustawienia", + "Configured" : "Skonfigurowano", + "Limited" : "Ograniczone", + "Not configured" : "Nie skonfigurowano", + "Simulated" : "Symulowane", + "Error" : "Błąd" }, "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/pl.json b/l10n/pl.json index dae5fe537..247042245 100644 --- a/l10n/pl.json +++ b/l10n/pl.json @@ -1091,6 +1091,19 @@ "Kind": "Rodzaj", "Version": "Wersja", "How many dashboards there are, and who they belong to.": "Ile jest pulpitów i do kogo należą.", - "Store": "Sklep" + "Store": "Sklep", + "Integrations": "Integracje", + "Connection": "Połączenie", + "Status": "Status", + "Status message": "Komunikat o stanie", + "Last checked": "Ostatnio sprawdzono", + "All connections": "Wszystkie połączenia", + "Add integration": "Dodaj integrację", + "Open settings": "Otwórz ustawienia", + "Configured": "Skonfigurowano", + "Limited": "Ograniczone", + "Not configured": "Nie skonfigurowano", + "Simulated": "Symulowane", + "Error": "Błąd" } } diff --git a/l10n/pt.js b/l10n/pt.js index b6fd7c7b9..2b4dbd011 100644 --- a/l10n/pt.js +++ b/l10n/pt.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tipo", "Version": "Versão", "How many dashboards there are, and who they belong to.": "Quantos painéis existem e a quem pertencem.", - "Store": "Loja" + "Store": "Loja", + "Integrations" : "Integrações", + "Connection" : "Ligação", + "Status" : "Estado", + "Status message" : "Mensagem de estado", + "Last checked" : "Última verificação", + "All connections" : "Todas as ligações", + "Add integration" : "Adicionar integração", + "Open settings" : "Abrir definições", + "Configured" : "Configurado", + "Limited" : "Limitado", + "Not configured" : "Não configurado", + "Simulated" : "Simulado", + "Error" : "Erro" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/pt.json b/l10n/pt.json index 81ac87a7d..ea698907b 100644 --- a/l10n/pt.json +++ b/l10n/pt.json @@ -1090,6 +1090,19 @@ "Kind": "Tipo", "Version": "Versão", "How many dashboards there are, and who they belong to.": "Quantos painéis existem e a quem pertencem.", - "Store": "Loja" + "Store": "Loja", + "Integrations": "Integrações", + "Connection": "Ligação", + "Status": "Estado", + "Status message": "Mensagem de estado", + "Last checked": "Última verificação", + "All connections": "Todas as ligações", + "Add integration": "Adicionar integração", + "Open settings": "Abrir definições", + "Configured": "Configurado", + "Limited": "Limitado", + "Not configured": "Não configurado", + "Simulated": "Simulado", + "Error": "Erro" } } diff --git a/l10n/rm.js b/l10n/rm.js index 941b0a9fa..c163ae247 100644 --- a/l10n/rm.js +++ b/l10n/rm.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Gener", "Version": "Versiun", "How many dashboards there are, and who they belong to.": "Quants panels ch'i dat e tgi ch'els appartegnan.", - "Store": "Butia" + "Store": "Butia", + "Integrations" : "Integraziuns", + "Connection" : "Connexiun", + "Status" : "Status", + "Status message" : "Messadi da status", + "Last checked" : "Controllà l'ultima giada", + "All connections" : "Tut las connexiuns", + "Add integration" : "Agiuntar ina integraziun", + "Open settings" : "Avrir las configuraziuns", + "Configured" : "Configurà", + "Limited" : "Limità", + "Not configured" : "Betg configurà", + "Simulated" : "Simulà", + "Error" : "Errur" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/rm.json b/l10n/rm.json index f98864d96..aaa4888e1 100644 --- a/l10n/rm.json +++ b/l10n/rm.json @@ -1090,6 +1090,19 @@ "Kind": "Gener", "Version": "Versiun", "How many dashboards there are, and who they belong to.": "Quants panels ch'i dat e tgi ch'els appartegnan.", - "Store": "Butia" + "Store": "Butia", + "Integrations": "Integraziuns", + "Connection": "Connexiun", + "Status": "Status", + "Status message": "Messadi da status", + "Last checked": "Controllà l'ultima giada", + "All connections": "Tut las connexiuns", + "Add integration": "Agiuntar ina integraziun", + "Open settings": "Avrir las configuraziuns", + "Configured": "Configurà", + "Limited": "Limità", + "Not configured": "Betg configurà", + "Simulated": "Simulà", + "Error": "Errur" } } diff --git a/l10n/ro.js b/l10n/ro.js index fe0ad3aa6..5d7625d1b 100644 --- a/l10n/ro.js +++ b/l10n/ro.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Fel", "Version": "Versiune", "How many dashboards there are, and who they belong to.": "Câte tablouri de bord există și cui aparțin.", - "Store": "Magazin" + "Store": "Magazin", + "Integrations" : "Integrări", + "Connection" : "Conexiune", + "Status" : "Stare", + "Status message" : "Mesaj de stare", + "Last checked" : "Ultima verificare", + "All connections" : "Toate conexiunile", + "Add integration" : "Adaugă integrare", + "Open settings" : "Deschide setările", + "Configured" : "Configurat", + "Limited" : "Limitat", + "Not configured" : "Neconfigurat", + "Simulated" : "Simulat", + "Error" : "Eroare" }, "nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);" ); diff --git a/l10n/ro.json b/l10n/ro.json index f65fed0f4..4ec4e6d23 100644 --- a/l10n/ro.json +++ b/l10n/ro.json @@ -1091,6 +1091,19 @@ "Kind": "Fel", "Version": "Versiune", "How many dashboards there are, and who they belong to.": "Câte tablouri de bord există și cui aparțin.", - "Store": "Magazin" + "Store": "Magazin", + "Integrations": "Integrări", + "Connection": "Conexiune", + "Status": "Stare", + "Status message": "Mesaj de stare", + "Last checked": "Ultima verificare", + "All connections": "Toate conexiunile", + "Add integration": "Adaugă integrare", + "Open settings": "Deschide setările", + "Configured": "Configurat", + "Limited": "Limitat", + "Not configured": "Neconfigurat", + "Simulated": "Simulat", + "Error": "Eroare" } } diff --git a/l10n/ru.js b/l10n/ru.js index 847bffa6c..19e49d5a1 100644 --- a/l10n/ru.js +++ b/l10n/ru.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Сколько есть панелей и кому они принадлежат.", - "Store": "Магазин" + "Store": "Магазин", + "Integrations" : "Интеграции", + "Connection" : "Подключение", + "Status" : "Статус", + "Status message" : "Сообщение о статусе", + "Last checked" : "Последняя проверка", + "All connections" : "Все подключения", + "Add integration" : "Добавить интеграцию", + "Open settings" : "Открыть настройки", + "Configured" : "Настроено", + "Limited" : "Ограничено", + "Not configured" : "Не настроено", + "Simulated" : "Симулировано", + "Error" : "Ошибка" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/ru.json b/l10n/ru.json index a1e718f7f..865422299 100644 --- a/l10n/ru.json +++ b/l10n/ru.json @@ -1091,6 +1091,19 @@ "Kind": "Вид", "Version": "Версия", "How many dashboards there are, and who they belong to.": "Сколько есть панелей и кому они принадлежат.", - "Store": "Магазин" + "Store": "Магазин", + "Integrations": "Интеграции", + "Connection": "Подключение", + "Status": "Статус", + "Status message": "Сообщение о статусе", + "Last checked": "Последняя проверка", + "All connections": "Все подключения", + "Add integration": "Добавить интеграцию", + "Open settings": "Открыть настройки", + "Configured": "Настроено", + "Limited": "Ограничено", + "Not configured": "Не настроено", + "Simulated": "Симулировано", + "Error": "Ошибка" } } diff --git a/l10n/sk.js b/l10n/sk.js index eb2bc99c4..f9ee02169 100644 --- a/l10n/sk.js +++ b/l10n/sk.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Druh", "Version": "Verzia", "How many dashboards there are, and who they belong to.": "Koľko je prehľadov a komu patria.", - "Store": "Obchod" + "Store": "Obchod", + "Integrations" : "Integrácie", + "Connection" : "Pripojenie", + "Status" : "Stav", + "Status message" : "Správa o stave", + "Last checked" : "Naposledy skontrolované", + "All connections" : "Všetky pripojenia", + "Add integration" : "Pridať integráciu", + "Open settings" : "Otvoriť nastavenia", + "Configured" : "Nastavené", + "Limited" : "Obmedzené", + "Not configured" : "Nenastavené", + "Simulated" : "Simulované", + "Error" : "Chyba" }, "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;" ); diff --git a/l10n/sk.json b/l10n/sk.json index 3f3e0a653..081f4b278 100644 --- a/l10n/sk.json +++ b/l10n/sk.json @@ -1091,6 +1091,19 @@ "Kind": "Druh", "Version": "Verzia", "How many dashboards there are, and who they belong to.": "Koľko je prehľadov a komu patria.", - "Store": "Obchod" + "Store": "Obchod", + "Integrations": "Integrácie", + "Connection": "Pripojenie", + "Status": "Stav", + "Status message": "Správa o stave", + "Last checked": "Naposledy skontrolované", + "All connections": "Všetky pripojenia", + "Add integration": "Pridať integráciu", + "Open settings": "Otvoriť nastavenia", + "Configured": "Nastavené", + "Limited": "Obmedzené", + "Not configured": "Nenastavené", + "Simulated": "Simulované", + "Error": "Chyba" } } diff --git a/l10n/sl.js b/l10n/sl.js index cec16c49a..b60395262 100644 --- a/l10n/sl.js +++ b/l10n/sl.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Vrsta", "Version": "Različica", "How many dashboards there are, and who they belong to.": "Koliko je nadzornih plošč in komu pripadajo.", - "Store": "Trgovina" + "Store": "Trgovina", + "Integrations" : "Integracije", + "Connection" : "Povezava", + "Status" : "Stanje", + "Status message" : "Sporočilo o stanju", + "Last checked" : "Nazadnje preverjeno", + "All connections" : "Vse povezave", + "Add integration" : "Dodaj integracijo", + "Open settings" : "Odpri nastavitve", + "Configured" : "Nastavljeno", + "Limited" : "Omejeno", + "Not configured" : "Ni nastavljeno", + "Simulated" : "Simulirano", + "Error" : "Napaka" }, "nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);" ); diff --git a/l10n/sl.json b/l10n/sl.json index a98b91ec8..489a42f60 100644 --- a/l10n/sl.json +++ b/l10n/sl.json @@ -1092,6 +1092,19 @@ "Kind": "Vrsta", "Version": "Različica", "How many dashboards there are, and who they belong to.": "Koliko je nadzornih plošč in komu pripadajo.", - "Store": "Trgovina" + "Store": "Trgovina", + "Integrations": "Integracije", + "Connection": "Povezava", + "Status": "Stanje", + "Status message": "Sporočilo o stanju", + "Last checked": "Nazadnje preverjeno", + "All connections": "Vse povezave", + "Add integration": "Dodaj integracijo", + "Open settings": "Odpri nastavitve", + "Configured": "Nastavljeno", + "Limited": "Omejeno", + "Not configured": "Ni nastavljeno", + "Simulated": "Simulirano", + "Error": "Napaka" } } diff --git a/l10n/sq.js b/l10n/sq.js index 250599de3..3351c3e61 100644 --- a/l10n/sq.js +++ b/l10n/sq.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Lloji", "Version": "Versioni", "How many dashboards there are, and who they belong to.": "Sa panele ka dhe kujt i përkasin.", - "Store": "Dyqani" + "Store": "Dyqani", + "Integrations" : "Integrime", + "Connection" : "Lidhje", + "Status" : "Gjendja", + "Status message" : "Mesazh gjendjeje", + "Last checked" : "Kontrolluar së fundi", + "All connections" : "Të gjitha lidhjet", + "Add integration" : "Shto integrim", + "Open settings" : "Hap cilësimet", + "Configured" : "I konfiguruar", + "Limited" : "I kufizuar", + "Not configured" : "I pakonfiguruar", + "Simulated" : "I simuluar", + "Error" : "Gabim" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/sq.json b/l10n/sq.json index 45088f228..217b4b43a 100644 --- a/l10n/sq.json +++ b/l10n/sq.json @@ -1090,6 +1090,19 @@ "Kind": "Lloji", "Version": "Versioni", "How many dashboards there are, and who they belong to.": "Sa panele ka dhe kujt i përkasin.", - "Store": "Dyqani" + "Store": "Dyqani", + "Integrations": "Integrime", + "Connection": "Lidhje", + "Status": "Gjendja", + "Status message": "Mesazh gjendjeje", + "Last checked": "Kontrolluar së fundi", + "All connections": "Të gjitha lidhjet", + "Add integration": "Shto integrim", + "Open settings": "Hap cilësimet", + "Configured": "I konfiguruar", + "Limited": "I kufizuar", + "Not configured": "I pakonfiguruar", + "Simulated": "I simuluar", + "Error": "Gabim" } } diff --git a/l10n/sr.js b/l10n/sr.js index 62349d493..033d89b4a 100644 --- a/l10n/sr.js +++ b/l10n/sr.js @@ -1114,6 +1114,19 @@ OC.L10N.register( "Kind": "Врста", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колико има контролних табли и коме припадају.", - "Store": "Продавница" + "Store": "Продавница", + "Integrations" : "Интеграције", + "Connection" : "Веза", + "Status" : "Статус", + "Status message" : "Порука о статусу", + "Last checked" : "Последња провера", + "All connections" : "Све везе", + "Add integration" : "Додај интеграцију", + "Open settings" : "Отвори подешавања", + "Configured" : "Подешено", + "Limited" : "Ограничено", + "Not configured" : "Није подешено", + "Simulated" : "Симулирано", + "Error" : "Грешка" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);"); diff --git a/l10n/sr.json b/l10n/sr.json index 686e1911c..2ceb632b2 100644 --- a/l10n/sr.json +++ b/l10n/sr.json @@ -1091,6 +1091,19 @@ "Kind": "Врста", "Version": "Верзија", "How many dashboards there are, and who they belong to.": "Колико има контролних табли и коме припадају.", - "Store": "Продавница" + "Store": "Продавница", + "Integrations": "Интеграције", + "Connection": "Веза", + "Status": "Статус", + "Status message": "Порука о статусу", + "Last checked": "Последња провера", + "All connections": "Све везе", + "Add integration": "Додај интеграцију", + "Open settings": "Отвори подешавања", + "Configured": "Подешено", + "Limited": "Ограничено", + "Not configured": "Није подешено", + "Simulated": "Симулирано", + "Error": "Грешка" } } diff --git a/l10n/sv.js b/l10n/sv.js index 4954a6065..57f63ff8a 100644 --- a/l10n/sv.js +++ b/l10n/sv.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Typ", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hur många instrumentpaneler det finns, och vem de tillhör.", - "Store": "Butik" + "Store": "Butik", + "Integrations" : "Integrationer", + "Connection" : "Anslutning", + "Status" : "Status", + "Status message" : "Statusmeddelande", + "Last checked" : "Senast kontrollerad", + "All connections" : "Alla anslutningar", + "Add integration" : "Lägg till integration", + "Open settings" : "Öppna inställningar", + "Configured" : "Konfigurerad", + "Limited" : "Begränsad", + "Not configured" : "Inte konfigurerad", + "Simulated" : "Simulerad", + "Error" : "Fel" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/sv.json b/l10n/sv.json index 59a4bd689..458f035a7 100644 --- a/l10n/sv.json +++ b/l10n/sv.json @@ -1090,6 +1090,19 @@ "Kind": "Typ", "Version": "Version", "How many dashboards there are, and who they belong to.": "Hur många instrumentpaneler det finns, och vem de tillhör.", - "Store": "Butik" + "Store": "Butik", + "Integrations": "Integrationer", + "Connection": "Anslutning", + "Status": "Status", + "Status message": "Statusmeddelande", + "Last checked": "Senast kontrollerad", + "All connections": "Alla anslutningar", + "Add integration": "Lägg till integration", + "Open settings": "Öppna inställningar", + "Configured": "Konfigurerad", + "Limited": "Begränsad", + "Not configured": "Inte konfigurerad", + "Simulated": "Simulerad", + "Error": "Fel" } } diff --git a/l10n/tr.js b/l10n/tr.js index d9e906636..fc3596fe5 100644 --- a/l10n/tr.js +++ b/l10n/tr.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Tür", "Version": "Sürüm", "How many dashboards there are, and who they belong to.": "Kaç panonun olduğu ve kime ait oldukları.", - "Store": "Mağaza" + "Store": "Mağaza", + "Integrations" : "Entegrasyonlar", + "Connection" : "Bağlantı", + "Status" : "Durum", + "Status message" : "Durum mesajı", + "Last checked" : "Son denetim", + "All connections" : "Tüm bağlantılar", + "Add integration" : "Entegrasyon ekle", + "Open settings" : "Ayarları aç", + "Configured" : "Yapılandırıldı", + "Limited" : "Sınırlı", + "Not configured" : "Yapılandırılmadı", + "Simulated" : "Benzetildi", + "Error" : "Hata" }, "nplurals=2; plural=(n != 1);" ); diff --git a/l10n/tr.json b/l10n/tr.json index 3b8fa3299..30289c24b 100644 --- a/l10n/tr.json +++ b/l10n/tr.json @@ -1090,6 +1090,19 @@ "Kind": "Tür", "Version": "Sürüm", "How many dashboards there are, and who they belong to.": "Kaç panonun olduğu ve kime ait oldukları.", - "Store": "Mağaza" + "Store": "Mağaza", + "Integrations": "Entegrasyonlar", + "Connection": "Bağlantı", + "Status": "Durum", + "Status message": "Durum mesajı", + "Last checked": "Son denetim", + "All connections": "Tüm bağlantılar", + "Add integration": "Entegrasyon ekle", + "Open settings": "Ayarları aç", + "Configured": "Yapılandırıldı", + "Limited": "Sınırlı", + "Not configured": "Yapılandırılmadı", + "Simulated": "Benzetildi", + "Error": "Hata" } } diff --git a/l10n/uk.js b/l10n/uk.js index 09e1433b5..d0c94c9fc 100644 --- a/l10n/uk.js +++ b/l10n/uk.js @@ -1123,7 +1123,20 @@ OC.L10N.register( "Kind": "Вид", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Скільки є дашбордів і кому вони належать.", - "Store": "Магазин" + "Store": "Магазин", + "Integrations" : "Інтеграції", + "Connection" : "Підключення", + "Status" : "Статус", + "Status message" : "Повідомлення про статус", + "Last checked" : "Остання перевірка", + "All connections" : "Усі підключення", + "Add integration" : "Додати інтеграцію", + "Open settings" : "Відкрити налаштування", + "Configured" : "Налаштовано", + "Limited" : "Обмежено", + "Not configured" : "Не налаштовано", + "Simulated" : "Змодельовано", + "Error" : "Помилка" }, "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);" ); diff --git a/l10n/uk.json b/l10n/uk.json index f84467e75..b9bfdb65f 100644 --- a/l10n/uk.json +++ b/l10n/uk.json @@ -1091,6 +1091,19 @@ "Kind": "Вид", "Version": "Версія", "How many dashboards there are, and who they belong to.": "Скільки є дашбордів і кому вони належать.", - "Store": "Магазин" + "Store": "Магазин", + "Integrations": "Інтеграції", + "Connection": "Підключення", + "Status": "Статус", + "Status message": "Повідомлення про статус", + "Last checked": "Остання перевірка", + "All connections": "Усі підключення", + "Add integration": "Додати інтеграцію", + "Open settings": "Відкрити налаштування", + "Configured": "Налаштовано", + "Limited": "Обмежено", + "Not configured": "Не налаштовано", + "Simulated": "Змодельовано", + "Error": "Помилка" } } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 6a0e7c6f9..e90b899d6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -37,6 +37,7 @@ use OCA\LaunchPad\Listener\WidgetPlacementsListener; use OCA\LaunchPad\Notification\Notifier; use OCA\LaunchPad\Search\LaunchPadSearchProvider; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCA\LaunchPad\Service\ImportService; use OCA\LaunchPad\Service\PublicShareContext; use OCA\LaunchPad\Service\StoreService; @@ -265,6 +266,7 @@ private function registerIntegrations(IRegistrationContext $context): void { * @return void * * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-002-an-absent-openregister-must-degrade-never-fatal + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-002-a-registry-settings-save-asks-integriq-to-look-again */ private function registerStore(IRegistrationContext $context): void { $context->registerService( @@ -278,7 +280,11 @@ static function (\Psr\Container\ContainerInterface $c): StoreService { ), importService: $c->get(ImportService::class), appConfig: $c->get(\OCP\IAppConfig::class), - logger: $c->get(\Psr\Log\LoggerInterface::class) + logger: $c->get(\Psr\Log\LoggerInterface::class), + // Adopt-connection-registry: without it a registry save would + // never ask integriq to look again and no search would report. + // The argument is optional, so leaving it out is a silent no-op. + connectionReporter: $c->get(ConnectionReporter::class) ); } ); diff --git a/lib/Service/CalendarWidgetService.php b/lib/Service/CalendarWidgetService.php index 532b29e67..55dbb6c3a 100644 --- a/lib/Service/CalendarWidgetService.php +++ b/lib/Service/CalendarWidgetService.php @@ -38,6 +38,7 @@ use DateTimeImmutable; use DateTimeInterface; use OCA\LaunchPad\AppInfo\Application; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCP\Calendar\IManager; use OCP\Http\Client\IClientService; use OCP\IAppConfig; @@ -119,6 +120,9 @@ class CalendarWidgetService { * @param LoggerInterface $logger Logger. * @param UrlSafetyValidator $urlValidator Shared SSRF / allow-list guard. * @param IManager|null $calendarMgr NC Calendar manager (optional). + * @param ConnectionReporter|null $connectionReporter Tells integriq what an ICS fetch met, or nothing when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function __construct( private readonly IAppConfig $appConfig, @@ -127,6 +131,7 @@ public function __construct( private readonly LoggerInterface $logger, private readonly UrlSafetyValidator $urlValidator, private readonly ?IManager $calendarMgr = null, + private readonly ?ConnectionReporter $connectionReporter = null, ) { $this->cache = $cacheFactory->createDistributed(prefix: self::CACHE_NAMESPACE); }//end __construct() @@ -424,6 +429,11 @@ public function checkAllowList(string $url): bool { /** * Fetch the raw ICS body for a URL, using ICache when fresh. * + * A live fetch (not a cache hit) is reported to integriq's connection + * registry, at most once an hour while it stays the same + * (adopt-connection-registry). The report never changes the body or the + * exception. + * * @param string $url The URL to fetch. * * @return string The raw ICS body. @@ -431,6 +441,7 @@ public function checkAllowList(string $url): bool { * @throws \RuntimeException When the response is too large or non-2xx. * * @spec openspec/specs/calendar-widget/spec.md + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function fetchIcsBody(string $url): string { $cacheKey = 'ics_' . md5(string: $url); @@ -452,16 +463,31 @@ public function fetchIcsBody(string $url): string { ); } - $client = $this->clientService->newClient(); - $response = $client->get( - uri: $url, - options: [ - 'timeout' => self::FETCH_TIMEOUT_SECONDS, - 'connect_timeout' => self::FETCH_TIMEOUT_SECONDS, - // H2: disable redirect-following so an attacker cannot - // chain a public URL to an internal redirect target. - 'allow_redirects' => false, - ] + try { + $client = $this->clientService->newClient(); + $response = $client->get( + uri: $url, + options: [ + 'timeout' => self::FETCH_TIMEOUT_SECONDS, + 'connect_timeout' => self::FETCH_TIMEOUT_SECONDS, + // H2: disable redirect-following so an attacker cannot + // chain a public URL to an internal redirect target. + 'allow_redirects' => false, + ] + ); + } catch (Throwable $exception) { + $this->connectionReporter?->reportCall( + key: ConnectionReporter::KEY_ICS_CALENDARS, + url: $url, + httpStatus: $this->connectionReporter->httpStatusOf(exception: $exception) + ); + throw $exception; + } + + $this->connectionReporter?->reportCall( + key: ConnectionReporter::KEY_ICS_CALENDARS, + url: $url, + httpStatus: (int)$response->getStatusCode() ); $body = (string)$response->getBody(); diff --git a/lib/Service/Connection/ConnectionObservations.php b/lib/Service/Connection/ConnectionObservations.php new file mode 100644 index 000000000..cc54b6332 --- /dev/null +++ b/lib/Service/Connection/ConnectionObservations.php @@ -0,0 +1,257 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Service\Connection; + +use Throwable; + +/** + * Maps call outcomes to connection statuses and messages. + * + * Every method answers `[status, message]`, or null when the outcome says + * nothing about the connection (it is about one address, one widget or one + * user) and so must not be reported. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ +class ConnectionObservations { + + /** + * HTTP statuses that say the other side refused the request. + * + * @var array + */ + public const REFUSED_STATUSES = [401, 403]; + + /** + * The HTTP status that says the other side limited the request. + * + * @var int + */ + public const RATE_LIMITED_STATUS = 429; + + /** + * HTTP statuses that say a gateway could not reach the other side. + * + * A 500 is left out, because it is often about one request. + * + * @var array + */ + public const GATEWAY_ERROR_STATUSES = [502, 503, 504]; + + /** + * What a dashboard registry search outcome says about the registry. + * + * The outcome values are OpenRegister `GenericStoreService::OUTCOME_*`. + * + * @param string $outcome The search outcome. + * @param bool $engineAvailable Whether OpenRegister's store engine answered the container. + * @param string $registryUrl The saved `registry_url`, for the host in the message. + * + * @return array{0: string, 1: string}|null The status and message, or null for an unknown outcome. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function registrySearch(string $outcome, bool $engineAvailable, string $registryUrl): ?array { + if ($engineAvailable === false) { + return ['unavailable', 'OpenRegister is not enabled, so LaunchPad cannot reach a dashboard registry.']; + } + + $registry = $this->withHost(name: 'dashboard registry', url: $registryUrl); + + return match ($outcome) { + 'ok' => ['configured', ucfirst($registry) . ' answered the last search.'], + 'not_configured' => [ + 'unconfigured', + 'No registry address is set. Set the registry URL on the Sharing tab of the LaunchPad settings.', + ], + 'store_unreachable' => ['error', 'The last search could not reach ' . $registry . '.'], + 'store_invalid_response' => ['error', ucfirst($registry) . ' answered, but not with a list of templates.'], + 'rate_limited' => ['limited', ucfirst($registry) . ' limited the last search.'], + default => null, + }; + }//end registrySearch() + + /** + * What one outbound HTTP call says about the connection behind it. + * + * @param string $name How the message names the other side, such as `news feed`. + * @param string $url The address called. Only its host reaches the message. + * @param int|null $httpStatus The answer's HTTP status, or null when nothing answered. + * + * @return array{0: string, 1: string}|null The status and message, or null when the answer is about one address. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function httpCall(string $name, string $url, ?int $httpStatus): ?array { + $target = $this->withHost(name: $name, url: $url); + + if ($httpStatus === null) { + return ['error', 'The last call to ' . $target . ' got no answer.']; + } + + if (in_array($httpStatus, self::REFUSED_STATUSES, true) === true) { + return ['error', ucfirst($target) . ' refused the request (HTTP ' . $httpStatus . ').']; + } + + if ($httpStatus === self::RATE_LIMITED_STATUS) { + return ['limited', ucfirst($target) . ' limited the last call (HTTP ' . $httpStatus . ').']; + } + + if (in_array($httpStatus, self::GATEWAY_ERROR_STATUSES, true) === true) { + return ['error', ucfirst($target) . ' answered HTTP ' . $httpStatus . ' on the last call.']; + } + + if ($httpStatus >= 200 && $httpStatus < 300) { + return ['configured', ucfirst($target) . ' answered the last call.']; + } + + return null; + }//end httpCall() + + /** + * A weather widget with a location found no provider URL. + * + * @param string $configKey The app-config key that holds the provider URL. + * + * @return array{0: string, 1: string} + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function weatherNotConfigured(string $configKey): array { + return [ + 'unconfigured', + 'A weather widget with a location found no provider URL. Set ' . $configKey . ' with occ.', + ]; + }//end weatherNotConfigured() + + /** + * The weather provider URL does not start with http or https, so nothing was called. + * + * @param string $configKey The app-config key that holds the provider URL. + * + * @return array{0: string, 1: string} + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function weatherInvalidUrl(string $configKey): array { + return [ + 'error', + 'The weather provider URL does not start with http or https. Set ' . $configKey . ' with occ.', + ]; + }//end weatherInvalidUrl() + + /** + * The weather provider answered, but not with a reading LaunchPad can read. + * + * @param string $url The address called. Only its host reaches the message. + * + * @return array{0: string, 1: string} + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function weatherUnreadable(string $url): array { + return [ + 'error', + ucfirst($this->withHost(name: 'weather provider', url: $url)) . ' answered, but not with a temperature LaunchPad can read.', + ]; + }//end weatherUnreadable() + + /** + * What a fail-closed allow-list refusal says about the connection. + * + * A list that holds at least one host refused one widget's address, which + * says nothing about the instance. A list that holds no host refuses + * every address, and that is what the admin needs to know. + * + * @param string $configKey The app-config key that holds the list. + * @param string $rawList The value stored under that key. + * + * @return array{0: string, 1: string}|null The status and message, or null when the list holds a host. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function allowListRefused(string $configKey, string $rawList): ?array { + $decoded = json_decode($rawList, true); + if (is_array($decoded) === true && $decoded !== []) { + return null; + } + + return [ + 'unconfigured', + $configKey . ' holds no JSON list of hosts, so LaunchPad refused the last call. Set it with occ.', + ]; + }//end allowListRefused() + + /** + * The HTTP status a failed call still carries, or null when nothing answered. + * + * Nextcloud's HTTP client throws on a 4xx or 5xx answer unless the caller + * turns that off. Guzzle's request exceptions keep that answer, and a + * connection failure has none. + * + * @param Throwable $exception What the call threw. + * + * @return int|null The answer's HTTP status, or null. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function httpStatusOf(Throwable $exception): ?int { + if (method_exists($exception, 'getResponse') === false) { + return null; + } + + $response = $exception->getResponse(); + if (is_object($response) === false || method_exists($response, 'getStatusCode') === false) { + return null; + } + + return (int) $response->getStatusCode(); + }//end httpStatusOf() + + /** + * Name a thing with the host of its URL, when the URL has one. + * + * Only the host leaves the URL: a path, a query or user info can carry a + * secret such as a feed token or a weather API key, and every admin reads + * the row. + * + * @param string $name The plain name, without an article. + * @param string $url The URL to take the host from. + * + * @return string `the {name}`, followed by `at {host}` when there is a host. + */ + private function withHost(string $name, string $url): string { + $host = parse_url(trim($url), PHP_URL_HOST); + if (is_string($host) === false || $host === '') { + return 'the ' . $name; + } + + return 'the ' . $name . ' at ' . $host; + }//end withHost() +}//end class diff --git a/lib/Service/Connection/ConnectionReporter.php b/lib/Service/Connection/ConnectionReporter.php new file mode 100644 index 000000000..02e806355 --- /dev/null +++ b/lib/Service/Connection/ConnectionReporter.php @@ -0,0 +1,539 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\LaunchPad\Service\Connection; + +use OCA\LaunchPad\AppInfo\Application; +use OCA\LaunchPad\Service\HealthPingService; +use OCA\LaunchPad\Service\LiveTileService; +use OCA\LaunchPad\Service\StoreService; +use OCA\LaunchPad\Service\WeatherService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Throwable; + +/** + * Sends connection reports and refresh requests to integriq. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ +class ConnectionReporter { + + /** + * The app id integriq keys the rows by. + * + * @var string + */ + public const APP_ID = Application::APP_ID; + + /** + * Integriq's report event (ADR-041). Named by string so LaunchPad stays + * installable without integriq: the class only exists when integriq does. + * + * @var string + */ + public const STATUS_EVENT = 'OCA\Integriq\Event\ConnectionStatusReportedEvent'; + + /** + * Integriq's refresh event. Named by string for the same reason. + * + * @var string + */ + public const REFRESH_EVENT = 'OCA\Integriq\Event\ConnectionRefreshRequestedEvent'; + + /** + * The dashboard registry row. + * + * @var string + */ + public const KEY_REGISTRY = 'dashboard-registry'; + + /** + * The weather provider row. + * + * @var string + */ + public const KEY_WEATHER = 'weather'; + + /** + * The news feed family row. + * + * @var string + */ + public const KEY_NEWS_FEEDS = 'news-feeds'; + + /** + * The calendar feed family row. + * + * @var string + */ + public const KEY_ICS_CALENDARS = 'ics-calendars'; + + /** + * The live tile family row. + * + * @var string + */ + public const KEY_LIVE_TILES = 'live-tiles'; + + /** + * The health ping family row. + * + * @var string + */ + public const KEY_HEALTH_PING = 'health-ping'; + + /** + * The keys `lib/Settings/connections.json` declares, in declared order. + * + * A key outside this set is a caller's typo, not a new connection. A unit + * test keeps the two equal. + * + * @var array + */ + public const KEYS = [ + self::KEY_REGISTRY, + self::KEY_WEATHER, + self::KEY_NEWS_FEEDS, + self::KEY_ICS_CALENDARS, + self::KEY_LIVE_TILES, + self::KEY_HEALTH_PING, + ]; + + /** + * The statuses integriq accepts in a report (design D6), `limited` included. + * + * @var array + */ + public const STATUSES = ['configured', 'limited', 'unconfigured', 'simulated', 'unavailable', 'error']; + + /** + * How a message names the other side of each row whose calls are plain HTTP. + * + * @var array + */ + public const CALL_NAMES = [ + self::KEY_WEATHER => 'weather provider', + self::KEY_NEWS_FEEDS => 'news feed', + self::KEY_ICS_CALENDARS => 'calendar feed', + self::KEY_LIVE_TILES => 'live tile source', + self::KEY_HEALTH_PING => 'health ping target', + ]; + + /** + * The fail-closed allow-list behind each row that has one. + * + * @var array + */ + public const ALLOW_LISTS = [ + self::KEY_LIVE_TILES => LiveTileService::CONFIG_KEY_ALLOWED_HOSTS, + self::KEY_HEALTH_PING => HealthPingService::CONFIG_KEY_ALLOWED_HOSTS, + ]; + + /** + * App-config keys per connection whose save asks integriq to resolve again. + * + * The declared `requiredConfig`, plus the two keys that change what a + * registry search meets. A unit test keeps the declared keys inside this map. + * + * @var array> + */ + public const REFRESH_KEYS = [ + self::KEY_REGISTRY => [StoreService::CONFIG_URL, StoreService::CONFIG_REGISTER, StoreService::CONFIG_TOKEN], + ]; + + /** + * Prefix of the app-config key that remembers the last report per connection. + * + * @var string + */ + public const MEMORY_KEY_PREFIX = 'connection_report_'; + + /** + * Seconds after which the same status is reported again. + * + * @var int + */ + public const REPEAT_SECONDS = 3600; + + /** + * Seconds that must pass before a different status is reported. + * + * @var int + */ + public const CHANGE_SECONDS = 300; + + /** + * The pure outcome mapper. + * + * @var ConnectionObservations + */ + private readonly ConnectionObservations $observations; + + /** + * Constructor. + * + * @param IEventDispatcher $eventDispatcher Sends the integriq events (ADR-041). + * @param IAppConfig $appConfig Reads the registry address and allow-lists, and keeps the report memory. + * @param ITimeFactory $timeFactory Tells the time for the report memory. + * @param LoggerInterface $logger Records what could not be sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function __construct( + private readonly IEventDispatcher $eventDispatcher, + private readonly IAppConfig $appConfig, + private readonly ITimeFactory $timeFactory, + private readonly LoggerInterface $logger, + ) { + $this->observations = new ConnectionObservations(); + }//end __construct() + + /** + * Report what a dashboard registry search met. + * + * @param string $outcome The search outcome, `GenericStoreService::OUTCOME_*`. + * @param bool $engineAvailable Whether OpenRegister's store engine is available. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function reportRegistrySearch(string $outcome, bool $engineAvailable): bool { + return $this->reportObserved( + key: self::KEY_REGISTRY, + observe: fn (): ?array => $this->observations->registrySearch( + outcome: $outcome, + engineAvailable: $engineAvailable, + registryUrl: $this->appConfig->getValueString(self::APP_ID, StoreService::CONFIG_URL, '') + ) + ); + }//end reportRegistrySearch() + + /** + * Report what one outbound HTTP call of a widget or tile met. + * + * @param string $key One of the keys in {@see self::CALL_NAMES}. + * @param string $url The address called. Only its host reaches the message. + * @param int|null $httpStatus The answer's HTTP status, or null when nothing answered. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function reportCall(string $key, string $url, ?int $httpStatus): bool { + if (array_key_exists($key, self::CALL_NAMES) === false) { + return false; + } + + return $this->reportObserved( + key: $key, + observe: fn (): ?array => $this->observations->httpCall( + name: self::CALL_NAMES[$key], + url: $url, + httpStatus: $httpStatus + ) + ); + }//end reportCall() + + /** + * Report that a weather widget with a location found no provider URL. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function reportWeatherNotConfigured(): bool { + return $this->reportObserved( + key: self::KEY_WEATHER, + observe: fn (): array => $this->observations->weatherNotConfigured(configKey: WeatherService::CONFIG_KEY_PROVIDER_URL) + ); + }//end reportWeatherNotConfigured() + + /** + * Report that the weather provider URL is not http or https. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function reportWeatherInvalidUrl(): bool { + return $this->reportObserved( + key: self::KEY_WEATHER, + observe: fn (): array => $this->observations->weatherInvalidUrl(configKey: WeatherService::CONFIG_KEY_PROVIDER_URL) + ); + }//end reportWeatherInvalidUrl() + + /** + * Report that the weather provider answered without a readable temperature. + * + * @param string $url The address called. Only its host reaches the message. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function reportWeatherUnreadable(string $url): bool { + return $this->reportObserved( + key: self::KEY_WEATHER, + observe: fn (): array => $this->observations->weatherUnreadable(url: $url) + ); + }//end reportWeatherUnreadable() + + /** + * Report a fail-closed allow-list refusal, when the list holds no host. + * + * The list is read only after the class check, so an instance without + * integriq reads nothing extra. + * + * @param string $key One of the keys in {@see self::ALLOW_LISTS}. + * + * @return bool True when a report was sent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function reportAllowListRefusal(string $key): bool { + if (array_key_exists($key, self::ALLOW_LISTS) === false) { + return false; + } + + $configKey = self::ALLOW_LISTS[$key]; + + return $this->reportObserved( + key: $key, + observe: fn (): ?array => $this->observations->allowListRefused( + configKey: $configKey, + rawList: $this->appConfig->getValueString(self::APP_ID, $configKey, '') + ) + ); + }//end reportAllowListRefusal() + + /** + * The HTTP status a failed call still carries, for {@see reportCall()}. + * + * Pure: reads, stores and sends nothing. + * + * @param Throwable $exception What the call threw. + * + * @return int|null The answer's HTTP status, or null when nothing answered. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function httpStatusOf(Throwable $exception): ?int { + return $this->observations->httpStatusOf(exception: $exception); + }//end httpStatusOf() + + /** + * Ask integriq to resolve every connection whose config keys a save wrote. + * + * Clears that connection's report memory too, so the next call reports at + * once instead of waiting out the window. The refresh is sent here, before + * any report that follows the save. Integriq reads the saved values itself + * and decides the status (design D6). Never throws. + * + * @param array $savedKeys The app-config keys the save wrote. + * + * @return array The connection keys a refresh was sent for. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-002-a-registry-settings-save-asks-integriq-to-look-again + */ + public function refreshFromSave(array $savedKeys): array { + $eventClass = $this->resolveEventClass(eventClass: self::REFRESH_EVENT); + if ($eventClass === null) { + return []; + } + + $refreshed = []; + foreach (self::REFRESH_KEYS as $key => $configKeys) { + if (array_intersect($configKeys, $savedKeys) === []) { + continue; + } + + $this->forget(key: $key); + $sent = $this->send( + key: $key, + build: static fn (): object => new $eventClass( + app: self::APP_ID, + key: $key, + ) + ); + if ($sent === true) { + $refreshed[] = $key; + } + } + + return $refreshed; + }//end refreshFromSave() + + /** + * The event class to instantiate, or null when integriq does not ship it. + * + * @param string $eventClass The fully qualified class name, without a leading backslash. + * + * @return string|null The class name to instantiate, or null when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + protected function resolveEventClass(string $eventClass): ?string { + $qualified = '\\' . $eventClass; + if (class_exists($qualified) === false) { + return null; + } + + return $qualified; + }//end resolveEventClass() + + /** + * Observe, throttle and send one status report. Never throws. + * + * Without integriq the class check fails first, so nothing is read, + * stored, sent or logged. + * + * @param string $key One of {@see self::KEYS}. + * @param callable(): (array{0: string, 1: string}|null) $observe Works out the status and message, or null to report nothing. + * + * @return bool True when a report was sent. + */ + private function reportObserved(string $key, callable $observe): bool { + $eventClass = $this->resolveEventClass(eventClass: self::STATUS_EVENT); + if ($eventClass === null) { + return false; + } + + try { + $observed = $observe(); + if ($observed === null) { + return false; + } + + [$status, $message] = $observed; + + $now = $this->timeFactory->getTime(); + if ($this->isDue(key: $key, status: $status, now: $now) === false) { + return false; + } + + $sent = $this->send( + key: $key, + build: static fn (): object => new $eventClass( + app: self::APP_ID, + key: $key, + status: $status, + message: $message, + ) + ); + if ($sent === true) { + $this->appConfig->setValueString(self::APP_ID, self::MEMORY_KEY_PREFIX . $key, $status . '|' . $now); + } + + return $sent; + } catch (Throwable $e) { + $this->logger->warning( + 'LaunchPad: could not report a connection to integriq', + ['key' => $key, 'exception' => $e->getMessage()] + ); + return false; + }//end try + }//end reportObserved() + + /** + * Whether the report memory allows a report with this status now. + * + * A different status waits five minutes after the last report, so two + * widgets that disagree cannot report on every page load. The same status + * reports again after an hour. + * + * @param string $key The connection key. + * @param string $status The status the call observed. + * @param int $now The current Unix time. + * + * @return bool + */ + private function isDue(string $key, string $status, int $now): bool { + $memory = $this->appConfig->getValueString(self::APP_ID, self::MEMORY_KEY_PREFIX . $key, ''); + $parts = explode('|', $memory, 2); + if (count($parts) !== 2 || ctype_digit($parts[1]) === false) { + return true; + } + + $elapsed = ($now - (int) $parts[1]); + if ($parts[0] === $status) { + return $elapsed >= self::REPEAT_SECONDS; + } + + return $elapsed >= self::CHANGE_SECONDS; + }//end isDue() + + /** + * Clear the report memory of one connection. + * + * @param string $key The connection key. + * + * @return void + */ + private function forget(string $key): void { + try { + $this->appConfig->deleteKey(self::APP_ID, self::MEMORY_KEY_PREFIX . $key); + } catch (Throwable $e) { + $this->logger->warning( + 'LaunchPad: could not clear a connection report memory', + ['key' => $key, 'exception' => $e->getMessage()] + ); + } + }//end forget() + + /** + * Build and dispatch one event, swallowing anything a listener throws. + * + * @param string $key The connection the event is about, for the log. + * @param callable(): object $build Builds the event. + * + * @return bool True when the event was dispatched without an exception. + */ + private function send(string $key, callable $build): bool { + try { + $event = $build(); + if (($event instanceof Event) === false) { + return false; + } + + $this->eventDispatcher->dispatchTyped($event); + return true; + } catch (Throwable $e) { + $this->logger->warning( + 'LaunchPad: could not send a connection event to integriq', + ['key' => $key, 'exception' => $e->getMessage()] + ); + return false; + } + }//end send() +}//end class diff --git a/lib/Service/HealthPingService.php b/lib/Service/HealthPingService.php index e38acb1a5..623a3ec20 100644 --- a/lib/Service/HealthPingService.php +++ b/lib/Service/HealthPingService.php @@ -46,6 +46,7 @@ use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\WidgetPlacement; use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\ICache; @@ -159,6 +160,9 @@ class HealthPingService { * @param IAppConfig $appConfig Admin config: allow-listed hosts, latency threshold. * @param WidgetPlacementMapper $placementMapper Resolves placements by id / enumerates all placements. * @param LoggerInterface $logger PSR logger. + * @param ConnectionReporter|null $connectionReporter Tells integriq what a ping met, or nothing when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function __construct( private readonly IClientService $clientService, @@ -166,6 +170,7 @@ public function __construct( private readonly IAppConfig $appConfig, private readonly WidgetPlacementMapper $placementMapper, private readonly LoggerInterface $logger, + private readonly ?ConnectionReporter $connectionReporter = null, ) { }//end __construct() @@ -397,6 +402,8 @@ private function isDue(int $placementId, array $config): bool { * @param array $config The placement's health-ping config. * * @return array|null `{state, latencyMs}` or `null` when refused. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ private function attemptPing(array $config): ?array { $url = trim(string: (string)($config['healthUrl'] ?? '')); @@ -409,6 +416,7 @@ private function attemptPing(array $config): ?array { message: 'HealthPingService: host not on healthping_allowed_hosts, refusing ping (fail-closed)', context: ['app' => Application::APP_ID] ); + $this->connectionReporter?->reportAllowListRefusal(key: ConnectionReporter::KEY_HEALTH_PING); return null; } @@ -437,11 +445,13 @@ private function attemptPing(array $config): ?array { message: 'HealthPingService: ping transport failure, classifying offline', context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] ); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_HEALTH_PING, url: $url, httpStatus: null); return ['state' => 'offline', 'latencyMs' => $latencyMs]; }//end try $latencyMs = (int)round((microtime(as_float: true) - $startedAt) * 1000); $status = (int)$response->getStatusCode(); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_HEALTH_PING, url: $url, httpStatus: $status); if ($this->matchesExpectedStatus(status: $status, expectedStatus: $expectedStatus) === false) { return ['state' => 'offline', 'latencyMs' => $latencyMs]; diff --git a/lib/Service/LiveTileService.php b/lib/Service/LiveTileService.php index 3b4c65b5f..86b8fdf25 100644 --- a/lib/Service/LiveTileService.php +++ b/lib/Service/LiveTileService.php @@ -51,6 +51,7 @@ use DateTime; use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCA\LaunchPad\Support\FleetAppId; use OCP\App\IAppManager; use OCP\Http\Client\IClientService; @@ -181,6 +182,9 @@ class LiveTileService { * @param IAppConfig $appConfig Admin config: allow-listed hosts. * @param WidgetPlacementMapper $placementMapper Resolves placements by id. * @param LoggerInterface $logger PSR logger. + * @param ConnectionReporter|null $connectionReporter Tells integriq what a direct-URL fetch met, or nothing when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function __construct( private readonly IAppManager $appManager, @@ -190,6 +194,7 @@ public function __construct( private readonly IAppConfig $appConfig, private readonly WidgetPlacementMapper $placementMapper, private readonly LoggerInterface $logger, + private readonly ?ConnectionReporter $connectionReporter = null, ) { }//end __construct() @@ -423,6 +428,8 @@ private function fetchFromConnector(array $config): ?array { * @param array $config The placement's resolved config (`url`, `valueExpr`). * * @return array|null `{rawValue: mixed}` or `null`. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ private function fetchFromUrl(array $config): ?array { $url = trim(string: (string)($config['url'] ?? '')); @@ -439,6 +446,7 @@ private function fetchFromUrl(array $config): ?array { message: 'LiveTileService: host not on livetile_allowed_hosts, refusing fetch (fail-closed)', context: ['app' => Application::APP_ID] ); + $this->connectionReporter?->reportAllowListRefusal(key: ConnectionReporter::KEY_LIVE_TILES); return null; } @@ -460,10 +468,12 @@ private function fetchFromUrl(array $config): ?array { message: 'LiveTileService: direct-URL fetch failed', context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] ); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_LIVE_TILES, url: $url, httpStatus: null); return null; } $status = (int)$response->getStatusCode(); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_LIVE_TILES, url: $url, httpStatus: $status); if ($status < 200 || $status >= 300) { return null; } diff --git a/lib/Service/NewsWidgetService.php b/lib/Service/NewsWidgetService.php index d842e0a74..3b4b35423 100644 --- a/lib/Service/NewsWidgetService.php +++ b/lib/Service/NewsWidgetService.php @@ -30,6 +30,7 @@ use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\WidgetPlacement; use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCP\AppFramework\Db\DoesNotExistException; use OCP\Http\Client\IClientService; use OCP\IAppConfig; @@ -125,6 +126,9 @@ class NewsWidgetService { * hold raw feed payloads. * @param LoggerInterface $logger PSR logger. * @param UrlSafetyValidator $urlValidator Shared SSRF / allow-list guard. + * @param ConnectionReporter|null $connectionReporter Tells integriq what a feed fetch met, or nothing when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function __construct( private readonly WidgetPlacementMapper $placementMapper, @@ -133,6 +137,7 @@ public function __construct( private readonly ICacheFactory $cacheFactory, private readonly LoggerInterface $logger, private readonly UrlSafetyValidator $urlValidator, + private readonly ?ConnectionReporter $connectionReporter = null, ) { }//end __construct() @@ -825,9 +830,15 @@ private function clampLimit(int $limit): int { * caller can record the URL as failed without short-circuiting the * other URLs in the batch. * + * A live fetch (not a cache hit) is reported to integriq's connection + * registry, at most once an hour while it stays the same + * (adopt-connection-registry). The report never changes the payload. + * * @param string $url Feed URL to fetch. * * @return string|null Raw feed payload, or null on failure. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ private function fetchFeedPayload(string $url): ?string { $cache = $this->getCache(); @@ -858,9 +869,12 @@ private function fetchFeedPayload(string $url): ?string { $this->logger->warning( message: 'NewsWidget: feed fetch returned HTTP ' . $statusCode . ' for ' . $url ); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_NEWS_FEEDS, url: $url, httpStatus: $statusCode); return null; } + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_NEWS_FEEDS, url: $url, httpStatus: $statusCode); + $body = (string)$response->getBody(); if ($body === '') { return null; @@ -889,6 +903,11 @@ private function fetchFeedPayload(string $url): ?string { message: 'NewsWidget: feed fetch failed for ' . $url, context: ['exception' => $e] ); + $this->connectionReporter?->reportCall( + key: ConnectionReporter::KEY_NEWS_FEEDS, + url: $url, + httpStatus: $this->connectionReporter->httpStatusOf(exception: $e) + ); return null; }//end try }//end fetchFeedPayload() diff --git a/lib/Service/StoreService.php b/lib/Service/StoreService.php index 1feee8d4a..af51ac181 100644 --- a/lib/Service/StoreService.php +++ b/lib/Service/StoreService.php @@ -38,6 +38,7 @@ namespace OCA\LaunchPad\Service; use OCA\LaunchPad\AppInfo\Application; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCP\IAppConfig; use Psr\Log\LoggerInterface; use RuntimeException; @@ -121,14 +122,18 @@ class StoreService { * @param ImportService $importService LaunchPad's one and only dashboard importer. * @param IAppConfig $appConfig Where the engine reads the registry connection. * @param LoggerInterface $logger PSR logger — server-side diagnostics only. + * @param ConnectionReporter|null $connectionReporter Tells integriq what a search met and when a save changed the registry, or nothing when absent. * * @return void + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function __construct( private readonly ?object $discovery, private readonly ImportService $importService, private readonly IAppConfig $appConfig, private readonly LoggerInterface $logger, + private readonly ?ConnectionReporter $connectionReporter = null, ) { }//end __construct() @@ -151,6 +156,29 @@ public function isAvailable(): bool { * registry would otherwise read as an offline one, and the two need * different fixes. * + * The outcome is also reported to integriq's connection registry, at most + * once an hour while it stays the same (adopt-connection-registry). The + * report never changes the answer. + * + * @param string|null $query Optional free-text search term. + * @param string|null $kind Optional kind filter. + * + * @return array{outcome: string, cards: array>} + * + * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-003-the-engines-outcome-must-reach-the-caller-unchanged + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met + */ + public function search(?string $query = null, ?string $kind = null): array { + $answer = $this->searchRegistry(query: $query, kind: $kind); + + $this->connectionReporter?->reportRegistrySearch(outcome: $answer['outcome'], engineAvailable: $this->isAvailable()); + + return $answer; + }//end search() + + /** + * Ask the discovery engine, and pass its outcome through verbatim. + * * @param string|null $query Optional free-text search term. * @param string|null $kind Optional kind filter. * @@ -160,7 +188,7 @@ public function isAvailable(): bool { * * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-003-the-engines-outcome-must-reach-the-caller-unchanged */ - public function search(?string $query = null, ?string $kind = null): array { + private function searchRegistry(?string $query, ?string $kind): array { if ($this->discovery === null) { return ['outcome' => self::OUTCOME_NOT_CONFIGURED, 'cards' => []]; } @@ -184,7 +212,7 @@ public function search(?string $query = null, ?string $kind = null): array { 'outcome' => (string)($result['outcome'] ?? self::OUTCOME_NOT_CONFIGURED), 'cards' => $cards, ]; - }//end search() + }//end searchRegistry() /** * Install one remote dashboard template into this instance. @@ -286,26 +314,39 @@ public function getRegistryConfig(): array { * @param string|null $registryToken Bearer token, or null to leave it. * @param string|null $registryRegister Remote register segment, or null to leave it. * + * After the write it asks integriq to resolve the dashboard registry again + * when a registry key was written (adopt-connection-registry). That never + * throws, does nothing without integriq, and goes before any report that + * follows the save. + * * @return void * * @spec openspec/changes/store-plane-dashboard-sharing/specs/dashboard-store/spec.md#requirement-req-store-007-the-registry-token-must-not-be-readable-back + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-002-a-registry-settings-save-asks-integriq-to-look-again */ public function updateRegistryConfig( ?string $registryUrl = null, ?string $registryToken = null, ?string $registryRegister = null, ): void { + $written = []; + if ($registryUrl !== null) { $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_URL, trim($registryUrl)); + $written[] = self::CONFIG_URL; } if ($registryToken !== null) { $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_TOKEN, trim($registryToken)); + $written[] = self::CONFIG_TOKEN; } if ($registryRegister !== null) { $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_REGISTER, trim($registryRegister)); + $written[] = self::CONFIG_REGISTER; } + + $this->connectionReporter?->refreshFromSave(savedKeys: $written); }//end updateRegistryConfig() /** diff --git a/lib/Service/WeatherService.php b/lib/Service/WeatherService.php index 7b3f73844..6a063f905 100644 --- a/lib/Service/WeatherService.php +++ b/lib/Service/WeatherService.php @@ -35,6 +35,7 @@ use DateTime; use OCA\LaunchPad\AppInfo\Application; use OCA\LaunchPad\Db\WidgetPlacementMapper; +use OCA\LaunchPad\Service\Connection\ConnectionReporter; use OCP\App\IAppManager; use OCP\Http\Client\IClientService; use OCP\IAppConfig; @@ -200,6 +201,9 @@ class WeatherService { * @param IConfig $config Reads the requesting user's locale/language and * the system default. * @param LoggerInterface $logger PSR logger. + * @param ConnectionReporter|null $connectionReporter Tells integriq what a provider reading met, or nothing when absent. + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ public function __construct( private readonly IAppManager $appManager, @@ -210,6 +214,7 @@ public function __construct( private readonly WidgetPlacementMapper $placementMapper, private readonly IConfig $config, private readonly LoggerInterface $logger, + private readonly ?ConnectionReporter $connectionReporter = null, ) { }//end __construct() @@ -515,11 +520,17 @@ private function mapMetNoSymbol(string $symbolCode): string { * `null` — never throws — on any failure: no template configured, * invalid scheme, transport error, non-2xx, or unparseable body. * + * What the fetch met is reported to integriq's connection registry, at + * most once an hour while it stays the same (adopt-connection-registry). + * The report never changes the reading. + * * @param string $location The author-configured location string. * @param string $units `metric` or `imperial`. * @param string $language The resolved forecast language. * * @return array|null + * + * @spec openspec/changes/adopt-connection-registry/specs/app-connections/spec.md#requirement-req-lp-conn-003-launchpad-reports-what-its-outbound-calls-met */ private function fetchFromProviderUrl(string $location, string $units, string $language): ?array { $template = $this->appConfig->getValueString( @@ -528,6 +539,7 @@ private function fetchFromProviderUrl(string $location, string $units, string $l default: '' ); if ($template === '') { + $this->connectionReporter?->reportWeatherNotConfigured(); return null; } @@ -549,6 +561,7 @@ private function fetchFromProviderUrl(string $location, string $units, string $l message: 'WeatherService: provider URL has an invalid scheme, rejecting', context: ['app' => Application::APP_ID] ); + $this->connectionReporter?->reportWeatherInvalidUrl(); return null; } @@ -570,20 +583,30 @@ private function fetchFromProviderUrl(string $location, string $units, string $l message: 'WeatherService: provider fetch failed', context: ['app' => Application::APP_ID, 'exception' => $exception->getMessage()] ); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_WEATHER, url: $url, httpStatus: null); return null; } $status = (int)$response->getStatusCode(); if ($status < 200 || $status >= 300) { + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_WEATHER, url: $url, httpStatus: $status); return null; } $decoded = json_decode(json: (string)$response->getBody(), associative: true); - if (is_array(value: $decoded) === false) { + $reading = null; + if (is_array(value: $decoded) === true) { + $reading = $this->normaliseProviderPayload(payload: $decoded, location: $location, units: $units, language: $language); + } + + if ($reading === null) { + $this->connectionReporter?->reportWeatherUnreadable(url: $url); return null; } - return $this->normaliseProviderPayload(payload: $decoded, location: $location, units: $units, language: $language); + $this->connectionReporter?->reportCall(key: ConnectionReporter::KEY_WEATHER, url: $url, httpStatus: $status); + + return $reading; }//end fetchFromProviderUrl() /** diff --git a/lib/Settings/connections.json b/lib/Settings/connections.json new file mode 100644 index 000000000..647ee846a --- /dev/null +++ b/lib/Settings/connections.json @@ -0,0 +1,54 @@ +{ + "app": "launchpad", + "connections": [ + { + "key": "dashboard-registry", + "title": "Dashboard registry", + "description": "A registry of another instance, where you find and install dashboards other organisations published.", + "order": 10, + "settingsUrl": "/settings/admin/launchpad?tab=sharing#section-dashboard-registry", + "requiredConfig": ["registry_url"], + "unconfiguredMessage": "No registry address is set. Set the registry URL on the Sharing tab of the LaunchPad settings." + }, + { + "key": "weather", + "title": "Weather provider", + "description": "Weather widgets with a location read the provider URL an admin sets. Widgets without a location read Nextcloud's weather status app.", + "order": 20, + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. LaunchPad reports here after a weather widget with a location fetches a reading. Set weather_provider_url with occ." + }, + { + "key": "news-feeds", + "title": "News feeds", + "description": "News widgets fetch the RSS and Atom feeds they name. Each widget names its own address, so this row shows the last fetch.", + "order": 30, + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. LaunchPad reports here after a news widget fetches a feed." + }, + { + "key": "ics-calendars", + "title": "Calendar feeds", + "description": "Calendar widgets fetch the ICS feeds they name. Each widget names its own address, so this row shows the last fetch.", + "order": 40, + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. LaunchPad reports here after a calendar widget fetches an ICS feed." + }, + { + "key": "live-tiles", + "title": "Live tiles", + "description": "Live tiles fetch a value from the address they name. Only hosts in livetile_allowed_hosts are fetched.", + "order": 50, + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. LaunchPad reports here after a live tile fetches its value. Set livetile_allowed_hosts with occ." + }, + { + "key": "health-ping", + "title": "Health ping", + "description": "Tiles check whether the service they link to answers. Only hosts in healthping_allowed_hosts are pinged.", + "order": 60, + "reportedOnly": true, + "unconfiguredMessage": "Not checked yet. LaunchPad reports here after a tile pings its service. Set healthping_allowed_hosts with occ." + } + ] +} diff --git a/openspec/changes/adopt-connection-registry/.openspec.yaml b/openspec/changes/adopt-connection-registry/.openspec.yaml new file mode 100644 index 000000000..a40cb63c1 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-14 diff --git a/openspec/changes/adopt-connection-registry/design.md b/openspec/changes/adopt-connection-registry/design.md new file mode 100644 index 000000000..6686fe441 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/design.md @@ -0,0 +1,91 @@ +# Design: adopt-connection-registry + +The contract is hydra `openspec/changes/connection-registry/design.md` (hydra#667, amended in hydra#673, hydra#674 and hydra#676). This file records how LaunchPad meets it and where it fits loosely. + +## D1. Which connections are declared + +Each candidate was checked against the code on `development`, not against its name. + +| Key | Declared as | Why | +|---|---|---| +| `dashboard-registry` | `requiredConfig: ["registry_url"]`, `settingsUrl` to the Sharing tab | OpenRegister's `GenericStoreService` reads `registry_url` and `registry_token` from LaunchPad's app config. `StoreService::search()` is the one caller. The token is optional, so only the URL is required. | +| `weather` | `reportedOnly: true` | `WeatherService` picks its source per placement. A placement with a location calls `weather_provider_url`. A placement without one reads Nextcloud's weather status app for the viewing user. So an empty URL is a working default for some widgets and a broken one for others, and only a fetch can tell. | +| `news-feeds` | `reportedOnly: true` | `NewsWidgetService` fetches the feed URLs each widget names. `news_widget_allowed_feed_hosts` is fail-open: an empty list allows every HTTPS host, so there is nothing to require. | +| `ics-calendars` | `reportedOnly: true` | `CalendarWidgetService` fetches the ICS URLs each widget names. `calendar_widget_allowed_ics_hosts` is fail-open like the news list. | +| `live-tiles` | `reportedOnly: true` | `LiveTileService` fetches the URL each tile names. `livetile_allowed_hosts` is fail-closed: an empty list refuses every host. | +| `health-ping` | `reportedOnly: true` | `HealthPingService` pings the URL each tile names, on a page load and from `HealthPingRefreshJob`. `healthping_allowed_hosts` is fail-closed like the live tile list. | + +**Why only the registry links to settings.** The weather provider and the four allow-lists have no admin screen; `docs/features` tells an admin to set them with `occ`. A link would open a page that cannot change them. The registry form on the Sharing tab now carries `id="section-dashboard-registry"`, and the link adds `?tab=sharing` because `BeheerTabs` renders only the active tab. + +**Why a fail-closed allow-list is not `requiredConfig`.** The value is a JSON list. An admin who has not set it holds `""`, which D4 reads as empty. An admin who cleared it holds `[]`, which D4 reads as filled. So rule 5 would call a list that allows nothing Configured. A report after a refused fetch says it instead. + +**Why each family is one row.** Every widget names its own address, which is the case D12 leaves out. Each row stands for the family and shows the last fetch. + +## D2. What the reports say + +`ConnectionReporter` sends the events. `ConnectionObservations` turns an outcome into a status and a message, and holds no state. Each caller hands over the outcome it already had; no call is added. + +| Row | Outcome | Status | Message | +|---|---|---|---| +| `dashboard-registry` | OpenRegister absent | `unavailable` | "OpenRegister is not enabled, so LaunchPad cannot reach a dashboard registry." | +| | `ok` | `configured` | "The dashboard registry at {host} answered the last search." | +| | `not_configured` | `unconfigured` | "No registry address is set. Set the registry URL on the Sharing tab of the LaunchPad settings." | +| | `store_unreachable` | `error` | "The last search could not reach the dashboard registry at {host}." | +| | `store_invalid_response` | `error` | "The dashboard registry at {host} answered, but not with a list of templates." | +| | `rate_limited` | `limited` | "The dashboard registry at {host} limited the last search." | +| `weather` | no provider URL | `unconfigured` | "A weather widget with a location found no provider URL. Set weather_provider_url with occ." | +| | URL not http or https | `error` | "The weather provider URL does not start with http or https. Set weather_provider_url with occ." | +| | 2xx without a temperature | `error` | "The weather provider at {host} answered, but not with a temperature LaunchPad can read." | +| any HTTP row | no answer | `error` | "The last call to the {name} at {host} got no answer." | +| | HTTP 401 or 403 | `error` | "The {name} at {host} refused the request (HTTP 401)." | +| | HTTP 429 | `limited` | "The {name} at {host} limited the last call (HTTP 429)." | +| | HTTP 502, 503 or 504 | `error` | "The {name} at {host} answered HTTP 503 on the last call." | +| | HTTP 2xx | `configured` | "The {name} at {host} answered the last call." | +| `live-tiles`, `health-ping` | host refused, and the list holds no host | `unconfigured` | "{configKey} holds no JSON list of hosts, so LaunchPad refused the last call. Set it with occ." | + +The HTTP rows are `weather` (weather provider), `news-feeds` (news feed), `ics-calendars` (calendar feed), `live-tiles` (live tile source) and `health-ping` (health ping target). + +Anything else sends nothing. A 404, a 400, a 500 or a 3xx is about one address. A host that a non-empty allow-list refuses is about one widget. A weather status reading depends on one user. Those would make a working connection read Error. + +A message names the host of the address, and nothing else from it. No path, query, user info or API key reaches the row. + +## D3. When it reports + +The report memory is one app-config value per connection, `connection_report_{key}`, holding the last status and its time. + +- A different status reports once five minutes have passed since the last report, so two widgets that disagree cannot write on every page load. +- The same status reports again after an hour, so `lastReport` stays newer than a stale probe. +- A registry save clears the registry's memory, so the next search reports at once. + +Without integriq the class check fails first: nothing is read, stored, sent or logged. Every report method catches everything, because it runs beside a widget response whose own answer is what the caller returns. + +**Why this is cheap enough.** Dashboard widgets fetch on page load. Every service already caches its fetch (weather 900 s, feeds 3600 s, calendars 1800 s, live tiles and pings per tile), so a report can only follow a real outbound call. A report costs a `class_exists` and one app-config read, which Nextcloud has already loaded for the request. A write and an event happen at most once per window per connection. The report never changes what the widget answers. + +## D4. The refresh + +`StoreService::updateRegistryConfig()` is the one writer of the registry keys; `StoreController::updateConfig()` calls it for the Sharing tab. After the write it hands the keys it wrote to `ConnectionReporter::refreshFromSave()`. When `registry_url`, `registry_register` or `registry_token` is among them, the reporter clears the registry's report memory and sends `ConnectionRefreshRequestedEvent('launchpad', 'dashboard-registry')`. + +The refresh goes first and any report after it. A save sends no report of its own; the next search does, and because the memory was cleared, that report lands at once instead of waiting out the window. Under hydra#674 the refresh retires older observations, so a save brings back rule 5 until that search reports. + +The other five rows have no save to hook. A key set with `occ` sends no event, and integriq's hourly resolver pass covers that (D12 amendment 3). + +## D5. The page + +- `src/manifest.d/connection-registry.json`: an `index` page `Integrations` at `/settings/integrations`, `requiresApp` integriq, `permission: admin`, `showAdd: false`, and the contract columns: connection, status, status message, last checked, settings. +- Its menu entry `IntegrationsMenu` sits in the settings gear with `query: {app: launchpad}`, `permission: admin` and `visibleIf.appInstalled: integriq`. +- `src/services/connectionRegistry.js` holds `connectionStatus`, `connectionSettingsLabel` and `openIntegriqConnections`. +- `src/customComponents.js` exposes the handler, and `App.vue` passes it with the formatters to `CnAppRoot`. CnIndexPage resolves a header action's handler against `customComponents`, not against the v2 `registry`. + +**Formatters.** The installed `@conduction/nextcloud-vue` 2.46.0 ships no `connectionStatus` built-in, so LaunchPad carries a local copy with all six labels, `limited` included. + +## D6. Contract misfits + +- **An allow-list is a JSON list.** D4 counts `""`, `false`, `0` and `null` as empty, not `[]`. A fail-closed list therefore cannot be `requiredConfig` (D1). +- **No settings surface.** Five of six rows are configured with `occ` only, so they carry no `settingsUrl`, and their unconfigured messages name the key. +- **A source chosen per widget.** The weather source depends on whether a placement has a location. `reportedOnly` plus outcome reports is the closest fit. +- **A family row shows one address.** The last fetch stands for every widget. The message names that host, so the reader can tell which one. + +## Risks + +- **A mixed dashboard flips a family row.** One dead feed among working ones moves the row between Configured and Error, at most every five minutes. +- **A busy instance writes app config at most every five minutes per connection.** That is six keys. diff --git a/openspec/changes/adopt-connection-registry/proposal.md b/openspec/changes/adopt-connection-registry/proposal.md new file mode 100644 index 000000000..615de8e27 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/proposal.md @@ -0,0 +1,49 @@ +--- +kind: code +--- + +# Proposal: adopt-connection-registry + +## Why + +LaunchPad reaches six things outside its own code, and no screen says whether any of them works. + +- The dashboard registry. An admin saves a registry URL under Sharing. A search that cannot reach it shows an empty store and logs the reason. +- The weather provider. A weather widget with a set location calls the provider URL an admin set with `occ`. Without that URL the widget shows an error. +- News feeds, calendar feeds, live tiles and health pings. Each widget or tile names its own address. A failed fetch is a log line. +- Live tiles and health pings refuse every host until an admin sets an allow-list with `occ`. Nothing on a screen says so. + +Hydra change `connection-registry` (hydra#667, amended in hydra#673, hydra#674 and hydra#676) gives every app one page of its connections, backed by integriq. + +## What changes + +- New `lib/Settings/connections.json` with six connections: `dashboard-registry`, `weather`, `news-feeds`, `ics-calendars`, `live-tiles` and `health-ping`. +- The dashboard registry requires `registry_url` and links to a new `#section-dashboard-registry` anchor on the Sharing tab. The other five are reported only. +- An Integrations page under the settings gear, over integriq's `app_connection` schema, preset to `app=launchpad`, admin only, and only shown when integriq is installed. +- Add integration opens `/apps/integriq/connections?app=launchpad&link=1`. +- A registry settings save asks integriq to resolve the registry again. +- LaunchPad reports what a registry search, a weather reading, a feed fetch, a live tile fetch and a health ping met. It reports a change after five minutes, and the same status at most once an hour. +- Local `connectionStatus` and `connectionSettingsLabel` formatters with all six statuses, and the strings in English and Dutch. +- CI installs integriq, so the e2e spec can reach the page. + +## Not declared + +- **Nextcloud's weather status app.** A weather widget without a location reads it. Its reading depends on each user's own location, so a failure says nothing about the instance. +- **Live tiles in connector mode.** They call integriq on the same instance, which already shows its own sources. +- **Iframe embeds.** The browser loads the page, not LaunchPad, so LaunchPad sees no outcome. + +## Depends on + +- hydra `openspec/changes/connection-registry`, design D2, D4, D6, D8, D9 and D12. +- integriq on `development`: the `app_connection` schema, the declaration sync, both events and the Connections overview. + +Without integriq the menu entry is hidden, a deep link shows the missing-dependency screen, and nothing is sent. + +## Out of scope + +- Admin screens for the weather provider and the four allow-lists. They are set with `occ` today, so their rows carry no settings link. +- Per-widget rows. A static file cannot list them (design D12). + +## Rollback + +Revert the change. LaunchPad writes no rows of its own. Integriq removes the rows without a linked source on its next sync, and the report memory keys in app config stop being read. diff --git a/openspec/changes/adopt-connection-registry/specs/app-connections/spec.md b/openspec/changes/adopt-connection-registry/specs/app-connections/spec.md new file mode 100644 index 000000000..480ce362f --- /dev/null +++ b/openspec/changes/adopt-connection-registry/specs/app-connections/spec.md @@ -0,0 +1,134 @@ +# app-connections Specification Delta + +**Status**: proposed +**Scope**: launchpad +**OpenSpec changes**: +- [adopt-connection-registry](../../) + +## Purpose + +Admins see LaunchPad's outside connections on one page, with a status the app can back. + +## ADDED Requirements + +### Requirement: REQ-LP-CONN-001 LaunchPad declares its outside connections in one static file + +LaunchPad SHALL declare its outside connections in `lib/Settings/connections.json` in the shape of hydra connection-registry design D2 (hydra REQ-CONN-001). The file SHALL validate against integriq's `connections.schema.json`, and its `app` SHALL equal the id in `appinfo/info.xml`. It SHALL declare `dashboard-registry`, `weather`, `news-feeds`, `ics-calendars`, `live-tiles` and `health-ping`. `dashboard-registry` SHALL require `registry_url` and SHALL link to the `#section-dashboard-registry` anchor on the Sharing tab of the LaunchPad admin page, and that anchor SHALL exist. The other five SHALL be `reportedOnly`, SHALL carry no `requiredConfig` or `adapter`, and SHALL carry no `settingsUrl`, because no admin screen sets their keys. + +#### Scenario: The declaration names this app and passes integriq's schema +@e2e exclude A static file with no browser surface; tests/Unit/Settings/ConnectionsDeclarationTest.php validates it against the vendored schema, checks the app id, the unique keys and that every settings anchor exists. + +- **GIVEN** `lib/Settings/connections.json` +- **WHEN** it is validated against integriq's `connections.schema.json` +- **THEN** it SHALL validate +- **AND** its `app` SHALL equal the id in `appinfo/info.xml` +- **AND** every key SHALL be unique +- **AND** every `settingsUrl` anchor SHALL exist under `src/` + +#### Scenario: A saved registry URL reads configured +@e2e tests/e2e/integrations-page.spec.ts + +- **GIVEN** integriq has synced LaunchPad's declaration +- **WHEN** an admin saves a registry URL on the Sharing tab +- **THEN** the Dashboard registry row SHALL read Configured with "Required settings are filled." + +### Requirement: REQ-LP-CONN-002 A registry settings save asks integriq to look again + +When `StoreService::updateRegistryConfig()` writes `registry_url`, `registry_register` or `registry_token`, LaunchPad SHALL send `ConnectionRefreshRequestedEvent` with app `launchpad` and key `dashboard-registry` (hydra REQ-CONN-004), and SHALL clear the registry's report memory. The refresh SHALL be sent before any report that follows the save. A save that writes none of those keys SHALL send nothing. The event SHALL be named by string and sent only when the class exists. It SHALL NOT change the result of the save. + +#### Scenario: Saving the registry URL asks for a refresh +@e2e exclude The event is not observable from a browser; tests/Unit/Service/Connection/ConnectionCallersTest.php asserts the refresh and the written keys. + +- **GIVEN** integriq is installed +- **WHEN** an admin saves `registry_url` +- **THEN** LaunchPad SHALL send a refresh request for `dashboard-registry` + +#### Scenario: The refresh goes before the report that follows it +@e2e exclude Event order is not observable from a browser; tests/Unit/Service/Connection/ConnectionCallersTest.php asserts the order. + +- **GIVEN** a registry search reported `error` two minutes ago +- **WHEN** an admin saves `registry_url` and a user then searches the store +- **THEN** LaunchPad SHALL send the refresh request first +- **AND** it SHALL send the search's report after it, without waiting out the five minutes + +#### Scenario: A save that writes no registry key sends nothing +@e2e exclude The event is not observable from a browser; tests/Unit/Service/Connection/ConnectionReporterTest.php asserts that only registry keys refresh. + +- **GIVEN** integriq is installed +- **WHEN** the save writes no registry key +- **THEN** LaunchPad SHALL send no refresh request + +### Requirement: REQ-LP-CONN-003 LaunchPad reports what its outbound calls met + +LaunchPad SHALL report with `ConnectionStatusReportedEvent` what a registry search, a weather provider reading, a news feed fetch, a calendar feed fetch, a live tile fetch and a health ping met, mapped as in the change's design D2. It SHALL report only after a real outbound call or a refusal that says something about the instance, and SHALL NOT report outcomes about one address, one widget or one user. A message SHALL carry at most the host of an address. It SHALL report a different status only after five minutes have passed since the last report, and the same status only after an hour. Without integriq it SHALL read, store, send and log nothing. A report SHALL never throw into the call it observes or change the widget's response. + +#### Scenario: A registry that cannot be reached reads error +@e2e tests/e2e/integrations-page.spec.ts + +- **GIVEN** an admin saved a registry URL where nothing answers +- **WHEN** a user searches the dashboard store +- **THEN** LaunchPad SHALL report `dashboard-registry` as `error` with a message naming the host +- **AND** the search SHALL answer with the same outcome as before this change + +#### Scenario: A weather widget with a location and no provider URL reads unconfigured +@e2e exclude The CI instance sets no weather provider and no widget with a location; tests/Unit/Service/Connection/ConnectionCallersTest.php asserts the report and the unchanged error response. + +- **GIVEN** `weather_provider_url` is empty +- **WHEN** a weather widget with a location asks for a reading +- **THEN** LaunchPad SHALL report `weather` as `unconfigured`, naming `weather_provider_url` + +#### Scenario: A feed fetch reports only the host +@e2e exclude A feed needs a host outside the instance; tests/Unit/Service/Connection/ConnectionObservationsTest.php and tests/Unit/Service/Connection/ConnectionCallersTest.php assert the report and that no path, query or user info reaches it. + +- **GIVEN** a news widget names `https://user:secret@feeds.example.nl/rss?token=abc` +- **WHEN** the feed host answers HTTP 503 +- **THEN** LaunchPad SHALL report `news-feeds` as `error` naming `feeds.example.nl` +- **AND** the message SHALL NOT contain the path, the query or the user info + +#### Scenario: An empty fail-closed allow-list reads unconfigured +@e2e exclude The CI instance sets no live tile or health ping; tests/Unit/Service/Connection/ConnectionCallersTest.php asserts the report through the health ping service, and tests/Unit/Service/Connection/ConnectionReporterTest.php asserts both lists and that a refusal by a non-empty list sends nothing. + +- **GIVEN** `healthping_allowed_hosts` holds no JSON list of hosts +- **WHEN** a tile's health ping is refused +- **THEN** LaunchPad SHALL report `health-ping` as `unconfigured`, naming `healthping_allowed_hosts` + +#### Scenario: Many widget fetches send one report per window +@e2e exclude Timing is not observable from a browser; tests/Unit/Service/Connection/ConnectionReporterTest.php drives the clock through a burst of fetches. + +- **GIVEN** a calendar feed fetch reported `configured` ten minutes ago +- **WHEN** fifty more fetches get an answer within the hour +- **THEN** LaunchPad SHALL send no report + +#### Scenario: Without integriq nothing is sent +@e2e exclude The CI instance installs integriq; tests/Unit/Service/Connection/ConnectionReporterTest.php asserts that nothing is sent, stored or logged when the class is absent. + +- **GIVEN** integriq is not installed +- **WHEN** a feed fetch runs or an admin saves `registry_url` +- **THEN** no event SHALL be sent and nothing SHALL be logged +- **AND** no report memory SHALL be written to app config + +### Requirement: REQ-LP-CONN-004 An admin reads the connections on an Integrations page + +LaunchPad SHALL render an `index` page at `/settings/integrations` over `integriq/app_connection`, reached from the settings gear and preset to `app` equal to `launchpad` through its menu entry's `query` (hydra REQ-CONN-006). The page and its menu entry SHALL be admin only. The page SHALL require Integriq, and the menu entry SHALL only render when integriq is installed. The status column SHALL name all six statuses, `limited` included. The page SHALL NOT offer a generic Add button. Its Add integration action SHALL open `/apps/integriq/connections?app=launchpad&link=1`. + +#### Scenario: The page lists only the rows of launchpad +@e2e tests/e2e/integrations-page.spec.ts + +- **GIVEN** launchpad and integriq are installed and integriq has synced the declaration +- **WHEN** an admin opens the Integrations page +- **THEN** the page SHALL list the six declared connections +- **AND** every listed row SHALL have `app` equal to `launchpad` + +#### Scenario: Add integration goes to integriq +@e2e tests/e2e/integrations-page.spec.ts + +- **GIVEN** the Integrations page +- **WHEN** the admin chooses Add integration +- **THEN** the browser SHALL open integriq's Connections overview with `app=launchpad` and `link=1` + +#### Scenario: A connection that works in part reads Limited +@e2e exclude No CI host rate-limits a call, so no row reads limited there; src/services/__tests__/connectionRegistry.spec.js asserts the label in English and Dutch. + +- **GIVEN** a row whose status is `limited` +- **WHEN** the page renders it +- **THEN** the cell SHALL read Limited, or Beperkt on a Dutch instance diff --git a/openspec/changes/adopt-connection-registry/tasks.md b/openspec/changes/adopt-connection-registry/tasks.md new file mode 100644 index 000000000..8bb96e810 --- /dev/null +++ b/openspec/changes/adopt-connection-registry/tasks.md @@ -0,0 +1,32 @@ +# adopt-connection-registry tasks + +## 1. Declare + +- [x] 1.1 Write `lib/Settings/connections.json` with `dashboard-registry`, `weather`, `news-feeds`, `ics-calendars`, `live-tiles` and `health-ping`. +- [x] 1.2 Vendor integriq's schema in `tests/Fixtures/Integriq/` and guard the file in `tests/Unit/Settings/ConnectionsDeclarationTest.php`. +- [x] 1.3 Give the dashboard registry form the `section-dashboard-registry` id. + +## 2. Page + +- [x] 2.1 Add `src/manifest.d/connection-registry.json` with the page and its settings-gear menu entry. +- [x] 2.2 Add `src/services/connectionRegistry.js` with the two formatters and the Add integration handler. +- [x] 2.3 Wire the formatters and the handler through `src/App.vue` and `src/customComponents.js`; register `PowerPlugOutline` in `src/icons.js`. +- [x] 2.4 Add the strings to `l10n/en` and `l10n/nl`. +- [x] 2.5 Cover it in `src/services/__tests__/connectionRegistry.spec.js`. + +## 3. Reports and refresh + +- [x] 3.1 Add `lib/Service/Connection/ConnectionReporter.php` and `ConnectionObservations.php`. +- [x] 3.2 Refresh from `StoreService::updateRegistryConfig()`, and wire the reporter into the hand-built `StoreService` in `Application.php`. +- [x] 3.3 Report from `StoreService`, `WeatherService`, `NewsWidgetService`, `CalendarWidgetService`, `LiveTileService` and `HealthPingService`. +- [x] 3.4 Add the integriq event stubs to `tests/Stubs`, `tests/bootstrap.php` and `psalm.xml`. +- [x] 3.5 Cover it in unit tests, including a burst of widget fetches against the throttle. + +## 4. End to end + +- [x] 4.1 Write `tests/e2e/integrations-page.spec.ts`. +- [x] 4.2 Install integriq in the CI `additional-apps`. + +## 5. After an instance runs both apps + +- [ ] 5.1 Run the e2e spec against an instance with launchpad and integriq, then archive this change. diff --git a/psalm.xml b/psalm.xml index 545c2de96..8bb96e880 100644 --- a/psalm.xml +++ b/psalm.xml @@ -16,6 +16,14 @@ + + + diff --git a/src/App.vue b/src/App.vue index 2edcce6bc..69b0a842c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -17,12 +17,17 @@ :manifest="liveManifest" :registry="registry" :pageTypes="pageTypes" + :customComponents="customComponents" + :formatters="formatters" :permissions="permissions" appId="launchpad" />