Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,5 @@ cliff.toml
# script
update_dependencies.py
test-server/

.idea

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
.idea
.idea

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I meant was: why did you add this to the file?

13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They shortcurs are covered on the website documentation. That's why I didn't choose to have it on the README.md.

| 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:
Expand Down
20 changes: 16 additions & 4 deletions src/cronboard/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
)

Expand Down
4 changes: 3 additions & 1 deletion src/cronboard/screens/CronCreator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are you using self.log_enabled as a boolean, but on CronTable you are converting the value to a string.

self.cron: CronTab = cron
self.remote = remote
self.ssh_client = ssh_client
Expand Down
35 changes: 34 additions & 1 deletion src/cronboard/widgets/CronTable.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@


class CronTable(DataTable):
NO_ID_LABEL = "No ID"

BINDINGS = [
Binding("/", "cron_search", "Search"),
Binding("escape", "clear_search", "Clear Search"),
Expand All @@ -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"),
]

Expand Down Expand Up @@ -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",
Expand All @@ -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())
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you converting a bool to string? And why are you converting it this way?


# 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."""

Expand Down
27 changes: 27 additions & 0 deletions tests/widgets/CronTable_duplicate_test.py
Original file line number Diff line number Diff line change
@@ -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"