From 3a4826c798b913c6301c11ee520643ffa04c9806 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:00:19 -0400 Subject: [PATCH] Add: import links and folders from another dashboard Reads gethomepage services.yaml and bookmarks.yaml and Dashy conf.yml, converting groups to folders and services to app tiles. A widget in the source becomes a plain link, never a Stackyard widget. Parsing is an indentation-only YAML subset with no dependency. It refuses anchors, flow collections, block scalars, tabs and multi-document files with the offending line, rather than guessing at a construct it cannot represent faithfully. An import appends and never merges: it is previewed first, listing what will be added, what changed on the way in, and everything skipped with a reason. The save reads the stored config immediately before writing, so a change made elsewhere while the preview was open survives. --- CHANGELOG.md | 5 + README.md | 2 +- tsconfig.frontend.json | 18 + ui/admin/index.html | 6 +- ui/css/admin.css | 22 +- ui/i18n/de.json | 52 ++- ui/i18n/en.json | 52 ++- ui/i18n/es.json | 52 ++- ui/i18n/fa.json | 52 ++- ui/i18n/fr.json | 52 ++- ui/i18n/zh-Hans.json | 52 ++- ui/js/admin.js | 303 +++++++++++----- ui/js/import-foreign.js | 423 +++++++++++++++++++++++ ui/js/modal.js | 147 ++++++++ ui/js/yaml-lite.js | 196 +++++++++++ ui/test/fixtures/dashy-conf.yml | 102 ++++++ ui/test/fixtures/homepage-bookmarks.yaml | 25 ++ ui/test/fixtures/homepage-services.yaml | 76 ++++ ui/test/import-fixtures.test.mjs | 142 ++++++++ ui/test/import-foreign.test.mjs | 362 +++++++++++++++++++ ui/test/yaml-lite.test.mjs | 139 ++++++++ 21 files changed, 2163 insertions(+), 117 deletions(-) create mode 100644 ui/js/import-foreign.js create mode 100644 ui/js/modal.js create mode 100644 ui/js/yaml-lite.js create mode 100644 ui/test/fixtures/dashy-conf.yml create mode 100644 ui/test/fixtures/homepage-bookmarks.yaml create mode 100644 ui/test/fixtures/homepage-services.yaml create mode 100644 ui/test/import-fixtures.test.mjs create mode 100644 ui/test/import-foreign.test.mjs create mode 100644 ui/test/yaml-lite.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ea400e32..cd75a9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Import links and folders from a gethomepage or Dashy YAML config. + - The Docker Hub repository description is published from `README.md` on each stable release. @@ -17,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Confirming an import and naming a new folder use in-page dialogs instead of + the browser's own. + - The container prints six lines when it starts, instead of twenty-three. - Nginx request errors now appear in `docker logs`, and Nginx no longer writes diff --git a/README.md b/README.md index 56b69f18..d0f31a7d 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Then run `stackyard:local` the same way as above. For working on the code withou Everything is configured in the admin UI (`/admin`), split into a few sections: -- **General**: title, description, and your server's **Host IP** (used to allow your own server in badge URLs and SSRF checks). Also **Monitoring** (logging level, Docker container health checks, and the socket URL used to reach Docker), optional **password protection**, and config **import / export**. +- **General**: title, description, and your server's **Host IP** (used to allow your own server in badge URLs and SSRF checks). Also **Monitoring** (logging level, Docker container health checks, and the socket URL used to reach Docker), optional **password protection**, and config **import / export**. Backup also imports a gethomepage or Dashy YAML config: links and folders are added to the dashboard, existing items are left alone, and anything that cannot be carried across is listed before you confirm. - **Appearance**: wallpaper and the overall look. - **Dashboard**: add and arrange your apps, folders, and widgets, and configure each one (including live activity badges). - **About**: version and links. diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json index 01e09fad..db9c26cf 100644 --- a/tsconfig.frontend.json +++ b/tsconfig.frontend.json @@ -136,6 +136,12 @@ "/js/html.js?v=*": [ "./ui/js/html.js" ], + "/js/import-foreign.js": [ + "./ui/js/import-foreign.js" + ], + "/js/import-foreign.js?v=*": [ + "./ui/js/import-foreign.js" + ], "/js/i18n-markup.js": [ "./ui/js/i18n-markup.js" ], @@ -160,6 +166,12 @@ "/js/link-url.js?v=*": [ "./ui/js/link-url.js" ], + "/js/modal.js": [ + "./ui/js/modal.js" + ], + "/js/modal.js?v=*": [ + "./ui/js/modal.js" + ], "/js/mobile-metrics.js": [ "./ui/js/mobile-metrics.js" ], @@ -190,6 +202,12 @@ "/js/utils.js?v=*": [ "./ui/js/utils.js" ], + "/js/yaml-lite.js": [ + "./ui/js/yaml-lite.js" + ], + "/js/yaml-lite.js?v=*": [ + "./ui/js/yaml-lite.js" + ], "/js/widget-config-form.js": [ "./ui/js/widget-config-form.js" ], diff --git a/ui/admin/index.html b/ui/admin/index.html index b6e8535c..262bdf8b 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -8,7 +8,7 @@ Stackyard Settings - + @@ -107,7 +107,9 @@
Backup
Import / Export
+
Import from another dashboard
+

Reads gethomepage and Dashy YAML files. Links and folders are added to your dashboard; widgets from the other dashboard become plain links.

@@ -283,6 +285,6 @@

General

