From 755eca6ecda42a4599ef6978a369ad1bc9f95abb Mon Sep 17 00:00:00 2001 From: SISUESS Date: Tue, 23 Jun 2026 11:16:35 +0200 Subject: [PATCH] feat: add duplicate cron job functionality --- .gitignore | 2 ++ README.md | 13 +++++++++ src/cronboard/app.py | 20 ++++++++++--- src/cronboard/screens/CronCreator.py | 4 ++- src/cronboard/widgets/CronTable.py | 35 ++++++++++++++++++++++- tests/widgets/CronTable_duplicate_test.py | 27 +++++++++++++++++ 6 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 tests/widgets/CronTable_duplicate_test.py diff --git a/.gitignore b/.gitignore index 70854e3..a4db141 100644 --- a/.gitignore +++ b/.gitignore @@ -183,3 +183,5 @@ cliff.toml # script update_dependencies.py test-server/ + +.idea diff --git a/README.md b/README.md index 18af314..ba8f811 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Full documentation is available at [cronboard.dev](https://cronboard.dev). - Check cron jobs, view the logs, and detect potential issues when running - Autocompletion for paths when creating or editing cron jobs - Create cron jobs with validation and human-readable feedback +- Duplicate existing cron jobs with a single keystroke (preserves schedule, command, and logging) - Pause/resume, edit and delete cron jobs - View formatted last and next run times - Accept `special expressions` like `@daily`, `@yearly`, and `@monthly` @@ -74,6 +75,18 @@ Path autocompletion when creating or editing cron jobs helps you enter file path The default starting point for autocompletion is the home directory of the user whose cron jobs you are managing. Accept a suggestion with the `Tab` key. +### 📋 Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `c` | Create new cron job | +| `e` | Edit selected cron job | +| `d` | Duplicate selected cron job | +| `D` | Delete selected cron job | +| `p` | Pause/resume cron job | +| `/` | Search cron jobs | +| `L` | View logs for selected job | + ## ❤️ Do you like my work? If you find the project useful, you can support the author here: diff --git a/src/cronboard/app.py b/src/cronboard/app.py index f48ab10..f2e5d41 100644 --- a/src/cronboard/app.py +++ b/src/cronboard/app.py @@ -170,7 +170,7 @@ def _focus_active_panel(self) -> None: self.servers.focus_tree() def action_create_cronjob( - self, cron: CronTab, remote=False, ssh_client=None, crontab_user=None + self, cron: CronTab, remote=False, ssh_client=None, crontab_user=None, duplicate_data=None ) -> None: def check_save(save: bool | None) -> None: if save: @@ -182,10 +182,22 @@ def check_save(save: bool | None) -> None: ): self.servers.current_cron_table.action_refresh() + kwargs = { + "cron": cron, + "remote": remote, + "ssh_client": ssh_client, + "crontab_user": crontab_user, + } + + if duplicate_data: + kwargs["identificator"] = duplicate_data.get("identificator") + kwargs["expression"] = duplicate_data.get("expression") + kwargs["command"] = duplicate_data.get("command") + if "log_enabled" in duplicate_data: + kwargs["log_enabled"] = duplicate_data.get("log_enabled") + self.push_screen( - CronCreator( - cron, remote=remote, ssh_client=ssh_client, crontab_user=crontab_user - ), + CronCreator(**kwargs), check_save, ) diff --git a/src/cronboard/screens/CronCreator.py b/src/cronboard/screens/CronCreator.py index 912552c..809f229 100644 --- a/src/cronboard/screens/CronCreator.py +++ b/src/cronboard/screens/CronCreator.py @@ -38,12 +38,14 @@ def __init__( remote=False, ssh_client=None, crontab_user=None, + log_enabled=None, ) -> None: super().__init__() self.expression = expression self.command = command self.identificator = identificator - self.log_enabled = has_wrapper(command) if command else False + # If log_enabled is explicitly provided, use it; otherwise detect from command + self.log_enabled = log_enabled if log_enabled is not None else (has_wrapper(command) if command else False) self.cron: CronTab = cron self.remote = remote self.ssh_client = ssh_client diff --git a/src/cronboard/widgets/CronTable.py b/src/cronboard/widgets/CronTable.py index 9407ad2..7157568 100644 --- a/src/cronboard/widgets/CronTable.py +++ b/src/cronboard/widgets/CronTable.py @@ -14,6 +14,8 @@ class CronTable(DataTable): + NO_ID_LABEL = "No ID" + BINDINGS = [ Binding("/", "cron_search", "Search"), Binding("escape", "clear_search", "Clear Search"), @@ -28,6 +30,7 @@ class CronTable(DataTable): Binding("r", "refresh", "Refresh"), Binding("p", "pause_cronjob", "Pause Toggle"), Binding("e", "edit_cronjob", "Edit"), + Binding("d", "duplicate_cronjob", "Duplicate"), Binding("L", "view_logs", "View Logs"), ] @@ -85,6 +88,7 @@ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | No "edit_cronjob", "delete_cronjob", "pause_cronjob", + "duplicate_cronjob", "cursor_up", "cursor_down", "cursor_left", @@ -103,7 +107,7 @@ def parse_cron(self, cron): expr = job.slices.render() cmd = command_without_wrapper(job.command) log_enabled = has_wrapper(job.command) - identificator = job.comment if job.comment else "No ID" + identificator = job.comment if job.comment else self.NO_ID_LABEL try: active_status = "Active" if job.is_enabled() else "Paused" schedule = job.schedule(date_from=datetime.now()) @@ -436,6 +440,35 @@ def write_remote_crontab(self): print(f"❌ Error writing remote crontab: {e}") return False + def action_duplicate_cronjob(self) -> None: + """Duplicate the selected cronjob and open in edit mode.""" + row = self.get_row_at(self.cursor_row) + identificator = row[0] + expr = row[1] + cmd = row[2] + log_enabled = row[3] == "True" # Convert string to bool + + # Generate new ID with "_copy" suffix (no spaces allowed in IDs) + if identificator == self.NO_ID_LABEL: + new_identificator = "No_ID_copy" + else: + new_identificator = f"{identificator}_copy" + + # Open editor with duplicated job data + used_cron = self.ssh_cron if self.remote and self.ssh_client else self.cron + self.app.action_create_cronjob( + used_cron, + remote=self.remote, + ssh_client=self.ssh_client, + crontab_user=self.crontab_user, + duplicate_data={ + "identificator": new_identificator, + "expression": expr, + "command": cmd, + "log_enabled": log_enabled, + }, + ) + def action_view_logs(self) -> None: """View logs for the selected cronjob.""" diff --git a/tests/widgets/CronTable_duplicate_test.py b/tests/widgets/CronTable_duplicate_test.py new file mode 100644 index 0000000..d162902 --- /dev/null +++ b/tests/widgets/CronTable_duplicate_test.py @@ -0,0 +1,27 @@ +"""Simple tests for duplicate cronjob functionality.""" + +from cronboard.widgets.CronTable import CronTable + + +def test_duplicate_binding_exists(): + """Verify 'd' keybinding is registered for duplicate.""" + binding_keys = [b.key for b in CronTable.BINDINGS] + assert "d" in binding_keys + + +def test_duplicate_binding_calls_action(): + """Verify 'd' binding calls action_duplicate_cronjob.""" + binding = next(b for b in CronTable.BINDINGS if b.key == "d") + assert binding.action == "duplicate_cronjob" + + +def test_action_duplicate_cronjob_method_exists(): + """Verify action_duplicate_cronjob method exists and is callable.""" + assert hasattr(CronTable, "action_duplicate_cronjob") + assert callable(getattr(CronTable, "action_duplicate_cronjob")) + + +def test_duplicate_binding_label(): + """Verify 'd' binding has correct description.""" + binding = next(b for b in CronTable.BINDINGS if b.key == "d") + assert binding.description == "Duplicate"