diff --git a/README.md b/README.md index b9f37ac..67a6942 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,11 @@ timetrace edit project |-|-| |`KEY`|The project key.| +**Flags:** +|Flag|Short|Description| +|-|-|-| +|`--revert`|`-r`|Revert the project to it's state prior to the last edit.| + **Example:** Edit a project called `make-coffee`: @@ -260,6 +265,12 @@ Edit a project called `make-coffee`: timetrace edit project make-coffee ``` +Restore the project to it's state prior to the last edit: + +``` +timetrace edit project make-coffee --revert +``` + ### Edit a record **Syntax:** @@ -276,10 +287,11 @@ timetrace edit record {|latest} **Flags:** -|Flag|Description| -|-|-| -|`--plus`|Add the given duration to the record's end time, e.g. `--plus 1h 10m`| -|`--minus`|Subtract the given duration from the record's end time, e.g. `--minus 1h 10m`| +|Flag|Short|Description| +|-|-|-| +|`--plus`|`-p`|Add the given duration to the record's end time, e.g. `--plus 1h 10m`| +|`--minus`|`-m`|Subtract the given duration from the record's end time, e.g. `--minus 1h 10m`| +|`--revert`|`-r`|Revert the record to it's state prior to the last edit.| **Example:** @@ -295,6 +307,12 @@ Add 15 minutes to the end of the record created on May 1st, 3PM: timetrace edit record 2021-05-01-15-00 --plus 15m ``` +Restore the record to it's state prior to the last edit: + +``` +timetrace edit record 2021-05-01-15-00 --revert +``` + Tip: You can get the record key `2021-05-01-15-00` using [`timetrace list records`](#list-all-records-from-a-date). ### Delete a project @@ -311,6 +329,11 @@ timetrace delete project |-|-| |`KEY`|The project key.| +**Flags:** +|Flag|Short|Description| +|-|-|-| +|`--revert`|`-r`|Restore a deleted project.| + **Example:** Delete a project called `make-coffee`: @@ -319,6 +342,12 @@ Delete a project called `make-coffee`: timetrace delete project make-coffee ``` +Restore the project to it's pre-deletion state: + +``` +timetrace delete project make-coffee --revert +``` + ### Delete a record **Syntax:** @@ -333,9 +362,10 @@ timetrace delete record |-|-| |`YYYY-MM-DD-HH-MM`|The start time of the desired record.| -|Flat|Description| -|-|-| -|--yes|Do not ask for confirmation| +|Flag|Short|Description| +|-|-|-| +|`--yes`| |Do not ask for confirmation| +|`--revert`|`-r`|Restore a deleted record.| **Example:** @@ -345,6 +375,12 @@ Delete a record created on May 1st 2021, 3:00 PM: timetrace delete record 2021-05-01-15-00 ``` +Restore the record to it's pre-deletion state: + +``` +timetrace delete record 2021-05-01-15-00 --revert +``` + ### Start tracking **Syntax:** diff --git a/cli/delete.go b/cli/delete.go index b055c49..ec2293f 100644 --- a/cli/delete.go +++ b/cli/delete.go @@ -14,6 +14,10 @@ import ( var confirmed bool +type deleteOptions struct { + Revert bool +} + func deleteCommand(t *core.Timetrace) *cobra.Command { delete := &cobra.Command{ Use: "delete", @@ -31,17 +35,31 @@ func deleteCommand(t *core.Timetrace) *cobra.Command { } func deleteProjectCommand(t *core.Timetrace) *cobra.Command { + var options deleteOptions deleteProject := &cobra.Command{ Use: "project ", Short: "Delete a project", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] + if options.Revert { + if err := t.RevertProject(key); err != nil { + out.Err("Failed to revert project: %s", err.Error()) + } else { + out.Info("Project backup restored successfully") + } + return + } project := core.Project{ Key: key, } + if err := t.BackupProject(key); err != nil { + out.Err("Failed to backup project before deletion: %s", err.Error()) + return + } + if err := t.DeleteProject(project); err != nil { out.Err("Failed to delete %s", err.Error()) return @@ -51,11 +69,13 @@ func deleteProjectCommand(t *core.Timetrace) *cobra.Command { }, } + deleteProject.PersistentFlags().BoolVarP(&options.Revert, "revert", "r", false, "Restores the record to it's state prior to the last 'delete' command.") + return deleteProject } func deleteRecordCommand(t *core.Timetrace) *cobra.Command { - + var options deleteOptions // Depending on the use12hours setting, the command syntax either is // `record YYYY-MM-DD-HH-MM` or `record YYYY-MM-DD-HH-MMPM`. use := fmt.Sprintf("record %s", t.Formatter().RecordKeyLayout()) @@ -71,6 +91,15 @@ func deleteRecordCommand(t *core.Timetrace) *cobra.Command { return } + if options.Revert { + if err := t.RevertRecord(start); err != nil { + out.Err("Failed to revert record: %s", err.Error()) + } else { + out.Info("Record backup restored successfully") + } + return + } + record, err := t.LoadRecord(start) if err != nil { out.Err("Failed to read record: %s", err.Error()) @@ -85,6 +114,11 @@ func deleteRecordCommand(t *core.Timetrace) *cobra.Command { } } + if err := t.BackupRecord(start); err != nil { + out.Err("Failed to backup record before deletion: %s", err.Error()) + return + } + if err := t.DeleteRecord(*record); err != nil { out.Err("Failed to delete %s", err.Error()) return @@ -94,6 +128,8 @@ func deleteRecordCommand(t *core.Timetrace) *cobra.Command { }, } + deleteRecord.PersistentFlags().BoolVarP(&options.Revert, "revert", "r", false, "Restores the record to it's state prior to the last 'delete' command.") + return deleteRecord } diff --git a/cli/edit.go b/cli/edit.go index 45da8ba..30c2e04 100644 --- a/cli/edit.go +++ b/cli/edit.go @@ -27,12 +27,26 @@ func editCommand(t *core.Timetrace) *cobra.Command { } func editProjectCommand(t *core.Timetrace) *cobra.Command { + var options editOptions editProject := &cobra.Command{ Use: "project ", Short: "Edit a project", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { key := args[0] + if options.Revert { + if err := t.RevertProject(key); err != nil { + out.Err("Failed to revert project: %s", err.Error()) + } else { + out.Info("Project backup restored successfuly") + } + return + } + + if err := t.BackupProject(key); err != nil { + out.Err("Failed to backup project before edit: %s", err.Error()) + return + } out.Info("Opening %s in default editor", key) if err := t.EditProject(key); err != nil { @@ -44,12 +58,15 @@ func editProjectCommand(t *core.Timetrace) *cobra.Command { }, } + editProject.PersistentFlags().BoolVarP(&options.Revert, "revert", "r", false, "Restores the project to it's state prior to the last 'edit' command.") + return editProject } type editOptions struct { - Plus string - Minus string + Plus string + Minus string + Revert bool } func editRecordCommand(t *core.Timetrace) *cobra.Command { @@ -83,15 +100,29 @@ func editRecordCommand(t *core.Timetrace) *cobra.Command { } } + if options.Revert { + if err := t.RevertRecord(recordTime); err != nil { + out.Err("Failed to revert record: %s", err.Error()) + } else { + out.Info("Record backup restored successfully") + } + return + } + + if err := t.BackupRecord(recordTime); err != nil { + out.Err("Failed to backup record before edit: %s", err.Error()) + return + } + if options.Minus == "" && options.Plus == "" { out.Info("Opening %s in default editor", recordTime) if err := t.EditRecordManual(recordTime); err != nil { - out.Err("Failed to edit project: %s", err.Error()) + out.Err("Failed to edit record: %s", err.Error()) return } } else { if err := t.EditRecord(recordTime, options.Plus, options.Minus); err != nil { - out.Err("Failed to edit project: %s", err.Error()) + out.Err("Failed to edit record: %s", err.Error()) return } } @@ -102,6 +133,7 @@ func editRecordCommand(t *core.Timetrace) *cobra.Command { editRecord.PersistentFlags().StringVarP(&options.Plus, "plus", "p", "", "Adds the given duration to the end time of the record") editRecord.PersistentFlags().StringVarP(&options.Minus, "minus", "m", "", "Substracts the given duration to the end time of the record") + editRecord.PersistentFlags().BoolVarP(&options.Revert, "revert", "r", false, "Restores the record to it's state prior to the last 'edit' command.") return editRecord } diff --git a/core/project.go b/core/project.go index 260ecd1..5b8baf6 100644 --- a/core/project.go +++ b/core/project.go @@ -14,9 +14,10 @@ const ( ) var ( - ErrProjectNotFound = errors.New("project not found") - ErrProjectAlreadyExists = errors.New("project already exists") - ErrParentlessModule = errors.New("no parent project for module exists, please create parent first") + ErrProjectNotFound = errors.New("project not found") + ErrBackupProjectNotFound = errors.New("backup project not found") + ErrProjectAlreadyExists = errors.New("project already exists") + ErrParentlessModule = errors.New("no parent project for module exists, please create parent first") ) type Project struct { @@ -46,6 +47,11 @@ func (t *Timetrace) LoadProject(key string) (*Project, error) { return t.loadProject(path) } +func (t *Timetrace) LoadBackupProject(key string) (*Project, error) { + path := t.fs.ProjectBackupFilepath(key) + return t.loadProject(path) +} + // ListProjectModules loads all modules for a project and returns their keys as a concatenated string func (t *Timetrace) ListProjectModules(project *Project) (string, error) { allModules, err := t.loadProjectModules(project) @@ -121,7 +127,54 @@ func (t *Timetrace) SaveProject(project Project, force bool) error { return err } -// EditProject opens the project file in the preferred or default editor. +// BackupProject creates a backup of the given project file. +func (t *Timetrace) BackupProject(projectKey string) error { + project, err := t.LoadProject(projectKey) + if err != nil { + return err + } + + path := t.fs.ProjectBackupFilepath(projectKey) + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + bytes, err := json.MarshalIndent(&project, "", "\t") + if err != nil { + return err + } + + _, err = file.Write(bytes) + + return err +} + +func (t *Timetrace) RevertProject(projectKey string) error { + project, err := t.LoadBackupProject(projectKey) + if err != nil { + return err + } + // get filepath of reverted file + path := t.fs.ProjectFilepath(projectKey) + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + bytes, err := json.MarshalIndent(&project, "", "\t") + if err != nil { + return err + } + + _, err = file.Write(bytes) + + return err +} + +// EditProject opens the project file in the preferred or default editor . func (t *Timetrace) EditProject(projectKey string) error { if _, err := t.LoadProject(projectKey); err != nil { return err @@ -154,6 +207,9 @@ func (t *Timetrace) loadProject(path string) (*Project, error) { file, err := ioutil.ReadFile(path) if err != nil { if os.IsNotExist(err) { + if strings.HasSuffix(path, ".bak") { + return nil, ErrBackupProjectNotFound + } return nil, ErrProjectNotFound } return nil, err diff --git a/core/record.go b/core/record.go index 5026f65..b1dc159 100644 --- a/core/record.go +++ b/core/record.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "os" "os/exec" + "strings" "time" ) @@ -14,8 +15,9 @@ const ( ) var ( - ErrRecordNotFound = errors.New("record not found") - ErrRecordAlreadyExists = errors.New("record already exists") + ErrRecordNotFound = errors.New("record not found") + ErrBackupRecordNotFound = errors.New("backup record not found") + ErrRecordAlreadyExists = errors.New("record already exists") ) type Record struct { @@ -32,6 +34,11 @@ func (t *Timetrace) LoadRecord(start time.Time) (*Record, error) { return t.loadRecord(path) } +func (t *Timetrace) LoadBackupRecord(start time.Time) (*Record, error) { + path := t.fs.RecordBackupFilepath(start) + return t.loadRecord(path) +} + // ListRecords loads and returns all records from the given date. If no records // are found, an empty slice and no error will be returned. func (t *Timetrace) ListRecords(date time.Time) ([]*Record, error) { @@ -84,6 +91,54 @@ func (t *Timetrace) SaveRecord(record Record, force bool) error { return err } +// BackupRecord creates a backup of the given record file +func (t *Timetrace) BackupRecord(recordKey time.Time) error { + path := t.fs.RecordFilepath(recordKey) + record, err := t.loadRecord(path) + if err != nil { + return err + } + // create a new .bak filepath from the record struct + backupPath := t.fs.RecordBackupFilepath(recordKey) + + backupFile, err := os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + bytes, err := json.MarshalIndent(&record, "", "\t") + if err != nil { + return err + } + + _, err = backupFile.Write(bytes) + + return err +} + +func (t *Timetrace) RevertRecord(recordKey time.Time) error { + record, err := t.LoadBackupRecord(recordKey) + if err != nil { + return err + } + + path := t.fs.RecordFilepath(recordKey) + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + + bytes, err := json.MarshalIndent(&record, "", "\t") + if err != nil { + return err + } + + _, err = file.Write(bytes) + + return err +} + // DeleteRecord removes the given record. Returns ErrRecordNotFound if the // project doesn't exist. func (t *Timetrace) DeleteRecord(record Record) error { @@ -217,6 +272,9 @@ func (t *Timetrace) loadRecord(path string) (*Record, error) { file, err := ioutil.ReadFile(path) if err != nil { if os.IsNotExist(err) { + if strings.HasSuffix(path, ".bak") { + return nil, ErrBackupRecordNotFound + } return nil, ErrRecordNotFound } return nil, err diff --git a/core/timetrace.go b/core/timetrace.go index 89179a8..ac324fb 100644 --- a/core/timetrace.go +++ b/core/timetrace.go @@ -22,8 +22,10 @@ type Report struct { // Filesystem represents a filesystem used for storing and loading resources. type Filesystem interface { ProjectFilepath(key string) string + ProjectBackupFilepath(key string) string ProjectFilepaths() ([]string, error) RecordFilepath(start time.Time) string + RecordBackupFilepath(start time.Time) string RecordFilepaths(dir string, less func(a, b string) bool) ([]string, error) RecordDirs() ([]string, error) RecordDirFromDate(date time.Time) string diff --git a/fs/fs.go b/fs/fs.go index 6d80645..c3747ff 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -19,8 +19,9 @@ const ( ) const ( - recordDirLayout = "2006-01-02" - recordFilepathLayout = "15-04.json" + recordDirLayout = "2006-01-02" + recordFilepathLayout = "15-04.json" + recordBackupFilepathLayout = "15-04.json.bak" ) type Fs struct { @@ -42,7 +43,15 @@ func (fs *Fs) ProjectFilepath(key string) string { return filepath.Join(fs.projectsDir(), name) } -// ProjectFilepaths returns all project filepaths sorted alphabetically. +// ProjectBackupFilepath return the filepath of the backup project with the +// given key. +func (fs *Fs) ProjectBackupFilepath(key string) string { + key = fs.sanitizer.Replace(key) + name := fmt.Sprintf("%s.json.bak", key) + return filepath.Join(fs.projectsDir(), name) +} + +// ProjectFilepaths returns all non-backup project filepaths sorted alphabetically. func (fs *Fs) ProjectFilepaths() ([]string, error) { dir := fs.projectsDir() @@ -57,9 +66,13 @@ func (fs *Fs) ProjectFilepaths() ([]string, error) { if item.IsDir() { continue } - filepaths = append(filepaths, filepath.Join(dir, item.Name())) - } + itemName := item.Name() + if strings.HasSuffix(itemName, ".bak") { + continue + } + filepaths = append(filepaths, filepath.Join(dir, itemName)) + } sort.Strings(filepaths) return filepaths, nil @@ -74,8 +87,13 @@ func (fs *Fs) RecordFilepath(start time.Time) string { return filepath.Join(fs.RecordDirFromDate(start), name) } -// RecordFilepaths returns all record filepaths within the given directory -// sorted by the given function. +func (fs *Fs) RecordBackupFilepath(start time.Time) string { + name := start.Format(recordBackupFilepathLayout) + return filepath.Join(fs.RecordDirFromDate(start), name) +} + +// RecordFilepaths returns all non-backup record filepaths within the given +// directory sorted by the given function. // // The directory can be obtained using functions like recordDir or RecordDirs. // If you have a record date, use RecordDirFromDate to get the directory name. @@ -108,7 +126,12 @@ func (fs *Fs) RecordFilepaths(dir string, less func(a, b string) bool) ([]string if item.IsDir() { continue } - filepaths = append(filepaths, filepath.Join(dir, item.Name())) + itemName := item.Name() + if strings.HasSuffix(itemName, ".bak") { + continue + } + + filepaths = append(filepaths, filepath.Join(dir, itemName)) } sort.Slice(filepaths, func(i, j int) bool {