From 653dee192c86fe1811d15081d43cc740fd650fca Mon Sep 17 00:00:00 2001 From: MacRimi Date: Fri, 16 Oct 2026 18:26:27 +0200 Subject: [PATCH] fix(oci): end a pasted definition with a single Ctrl+D --- AppImage/components/host-backup.tsx | 2 +- AppImage/messages/es/common.json | 30 +++++++-------- lang/es.json | 1 + oci/src/proxmenux_oci/custom.py | 19 ++++++++-- oci/tests/test_paste_until_ctrl_d.py | 55 ++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 19 deletions(-) create mode 100644 oci/tests/test_paste_until_ctrl_d.py diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 8b56bccb..07e10b94 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -1207,7 +1207,7 @@ export function HostBackup() { {!j.attached && j.next_run && ( - {t("backup.jobs.nextRun", { time: formatNext(j.next_run) })} + {t("backup.jobs.nextRun")}: {formatNext(j.next_run)} )} {(statusBadge || lastRunWhen) && ( diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 1d5308a1..166bf6a5 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -4016,7 +4016,7 @@ }, "backends": { "borgDescription": "Repositorio Borg para backups del host deduplicados.", - "borgDescriptionTimerOnly": "Los destinos Borg son utilizados por los temporizadores Monitor, no por los tareas PVE vzdump.", + "borgDescriptionTimerOnly": "Los destinos Borg los usan los temporizadores del Monitor, no las tareas vzdump de PVE.", "borgShortDescription": "Repositorio Borg deduplicado", "local": "Local", "localDescription": "Carpeta en este host o en una unidad USB montada.", @@ -4100,10 +4100,10 @@ "localUsbDescription": "Carpeta en este host o una unidad USB montada.", "managedByPve": "Gestionado por PVE", "manuallyAdded": "Agregado manualmente", - "nameCredentialsLocked": "El nombre y las credenciales están bloqueados mientras los tareas utilizan este destino.", - "namePassphraseLocked": "El nombre del repositorio y la frase de contraseña están bloqueados mientras los tareas utilizan este destino.", + "nameCredentialsLocked": "El nombre y las credenciales están bloqueados mientras haya tareas que usen este destino.", + "namePassphraseLocked": "El nombre del repositorio y la frase de contraseña están bloqueados mientras haya tareas que usen este destino.", "noBorgInDialog": "La edición de destinos de Borg está disponible desde el flujo de configuración de Borg.", - "noBorgInWizard": "Borg no está disponible para tareas adjuntos PVE.", + "noBorgInWizard": "Borg no está disponible para las tareas vinculadas a PVE.", "noPbsInDialog": "La edición de destino de PBS está disponible desde el flujo de configuración de PBS.", "noPbsInWizard": "Aún no se han configurado repositorios de PBS.", "not": "no", @@ -4141,7 +4141,7 @@ "autoDetectedFromPveStorage": "Detectado desde el almacenamiento PVE", "borgPassphraseLossBefore": "Si pierde la frase de contraseña de Borg, los backups cifrados no se podrán restaurar.", "borgPassphraseOnlyWay": "Esta frase de contraseña es la única forma de desbloquear el repositorio.", - "borgPassphraseSavedAfter": "para que los tareas programados puedan ejecutarse sin supervisión.", + "borgPassphraseSavedAfter": "para que las tareas programadas puedan ejecutarse sin supervisión.", "borgPassphraseSavedAt": "La frase de contraseña se guarda en", "encryptBackups": "Cifrar backups", "encryptThisBackup": "Cifrar este backup", @@ -4251,7 +4251,7 @@ "pathLabel": "Ruta", "pbsRepository": "repositorio de PBS", "profileLabel": "Perfil", - "pveJobLabel": "tarea PvE", + "pveJobLabel": "Tarea PVE", "pveStorage": "almacenamiento PVE", "pveVersion": "versión PvE", "recoveryPassphrase": "Frase de contraseña de recuperación", @@ -4288,20 +4288,20 @@ "sshPort": "Puerto SSH" }, "jobs": { - "attachDescriptionAfter": "backups.", - "attachDescriptionBefore": "Une Monitor a una tarea PVE vzdump y ejecútela después del", - "attachDescriptionMiddle": "evento para seleccionados", + "attachDescriptionAfter": "seleccionados.", + "attachDescriptionBefore": "Vincula el Monitor a una tarea vzdump de PVE y se ejecuta tras el evento", + "attachDescriptionMiddle": "para los backups de", "attachToPveJob": "Unirse a una tarea de Backup en PVE", "attachedToPveVzdump": "Adjunto a PVE vzdump", - "backendEditHelp": "El backend no se puede cambiar una vez creado la tarea.", - "createJob": "crear tarea", - "createScheduledJob": "Crear tarea programado", + "backendEditHelp": "El backend no se puede cambiar una vez creada la tarea.", + "createJob": "Crear tarea", + "createScheduledJob": "Crear tarea programada", "customProfileTitle": "Perfil personalizado", "defaultProfileTitle": "Perfil predeterminado", "detailDescription": "Programación, perfil, destino y última ejecución de esta tarea.", "disableJob": "Desactivar tarea", "disableJobDescription": "El temporizador systemd se detendrá; no se ejecutarán más automáticamente. Puede volver a habilitarlo más tarde desde este mismo cuadro de diálogo.", - "editScheduledJob": "Editar tarea programado", + "editScheduledJob": "Editar tarea programada", "encryptedTitle": "cifrado", "inheritedRetentionLabel": "Retención de la tarea PVE", "inheritedScheduleLabel": "Programación de la tarea PVE", @@ -4338,7 +4338,7 @@ "runNowTitle": "Activa esta tarea ahora", "running": "Ejecutando...", "scheduledTitle": "Programado", - "standaloneScheduledJob": "Tarea programado independiente", + "standaloneScheduledJob": "Tarea programada independiente", "starting": "Iniciando...", "stepOf": "Paso {step} de {total}", "summary": "Resumen", @@ -4592,7 +4592,7 @@ "advancedHelpBefore": "Usar", "advancedHelpMiddle": "y", "advancedOption": "Avanzado", - "borgTimerOnly": "Los tareas Borg están programados mediante temporizadores Monitor.", + "borgTimerOnly": "Las tareas Borg se programan con temporizadores del Monitor.", "dailyOption": "A diario", "everyDayAt": "Todos los días a las {time}", "everyDayAtMidnight": "Todos los días a medianoche", diff --git a/lang/es.json b/lang/es.json index aa4c12a3..78ee0e15 100644 --- a/lang/es.json +++ b/lang/es.json @@ -4562,6 +4562,7 @@ "Passwords do not match. Please try again.": "Las contraseñas no coinciden. Por favor inténtalo de nuevo.", "Paste an oci-compose in the terminal": "Pegar un oci-compose en la terminal", "Paste it here and press Ctrl+D on an empty line.": "Pégalo aquí y pulsa Ctrl+D en una línea vacía.", + "Paste it here and press Ctrl+D.": "Pégalo aquí y pulsa Ctrl+D.", "Paste its Compose file in the terminal": "Pegar su archivo Compose en la terminal", "Paste its docker run command in the terminal": "Pegar su comando docker run en la terminal", "Paste the UUP Dump URL here": "Pegue aquí la URL de UUP Dump", diff --git a/oci/src/proxmenux_oci/custom.py b/oci/src/proxmenux_oci/custom.py index 4d8839ab..f15f3152 100644 --- a/oci/src/proxmenux_oci/custom.py +++ b/oci/src/proxmenux_oci/custom.py @@ -3,6 +3,7 @@ is translated into the same template the catalog applications use.""" from __future__ import annotations import json +import os import re import shlex import subprocess @@ -455,12 +456,24 @@ def describe_oci_compose(template: dict[str, Any]) -> str: def _read_pasted(ui, title: str | None = None, limit: int = MAX_COMPOSE_BYTES, missing: str | None = None) -> str: - """The definition is pasted in the terminal: dialog cannot take it.""" + """The definition is pasted in the terminal: dialog cannot take it. On a + terminal one Ctrl+D ends it, also at the end of a line that has text.""" console.show_logo() console.msg_title(title or translate('Compose file of the application')) - console.msg_info2(translate('Paste it here and press Ctrl+D on an empty line.')) + console.msg_info2(translate('Paste it here and press Ctrl+D.')) print() - text = sys.stdin.read(limit + 1) + if sys.stdin.isatty(): + data = b'' + while len(data) <= limit: + chunk = os.read(sys.stdin.fileno(), 65536) + data += chunk + # The terminal hands over whole lines; a chunk without its newline + # is the line Ctrl+D sent before the end of the line. + if not chunk.endswith(b'\n'): + break + text = data.decode('utf-8', errors='replace') + else: + text = sys.stdin.read(limit + 1) if not text.strip(): raise UserCancelled(missing or (translate('No docker run command was given') if title is not None else translate('No Compose file was given'))) diff --git a/oci/tests/test_paste_until_ctrl_d.py b/oci/tests/test_paste_until_ctrl_d.py new file mode 100644 index 00000000..bd8cec1b --- /dev/null +++ b/oci/tests/test_paste_until_ctrl_d.py @@ -0,0 +1,55 @@ +"""A pasted definition ends with one Ctrl+D, also when it is pressed at the +end of the last line instead of on an empty one.""" + +import io +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci import custom + + +def read_pasted(limit): + with patch.object(custom.console, "show_logo"), patch.object(custom.console, "msg_title"), \ + patch.object(custom.console, "msg_info2"), patch("builtins.print"): + return custom._read_pasted(None, limit=limit) + +COMPOSE = "services:\n app:\n image: nginx:latest\n" + + +@unittest.skipUnless(hasattr(os, "openpty"), "needs a pseudo terminal") +class PasteUntilCtrlDTests(unittest.TestCase): + def read(self, typed: bytes, limit: int = 1024) -> str: + controller, terminal = os.openpty() + with os.fdopen(terminal, "r") as stdin, patch("sys.stdin", stdin): + os.write(controller, typed) + try: + return read_pasted(limit) + finally: + os.close(controller) + + def test_ctrl_d_on_an_empty_line(self): + self.assertEqual(self.read(COMPOSE.encode() + b"\x04"), COMPOSE) + + def test_ctrl_d_at_the_end_of_the_last_line(self): + self.assertEqual(self.read(COMPOSE.rstrip("\n").encode() + b"\x04"), COMPOSE.rstrip("\n")) + + def test_reading_stops_past_the_limit(self): + text = self.read(b"a: 1\n" * 20 + b"\x04", limit=10) + self.assertGreater(len(text), 10) + self.assertLess(len(text), 100) + + +class PastedFromAPipeTests(unittest.TestCase): + def test_a_pipe_is_read_to_its_end(self): + with patch("sys.stdin", io.StringIO(COMPOSE)): + self.assertEqual(read_pasted(1024), COMPOSE) + + +if __name__ == "__main__": + unittest.main()