- + diff --git a/ui/css/admin.css b/ui/css/admin.css index 19bb445d..2dadc2b9 100644 --- a/ui/css/admin.css +++ b/ui/css/admin.css @@ -341,12 +341,21 @@ html{background:var(--bg-color,var(--bg-outer))} .sub{overflow:hidden;max-height:0;transition:max-height .25s ease;padding-inline-start:4px} .sub.open{max-height:2000px} -/* ── Folder picker ── */ -.fp-ov{position:fixed;inset:0;z-index:600;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:24px} -.fp-box{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);width:100%;max-width:380px;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,.6)} -.fp-hdr{padding:16px 20px;font-size:15px;font-weight:700;border-bottom:1px solid var(--bd-inner)} -.fp-list{overflow-y:auto;flex:1;padding:8px} -.fp-foot{padding:12px 16px;border-top:1px solid var(--bd-inner);display:flex;justify-content:flex-end} +/* ── Modal dialog (folder picker, import preview, prompts) ── */ +.dlg-ov{position:fixed;inset:0;z-index:600;background:rgba(0,0,0,.55);display:flex;align-items:center;justify-content:center;padding:24px} +.dlg-box{background:var(--bg-outer);border:1px solid var(--bd);border-radius:var(--r);width:100%;max-width:380px;max-height:70vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,.6)} +.dlg-ov.wide .dlg-box{max-width:560px;max-height:80vh} +.dlg-hdr{padding:16px 20px;font-size:15px;font-weight:700;border-bottom:1px solid var(--bd-inner)} +.dlg-body{overflow-y:auto;flex:1;padding:8px} +.dlg-foot{padding:12px 16px;border-top:1px solid var(--bd-inner);display:flex;justify-content:flex-end;gap:8px} +.dlg-field{display:flex;flex-direction:column;gap:6px;padding:12px} +.dlg-field span{font-size:13px;color:var(--dm)} +.dlg-lead{padding:8px 12px 4px;font-size:14px} +.dlg-sec{padding:12px 12px 4px;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--dm)} +.dlg-ul{list-style:none;margin:0;padding:0 12px 8px;display:flex;flex-direction:column;gap:4px} +.dlg-li{display:flex;align-items:baseline;gap:8px;font-size:13px} +.dlg-li .dlg-why{color:var(--dm);font-size:12px} +.dlg-empty{padding:24px;text-align:center;color:var(--dm);font-size:13px} .fp-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--rs);background:none;border:none;color:var(--tx);font-size:14px;cursor:pointer;width:100%;text-align:start;font-family:inherit;transition:background var(--t)} .fp-row:hover{background:rgba(255,255,255,.06)} .fp-row.cur{background:rgba(2,122,255,.12)} @@ -356,7 +365,6 @@ html{background:var(--bg-color,var(--bg-outer))} .fp-ic img{width:18px;height:18px;object-fit:contain} .fp-nm{flex:1} .fp-chk{color:var(--gn);font-weight:700} -.fp-empty{padding:24px;text-align:center;color:var(--dm);font-size:13px} /* ── Icon picker ── */ .ipw{position:relative} diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 124714d0..88e885bc 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -21,7 +21,9 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "edit": "Bearbeiten", - "color": "Farbe" + "color": "Farbe", + "create": "Erstellen", + "chooseFiles": "Dateien wählen" }, "general": { "title": "Titel", @@ -44,7 +46,9 @@ "passwordTip": "Leer lassen, um das bestehende Passwort beizubehalten. Mindestens 8 Zeichen. Für stärkeren Schutz solltest du das Dashboard hinter einem Authentication Proxy platzieren. Dieses integrierte Passwort ist nur eine leichte Absicherung, keine vollständige Authentifizierungsebene.", "backup": "Sicherung", "importExport": "Import / Export", - "signOut": "Abmelden" + "signOut": "Abmelden", + "importForeign": "Aus anderem Dashboard importieren", + "importForeignTip": "Liest YAML-Dateien von gethomepage und Dashy. Links und Ordner werden deinem Dashboard hinzugefügt; Widgets des anderen Dashboards werden zu einfachen Links." }, "appearance": { "appTitle": "App-Titel", @@ -85,7 +89,12 @@ "noApps": "Keine Apps verfügbar", "tip": "Tippen, um Apps in diesem Ordner hinzuzufügen oder zu entfernen.", "namePh": "Mein Ordner", - "addAppToFolder": "App zu diesem Ordner hinzufügen" + "addAppToFolder": "App zu diesem Ordner hinzufügen", + "moveTo": "\"{name}\" in Ordner verschieben", + "addApp": "App zum Ordner hinzufügen", + "none": "Kein Ordner", + "allInFolder": "Alle Apps sind bereits in diesem Ordner.", + "createNew": "Neuen Ordner erstellen" }, "app": { "name": "Name", @@ -159,7 +168,11 @@ "importNoChange": "Nichts zu importieren — die Datei stimmt mit deiner aktuellen Konfiguration überein.", "pwWeak": "Passwort zu schwach: {label}", "sessionsRevoked": "Alle Geräte wurden abgemeldet.", - "authNeedsPassword": "Legen Sie ein Passwort fest, bevor Sie die Authentifizierung aktivieren." + "authNeedsPassword": "Legen Sie ein Passwort fest, bevor Sie die Authentifizierung aktivieren.", + "importForeignDone": "Import abgeschlossen. Apps: {apps}. Ordner: {folders}.", + "importForeignNothing": "Aus diesen Dateien gibt es nichts zu importieren.", + "importYamlUnsupported": "{file}: {reason} (Zeile {line})", + "importUnknownFormat": "{file} ist keine gethomepage- oder Dashy-Konfiguration." }, "confirm": { "deleteFolder": "Ordner „{name}“ löschen? Enthaltene Apps werden nicht gelöscht.", @@ -172,7 +185,8 @@ "noMatches": "Keine Treffer." }, "import": { - "confirm": "{n} Elemente importieren?\n\n{added} hinzugefügt, {updated} aktualisiert, {deleted} gelöscht." + "confirm": "{n} Elemente importieren? {added} hinzugefügt, {updated} aktualisiert, {deleted} gelöscht.", + "confirmTitle": "Konfiguration importieren" }, "status": { "needsAttention": "Status: benötigt Aufmerksamkeit", @@ -215,5 +229,33 @@ "refusedPlural": "{n} Widgets konnten nicht geladen werden und werden unten nicht aufgeführt.", "name": "Name", "type": "Widget-Typ" + }, + "importForeign": { + "title": "Aus anderem Dashboard importieren", + "lead": "An deinem bestehenden Dashboard ändert sich nichts.", + "willAdd": "Hinzuzufügende Apps", + "willCreate": "Anzulegende Ordner", + "changed": "Mit Änderungen importiert", + "notImported": "Nicht importiert", + "appCount": "({n})", + "skipNoLabel": "kein Name", + "skipNoHref": "kein Link", + "skipUnsafeHref": "nicht unterstütztes Link-Schema", + "skipPlaceholderHref": "der Link braucht eine Variable, die nur das andere Dashboard kennt", + "noteIcon": "Symbol verworfen: {detail}", + "notePing": "ICMP-Ping verworfen: {detail}", + "noteRemoteContainer": "Container ignoriert, er läuft auf {detail}", + "noteFlattened": "verschachtelte Gruppe wurde ein eigener Ordner", + "noteSubItems": "Untereintrag in den Ordner verschoben", + "noteWidgetLink": "Widget als Link importiert", + "noteWidgetDropped": "{detail} Bereichs-Widgets verworfen", + "noteFields": "Felder verworfen: {detail}", + "notePages": "{detail} weitere Seiten nicht verfolgt", + "noteLocalUrl": "zweite lokale Adresse verworfen: {detail}", + "wholeFile": "diese Datei", + "untitledFolder": "Importiert", + "skipRelativeHref": "der Link hat keine Adresse, nur einen Pfad", + "skipUnreadable": "dieser Eintrag hat nicht die Form, die das Dateiformat erlaubt", + "monitors": "· fragt {url} ab" } } diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 76e002d2..3ff9b8b6 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -21,7 +21,9 @@ "showPassword": "Show password", "hidePassword": "Hide password", "edit": "Edit", - "color": "Color" + "color": "Color", + "create": "Create", + "chooseFiles": "Choose files" }, "general": { "title": "Title", @@ -44,7 +46,9 @@ "passwordTip": "Leave blank to keep existing password. Min 8 characters. For stronger protection, consider placing the dashboard behind a dedicated authentication proxy. This built-in password is a lightweight gate, not a full authentication layer.", "backup": "Backup", "importExport": "Import / Export", - "signOut": "Sign out" + "signOut": "Sign out", + "importForeign": "Import from another dashboard", + "importForeignTip": "Reads gethomepage and Dashy YAML files. Links and folders are added to your dashboard; widgets from the other dashboard become plain links." }, "appearance": { "appTitle": "App Title", @@ -85,7 +89,12 @@ "noApps": "No apps available", "tip": "Tap to add or remove apps from this folder.", "namePh": "My Folder", - "addAppToFolder": "Add app to this folder" + "addAppToFolder": "Add app to this folder", + "moveTo": "Move \"{name}\" to folder", + "addApp": "Add app to folder", + "none": "No folder", + "allInFolder": "All apps are already in this folder.", + "createNew": "Create new folder" }, "app": { "name": "Name", @@ -159,7 +168,11 @@ "importNoChange": "Nothing to import — the file matches your current config.", "pwWeak": "Password too weak: {label}", "sessionsRevoked": "All devices signed out.", - "authNeedsPassword": "Set a password before turning authentication on." + "authNeedsPassword": "Set a password before turning authentication on.", + "importForeignDone": "Import finished. Apps: {apps}. Folders: {folders}.", + "importForeignNothing": "Nothing to import from those files.", + "importYamlUnsupported": "{file}: {reason} (line {line})", + "importUnknownFormat": "{file} is not a gethomepage or Dashy config." }, "confirm": { "deleteFolder": "Delete folder \"{name}\"? Apps inside will not be deleted.", @@ -172,7 +185,8 @@ "noMatches": "No matches." }, "import": { - "confirm": "Import {n} items?\n\n{added} added, {updated} updated, {deleted} deleted." + "confirm": "Import {n} items? {added} added, {updated} updated, {deleted} deleted.", + "confirmTitle": "Import configuration" }, "status": { "needsAttention": "Status: needs attention", @@ -215,5 +229,33 @@ "refusedPlural": "{n} widgets could not be loaded, so they are not listed below.", "name": "Name", "type": "Widget Type" + }, + "importForeign": { + "title": "Import from another dashboard", + "lead": "Nothing already on your dashboard changes.", + "willAdd": "Apps to add", + "willCreate": "Folders to create", + "changed": "Imported with changes", + "notImported": "Not imported", + "appCount": "({n})", + "skipNoLabel": "no name", + "skipNoHref": "no link", + "skipUnsafeHref": "unsupported link scheme", + "skipPlaceholderHref": "the link needs a variable only the other dashboard can fill in", + "noteIcon": "icon dropped: {detail}", + "notePing": "ICMP ping dropped: {detail}", + "noteRemoteContainer": "container ignored, it runs on {detail}", + "noteFlattened": "nested group became its own folder", + "noteSubItems": "sub-item moved into the folder", + "noteWidgetLink": "widget imported as a link", + "noteWidgetDropped": "{detail} section widgets dropped", + "noteFields": "fields dropped: {detail}", + "notePages": "{detail} extra pages not followed", + "noteLocalUrl": "second local address dropped: {detail}", + "wholeFile": "this file", + "untitledFolder": "Imported", + "skipRelativeHref": "the link has no address, only a path", + "skipUnreadable": "this entry is not in a shape the file format allows", + "monitors": "· polls {url}" } } diff --git a/ui/i18n/es.json b/ui/i18n/es.json index 0d2bfb8c..cc8388ae 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -21,7 +21,9 @@ "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña", "edit": "Editar", - "color": "Color" + "color": "Color", + "create": "Crear", + "chooseFiles": "Elegir archivos" }, "general": { "title": "Título", @@ -44,7 +46,9 @@ "passwordTip": "Déjalo en blanco para mantener la contraseña actual. Mínimo 8 caracteres. Para una protección más sólida, considera colocar el panel detrás de un Authentication Proxy. Esta contraseña integrada es una barrera ligera, no una capa de autenticación completa.", "backup": "Copia de seguridad", "importExport": "Importar / Exportar", - "signOut": "Cerrar sesión" + "signOut": "Cerrar sesión", + "importForeign": "Importar desde otro panel", + "importForeignTip": "Lee archivos YAML de gethomepage y Dashy. Los enlaces y las carpetas se añaden a tu panel; los widgets del otro panel se convierten en enlaces simples." }, "appearance": { "appTitle": "Título de la aplicación", @@ -85,7 +89,12 @@ "noApps": "No hay aplicaciones disponibles", "tip": "Toca para añadir o quitar aplicaciones de esta carpeta.", "namePh": "Mi carpeta", - "addAppToFolder": "Añadir aplicación a esta carpeta" + "addAppToFolder": "Añadir aplicación a esta carpeta", + "moveTo": "Mover \"{name}\" a una carpeta", + "addApp": "Añadir app a la carpeta", + "none": "Sin carpeta", + "allInFolder": "Todas las apps ya están en esta carpeta.", + "createNew": "Crear carpeta nueva" }, "app": { "name": "Nombre", @@ -159,7 +168,11 @@ "importNoChange": "Nada que importar — el archivo coincide con tu configuración actual.", "pwWeak": "Contraseña demasiado débil: {label}", "sessionsRevoked": "Se cerró la sesión en todos los dispositivos.", - "authNeedsPassword": "Establece una contraseña antes de activar la autenticación." + "authNeedsPassword": "Establece una contraseña antes de activar la autenticación.", + "importForeignDone": "Importación terminada. Apps: {apps}. Carpetas: {folders}.", + "importForeignNothing": "No hay nada que importar en esos archivos.", + "importYamlUnsupported": "{file}: {reason} (línea {line})", + "importUnknownFormat": "{file} no es una configuración de gethomepage ni de Dashy." }, "confirm": { "deleteFolder": "¿Eliminar la carpeta \"{name}\"? Las aplicaciones que contiene no se eliminarán.", @@ -172,7 +185,8 @@ "noMatches": "Sin coincidencias." }, "import": { - "confirm": "¿Importar {n} elementos?\n\n{added} añadidos, {updated} actualizados, {deleted} eliminados." + "confirm": "¿Importar {n} elementos? {added} añadidos, {updated} actualizados, {deleted} eliminados.", + "confirmTitle": "Importar configuración" }, "status": { "needsAttention": "Estado: necesita atención", @@ -215,5 +229,33 @@ "refusedPlural": "No se pudieron cargar {n} widgets, por lo que no aparecen en la lista.", "name": "Nombre", "type": "Tipo de widget" + }, + "importForeign": { + "title": "Importar desde otro panel", + "lead": "No cambia nada de lo que ya tienes en el panel.", + "willAdd": "Apps que se añadirán", + "willCreate": "Carpetas que se crearán", + "changed": "Importado con cambios", + "notImported": "No importado", + "appCount": "({n})", + "skipNoLabel": "sin nombre", + "skipNoHref": "sin enlace", + "skipUnsafeHref": "esquema de enlace no admitido", + "skipPlaceholderHref": "el enlace necesita una variable que solo conoce el otro panel", + "noteIcon": "icono descartado: {detail}", + "notePing": "ping ICMP descartado: {detail}", + "noteRemoteContainer": "contenedor ignorado, se ejecuta en {detail}", + "noteFlattened": "el grupo anidado pasó a ser su propia carpeta", + "noteSubItems": "subelemento movido a la carpeta", + "noteWidgetLink": "widget importado como enlace", + "noteWidgetDropped": "{detail} widgets de sección descartados", + "noteFields": "campos descartados: {detail}", + "notePages": "{detail} páginas adicionales no seguidas", + "noteLocalUrl": "segunda dirección local descartada: {detail}", + "wholeFile": "este archivo", + "untitledFolder": "Importado", + "skipRelativeHref": "el enlace no tiene dirección, solo una ruta", + "skipUnreadable": "esta entrada no tiene la forma que permite el formato del archivo", + "monitors": "· sondea {url}" } } diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 91ea3e1e..630ea8ca 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -21,7 +21,9 @@ "showPassword": "نمایش گذرواژه", "hidePassword": "پنهان کردن گذرواژه", "edit": "ویرایش", - "color": "رنگ" + "color": "رنگ", + "create": "ساختن", + "chooseFiles": "انتخاب فایل‌ها" }, "general": { "title": "عنوان", @@ -44,7 +46,9 @@ "passwordTip": "برای حفظ رمز عبور فعلی، این قسمت را خالی بگذارید. حداقل ۸ کاراکتر. برای محافظت قوی‌تر، قرار دادن داشبورد پشت یک Authentication Proxy را در نظر بگیرید. این رمز عبور داخلی یک محافظ سبک است، نه یک لایه کامل احراز هویت.", "backup": "پشتیبان‌گیری", "importExport": "وارد کردن / خروجی گرفتن", - "signOut": "خروج از حساب" + "signOut": "خروج از حساب", + "importForeign": "درون‌ریزی از داشبورد دیگر", + "importForeignTip": "فایل‌های YAML مربوط به gethomepage و Dashy را می‌خواند. پیوندها و پوشه‌ها به داشبورد شما افزوده می‌شوند؛ ویجت‌های داشبورد دیگر به پیوند ساده تبدیل می‌شوند." }, "appearance": { "appTitle": "عنوان برنامه", @@ -85,7 +89,12 @@ "noApps": "هیچ برنامه‌ای موجود نیست", "tip": "برای افزودن یا حذف برنامه از این پوشه ضربه بزنید.", "namePh": "پوشه من", - "addAppToFolder": "افزودن برنامه به این پوشه" + "addAppToFolder": "افزودن برنامه به این پوشه", + "moveTo": "انتقال «{name}» به پوشه", + "addApp": "افزودن برنامه به پوشه", + "none": "بدون پوشه", + "allInFolder": "همه برنامه‌ها از پیش در این پوشه هستند.", + "createNew": "ساخت پوشه تازه" }, "app": { "name": "نام", @@ -159,7 +168,11 @@ "importNoChange": "چیزی برای Import نیست — فایل با تنظیمات فعلی شما مطابقت دارد.", "pwWeak": "رمز عبور ضعیف است: {label}", "sessionsRevoked": "همه دستگاه‌ها خارج شدند.", - "authNeedsPassword": "پیش از فعال‌سازی احراز هویت، یک گذرواژه تعیین کنید." + "authNeedsPassword": "پیش از فعال‌سازی احراز هویت، یک گذرواژه تعیین کنید.", + "importForeignDone": "درون‌ریزی پایان یافت. برنامه‌ها: {apps}. پوشه‌ها: {folders}.", + "importForeignNothing": "در این فایل‌ها چیزی برای درون‌ریزی نیست.", + "importYamlUnsupported": "{file}: {reason} (خط {line})", + "importUnknownFormat": "{file} پیکربندی gethomepage یا Dashy نیست." }, "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", @@ -172,7 +185,8 @@ "noMatches": "نتیجه‌ای یافت نشد." }, "import": { - "confirm": "{n} مورد Import شود؟\n\n{added} افزوده شد، {updated} به‌روزرسانی شد، {deleted} حذف شد." + "confirm": "{n} مورد درون‌ریزی شود؟ {added} افزوده، {updated} به‌روزرسانی، {deleted} حذف.", + "confirmTitle": "درون‌ریزی پیکربندی" }, "status": { "needsAttention": "وضعیت: نیاز به رسیدگی", @@ -215,5 +229,33 @@ "refusedPlural": "‏{n} ابزارک بارگذاری نشدند، بنابراین در فهرست زیر نیستند.", "name": "نام", "type": "نوع ابزارک" + }, + "importForeign": { + "title": "درون‌ریزی از داشبورد دیگر", + "lead": "آنچه از پیش در داشبورد دارید تغییر نمی‌کند.", + "willAdd": "برنامه‌های افزودنی", + "willCreate": "پوشه‌های ساختنی", + "changed": "با تغییر درون‌ریزی شد", + "notImported": "درون‌ریزی نشد", + "appCount": "({n})", + "skipNoLabel": "بدون نام", + "skipNoHref": "بدون پیوند", + "skipUnsafeHref": "طرح پیوند پشتیبانی‌نشده", + "skipPlaceholderHref": "پیوند به متغیری نیاز دارد که فقط داشبورد دیگر آن را می‌داند", + "noteIcon": "نماد کنار گذاشته شد: {detail}", + "notePing": "پینگ ICMP کنار گذاشته شد: {detail}", + "noteRemoteContainer": "کانتینر نادیده گرفته شد، روی {detail} اجرا می‌شود", + "noteFlattened": "گروه تودرتو به پوشه‌ای جداگانه تبدیل شد", + "noteSubItems": "زیرمورد به پوشه منتقل شد", + "noteWidgetLink": "ویجت به‌صورت پیوند درون‌ریزی شد", + "noteWidgetDropped": "{detail} ویجت بخش کنار گذاشته شد", + "noteFields": "فیلدهای کنارگذاشته: {detail}", + "notePages": "{detail} صفحه دیگر دنبال نشد", + "noteLocalUrl": "نشانی محلی دوم کنار گذاشته شد: {detail}", + "wholeFile": "همین پرونده", + "untitledFolder": "درون‌ریزی‌شده", + "skipRelativeHref": "پیوند نشانی ندارد، فقط یک مسیر", + "skipUnreadable": "این ورودی ساختاری ندارد که قالب پرونده بپذیرد", + "monitors": "· {url} را می‌پاید" } } diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index c7210713..20493492 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -21,7 +21,9 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "edit": "Modifier", - "color": "Couleur" + "color": "Couleur", + "create": "Créer", + "chooseFiles": "Choisir des fichiers" }, "general": { "title": "Titre", @@ -44,7 +46,9 @@ "passwordTip": "Laissez vide pour conserver le mot de passe actuel. 8 caractères minimum. Pour une protection renforcée, envisagez de placer le tableau de bord derrière un Authentication Proxy. Ce mot de passe intégré est une barrière légère, pas une couche d'authentification complète.", "backup": "Sauvegarde", "importExport": "Importer / Exporter", - "signOut": "Se déconnecter" + "signOut": "Se déconnecter", + "importForeign": "Importer depuis un autre tableau de bord", + "importForeignTip": "Lit les fichiers YAML de gethomepage et Dashy. Les liens et les dossiers sont ajoutés à votre tableau de bord ; les widgets de l’autre tableau de bord deviennent de simples liens." }, "appearance": { "appTitle": "Titre de l'application", @@ -85,7 +89,12 @@ "noApps": "Aucune application disponible", "tip": "Appuyez pour ajouter ou retirer des applications de ce dossier.", "namePh": "Mon dossier", - "addAppToFolder": "Ajouter une application à ce dossier" + "addAppToFolder": "Ajouter une application à ce dossier", + "moveTo": "Déplacer \"{name}\" vers un dossier", + "addApp": "Ajouter une app au dossier", + "none": "Aucun dossier", + "allInFolder": "Toutes les apps sont déjà dans ce dossier.", + "createNew": "Créer un dossier" }, "app": { "name": "Nom", @@ -159,7 +168,11 @@ "importNoChange": "Rien à importer — le fichier correspond à votre configuration actuelle.", "pwWeak": "Mot de passe trop faible : {label}", "sessionsRevoked": "Tous les appareils ont été déconnectés.", - "authNeedsPassword": "Définissez un mot de passe avant d'activer l'authentification." + "authNeedsPassword": "Définissez un mot de passe avant d'activer l'authentification.", + "importForeignDone": "Importation terminée. Apps : {apps}. Dossiers : {folders}.", + "importForeignNothing": "Rien à importer dans ces fichiers.", + "importYamlUnsupported": "{file} : {reason} (ligne {line})", + "importUnknownFormat": "{file} n’est pas une configuration gethomepage ou Dashy." }, "confirm": { "deleteFolder": "Supprimer le dossier « {name} » ? Les applications qu'il contient ne seront pas supprimées.", @@ -172,7 +185,8 @@ "noMatches": "Aucune correspondance." }, "import": { - "confirm": "Importer {n} éléments ?\n\n{added} ajoutés, {updated} mis à jour, {deleted} supprimés." + "confirm": "Importer {n} éléments ? {added} ajoutés, {updated} mis à jour, {deleted} supprimés.", + "confirmTitle": "Importer la configuration" }, "status": { "needsAttention": "État : nécessite une intervention", @@ -215,5 +229,33 @@ "refusedPlural": "{n} widgets n'ont pas pu être chargés et n'apparaissent pas dans la liste.", "name": "Nom", "type": "Type de widget" + }, + "importForeign": { + "title": "Importer depuis un autre tableau de bord", + "lead": "Rien de ce qui existe déjà sur votre tableau de bord ne change.", + "willAdd": "Apps à ajouter", + "willCreate": "Dossiers à créer", + "changed": "Importé avec des modifications", + "notImported": "Non importé", + "appCount": "({n})", + "skipNoLabel": "sans nom", + "skipNoHref": "sans lien", + "skipUnsafeHref": "schéma de lien non pris en charge", + "skipPlaceholderHref": "le lien exige une variable que seul l’autre tableau de bord connaît", + "noteIcon": "icône abandonnée : {detail}", + "notePing": "ping ICMP abandonné : {detail}", + "noteRemoteContainer": "conteneur ignoré, il tourne sur {detail}", + "noteFlattened": "le groupe imbriqué est devenu un dossier distinct", + "noteSubItems": "sous-élément déplacé dans le dossier", + "noteWidgetLink": "widget importé comme lien", + "noteWidgetDropped": "{detail} widgets de section abandonnés", + "noteFields": "champs abandonnés : {detail}", + "notePages": "{detail} pages supplémentaires non suivies", + "noteLocalUrl": "seconde adresse locale abandonnée : {detail}", + "wholeFile": "ce fichier", + "untitledFolder": "Importé", + "skipRelativeHref": "le lien n’a pas d’adresse, seulement un chemin", + "skipUnreadable": "cette entrée n’a pas la forme autorisée par le format du fichier", + "monitors": "· interroge {url}" } } diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index f56794a2..4d6a2e14 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -21,7 +21,9 @@ "showPassword": "显示密码", "hidePassword": "隐藏密码", "edit": "编辑", - "color": "颜色" + "color": "颜色", + "create": "创建", + "chooseFiles": "选择文件" }, "general": { "title": "标题", @@ -44,7 +46,9 @@ "passwordTip": "留空以保留现有密码。至少 8 个字符。若需更强的保护,建议将仪表盘置于 Authentication Proxy 之后。此内置密码只是一道简单的门槛,并非完整的身份验证层。", "backup": "备份", "importExport": "导入 / 导出", - "signOut": "退出登录" + "signOut": "退出登录", + "importForeign": "从其他仪表板导入", + "importForeignTip": "读取 gethomepage 与 Dashy 的 YAML 文件。链接和文件夹会添加到你的仪表板;其他仪表板的挂件会变成普通链接。" }, "appearance": { "appTitle": "应用标题", @@ -85,7 +89,12 @@ "noApps": "没有可用的应用", "tip": "点击以在此文件夹中添加或移除应用。", "namePh": "我的文件夹", - "addAppToFolder": "将应用添加到此文件夹" + "addAppToFolder": "将应用添加到此文件夹", + "moveTo": "将“{name}”移入文件夹", + "addApp": "将应用加入文件夹", + "none": "不放入文件夹", + "allInFolder": "所有应用都已在此文件夹中。", + "createNew": "新建文件夹" }, "app": { "name": "名称", @@ -159,7 +168,11 @@ "importNoChange": "没有可导入的内容 — 该文件与您当前的配置一致。", "pwWeak": "密码强度不足:{label}", "sessionsRevoked": "已退出所有设备。", - "authNeedsPassword": "启用身份验证前请先设置密码。" + "authNeedsPassword": "启用身份验证前请先设置密码。", + "importForeignDone": "导入完成。应用:{apps}。文件夹:{folders}。", + "importForeignNothing": "这些文件中没有可导入的内容。", + "importYamlUnsupported": "{file}:{reason}(第 {line} 行)", + "importUnknownFormat": "{file} 不是 gethomepage 或 Dashy 配置。" }, "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", @@ -172,7 +185,8 @@ "noMatches": "没有匹配项。" }, "import": { - "confirm": "导入 {n} 个项目?\n\n新增 {added} 个,更新 {updated} 个,删除 {deleted} 个。" + "confirm": "导入 {n} 个项目?新增 {added} 个,更新 {updated} 个,删除 {deleted} 个。", + "confirmTitle": "导入配置" }, "status": { "needsAttention": "状态:需要处理", @@ -215,5 +229,33 @@ "refusedPlural": "有 {n} 个小组件无法加载,因此未在下方列出。", "name": "名称", "type": "小组件类型" + }, + "importForeign": { + "title": "从其他仪表板导入", + "lead": "仪表板上已有的内容不会改变。", + "willAdd": "将添加的应用", + "willCreate": "将创建的文件夹", + "changed": "导入时有改动", + "notImported": "未导入", + "appCount": "({n})", + "skipNoLabel": "没有名称", + "skipNoHref": "没有链接", + "skipUnsafeHref": "不支持的链接协议", + "skipPlaceholderHref": "链接需要只有对方仪表板才知道的变量", + "noteIcon": "已舍弃图标:{detail}", + "notePing": "已舍弃 ICMP ping:{detail}", + "noteRemoteContainer": "已忽略容器,它运行在 {detail}", + "noteFlattened": "嵌套分组已成为独立文件夹", + "noteSubItems": "子项已移入该文件夹", + "noteWidgetLink": "挂件已作为链接导入", + "noteWidgetDropped": "已舍弃 {detail} 个分区挂件", + "noteFields": "已舍弃字段:{detail}", + "notePages": "未跟随其余 {detail} 个页面", + "noteLocalUrl": "已舍弃第二个本地地址:{detail}", + "wholeFile": "此文件", + "untitledFolder": "已导入", + "skipRelativeHref": "链接没有地址,只有路径", + "skipUnreadable": "此条目不符合该文件格式允许的结构", + "monitors": "· 轮询 {url}" } } diff --git a/ui/js/admin.js b/ui/js/admin.js index 38f2ba49..97f25bf3 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -11,6 +11,11 @@ import { buildAppForm, buildFolderForm, serializeKvRows } from '/js/admin-app-fo import { LANGUAGES, initI18n, t } from '/js/i18n.js?v=133a7aac'; import { loadSettings, showBgFields } from '/js/admin-settings.js?v=c2d82934'; import { canJoinFolder, applyDrop } from '/js/admin-drag-logic.js?v=53aeaa55'; +/* openModal is the item editor in this file, so the shared dialog comes in + under a different name. */ +import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=10d34989'; +import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=d4e5311d'; +import { detectSource, convert, SKIP, NOTE } from '/js/import-foreign.js?v=e0db2fb3'; /* Admin UI: Stackyard Dashboard */ @@ -80,38 +85,56 @@ async function applyBg() { } } catch {} } +/** Returns whether the write reached the server, so a caller that reports its + own outcome does not announce success over a failed save. */ async function save() { - if (state.saving) return; + if (state.saving) return false; state.saving = true; + let ok = false; try { const full = await ag('/api/config'); full.items = state.items; await ap('/api/config', full); toast('Saved'); + ok = true; } catch (e) { toast('Save failed: ' + e.message, 'err'); } state.saving = false; render(); + return ok; } -function trapFocus(box) { - const sel = 'button:not([disabled]),[href],input,select,textarea,[tabindex]:not([tabindex="-1"])'; - return e => { - if (e.key !== 'Tab') return; - const f = qa(sel, box); - if (!f.length) return; - const first = f[0], - last = f[f.length - 1]; - if (e.shiftKey && document.activeElement === first) { - e.preventDefault(); - last.focus(); - } else if (!e.shiftKey && document.activeElement === last) { - e.preventDefault(); - first.focus(); - } - }; +/** Append items to what is on the server right now, rather than to the copy + this page loaded. + + An import promises that nothing already on the dashboard changes. Writing + back the in-memory list would break that promise for anything added from + another tab or device while the preview was open: the read-modify-write + would silently drop it. Throws, so the caller can report the failure. + + @param {any[]} newItems */ +async function appendAndSave(newItems) { + if (state.saving) throw new Error('A save is already in progress'); + state.saving = true; + try { + const full = await ag('/api/config'); + const current = Array.isArray(full.items) ? full.items : []; + /* Ids were allocated against the list the preview was built from. The + server refuses duplicates outright, so a collision with something saved + in the meantime is caught here with a message naming the item. */ + const taken = new Set(current.map(i => i && i.id)); + const clash = newItems.find(i => taken.has(i.id)); + if (clash) throw new Error(`${clash.label}: this id already exists. Reload and import again.`); + full.items = [...current, ...newItems]; + await ap('/api/config', full); + state.items = full.items; + } finally { + state.saving = false; + render(); + } } + function moveRow(item, dir, opts = {}) { if (reorderItems(state.items, item, dir, opts)) save(); } @@ -698,43 +721,16 @@ function closeModal() { } function openFolderPicker(appId, targetFolderId = null) { - const trigger = /** @type {HTMLElement} */ (document.activeElement); const folders = state.items.filter(i => i.type === 'folder'); const currentFolder = folders.find(f => (f.children || []).includes(appId)); const appItem = state.items.find(i => i.id === appId); const appName = appItem?.label || appId; - el('folder-picker-ov')?.remove(); - - const ov = document.createElement('div'); - ov.id = 'folder-picker-ov'; - ov.className = 'fp-ov'; - const box = document.createElement('div'); - box.className = 'fp-box'; - box.setAttribute('role', 'dialog'); - box.setAttribute('aria-modal', 'true'); - box.setAttribute('aria-labelledby', 'fp-hdr'); - - const hdr = document.createElement('div'); - hdr.className = 'fp-hdr'; - hdr.id = 'fp-hdr'; - hdr.textContent = appId ? `Move "${appName}" to folder` : 'Add app to folder'; - - const list = document.createElement('div'); - list.className = 'fp-list'; - - const close = () => { - document.removeEventListener('keydown', onKey); - ov.remove(); - if (trigger && trigger.focus) trigger.focus(); - }; - const trap = trapFocus(box); - const onKey = e => { - if (e.key === 'Escape') { - e.preventDefault(); - close(); - } else trap(e); - }; + const dlg = openDialog({ + title: appId ? t('folder.moveTo', { name: appName }) : t('folder.addApp'), + }); + const list = dlg.body; + const close = dlg.close; const rowBtn = (cls, onAct) => { const b = document.createElement('button'); @@ -751,8 +747,8 @@ function openFolderPicker(appId, targetFolderId = null) { : []; if (!available.length) { const em = document.createElement('div'); - em.className = 'fp-empty'; - em.textContent = 'All apps are already in this folder.'; + em.className = 'dlg-empty'; + em.textContent = t('folder.allInFolder'); list.appendChild(em); } available.forEach(app => { @@ -789,7 +785,7 @@ function openFolderPicker(appId, targetFolderId = null) { close(); }); const ns = document.createElement('span'); - ns.textContent = 'No folder'; + ns.textContent = t('folder.none'); none.append(ns); list.appendChild(none); @@ -818,44 +814,37 @@ function openFolderPicker(appId, targetFolderId = null) { divider.style.margin = '4px 8px'; list.appendChild(divider); - const nr = rowBtn('accent', () => { - const name = prompt('Folder name:'); - if (!name?.trim()) return; + const nr = rowBtn('accent', async () => { + /* Closed first: the name prompt is a modal of its own, and two overlays + fighting over the focus trap leaves focus in the one underneath. */ + close(); + const name = await promptModal({ + title: t('folder.createNew'), + label: t('folder.name'), + placeholder: t('folder.namePh'), + confirmLabel: t('common.create'), + cancelLabel: t('common.cancel'), + }); + if (!name) return; const fid = newItemId( - 'folder', + name, 'folder', state.items.map(i => i.id), ); - state.items.push({ id: fid, type: 'folder', label: name.trim(), children: [appId] }); + state.items.push({ id: fid, type: 'folder', label: name, children: [appId] }); state.items.forEach(f => { if (f.type === 'folder' && f.id !== fid) f.children = (f.children || []).filter(id => id !== appId); }); save(); - close(); }); const nrs = document.createElement('span'); - nrs.textContent = '+ Create new folder'; + nrs.textContent = '+ ' + t('folder.createNew'); nr.append(nrs); list.appendChild(nr); } - const footer = document.createElement('div'); - footer.className = 'fp-foot'; - const cancel = document.createElement('button'); - cancel.type = 'button'; - cancel.className = 'btn bg sm'; - cancel.textContent = 'Cancel'; - cancel.onclick = close; - footer.appendChild(cancel); - - ov.onclick = e => { - if (e.target === ov) close(); - }; - document.addEventListener('keydown', onKey); - box.append(hdr, list, footer); - ov.appendChild(box); - document.body.appendChild(ov); - (q('button', list) || cancel).focus(); + dlg.addAction(t('common.cancel'), 'bg sm', close); + dlg.focus(q('button', list)); } async function doSave(orig) { @@ -1239,7 +1228,17 @@ el('imp').onchange = async e => { tgt(e).value = ''; return; } - if (!confirm(t('import.confirm', { n: d.items.length, added, updated, deleted }))) { + const lead = document.createElement('p'); + lead.className = 'dlg-lead'; + lead.textContent = t('import.confirm', { n: d.items.length, added, updated, deleted }); + const ok = await confirmModal({ + title: t('import.confirmTitle'), + body: lead, + confirmLabel: t('common.import'), + cancelLabel: t('common.cancel'), + destructive: deleted > 0, + }); + if (!ok) { tgt(e).value = ''; return; } @@ -1252,6 +1251,156 @@ el('imp').onchange = async e => { tgt(e).value = ''; }; +/* Why an item did not import, and what changed about one that did. Written out + as literals rather than assembled from the code, so a key can be traced from + the catalog back to here. */ +const SKIP_TEXT = { + [SKIP.NO_LABEL]: 'importForeign.skipNoLabel', + [SKIP.NO_HREF]: 'importForeign.skipNoHref', + [SKIP.UNSAFE_HREF]: 'importForeign.skipUnsafeHref', + [SKIP.PLACEHOLDER_HREF]: 'importForeign.skipPlaceholderHref', + [SKIP.RELATIVE_HREF]: 'importForeign.skipRelativeHref', + [SKIP.UNREADABLE]: 'importForeign.skipUnreadable', +}; +const NOTE_TEXT = { + [NOTE.ICON_DROPPED]: 'importForeign.noteIcon', + [NOTE.PING_DROPPED]: 'importForeign.notePing', + [NOTE.CONTAINER_ON_REMOTE]: 'importForeign.noteRemoteContainer', + [NOTE.GROUP_FLATTENED]: 'importForeign.noteFlattened', + [NOTE.SUBITEMS_FLATTENED]: 'importForeign.noteSubItems', + [NOTE.WIDGET_AS_LINK]: 'importForeign.noteWidgetLink', + [NOTE.WIDGETS_DROPPED]: 'importForeign.noteWidgetDropped', + [NOTE.LOCAL_URL_DROPPED]: 'importForeign.noteLocalUrl', + [NOTE.FIELDS_DROPPED]: 'importForeign.noteFields', + [NOTE.PAGES_NOT_FOLLOWED]: 'importForeign.notePages', +}; + +/** One "Heading (n)" block followed by a line per entry. Every entry is listed: + a summary that says four services were dropped without naming them leaves + the person to diff two configs by hand. + @param {HTMLElement} parent @param {string} heading + @param {Array<{ name: string, group: string, why: string }>} rows */ +function dlgSection(parent, heading, rows) { + if (!rows.length) return; + const h = document.createElement('div'); + h.className = 'dlg-sec'; + h.textContent = `${heading} (${rows.length})`; + const ul = document.createElement('ul'); + ul.className = 'dlg-ul'; + for (const row of rows) { + const li = document.createElement('li'); + li.className = 'dlg-li'; + const nm = document.createElement('span'); + /* Either part can be missing: a note about the file as a whole has neither, + and a note about a group has no item name. */ + setUserText(nm, [row.group, row.name].filter(Boolean).join(' / ') || t('importForeign.wholeFile')); + const why = document.createElement('span'); + why.className = 'dlg-why'; + setUserText(why, row.why); + li.append(nm, why); + ul.appendChild(li); + } + parent.append(h, ul); +} + +el('imp-foreign').onchange = async e => { + const input = tgt(e); + const files = [...(input.files || [])]; + if (!files.length) return; + try { + /* Ids have to stay unique against what is already saved and against every + other file in the batch, so one taken set runs through all of them. */ + const taken = new Set(state.items.map(i => i.id)); + const items = [], + skipped = [], + notes = []; + for (const file of files) { + let doc; + try { + doc = parseYaml(await file.text()); + } catch (err) { + /* All or nothing. A half-imported dashboard is harder to undo than a + refused file, and the line number is what makes the file fixable. */ + if (err instanceof YamlLiteError) + throw new Error(t('toast.importYamlUnsupported', { file: file.name, reason: err.reason, line: err.line })); + throw err; + } + const kind = detectSource(doc); + if (!kind) throw new Error(t('toast.importUnknownFormat', { file: file.name })); + const out = convert(kind, doc, taken, t('importForeign.untitledFolder')); + items.push(...out.items); + skipped.push(...out.skipped); + notes.push(...out.notes); + } + + const apps = items.filter(i => i.type === 'app').length; + const folders = items.length - apps; + if (!apps) { + toast(t('toast.importForeignNothing'), 'err'); + input.value = ''; + return; + } + + const body = document.createElement('div'); + const lead = document.createElement('p'); + lead.className = 'dlg-lead'; + lead.textContent = t('importForeign.lead'); + body.appendChild(lead); + dlgSection( + body, + t('importForeign.willAdd'), + items + .filter(i => i.type === 'app') + .map(i => { + /* The monitored URL is shown next to the link because this server, not + the browser, polls it on a timer once the import is saved. */ + const ping = i.monitoring?.healthcheck?.pingUrl; + return { + name: i.label, + group: '', + why: ping ? `${i.href} ${t('importForeign.monitors', { url: ping })}` : i.href, + }; + }), + ); + dlgSection( + body, + t('importForeign.willCreate'), + items + .filter(i => i.type === 'folder') + .map(i => ({ name: i.label, group: '', why: t('importForeign.appCount', { n: i.children.length }) })), + ); + dlgSection( + body, + t('importForeign.changed'), + notes.map(n => ({ name: n.name, group: n.group, why: t(NOTE_TEXT[n.code], { detail: n.detail || '' }) })), + ); + dlgSection( + body, + t('importForeign.notImported'), + skipped.map(s => ({ name: s.name, group: s.group, why: t(SKIP_TEXT[s.reason], { detail: s.detail || '' }) })), + ); + + const ok = await confirmModal({ + title: t('importForeign.title'), + body, + confirmLabel: t('common.import'), + cancelLabel: t('common.cancel'), + className: 'wide', + }); + if (!ok) { + input.value = ''; + return; + } + /* Appended, never merged: nothing already on the dashboard is renamed, + reordered or removed by an import from somewhere else. */ + await appendAndSave(items); + toast(t('toast.importForeignDone', { apps, folders })); + } catch (err) { + toast(t('toast.importFailed', { err: err.message }), 'err'); + } + input.value = ''; +}; + el('btn-add').onclick = () => openModal(null); initNav(); diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js new file mode 100644 index 00000000..b4b371c0 --- /dev/null +++ b/ui/js/import-foreign.js @@ -0,0 +1,423 @@ +// @ts-check +/* Convert a gethomepage or Dashy config into Stackyard items. + + Links and folders only. A widget in the source becomes a plain app tile + pointing at the same service, never a Stackyard widget: the two widget + systems take different credentials and different options, and a tile that + looks configured but reports nothing is worse than a link. + + Nothing here touches the DOM or the network, and the item shape comes from + buildAppItem rather than being written out again, so an item that arrives by + import is the same object an item created in the admin UI is. + + Pure functions, DOM-free, so ui/test can exercise the rules directly. */ + +import { buildAppItem, newItemId } from '/js/admin-save-logic.js?v=77cac1d1'; +import { isSafeLinkUrl } from '/js/link-url.js?v=19038560'; + +/* An item is not imported at all. */ +export const SKIP = Object.freeze({ + NO_LABEL: 'no-label', + NO_HREF: 'no-href', + UNSAFE_HREF: 'unsafe-href', + PLACEHOLDER_HREF: 'placeholder-href', + RELATIVE_HREF: 'relative-href', + UNREADABLE: 'unreadable', +}); + +/* An item is imported, but something in the source did not survive the trip. */ +export const NOTE = Object.freeze({ + ICON_DROPPED: 'icon-dropped', + PING_DROPPED: 'ping-dropped', + CONTAINER_ON_REMOTE: 'container-on-remote', + GROUP_FLATTENED: 'group-flattened', + SUBITEMS_FLATTENED: 'subitems-flattened', + WIDGET_AS_LINK: 'widget-as-link', + WIDGETS_DROPPED: 'widgets-dropped', + LOCAL_URL_DROPPED: 'local-url-dropped', + FIELDS_DROPPED: 'fields-dropped', + PAGES_NOT_FOLLOWED: 'pages-not-followed', +}); + +/* Fields with no equivalent on a Stackyard item. Counted so the summary can say + what was left behind rather than quietly discarding it. */ +const IGNORED_FIELDS = Object.freeze([ + 'description', + 'abbr', + 'target', + 'color', + 'backgroundColor', + 'tags', + 'provider', + 'hotkey', + 'displayData', +]); + +const isMap = v => !!v && typeof v === 'object' && !Array.isArray(v); +const str = v => (typeof v === 'string' ? v.trim() : ''); +/* A name may legitimately be written unquoted and read back as a number or a + boolean: `title: 2024` and `title: no` are both real. A URL read as one of + those is not a URL, so only names go through this. */ +const text = v => (typeof v === 'string' ? v.trim() : typeof v === 'number' || typeof v === 'boolean' ? String(v) : ''); + +/* Whatever the source meant by it, a link with no scheme cannot reach the + service after the import: it resolves against the dashboard's own origin. */ +function isAbsoluteLink(href) { + if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return true; + return href.startsWith('//'); +} + +/* A value Homepage or Dashy resolves from its own environment. It cannot be + resolved here, so the link would be dead on arrival. */ +const hasPlaceholder = s => /\{\{[^}]*\}\}|\$\{[^}]*\}/.test(s); + +/** The single key of a `{ name: value }` wrapper, or null. + @param {any} v @returns {[string, any]|null} */ +function soleEntry(v) { + if (!isMap(v)) return null; + const keys = Object.keys(v); + return keys.length === 1 ? [keys[0], v[keys[0]]] : null; +} + +/** Which format a parsed document is, by shape rather than by filename: these + files get renamed, downloaded with a suffix, and pasted between installs. + @param {any} doc @returns {'homepage-services'|'homepage-bookmarks'|'dashy'|null} */ +export function detectSource(doc) { + if (isMap(doc)) { + if (Array.isArray(doc.sections) || Array.isArray(doc.pages) || isMap(doc.appConfig) || isMap(doc.pageInfo)) + return 'dashy'; + return null; + } + if (!Array.isArray(doc) || !doc.length) return null; + let sawGroup = false; + for (const group of doc) { + const g = soleEntry(group); + if (!g || !Array.isArray(g[1])) return null; + sawGroup = true; + for (const entry of g[1]) { + const e = soleEntry(entry); + if (!e) continue; + /* The one structural difference between the two Homepage files: a + bookmark's fields sit inside an extra list, a service's do not. */ + if (Array.isArray(e[1]) && e[1].some(row => isMap(row) && ('href' in row || 'abbr' in row))) { + return 'homepage-bookmarks'; + } + } + } + return sawGroup ? 'homepage-services' : null; +} + +/** An icon reference the dashboard can actually resolve, or nothing. + + A bare name is a dashboard-icons slug, which is exactly what iconChain + expects, so it is kept as it is. Icon fonts, emoji and paths into another + dashboard's own file tree have no equivalent and are blanked rather than + stored as a reference that resolves to a broken image. + + @param {unknown} raw @returns {{ iconUrl: string, dropped: boolean }} */ +export function convertIcon(raw) { + const s = str(raw); + if (!s) return { iconUrl: '', dropped: false }; + if (/^https?:\/\//i.test(s)) { + /* An icon is loaded into an , and the page's img-src allows only + this origin, data:, and the icon CDN. Any other host is refused by the + browser, so importing it would store a reference that can only ever + render as a broken image. See nginx/csp-default.conf. */ + let host = ''; + try { + host = new URL(s).hostname.toLowerCase(); + } catch { + return { iconUrl: '', dropped: true }; + } + return host === 'cdn.jsdelivr.net' ? { iconUrl: s, dropped: false } : { iconUrl: '', dropped: true }; + } + if (s.startsWith('/') || s.includes('/')) return { iconUrl: '', dropped: true }; + /* selfh.st and homelab-svg-assets prefixes. Both name the same service the + dashboard-icons catalogue does, so the remainder is a usable slug. */ + const body = /^(sh|hl)-/i.test(s) ? s.slice(3) : s; + /* iconChain only strips .svg and .png, so any other extension has to go here + or it ends up inside the CDN slug. */ + const name = body.replace(/\.(png|webp|svg|jpe?g|gif|ico)$/i, ''); + /* A whitespace-separated value is an icon-font class such as "fas fa-home", + never a dashboard-icons slug. */ + if (!name || /\s/.test(name) || /^(mdi|si|fa[srlbd]?)-/i.test(name) || name === 'favicon') + return { iconUrl: '', dropped: true }; + if (!/^[a-z0-9][a-z0-9._-]*$/i.test(name)) return { iconUrl: '', dropped: true }; + return { iconUrl: name, dropped: false }; +} + +/** Collector for one conversion run. Keeps ids unique across every file in the + batch, including against the items already on the dashboard. + @param {Iterable} takenIds */ +function collector(takenIds) { + const taken = new Set(takenIds || []); + return { + taken, + /** @type {any[]} */ items: [], + /** @type {Array<{ reason: string, name: string, group: string, detail?: string }>} */ skipped: [], + /** @type {Array<{ code: string, name: string, group: string, detail?: string }>} */ notes: [], + /** @param {string} reason @param {string} name @param {string} group @param {string} [detail] */ + skip(reason, name, group, detail) { + this.skipped.push(detail ? { reason, name, group, detail } : { reason, name, group }); + }, + /** @param {string} code @param {string} name @param {string} group @param {string} [detail] */ + note(code, name, group, detail) { + this.notes.push(detail ? { code, name, group, detail } : { code, name, group }); + }, + }; +} + +/** Build one app item from already-extracted parts, or record why it was not. + @param {any} col @param {string} group + @param {{ label: string, href: string, iconUrl: string, container: string, + pingUrl: string, skipTlsVerify?: boolean }} parts + @returns {any|null} */ +function addApp(col, group, { label, href, iconUrl, container, pingUrl, skipTlsVerify }) { + if (!label) { + col.skip(SKIP.NO_LABEL, href || '', group); + return null; + } + if (!href) { + col.skip(SKIP.NO_HREF, label, group); + return null; + } + if (hasPlaceholder(href)) { + col.skip(SKIP.PLACEHOLDER_HREF, label, group, href); + return null; + } + if (!isAbsoluteLink(href)) { + col.skip(SKIP.RELATIVE_HREF, label, group, href); + return null; + } + /* The server refuses the whole save over one unsafe link, so an item carrying + one has to be dropped here or nothing imports at all. */ + if (!isSafeLinkUrl(href)) { + col.skip(SKIP.UNSAFE_HREF, label, group, href); + return null; + } + const built = buildAppItem( + { + label, + href, + iconUrl, + scol: 'dark', + hcEn: !!(container || pingUrl), + hcCon: container || '', + hcPing: pingUrl || '', + skipTlsVerify: skipTlsVerify || false, + actEn: false, + actUrl: '', + actInt: 60, + }, + null, + col.taken, + ); + if (built.error) { + col.skip(SKIP.NO_HREF, label, group, built.error); + return null; + } + col.taken.add(built.item.id); + col.items.push(built.item); + return built.item; +} + +/** Create the folder for a group, given the apps that landed in it. Empty + groups produce nothing: a folder with no apps is a tile that opens onto an + empty sheet. */ +function addFolder(col, label, childIds, at) { + if (!childIds.length) return; + const id = newItemId(label, 'folder', col.taken); + col.taken.add(id); + col.items.splice(at, 0, { id, type: 'folder', label, children: childIds }); +} + +/** Record every source field that has no home on a Stackyard item. */ +function noteIgnored(col, src, name, group) { + const present = IGNORED_FIELDS.filter(f => f in src && src[f] !== null && src[f] !== ''); + if (present.length) col.note(NOTE.FIELDS_DROPPED, name, group, present.join(', ')); +} + +/** @param {any} doc @param {Iterable} [takenIds] */ +export function convertHomepageServices(doc, takenIds = []) { + const col = collector(takenIds); + if (!Array.isArray(doc)) return result(col); + + const walk = (entries, groupLabel, depth) => { + const at = col.items.length; + const children = []; + for (const entry of Array.isArray(entries) ? entries : []) { + const e = soleEntry(entry); + if (!e) { + /* Not a `{ name: fields }` wrapper, so there is no service to read. + Counted rather than passed over, or a mangled file imports as a + smaller dashboard with nothing saying why. */ + col.skip(SKIP.UNREADABLE, '', groupLabel); + continue; + } + const [name, value] = e; + if (Array.isArray(value)) { + /* Homepage nests groups; Stackyard folders do not, so the child group + becomes a folder of its own with a compound name. */ + const nested = groupLabel ? `${groupLabel} / ${name}` : name; + /* The child's own name, not the compound one: the preview prints + "group / name", so passing the compound name repeats the parent. */ + col.note(NOTE.GROUP_FLATTENED, name, groupLabel, nested); + walk(value, nested, depth + 1); + continue; + } + if (!isMap(value)) { + col.skip(SKIP.UNREADABLE, name, groupLabel); + continue; + } + const icon = convertIcon(value.icon); + const hasServer = 'server' in value && str(value.server) !== ''; + const app = addApp(col, groupLabel, { + label: name, + href: str(value.href), + iconUrl: icon.iconUrl, + /* A container name is only meaningful against the Docker socket this + install can reach; with a `server` it names someone else's daemon. */ + container: hasServer ? '' : str(value.container), + pingUrl: str(value.siteMonitor), + }); + if (!app) continue; + children.push(app.id); + if (icon.dropped) col.note(NOTE.ICON_DROPPED, name, groupLabel, str(value.icon)); + if (hasServer && str(value.container)) col.note(NOTE.CONTAINER_ON_REMOTE, name, groupLabel, str(value.server)); + /* Homepage's `ping` is an ICMP host, not a URL. The health check makes an + HTTP request, so importing it would report every host as down. */ + if (str(value.ping)) col.note(NOTE.PING_DROPPED, name, groupLabel, str(value.ping)); + if (value.widget || value.widgets) col.note(NOTE.WIDGET_AS_LINK, name, groupLabel); + noteIgnored(col, value, name, groupLabel); + } + addFolder(col, groupLabel, children, at); + }; + + for (const group of doc) { + const g = soleEntry(group); + if (!g || !Array.isArray(g[1])) continue; + walk(g[1], g[0], 0); + } + return result(col); +} + +/** @param {any} doc @param {Iterable} [takenIds] */ +export function convertHomepageBookmarks(doc, takenIds = []) { + const col = collector(takenIds); + if (!Array.isArray(doc)) return result(col); + + for (const group of doc) { + const g = soleEntry(group); + if (!g || !Array.isArray(g[1])) continue; + const [groupLabel, entries] = g; + const at = col.items.length; + const children = []; + for (const entry of entries) { + const e = soleEntry(entry); + if (!e) { + col.skip(SKIP.UNREADABLE, '', groupLabel); + continue; + } + const [name, value] = e; + /* A bookmark's fields arrive inside a one-element list. Services are + written without it, and both files are worth reading either way. */ + const fields = Array.isArray(value) ? value.find(isMap) : value; + if (!isMap(fields)) { + col.skip(SKIP.UNREADABLE, name, groupLabel); + continue; + } + const icon = convertIcon(fields.icon); + const app = addApp(col, groupLabel, { + label: name, + href: str(fields.href), + iconUrl: icon.iconUrl, + container: '', + pingUrl: '', + }); + if (!app) continue; + children.push(app.id); + if (icon.dropped) col.note(NOTE.ICON_DROPPED, name, groupLabel, str(fields.icon)); + noteIgnored(col, fields, name, groupLabel); + } + addFolder(col, groupLabel, children, at); + } + return result(col); +} + +/** @param {any} doc @param {Iterable} [takenIds] + @param {string} [untitledFolder] label for a section that has no name of its + own; passed in because this module stays free of the i18n catalog */ +export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { + const col = collector(takenIds); + if (!isMap(doc)) return result(col); + if (Array.isArray(doc.pages) && doc.pages.length) { + /* Each entry names another config file, often on another host. */ + col.note(NOTE.PAGES_NOT_FOLLOWED, '', '', String(doc.pages.length)); + } + + for (const section of Array.isArray(doc.sections) ? doc.sections : []) { + if (!isMap(section)) { + col.skip(SKIP.UNREADABLE, '', ''); + continue; + } + const groupLabel = text(section.name) || untitledFolder; + const at = col.items.length; + const children = []; + + const addDashyItem = (raw, viaSubItem) => { + if (!isMap(raw)) { + col.skip(SKIP.UNREADABLE, '', groupLabel); + return; + } + const title = text(raw.title); + const icon = convertIcon(raw.icon); + /* localUrl is the address on the other network. Importing it would give + half the tiles a link that only works from somewhere else. */ + const href = str(raw.url); + const check = raw.statusCheck === true; + /* Dashy's per-item switch for a health check against a self-signed + certificate. Stackyard has the same switch, so it carries across. */ + const insecure = check && raw.statusCheckAllowInsecure === true; + const app = addApp(col, groupLabel, { + label: title, + href, + iconUrl: icon.iconUrl, + container: '', + pingUrl: check ? str(raw.statusCheckUrl) || href : '', + skipTlsVerify: insecure, + }); + if (!app) return; + children.push(app.id); + if (icon.dropped) col.note(NOTE.ICON_DROPPED, title, groupLabel, str(raw.icon)); + if (viaSubItem) col.note(NOTE.SUBITEMS_FLATTENED, title, groupLabel); + /* A second address for the same service, reachable only from the other + network. Reported rather than dropped in silence, because the tile + keeps whichever of the two the source called the primary one. */ + if (str(raw.localUrl)) col.note(NOTE.LOCAL_URL_DROPPED, title, groupLabel, str(raw.localUrl)); + noteIgnored(col, raw, title, groupLabel); + for (const sub of Array.isArray(raw.subItems) ? raw.subItems : []) addDashyItem(sub, true); + }; + + for (const item of Array.isArray(section.items) ? section.items : []) addDashyItem(item, false); + /* A section widget has no link of its own to keep, unlike a Homepage + service widget, so there is nothing to import in its place. */ + if (Array.isArray(section.widgets) && section.widgets.length) + col.note(NOTE.WIDGETS_DROPPED, '', groupLabel, String(section.widgets.length)); + addFolder(col, groupLabel, children, at); + } + return result(col); +} + +function result(col) { + return { items: col.items, skipped: col.skipped, notes: col.notes }; +} + +/** Convert a parsed document of a detected kind. + @param {'homepage-services'|'homepage-bookmarks'|'dashy'} kind + @param {any} doc @param {Iterable} takenIds + @param {string} [untitledFolder] */ +export function convert(kind, doc, takenIds, untitledFolder) { + if (kind === 'dashy') return convertDashy(doc, takenIds, untitledFolder); + if (kind === 'homepage-bookmarks') return convertHomepageBookmarks(doc, takenIds); + return convertHomepageServices(doc, takenIds); +} diff --git a/ui/js/modal.js b/ui/js/modal.js new file mode 100644 index 00000000..a8d13f83 --- /dev/null +++ b/ui/js/modal.js @@ -0,0 +1,147 @@ +// @ts-check +/* The one modal in the admin UI. + + Everything a dialog owes the person using it lives here once: a backdrop that + dismisses, Escape, a focus trap, and focus returned to whatever opened it. + The browser's own confirm() and prompt() do all of that too, which is why + they were worth using, but they cannot show a list, cannot be styled, and + block the page while open. + + Structure only. Callers fill the body with their own nodes. */ + +import { trapFocus } from '/js/dialog.js?v=4ff94595'; + +/** Open a modal and return its parts. + + `close()` is safe to call more than once, since a dialog can be dismissed by + the backdrop, Escape, an action button, or the caller. + + The trap is armed by `focus()` rather than on open, because the element + worth focusing is usually one the caller has not appended yet. + + @param {{ title: string, className?: string, onClose?: () => void }} opts + @returns {{ box: HTMLElement, body: HTMLElement, footer: HTMLElement, + close: () => void, + addAction: (label: string, cls: string, onAct?: () => void) => HTMLButtonElement, + focus: (initial?: HTMLElement|null) => void }} */ +export function openModal({ title, className, onClose }) { + const ov = document.createElement('div'); + ov.className = 'dlg-ov' + (className ? ' ' + className : ''); + + const box = document.createElement('div'); + box.className = 'dlg-box'; + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + + const hdr = document.createElement('div'); + hdr.className = 'dlg-hdr'; + /* Unique per open: two ids would point the second dialog's label at the + first one's heading. */ + hdr.id = 'dlg-hdr-' + Math.random().toString(36).slice(2, 8); + hdr.textContent = title; + box.setAttribute('aria-labelledby', hdr.id); + + const body = document.createElement('div'); + body.className = 'dlg-body'; + + const footer = document.createElement('div'); + footer.className = 'dlg-foot'; + + let release = () => {}; + let closed = false; + const close = () => { + if (closed) return; + closed = true; + release(); + ov.remove(); + if (onClose) onClose(); + }; + + ov.onclick = e => { + if (e.target === ov) close(); + }; + + const addAction = (label, cls, onAct) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'btn ' + cls; + b.textContent = label; + b.onclick = () => { + if (onAct) onAct(); + else close(); + }; + footer.appendChild(b); + return b; + }; + + box.append(hdr, body, footer); + ov.appendChild(box); + document.body.appendChild(ov); + + const focus = initial => { + release = trapFocus(box, { onClose: close, initialFocus: initial }); + }; + + return { box, body, footer, close, addAction, focus }; +} + +/** A modal with Cancel and a confirming action, resolving to true or false. + Replaces confirm() where the question needs more than one line of text. + + @param {{ title: string, body: Node, confirmLabel: string, cancelLabel: string, + destructive?: boolean, className?: string }} opts + @returns {Promise} */ +export function confirmModal({ title, body, confirmLabel, cancelLabel, destructive, className }) { + return new Promise(resolve => { + let answer = false; + const m = openModal({ title, className, onClose: () => resolve(answer) }); + m.body.appendChild(body); + m.addAction(cancelLabel, 'bg sm', m.close); + const go = m.addAction(confirmLabel, destructive ? 'bd-btn sm' : 'bp sm', () => { + answer = true; + m.close(); + }); + m.focus(go); + }); +} + +/** A modal asking for one line of text, resolving to the trimmed value or null. + Replaces prompt(). + + @param {{ title: string, label: string, placeholder?: string, + confirmLabel: string, cancelLabel: string }} opts + @returns {Promise} */ +export function promptModal({ title, label, placeholder, confirmLabel, cancelLabel }) { + return new Promise(resolve => { + /** @type {string|null} */ + let answer = null; + const m = openModal({ title, onClose: () => resolve(answer) }); + + const field = document.createElement('label'); + field.className = 'dlg-field'; + const cap = document.createElement('span'); + cap.textContent = label; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'inp'; + if (placeholder) input.placeholder = placeholder; + field.append(cap, input); + m.body.appendChild(field); + + const accept = () => { + const v = input.value.trim(); + if (!v) return; + answer = v; + m.close(); + }; + input.onkeydown = e => { + if (e.key === 'Enter') { + e.preventDefault(); + accept(); + } + }; + m.addAction(cancelLabel, 'bg sm', m.close); + m.addAction(confirmLabel, 'bp sm', accept); + m.focus(input); + }); +} diff --git a/ui/js/yaml-lite.js b/ui/js/yaml-lite.js new file mode 100644 index 00000000..5e842a9b --- /dev/null +++ b/ui/js/yaml-lite.js @@ -0,0 +1,196 @@ +// @ts-check +/* An indentation-only subset of YAML, enough to read a gethomepage or Dashy + config without a dependency. + + The subset is deliberate. Every construct this cannot represent faithfully is + refused with the line number rather than approximated, because the failure + mode of a permissive hand-written parser is not an exception, it is an item + that imports with the wrong URL and nobody notices. Anchors, flow + collections, block scalars, tabs and multi-document files all throw. + + Pure: no DOM, no imports, no browser globals. */ + +/** Thrown for anything outside the supported subset, with the source line. + Distinguishable from a TypeError so the caller can report it as a file + problem rather than a bug. */ +export class YamlLiteError extends Error { + /** @param {string} message @param {number} line */ + constructor(message, line) { + super(`Line ${line}: ${message}`); + this.name = 'YamlLiteError'; + this.line = line; + this.reason = message; + } +} + +/* A key line: an unquoted or quoted key, a colon, then an optional value. The + key stops at the first colon that is followed by a space or end of line, so + "url: http://host:8080" splits once and keeps the port. */ +const KEY_RE = /^(?:(?:"((?:[^"\\]|\\.)*)")|(?:'((?:[^']|'')*)')|([^:#]+?))\s*:(?:\s+(.*))?$/; + +/** @param {string} raw @param {number} line */ +function scalar(raw, line) { + const s = raw.trim(); + if (s === '') return ''; + if (s[0] === '"') { + const m = /^"((?:[^"\\]|\\.)*)"\s*$/.exec(s); + if (!m) throw new YamlLiteError('unterminated double-quoted value', line); + return m[1].replace(/\\(["\\/nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] || c); + } + if (s[0] === "'") { + const m = /^'((?:[^']|'')*)'\s*$/.exec(s); + if (!m) throw new YamlLiteError('unterminated single-quoted value', line); + return m[1].replace(/''/g, "'"); + } + if (s === '|' || s === '>' || /^[|>][-+\d]*$/.test(s)) + throw new YamlLiteError('block scalars are not supported', line); + if (s[0] === '&' || s[0] === '*') throw new YamlLiteError('anchors and aliases are not supported', line); + if (s[0] === '{' || s[0] === '[') throw new YamlLiteError('flow collections are not supported', line); + if (s === '~' || s === 'null' || s === 'Null' || s === 'NULL') return null; + if (s === 'true' || s === 'True' || s === 'TRUE' || s === 'yes' || s === 'Yes') return true; + if (s === 'false' || s === 'False' || s === 'FALSE' || s === 'no' || s === 'No') return false; + if (/^-?\d+$/.test(s)) return Number(s); + if (/^-?\d*\.\d+$/.test(s)) return Number(s); + /* A trailing comment only counts when a space precedes the #, so a value like + "#00ff00" or a URL fragment survives. */ + const cut = s.search(/\s#/); + return cut === -1 ? s : s.slice(0, cut).trim(); +} + +/** @typedef {{ indent: number, text: string, line: number }} Line */ + +/** @param {string} text @returns {Line[]} */ +function scan(text) { + /** @type {Line[]} */ + const out = []; + let docs = 0; + /* A file saved on Windows starts with a byte order mark. Left in place it + becomes part of the first key and the document reads as malformed. */ + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + text.split(/\r\n|\r|\n/).forEach((raw, i) => { + const line = i + 1; + if (/^\s*$/.test(raw)) return; + if (/^\s*#/.test(raw)) return; + if (/^\t/.test(raw) || /^ *\t/.test(raw)) throw new YamlLiteError('tab indentation is not supported', line); + const trimmed = raw.trim(); + if (trimmed === '---') { + /* One leading marker is ordinary. A second means a multi-document file, + where taking only the first document would silently drop the rest. */ + if (++docs > 1 || out.length) throw new YamlLiteError('multi-document files are not supported', line); + return; + } + if (trimmed === '...') throw new YamlLiteError('multi-document files are not supported', line); + /* Before the anchor check: a merge key is written with an alias, and naming + the alias would send someone looking for the wrong thing. */ + if (/^<<\s*:/.test(trimmed)) throw new YamlLiteError('merge keys are not supported', line); + if (/(^|\s)[&*][^\s]/.test(trimmed)) throw new YamlLiteError('anchors and aliases are not supported', line); + out.push({ indent: raw.length - raw.trimStart().length, text: trimmed, line }); + }); + return out; +} + +/** Parse a block starting at `pos` whose lines are indented at least `indent`. + @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */ +function block(lines, cur, indent) { + const first = lines[cur.pos]; + return first.text.startsWith('- ') || first.text === '-' ? sequence(lines, cur, indent) : mapping(lines, cur, indent); +} + +/** @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */ +function sequence(lines, cur, indent) { + const out = []; + while (cur.pos < lines.length) { + const l = lines[cur.pos]; + if (l.indent < indent) break; + if (l.indent > indent) throw new YamlLiteError('unexpected indentation', l.line); + if (!(l.text.startsWith('- ') || l.text === '-')) break; + const rest = l.text === '-' ? '' : l.text.slice(2).trim(); + cur.pos++; + if (rest === '') { + /* "-" alone: the element is the block indented under it. */ + if (cur.pos < lines.length && lines[cur.pos].indent > indent) out.push(block(lines, cur, lines[cur.pos].indent)); + else out.push(null); + continue; + } + const m = KEY_RE.exec(rest); + if (m) { + /* The compound "- key: value" form. The element is a mapping whose first + key sits on the dash line, so its own indentation is the column the + key text starts at, not the dash. */ + const inner = indent + 2; + const map = Object.create(null); + assign(map, m, l.line, lines, cur, inner); + while (cur.pos < lines.length && lines[cur.pos].indent === inner) { + const nl = lines[cur.pos]; + if (nl.text.startsWith('- ') || nl.text === '-') break; + const nm = KEY_RE.exec(nl.text); + if (!nm) throw new YamlLiteError('expected "key: value"', nl.line); + cur.pos++; + assign(map, nm, nl.line, lines, cur, inner); + } + out.push(map); + continue; + } + out.push(scalar(rest, l.line)); + } + return out; +} + +/** Set one key on `map` from a matched key line, reading its nested block when + the value is empty. + @param {any} map @param {RegExpExecArray} m @param {number} line + @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */ +function assign(map, m, line, lines, cur, indent) { + const key = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2].replace(/''/g, "'") : m[3].trim(); + const inline = m[4]; + if (inline !== undefined && inline.trim() !== '' && !/^#/.test(inline.trim())) { + map[key] = scalar(inline, line); + return; + } + const next = lines[cur.pos]; + if (!next || next.indent <= indent) { + map[key] = null; + return; + } + /* A sequence may sit at the parent's own indentation, which is legal YAML and + common in both source formats. */ + map[key] = block(lines, cur, next.indent); +} + +/** @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */ +function mapping(lines, cur, indent) { + const map = Object.create(null); + while (cur.pos < lines.length) { + const l = lines[cur.pos]; + if (l.indent < indent) break; + if (l.indent > indent) throw new YamlLiteError('unexpected indentation', l.line); + if (l.text.startsWith('- ') || l.text === '-') { + /* A sequence at the same column as the keys of the mapping it belongs to. + Only valid as the whole value of the key above, which assign handles. */ + break; + } + const m = KEY_RE.exec(l.text); + if (!m) throw new YamlLiteError('expected "key: value"', l.line); + cur.pos++; + assign(map, m, l.line, lines, cur, indent); + } + return map; +} + +/** Parse a YAML subset document. + + Objects come back with a null prototype: the keys are attacker-influenced + names from someone else's config, and a key called "constructor" or + "__proto__" must answer as data rather than as an inherited member. + + @param {string} text @returns {any} + @throws {YamlLiteError} on anything outside the subset */ +export function parseYaml(text) { + const lines = scan(String(text == null ? '' : text)); + if (!lines.length) return null; + if (lines[0].indent !== 0) throw new YamlLiteError('unexpected indentation', lines[0].line); + const cur = { pos: 0 }; + const doc = block(lines, cur, 0); + if (cur.pos < lines.length) throw new YamlLiteError('unexpected indentation', lines[cur.pos].line); + return doc; +} diff --git a/ui/test/fixtures/dashy-conf.yml b/ui/test/fixtures/dashy-conf.yml new file mode 100644 index 00000000..f5acb9e8 --- /dev/null +++ b/ui/test/fixtures/dashy-conf.yml @@ -0,0 +1,102 @@ +# Dashy configuration for the lab +# Docs: https://dashy.to/docs/configuring +--- +pageInfo: + title: Lab Dashy + description: Self-hosted service index + navLinks: + - title: Homepage + path: http://127.0.0.1:18080/ + - title: Docs + path: https://dashy.to/docs/ + +appConfig: + theme: nord + layout: auto + iconSize: medium + language: en + statusCheck: true + statusCheckInterval: 300 + hideComponents: + hideSettings: false + auth: + enableGuestAccess: true + +sections: + - name: Media + icon: fas fa-photo-video + displayData: + sortBy: default + cols: 2 + collapsed: false + items: + - title: Plex + description: Movies and TV + icon: favicon + url: http://media.lab.internal:32400/web + target: newtab + statusCheck: true + statusCheckUrl: http://media.lab.internal:32400/identity + tags: + - media + - title: Jellyfin + description: Backup media server + icon: hl-jellyfin + url: https://jellyfin.lab.internal/ + localUrl: http://192.168.1.50:8096 + statusCheck: true + - title: Arr Stack + description: Automation tools + icon: si-sonarr + url: http://sonarr.lab.internal:8989 + subItems: + - title: Sonarr + icon: sonarr.png + url: http://sonarr.lab.internal:8989 + - title: Radarr + icon: mdi-filmstrip + url: http://radarr.lab.internal:7878 + + - name: Infrastructure + icon: fas fa-server + items: + - title: Proxmox + description: Hypervisor + icon: proxmox.png + url: https://pve.lab.internal:8006 + statusCheck: true + statusCheckAllowInsecure: true + - title: OPNsense + icon: https://opnsense.org/wp-content/themes/OPNsense/assets/img/favicon.png + url: https://10.0.0.1/ + color: "#d94f00" + backgroundColor: "#1a1a1a" + - title: Portainer + icon: /icons/portainer.png + url: http://portainer.lab.internal:9000 + statusCheck: true + statusCheckUrl: http://portainer.lab.internal:9000/api/status + hotkey: 3 + + - name: Bookmarks + icon: fas fa-bookmark + displayData: + sortBy: alphabetical + items: + - title: GitHub + icon: si-github + url: https://github.com/ + provider: GitHub + - title: Café ☕ + icon: 🍵 + url: https://example.com/caf%C3%A9 + description: Unicode and emoji test + + - name: System Widgets + icon: fas fa-chart-line + widgets: + - type: clock + options: + timeZone: Europe/Berlin + format: en-GB + - type: public-ip diff --git a/ui/test/fixtures/homepage-bookmarks.yaml b/ui/test/fixtures/homepage-bookmarks.yaml new file mode 100644 index 00000000..5d83f160 --- /dev/null +++ b/ui/test/fixtures/homepage-bookmarks.yaml @@ -0,0 +1,25 @@ +--- +- Developer: + - Github: + - abbr: GH + href: https://github.com/ + icon: github + - Stack Overflow: + - abbr: SO + href: https://stackoverflow.com/ + icon: stackoverflow.png + description: Answers + +- Homelab Docs: + - Homepage Docs: + - abbr: HP + href: https://gethomepage.dev/ + icon: homepage.png + - r/selfhosted: + - abbr: RS + href: https://www.reddit.com/r/selfhosted/ + icon: reddit + - Awesome Selfhosted: + - abbr: AS + href: https://awesome-selfhosted.net/ + icon: si-awesomelists diff --git a/ui/test/fixtures/homepage-services.yaml b/ui/test/fixtures/homepage-services.yaml new file mode 100644 index 00000000..5142ddf8 --- /dev/null +++ b/ui/test/fixtures/homepage-services.yaml @@ -0,0 +1,76 @@ +--- +# Homelab services, grouped the way the Homepage docs suggest. +- Media: + - Plex: + icon: plex.png + href: http://media.lab.internal:32400/web + description: Movies and TV + siteMonitor: http://media.lab.internal:32400/identity + container: sytest-plex + - Jellyfin: + icon: sh-jellyfin.webp + href: https://jellyfin.lab.internal/ + description: Backup media server + siteMonitor: https://jellyfin.lab.internal/health + - "Arr Stack": + - Sonarr: + icon: sonarr.png + href: http://sonarr.lab.internal:8989 + description: TV automation + container: sytest-sonarr + widget: + type: sonarr + url: http://sonarr.lab.internal:8989 + key: "{{HOMEPAGE_VAR_SONARR_KEY}}" + - Radarr: + icon: mdi-filmstrip + href: http://radarr.lab.internal:7878 + description: Movie automation + ping: radarr.lab.internal + - Prowlarr: + icon: si-prowlarr + href: http://prowlarr.lab.internal:9696 + container: prowlarr + server: remote-docker + +- Infrastructure: + - Proxmox: + icon: proxmox.png + href: https://pve.lab.internal:8006 + description: Hypervisor + siteMonitor: https://pve.lab.internal:8006 + - "OPNsense Firewall": + icon: opnsense.png + href: https://10.0.0.1/ + ping: 10.0.0.1 + - Portainer: + icon: /icons/portainer-local.png + href: http://portainer.lab.internal:9000 + description: Container management + siteMonitor: http://portainer.lab.internal:9000/api/status + - Grafana: + icon: https://grafana.com/static/assets/img/fav32.png + href: http://grafana.lab.internal:3000 + widget: + type: grafana + url: http://grafana.lab.internal:3000 + username: viewer + password: ${GRAFANA_PASSWORD} + +- "Tools & Utilities": + - Vaultwarden: + icon: vaultwarden.png + href: https://vault.lab.internal/ + description: Password manager + siteMonitor: https://vault.lab.internal/alive + - Uptime Kuma: + icon: uptime-kuma.png + href: http://kuma.lab.internal:3001 + widget: + type: uptimekuma + url: http://kuma.lab.internal:3001 + slug: homelab + - "Café Monitor": + icon: grafana.png + href: http://xn--caf-dma.lab.internal:8080/ + description: Unicode host test diff --git a/ui/test/import-fixtures.test.mjs b/ui/test/import-fixtures.test.mjs new file mode 100644 index 00000000..3e553ce0 --- /dev/null +++ b/ui/test/import-fixtures.test.mjs @@ -0,0 +1,142 @@ +/* The importer against real files, rather than against snippets written to suit + it. + + The three fixtures in ./fixtures were exported from gethomepage 1.x and Dashy + 4.5.7 running in containers: the Homepage pair is its config directory, and + the Dashy file is what /conf.yml serves to the browser. Between them they + carry a nested group, the extra list level a bookmarks file has, every icon + convention both projects support, service widgets, environment placeholders, + sub-items, status checks with and without their own URL, and non-ASCII names. + + Every one of these files parsed identically under js-yaml at the time it was + captured, which is the property the parser is really being held to. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +const { parseYaml } = await import('/js/yaml-lite.js'); +const { detectSource, convert, SKIP, NOTE } = await import('/js/import-foreign.js'); + +const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fixtures'); +const read = f => fs.readFileSync(path.join(DIR, f), 'utf8'); +const load = f => { + const doc = parseYaml(read(f)); + const kind = detectSource(doc); + return { kind, ...convert(kind, doc, []) }; +}; +const apps = out => out.items.filter(i => i.type === 'app'); +const folders = out => out.items.filter(i => i.type === 'folder'); + +test('a real Homepage services.yaml imports as its groups and services', () => { + const out = load('homepage-services.yaml'); + assert.equal(out.kind, 'homepage-services'); + assert.deepEqual( + folders(out).map(f => f.label), + ['Media', 'Media / Arr Stack', 'Infrastructure', 'Tools & Utilities'], + ); + assert.equal(apps(out).length, 12); + + const plex = apps(out).find(a => a.label === 'Plex'); + assert.equal(plex.href, 'http://media.lab.internal:32400/web'); + assert.equal(plex.iconUrl, 'plex'); + assert.equal(plex.monitoring.healthcheck.pingUrl, 'http://media.lab.internal:32400/identity'); + assert.equal(plex.monitoring.healthcheck.container, 'sytest-plex'); + + /* Prowlarr names a container on another host, so the container is reported + rather than imported: this install cannot see that daemon. */ + const prowlarr = apps(out).find(a => a.label === 'Prowlarr'); + assert.equal(prowlarr.monitoring.healthcheck.container, ''); + assert.ok(out.notes.some(n => n.code === NOTE.CONTAINER_ON_REMOTE && n.name === 'Prowlarr')); + + /* Radarr is monitored by ICMP, which the HTTP health check cannot do. */ + const radarr = apps(out).find(a => a.label === 'Radarr'); + assert.equal(radarr.monitoring.healthcheck.enabled, false); + assert.ok(out.notes.some(n => n.code === NOTE.PING_DROPPED && n.name === 'Radarr')); + + /* Every icon convention in the file, decided one way or the other. */ + assert.equal(apps(out).find(a => a.label === 'Jellyfin').iconUrl, 'jellyfin'); /* sh-jellyfin.webp */ + assert.equal(apps(out).find(a => a.label === 'Portainer').iconUrl, ''); /* /icons/*, local to Homepage */ + assert.equal(apps(out).find(a => a.label === 'Grafana').iconUrl, ''); /* a host the page's CSP refuses */ + assert.equal(apps(out).find(a => a.label === 'Café Monitor').iconUrl, 'grafana'); + + /* A Homepage widget keeps the service as a link and nothing else. */ + assert.ok(out.notes.some(n => n.code === NOTE.WIDGET_AS_LINK && n.name === 'Sonarr')); + assert.equal(JSON.stringify(out.items).includes('HOMEPAGE_VAR'), false, 'no placeholder reaches an item'); + assert.equal(JSON.stringify(out.items).includes('GRAFANA_PASSWORD'), false); +}); + +test('a real Homepage bookmarks.yaml is read through its extra list level', () => { + const out = load('homepage-bookmarks.yaml'); + assert.equal(out.kind, 'homepage-bookmarks'); + assert.deepEqual( + folders(out).map(f => f.label), + ['Developer', 'Homelab Docs'], + ); + assert.deepEqual( + apps(out).map(a => a.label), + ['Github', 'Stack Overflow', 'Homepage Docs', 'r/selfhosted', 'Awesome Selfhosted'], + ); + assert.equal(apps(out).find(a => a.label === 'Github').href, 'https://github.com/'); + /* Every bookmark carries an abbr, which has no equivalent here. */ + assert.equal(out.notes.filter(n => n.code === NOTE.FIELDS_DROPPED).length, 5); +}); + +test('a real Dashy conf.yml imports as its sections, sub-items flattened', () => { + const out = load('dashy-conf.yml'); + assert.equal(out.kind, 'dashy'); + assert.deepEqual( + folders(out).map(f => f.label), + ['Media', 'Infrastructure', 'Bookmarks'], + ); + assert.equal(apps(out).length, 10); + + /* The parent tile keeps its own link and its sub-items join the same folder, + since folders do not nest. */ + const media = folders(out).find(f => f.label === 'Media'); + assert.equal(media.children.length, 5); + assert.ok(out.notes.some(n => n.code === NOTE.SUBITEMS_FLATTENED && n.name === 'Sonarr')); + + /* Jellyfin carries a second address for the other network, and asks for a + status check without naming a URL for it. */ + const jellyfin = apps(out).find(a => a.label === 'Jellyfin'); + assert.equal(jellyfin.href, 'https://jellyfin.lab.internal/'); + assert.equal(jellyfin.monitoring.healthcheck.pingUrl, 'https://jellyfin.lab.internal/'); + assert.ok(out.notes.some(n => n.code === NOTE.LOCAL_URL_DROPPED && n.name === 'Jellyfin')); + + /* statusCheck with no URL of its own falls back to the link. */ + assert.equal( + apps(out).find(a => a.label === 'Plex').monitoring.healthcheck.pingUrl, + 'http://media.lab.internal:32400/identity', + ); + /* Proxmox asks for the check to tolerate its own certificate. */ + assert.equal(apps(out).find(a => a.label === 'Proxmox').skipTlsVerify, true); + /* A section holding only widgets contributes no folder and no app. */ + assert.equal( + folders(out).some(f => f.label === 'System Widgets'), + false, + ); + assert.ok(out.notes.some(n => n.code === NOTE.WIDGETS_DROPPED && n.group === 'System Widgets')); + /* Emoji and font icons have no equivalent; the tile keeps its letter. */ + assert.equal(apps(out).find(a => a.label === 'Café ☕').iconUrl, ''); +}); + +test('nothing in the real files produces an item the save route would refuse', () => { + for (const f of ['homepage-services.yaml', 'homepage-bookmarks.yaml', 'dashy-conf.yml']) { + const out = load(f); + const ids = out.items.map(i => i.id); + assert.equal(new Set(ids).size, ids.length, `${f}: ids are unique`); + for (const item of out.items) { + assert.ok(item.id && typeof item.id === 'string', `${f}: every item has an id`); + assert.ok(item.type === 'app' || item.type === 'folder', `${f}: known type`); + if (item.type === 'app') assert.equal(item.dock, false, `${f}: an import never fills the dock`); + if (item.type === 'folder') assert.ok(item.children.length > 0, `${f}: no empty folder`); + } + assert.equal(out.skipped.filter(s => s.reason === SKIP.UNREADABLE).length, 0, `${f}: nothing unreadable`); + } +}); diff --git a/ui/test/import-foreign.test.mjs b/ui/test/import-foreign.test.mjs new file mode 100644 index 00000000..e9899d4f --- /dev/null +++ b/ui/test/import-foreign.test.mjs @@ -0,0 +1,362 @@ +/* The conversion rules, exercised against the shapes real gethomepage and + Dashy files actually take. + + Two of these matter more than the rest. An href holding a placeholder only + the other dashboard can fill in would import as a dead tile, and an href with + a script scheme would be refused by the server for the whole save, so both + have to be dropped here rather than passed along. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +const { parseYaml } = await import('/js/yaml-lite.js'); +const { + detectSource, + convert, + convertIcon, + convertDashy, + convertHomepageServices, + convertHomepageBookmarks, + SKIP, + NOTE, +} = await import('/js/import-foreign.js'); + +const apps = out => out.items.filter(i => i.type === 'app'); +const folders = out => out.items.filter(i => i.type === 'folder'); +const byName = (rows, name) => rows.filter(r => r.name === name); + +const SERVICES = `- Media: + - Plex: + icon: plex.png + href: http://plex:32400 + description: Movies + siteMonitor: http://plex:32400/web + container: plex + - Arr Stack: + - Sonarr: + icon: mdi-television + href: http://sonarr:8989 + ping: sonarr.lan + container: sonarr + server: other-host +- Secrets: + - Broken: + href: http://{{HOMEPAGE_VAR_HOST}}:8080 + - Nasty: + href: "javascript:alert(1)" + - NoLink: + icon: nginx.png +`; + +const BOOKMARKS = `- Developer: + - Github: + - abbr: GH + href: https://github.com/ + icon: github + - Docs: + - href: https://example.com/docs + icon: sh-readthedocs.webp +`; + +const DASHY = `pageInfo: + title: My Dash +appConfig: + theme: nord +sections: + - name: Network + icon: fas fa-network-wired + items: + - title: Router + url: http://10.0.0.1 + icon: favicon + statusCheck: true + tags: net + subItems: + - title: Switch + url: http://10.0.0.2 + - title: NAS + url: http://nas.lan + localUrl: http://192.168.1.5 + statusCheck: true + statusCheckUrl: http://nas.lan/health + - title: Plain + url: http://plain.lan + statusCheck: false + statusCheckUrl: http://plain.lan/health +pages: + - name: Second + path: other.yml +`; + +test('detects each format from its shape, not its filename', () => { + assert.equal(detectSource(parseYaml(SERVICES)), 'homepage-services'); + assert.equal(detectSource(parseYaml(BOOKMARKS)), 'homepage-bookmarks'); + assert.equal(detectSource(parseYaml(DASHY)), 'dashy'); + assert.equal(detectSource(parseYaml('items:\n - id: a\n')), null); + assert.equal(detectSource(null), null); +}); + +test('a Homepage group becomes a folder holding its services', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + const media = folders(out).find(f => f.label === 'Media'); + const plex = apps(out).find(a => a.label === 'Plex'); + assert.ok(media); + assert.deepEqual(media.children, [plex.id]); + assert.equal(plex.href, 'http://plex:32400'); + assert.equal(plex.iconUrl, 'plex'); + assert.equal(plex.type, 'app'); + assert.equal(plex.dock, false); + assert.equal(plex.color, 'dark'); +}); + +test('a nested group becomes its own folder with a compound name', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + const nested = folders(out).find(f => f.label === 'Media / Arr Stack'); + assert.ok(nested, 'nested group produced a folder'); + const sonarr = apps(out).find(a => a.label === 'Sonarr'); + assert.deepEqual(nested.children, [sonarr.id]); + assert.equal(byName(out.notes, 'Arr Stack')[0].code, NOTE.GROUP_FLATTENED); +}); + +test('siteMonitor becomes the health check, ping does not', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + const plex = apps(out).find(a => a.label === 'Plex'); + const sonarr = apps(out).find(a => a.label === 'Sonarr'); + assert.equal(plex.monitoring.healthcheck.pingUrl, 'http://plex:32400/web'); + assert.equal(plex.monitoring.healthcheck.enabled, true); + assert.equal(sonarr.monitoring.healthcheck.pingUrl, ''); + assert.ok(out.notes.some(n => n.code === NOTE.PING_DROPPED && n.name === 'Sonarr')); +}); + +test('a container is imported only when it runs on this host', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + assert.equal(apps(out).find(a => a.label === 'Plex').monitoring.healthcheck.container, 'plex'); + const sonarr = apps(out).find(a => a.label === 'Sonarr'); + assert.equal(sonarr.monitoring.healthcheck.container, ''); + assert.equal(sonarr.monitoring.healthcheck.enabled, false); + assert.ok(out.notes.some(n => n.code === NOTE.CONTAINER_ON_REMOTE && n.detail === 'other-host')); +}); + +test('a link the other dashboard fills in from its own environment is skipped', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + const skip = out.skipped.find(s => s.name === 'Broken'); + assert.equal(skip.reason, SKIP.PLACEHOLDER_HREF); + assert.equal( + apps(out).some(a => a.label === 'Broken'), + false, + ); +}); + +test('a script-scheme link is skipped rather than saved and refused', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + assert.equal(out.skipped.find(s => s.name === 'Nasty').reason, SKIP.UNSAFE_HREF); +}); + +test('a service with no link is skipped', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + assert.equal(out.skipped.find(s => s.name === 'NoLink').reason, SKIP.NO_HREF); +}); + +test('a group whose services all fail produces no empty folder', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + assert.equal( + folders(out).some(f => f.label === 'Secrets'), + false, + ); +}); + +test('a widget keeps its service as a link', () => { + const out = convertHomepageServices( + parseYaml(`- Tools: + - Uptime: + href: http://up:3001 + widget: + type: uptimekuma + url: http://up:3001 +`), + ); + assert.equal(apps(out)[0].href, 'http://up:3001'); + assert.ok(out.notes.some(n => n.code === NOTE.WIDGET_AS_LINK && n.name === 'Uptime')); +}); + +test('bookmarks are read through their extra list level', () => { + const out = convertHomepageBookmarks(parseYaml(BOOKMARKS)); + const gh = apps(out).find(a => a.label === 'Github'); + assert.equal(gh.href, 'https://github.com/'); + assert.equal(gh.iconUrl, 'github'); + assert.equal(apps(out).find(a => a.label === 'Docs').iconUrl, 'readthedocs'); + assert.deepEqual(folders(out)[0].children.length, 2); + assert.ok(out.notes.some(n => n.code === NOTE.FIELDS_DROPPED && n.detail.includes('abbr'))); +}); + +test('a Dashy section becomes a folder and subItems join it', () => { + const out = convertDashy(parseYaml(DASHY)); + const net = folders(out).find(f => f.label === 'Network'); + assert.deepEqual( + net.children.map(id => apps(out).find(a => a.id === id).label), + ['Router', 'Switch', 'NAS', 'Plain'], + ); + assert.ok(out.notes.some(n => n.code === NOTE.SUBITEMS_FLATTENED && n.name === 'Switch')); +}); + +test('statusCheck decides the health check, with or without its own URL', () => { + const out = convertDashy(parseYaml(DASHY)); + const get = label => apps(out).find(a => a.label === label).monitoring.healthcheck; + assert.equal(get('Router').pingUrl, 'http://10.0.0.1'); + assert.equal(get('NAS').pingUrl, 'http://nas.lan/health'); + assert.equal(get('Plain').pingUrl, ''); + assert.equal(get('Plain').enabled, false); +}); + +test('localUrl is dropped and extra pages are reported', () => { + const out = convertDashy(parseYaml(DASHY)); + assert.equal(apps(out).find(a => a.label === 'NAS').href, 'http://nas.lan'); + assert.ok(out.notes.some(n => n.code === NOTE.PAGES_NOT_FOLLOWED && n.detail === '1')); +}); + +test('icons: a slug survives, an icon font does not', () => { + assert.deepEqual(convertIcon('sonarr.png'), { iconUrl: 'sonarr', dropped: false }); + assert.deepEqual(convertIcon('sonarr.webp'), { iconUrl: 'sonarr', dropped: false }); + assert.deepEqual(convertIcon('sh-jellyfin'), { iconUrl: 'jellyfin', dropped: false }); + assert.deepEqual(convertIcon('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/x.png'), { + iconUrl: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/x.png', + dropped: false, + }); + for (const bad of [ + 'mdi-television', + 'si-docker', + 'fas fa-home', + 'far fa-star', + 'favicon', + '/icons/local.png', + '🚀', + 'https://grafana.com/static/assets/img/fav32.png', + ]) { + assert.equal(convertIcon(bad).iconUrl, '', `${bad} should not become an icon`); + assert.equal(convertIcon(bad).dropped, true); + } +}); + +/* Found against a real Homepage export: grafana.com served the icon, the page's + img-src refused it, and the tile showed a broken image with a CSP violation + in the console. An icon the browser will not load is not an icon. */ +test('an icon URL the page cannot load is dropped, not stored', () => { + assert.deepEqual(convertIcon('https://grafana.com/static/assets/img/fav32.png'), { iconUrl: '', dropped: true }); + assert.deepEqual(convertIcon('http://192.168.1.5/icon.png'), { iconUrl: '', dropped: true }); + assert.deepEqual(convertIcon('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg'), { + iconUrl: 'https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg', + dropped: false, + }); + assert.deepEqual(convertIcon('https://['), { iconUrl: '', dropped: true }); +}); + +/* The preview prints "group / name", so the note for a flattened group used to + read "Media / Media / Arr Stack". */ +test('the flattened-group note names the child once', () => { + const out = convertHomepageServices(parseYaml(SERVICES)); + const note = out.notes.find(n => n.code === NOTE.GROUP_FLATTENED); + assert.equal(note.name, 'Arr Stack'); + assert.equal(note.group, 'Media'); + assert.equal(note.detail, 'Media / Arr Stack'); +}); + +/* All three found against the real Dashy export. */ +test('Dashy allow-insecure carries across to the TLS switch Stackyard already has', () => { + const out = convertDashy( + parseYaml(`sections: + - name: Net + items: + - title: PVE + url: https://pve.lan:8006 + statusCheck: true + statusCheckAllowInsecure: true + - title: Plain + url: https://plain.lan + statusCheck: true +`), + ); + assert.equal(apps(out).find(a => a.label === 'PVE').skipTlsVerify, true); + assert.equal(apps(out).find(a => a.label === 'Plain').skipTlsVerify, undefined); +}); + +test('a homelab-svg-assets icon resolves like a selfh.st one', () => { + assert.deepEqual(convertIcon('hl-jellyfin'), { iconUrl: 'jellyfin', dropped: false }); +}); + +test('a second local address is reported rather than dropped in silence', () => { + const out = convertDashy(parseYaml(DASHY)); + const note = out.notes.find(n => n.code === NOTE.LOCAL_URL_DROPPED); + assert.equal(note.name, 'NAS'); + assert.equal(note.detail, 'http://192.168.1.5'); +}); + +test('a note about a whole section carries no item name to repeat', () => { + const out = convertDashy( + parseYaml(`sections: + - name: Stats + widgets: + - type: clock +`), + ); + const note = out.notes.find(n => n.code === NOTE.WIDGETS_DROPPED); + assert.equal(note.name, ''); + assert.equal(note.group, 'Stats'); +}); + +/* All five found by feeding the converter deliberately damaged files. Silence + is the thing being tested against: a mangled entry that vanishes leaves a + smaller dashboard and no way to tell what went missing. */ +test('an entry that is not in a readable shape is reported, not passed over', () => { + const hp = convertHomepageServices(parseYaml('- G:\n - just a string\n - Two: keys\n Here: yes\n')); + assert.equal(hp.skipped.filter(s => s.reason === SKIP.UNREADABLE).length, 2); + + const dashy = convertDashy(parseYaml('sections:\n - name: A\n items:\n - just a string\n')); + assert.equal(dashy.skipped.find(s => s.reason === SKIP.UNREADABLE).group, 'A'); + + const bm = convertHomepageBookmarks(parseYaml('- G:\n - Name:\n - not a mapping\n')); + assert.equal(bm.skipped[0].reason, SKIP.UNREADABLE); +}); + +test('a link with no scheme is skipped, since it would resolve against this dashboard', () => { + const out = convertHomepageServices(parseYaml('- G:\n - Trav:\n href: ../../../../etc/passwd\n')); + assert.equal(out.skipped[0].reason, SKIP.RELATIVE_HREF); + assert.equal(apps(out).length, 0); +}); + +test('a name written unquoted as a number is still a name', () => { + const out = convertDashy( + parseYaml('sections:\n - name: 2024\n items:\n - title: 42\n url: http://a.lan\n'), + ); + assert.equal(apps(out)[0].label, '42'); + assert.equal(folders(out)[0].label, '2024'); +}); + +test('a section with no name takes the caller-supplied label, so it can be translated', () => { + const out = convertDashy( + parseYaml('sections:\n - items:\n - title: T\n url: http://a.lan\n'), + [], + 'Importé', + ); + assert.equal(folders(out)[0].label, 'Importé'); +}); + +test('ids never collide with what is already on the dashboard, or across files', () => { + const existing = ['Plex_abc', 'Media_abc']; + const taken = new Set(existing); + const first = convert('homepage-services', parseYaml(SERVICES), taken); + for (const item of first.items) taken.add(item.id); + const second = convert('homepage-services', parseYaml(SERVICES), taken); + const all = [...existing, ...first.items.map(i => i.id), ...second.items.map(i => i.id)]; + assert.equal(new Set(all).size, all.length, 'every id is unique'); +}); + +test('conversion adds items only, so nothing existing is touched', () => { + const out = convert('dashy', parseYaml(DASHY), []); + for (const item of out.items) { + assert.ok(item.id && item.type, 'every item has the id and type the server requires'); + assert.equal(item.type === 'folder' || item.type === 'app', true); + } +}); diff --git a/ui/test/yaml-lite.test.mjs b/ui/test/yaml-lite.test.mjs new file mode 100644 index 00000000..817e8674 --- /dev/null +++ b/ui/test/yaml-lite.test.mjs @@ -0,0 +1,139 @@ +/* The parser exists because the two source formats are YAML and a dependency + is not available. What matters is not how much YAML it reads but that it + never reads something as the wrong value: an item that imports with the wrong + URL is silent, and a refused file is not. + + So the refusals are tested as carefully as the parses. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +const { parseYaml, YamlLiteError } = await import('/js/yaml-lite.js'); + +const plain = v => JSON.parse(JSON.stringify(v)); + +test('reads a Homepage services file, nested group included', () => { + const doc = parseYaml(`--- +# My services +- Media: + - Plex: + href: http://plex:32400 + icon: plex.png + siteMonitor: http://plex:32400/web + - Downloads: + - Sonarr: + href: http://sonarr:8989 + ping: sonarr.lan +- Tools: + - Uptime: + href: 'http://up:3001' +`); + assert.deepEqual(plain(doc), [ + { + Media: [ + { Plex: { href: 'http://plex:32400', icon: 'plex.png', siteMonitor: 'http://plex:32400/web' } }, + { Downloads: [{ Sonarr: { href: 'http://sonarr:8989', ping: 'sonarr.lan' } }] }, + ], + }, + { Tools: [{ Uptime: { href: 'http://up:3001' } }] }, + ]); +}); + +test('reads the extra list level a bookmarks file has', () => { + const doc = parseYaml(`- Developer: + - Github: + - abbr: GH + href: https://github.com/ + icon: github +`); + assert.deepEqual(plain(doc), [ + { Developer: [{ Github: [{ abbr: 'GH', href: 'https://github.com/', icon: 'github' }] }] }, + ]); +}); + +test('keeps a port and a fragment out of the key split', () => { + const doc = parseYaml('url: http://host:8080/path#top\n'); + assert.equal(doc.url, 'http://host:8080/path#top'); +}); + +test('reads booleans, numbers, null and quoted scalars', () => { + const doc = parseYaml(`a: true +b: false +c: 42 +d: 3.5 +e: ~ +f: "quoted: value" +g: 'it''s here' +h: yes +`); + assert.deepEqual(plain(doc), { + a: true, + b: false, + c: 42, + d: 3.5, + e: null, + f: 'quoted: value', + g: "it's here", + h: true, + }); +}); + +test('strips a trailing comment but not a colour or a fragment', () => { + assert.equal(parseYaml('a: value # trailing\n').a, 'value'); + assert.equal(parseYaml('a: "#00ff00"\n').a, '#00ff00'); + assert.equal(parseYaml('a: http://h/#frag\n').a, 'http://h/#frag'); +}); + +test('objects have a null prototype, so a config key cannot answer as an inherited member', () => { + const doc = parseYaml('constructor: mine\ntoString: also mine\n'); + assert.equal(Object.getPrototypeOf(doc), null); + assert.equal(doc.constructor, 'mine'); + assert.equal(doc.toString, 'also mine'); +}); + +/* Each of these is a construct the parser could guess at. Guessing produces an + item that looks imported and points somewhere else. */ +for (const [name, text, reason] of [ + ['anchors', 'base: &defaults\n icon: x\napp:\n href: http://a\n', 'anchors'], + ['aliases', 'a: *defaults\n', 'anchors'], + ['merge keys', 'a:\n <<: *defaults\n', 'merge keys'], + ['flow mappings', 'a: { b: 1 }\n', 'flow collections'], + ['flow sequences', 'a: [1, 2]\n', 'flow collections'], + ['block scalars', 'a: |\n line one\n', 'block scalars'], + ['folded scalars', 'a: >\n line one\n', 'block scalars'], + ['tab indentation', 'a:\n\tb: 1\n', 'tab indentation'], + ['a second document', 'a: 1\n---\nb: 2\n', 'multi-document'], +]) { + test(`refuses ${name} rather than guessing`, () => { + assert.throws( + () => parseYaml(text), + err => { + assert.ok(err instanceof YamlLiteError, `expected YamlLiteError, got ${err.name}`); + assert.match(err.reason, new RegExp(reason)); + assert.ok(Number.isInteger(err.line) && err.line > 0, 'error carries a line number'); + return true; + }, + ); + }); +} + +test('reports the line the problem is on', () => { + try { + parseYaml('a: 1\nb: 2\nc: [3]\n'); + assert.fail('should have thrown'); + } catch (err) { + assert.equal(err.line, 3); + } +}); + +test('a single leading document marker is fine', () => { + assert.deepEqual(plain(parseYaml('---\na: 1\n')), { a: 1 }); +}); + +test('an empty or comment-only file parses to nothing', () => { + assert.equal(parseYaml(''), null); + assert.equal(parseYaml('# just a note\n\n'), null); +});