Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- **🌐 Global Projects** - Track time for any project from any directory without configuration files
- **🎯 Milestone Tracking** - Organize time entries into sprints, releases, or project phases
- **💾 Local & Private Storage** - All data stored locally in SQLite - your time tracking stays private
- **🔒 Built-in Backups** - Create, restore, and manage database backups with a single command
- **📊 Rich Reporting** - View stats, export to CSV/JSON, and track hourly rates
- **⚡ Zero Configuration Needed** - Works out of the box, configure only when you need to

Expand Down
18 changes: 18 additions & 0 deletions cmd/backups/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package backups

import "github.com/spf13/cobra"

func BackupCmds() *cobra.Command {
cmd := &cobra.Command{
Use: "backup",
Short: "Manage backups",
Long: `Manage backups to allow for easy restoration when things go wrong.`,
}

cmd.AddCommand(CreateCmd())
cmd.AddCommand(RestoreCmd())
cmd.AddCommand(ListCmd())
cmd.AddCommand(DeleteCmd())

return cmd
}
57 changes: 57 additions & 0 deletions cmd/backups/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package backups

import (
"fmt"
"os"

"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/spf13/cobra"
)

func CreateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "Create a new backup",
Long: `Create a new backup of your entire database to save all your data to be restored from later.`,
Run: func(cmd *cobra.Command, args []string) {
ui.NewlineAbove()

db, err := storage.Initialize()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("%v", err))
ui.NewlineBelow()
os.Exit(1)
}
defer db.Close()

running, err := db.GetRunningEntry()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("checking for active timer: %v", err))
ui.NewlineBelow()
os.Exit(1)
}
if running != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf(`timer is running for %s — stop it before creating a backup`, ui.Bold(running.ProjectName)))
ui.NewlineBelow()
os.Exit(1)
}

backup, err := db.CreateBackup()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("creating backup: %v", err))
ui.NewlineBelow()
os.Exit(1)
}

ui.PrintSuccess(ui.EmojiBackup, "Backup created successfully")
fmt.Println()
ui.PrintInfo(2, "File", backup.Filename)
ui.PrintInfo(2, "Path", backup.Path)
ui.PrintInfo(2, "Size", ui.FormatFileSize(backup.Size))
ui.NewlineBelow()
},
}

return cmd
}
123 changes: 123 additions & 0 deletions cmd/backups/delete.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package backups

import (
"fmt"
"os"
"strconv"

"github.com/DylanDevelops/tmpo/internal/settings"
"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/manifoldco/promptui"
"github.com/spf13/cobra"
)

var (
deleteIDFlag string
)

func DeleteCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "Delete a backup",
Long: `Permanently delete an existing backup. This action cannot be undone.`,
Run: func(cmd *cobra.Command, args []string) {
ui.NewlineAbove()

backups, err := storage.ListBackups()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err))
ui.NewlineBelow()
os.Exit(1)
}

if len(backups) == 0 {
ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "")
ui.PrintMuted(2, "Run 'tmpo backup create' to create one.")
ui.NewlineBelow()
return
}

var selected *storage.BackupInfo

if deleteIDFlag != "" {
if id, err := strconv.Atoi(deleteIDFlag); err == nil {
for i := range backups {
if backups[i].ID == id {
selected = &backups[i]
break
}
}
if selected == nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with ID %d", id))
ui.NewlineBelow()
os.Exit(1)
}
} else {
for i := range backups {
if backups[i].Filename == deleteIDFlag {
selected = &backups[i]
break
}
}
if selected == nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("no backup found with filename %q", deleteIDFlag))
ui.NewlineBelow()
os.Exit(1)
}
}
} else {
items := make([]string, len(backups))
for i, b := range backups {
versionTag := fmt.Sprintf("v%d, current", b.SchemaVersion)
if !b.IsUpToDate {
versionTag = fmt.Sprintf("v%d, outdated", b.SchemaVersion)
}
items[i] = fmt.Sprintf("[%d] %s %s (%s)",
b.ID,
settings.FormatDateTime(b.CreatedAt),
ui.FormatFileSize(b.Size),
versionTag,
)
}

prompt := promptui.Select{
Label: "Select backup to delete",
Items: items,
}

idx, _, err := prompt.Run()
if err != nil {
ui.NewlineBelow()
return
}

selected = &backups[idx]
}

confirmPrompt := promptui.Prompt{
Label: fmt.Sprintf("Permanently delete %s? This cannot be undone [y/N]", selected.Filename),
IsConfirm: true,
}

if _, err := confirmPrompt.Run(); err != nil {
ui.PrintInfo(0, ui.EmojiInfo+" Deletion cancelled", "")
ui.NewlineBelow()
return
}

if err := os.Remove(selected.Path); err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("deleting backup: %v", err))
ui.NewlineBelow()
os.Exit(1)
}

ui.PrintSuccess(ui.EmojiSuccess, fmt.Sprintf("Deleted %s", selected.Filename))
ui.NewlineBelow()
},
}

cmd.Flags().StringVarP(&deleteIDFlag, "id", "i", "", "backup ID or filename to delete (skips interactive selection)")

return cmd
}
60 changes: 60 additions & 0 deletions cmd/backups/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package backups

import (
"fmt"
"os"

"github.com/DylanDevelops/tmpo/internal/settings"
"github.com/DylanDevelops/tmpo/internal/storage"
"github.com/DylanDevelops/tmpo/internal/ui"
"github.com/spf13/cobra"
)

func ListCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "Lists all existing backups",
Long: `Lists all existing backups which can be used to restore.`,
Run: func(cmd *cobra.Command, args []string) {
ui.NewlineAbove()

backups, err := storage.ListBackups()
if err != nil {
ui.PrintError(ui.EmojiError, fmt.Sprintf("listing backups: %v", err))
ui.NewlineBelow()
os.Exit(1)
}

if len(backups) == 0 {
ui.PrintInfo(0, ui.EmojiInfo+" No backups found", "")
ui.PrintMuted(2, "Run 'tmpo backup create' to create one.")
ui.NewlineBelow()
return
}

fmt.Printf(" %s%-4s %-28s %-8s %s%s\n",
ui.FormatBold, "ID", "Created", "Size", "Schema", ui.ColorReset)
fmt.Println()

for _, b := range backups {
var schemaTag string
if b.IsUpToDate {
schemaTag = fmt.Sprintf("%s%s v%d (current)%s", ui.ColorGreen, ui.EmojiSuccess, b.SchemaVersion, ui.ColorReset)
} else {
schemaTag = fmt.Sprintf("%s%s v%d (outdated)%s", ui.ColorYellow, ui.EmojiWarning, b.SchemaVersion, ui.ColorReset)
}

fmt.Printf(" %-4d %-28s %-8s %s\n",
b.ID,
settings.FormatDateTime(b.CreatedAt),
ui.FormatFileSize(b.Size),
schemaTag,
)
}

ui.NewlineBelow()
},
}

return cmd
}
Loading
Loading