diff --git a/pkg/cmd/repair.go b/pkg/cmd/repair.go new file mode 100644 index 000000000..33a3da009 --- /dev/null +++ b/pkg/cmd/repair.go @@ -0,0 +1,40 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(repairCmd) +} + +var repairCmd = &cobra.Command{ + Use: "repair", + Short: "Repair and fix data integrity issues", + Long: `The repair command provides subcommands to detect and fix various +data integrity issues in your Vikunja installation. + +Available repair operations: + task-positions - Fix duplicate task positions in project views + projects - Fix orphaned projects with missing parents + file-mime-types - Detect and set MIME types for files + orphan-positions - Remove orphaned task position records + +Most subcommands support --dry-run to preview changes without applying them.`, +} diff --git a/pkg/cmd/repair_file_mime_types.go b/pkg/cmd/repair_file_mime_types.go index efc94b089..00ab220ac 100644 --- a/pkg/cmd/repair_file_mime_types.go +++ b/pkg/cmd/repair_file_mime_types.go @@ -26,33 +26,44 @@ import ( ) func init() { - rootCmd.AddCommand(repairFileMimeTypesCmd) + repairFileMimeTypesCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes") + repairCmd.AddCommand(repairFileMimeTypesCmd) } var repairFileMimeTypesCmd = &cobra.Command{ - Use: "repair-file-mime-types", + Use: "file-mime-types", Short: "Detect and set MIME types for all files that have none", Long: `Scans all files in the database that have no MIME type set, detects the type from the stored file content, and updates the database. This is useful after upgrading from a version that did not store MIME types -on file creation. Only files with an empty or NULL mime column are affected.`, +on file creation. Only files with an empty or NULL mime column are affected. + +Use --dry-run to preview what would be fixed without making changes.`, PreRun: func(_ *cobra.Command, _ []string) { initialize.FullInitWithoutAsync() }, - Run: func(_ *cobra.Command, _ []string) { + Run: func(cmd *cobra.Command, _ []string) { + dryRun, _ := cmd.Flags().GetBool("dry-run") + s := db.NewSession() defer s.Close() - result, err := files.RepairFileMimeTypes(s) + if dryRun { + log.Infof("Running in dry-run mode - no changes will be made") + } + + result, err := files.RepairFileMimeTypes(s, dryRun) if err != nil { log.Errorf("Failed to repair file MIME types: %s", err) return } - if err := s.Commit(); err != nil { - log.Errorf("Failed to commit changes: %s", err) - return + if !dryRun { + if err := s.Commit(); err != nil { + log.Errorf("Failed to commit changes: %s", err) + return + } } log.Infof("Repair complete:") diff --git a/pkg/cmd/maintenance.go b/pkg/cmd/repair_orphan_positions.go similarity index 53% rename from pkg/cmd/maintenance.go rename to pkg/cmd/repair_orphan_positions.go index 2da1cb5a5..9b346f1e7 100644 --- a/pkg/cmd/maintenance.go +++ b/pkg/cmd/repair_orphan_positions.go @@ -26,29 +26,44 @@ import ( ) func init() { - rootCmd.AddCommand(deleteOrphanTaskPositions) + repairOrphanPositionsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes") + repairCmd.AddCommand(repairOrphanPositionsCmd) } -var deleteOrphanTaskPositions = &cobra.Command{ - Use: "delete-orphan-task-positions", - Short: "Removes all task positions for tasks or project views which don't exist anymore.", +var repairOrphanPositionsCmd = &cobra.Command{ + Use: "orphan-positions", + Short: "Remove orphaned task position records for deleted tasks or views", + Long: `Removes all task position records that reference tasks or project views +which no longer exist in the database. + +This can happen when tasks or views are deleted but their position records +are not fully cleaned up. + +Use --dry-run to preview what would be deleted without making changes.`, PreRun: func(_ *cobra.Command, _ []string) { initialize.FullInitWithoutAsync() }, - Run: func(_ *cobra.Command, _ []string) { + Run: func(cmd *cobra.Command, _ []string) { + dryRun, _ := cmd.Flags().GetBool("dry-run") s := db.NewSession() defer s.Close() - count, err := models.DeleteOrphanedTaskPositions(s) + if dryRun { + log.Infof("Running in dry-run mode - no changes will be made") + } + + count, err := models.DeleteOrphanedTaskPositions(s, dryRun) if err != nil { log.Errorf("Could not delete orphaned task positions: %s", err) return } - if err := s.Commit(); err != nil { - log.Errorf("Could not commit orphaned task position deletion: %s", err) - return + if !dryRun { + if err := s.Commit(); err != nil { + log.Errorf("Could not commit orphaned task position deletion: %s", err) + return + } } if count == 0 { @@ -56,6 +71,10 @@ var deleteOrphanTaskPositions = &cobra.Command{ return } - log.Infof("Successfully deleted %d orphaned task positions.", count) + if dryRun { + log.Infof("Would delete %d orphaned task positions.", count) + } else { + log.Infof("Successfully deleted %d orphaned task positions.", count) + } }, } diff --git a/pkg/cmd/repair_projects.go b/pkg/cmd/repair_projects.go index 914d03308..613684de8 100644 --- a/pkg/cmd/repair_projects.go +++ b/pkg/cmd/repair_projects.go @@ -27,11 +27,11 @@ import ( func init() { repairProjectsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes") - rootCmd.AddCommand(repairProjectsCmd) + repairCmd.AddCommand(repairProjectsCmd) } var repairProjectsCmd = &cobra.Command{ - Use: "repair-projects", + Use: "projects", Short: "Repair orphaned projects whose parent project no longer exists", Long: `Finds projects whose parent_project_id references a project that no longer exists in the database and re-parents them to the top level (parent_project_id = 0). diff --git a/pkg/cmd/repair_task_positions.go b/pkg/cmd/repair_task_positions.go index 08a177638..61a1cdb3e 100644 --- a/pkg/cmd/repair_task_positions.go +++ b/pkg/cmd/repair_task_positions.go @@ -27,11 +27,11 @@ import ( func init() { repairTaskPositionsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes") - rootCmd.AddCommand(repairTaskPositionsCmd) + repairCmd.AddCommand(repairTaskPositionsCmd) } var repairTaskPositionsCmd = &cobra.Command{ - Use: "repair-task-positions", + Use: "task-positions", Short: "Detect and repair duplicate task positions across all views", Long: `Scans all project views for tasks with duplicate position values and repairs them. diff --git a/pkg/files/repair.go b/pkg/files/repair.go index caee024ab..a1d8f4fba 100644 --- a/pkg/files/repair.go +++ b/pkg/files/repair.go @@ -35,7 +35,8 @@ type RepairMimeTypesResult struct { // RepairFileMimeTypes finds all files with no MIME type set, detects it from // the stored file content, and updates the database. -func RepairFileMimeTypes(s *xorm.Session) (*RepairMimeTypesResult, error) { +// If dryRun is true, it reports what would be fixed without making changes. +func RepairFileMimeTypes(s *xorm.Session, dryRun bool) (*RepairMimeTypesResult, error) { var files []*File err := s.Where("mime = '' OR mime IS NULL").Find(&files) if err != nil { @@ -73,13 +74,15 @@ func RepairFileMimeTypes(s *xorm.Session) (*RepairMimeTypesResult, error) { } f.Mime = mime.String() - _, err = s.ID(f.ID).Cols("mime").Update(f) - if err != nil { - msg := fmt.Sprintf("file %d: failed to update mime type: %s", f.ID, err) - log.Errorf("file %d: failed to update mime type: %s", f.ID, err) - result.Errors = append(result.Errors, msg) - _ = bar.Add(1) - continue + if !dryRun { + _, err = s.ID(f.ID).Cols("mime").Update(f) + if err != nil { + msg := fmt.Sprintf("file %d: failed to update mime type: %s", f.ID, err) + log.Errorf("file %d: failed to update mime type: %s", f.ID, err) + result.Errors = append(result.Errors, msg) + _ = bar.Add(1) + continue + } } result.Updated++ diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 96c5997e3..0f4d9c0bb 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -338,10 +338,17 @@ func calculateNewPositionForTask(s *xorm.Session, a web.Auth, t *Task, view *Pro }, nil } -func DeleteOrphanedTaskPositions(s *xorm.Session) (count int64, err error) { - return s. - Where("task_id not in (select id from tasks) OR project_view_id not in (select id from project_views)"). - Delete(&TaskPosition{}) +// DeleteOrphanedTaskPositions removes task position records that reference +// tasks or project views that no longer exist. +// If dryRun is true, it counts the orphaned records without deleting them. +func DeleteOrphanedTaskPositions(s *xorm.Session, dryRun bool) (count int64, err error) { + whereClause := "task_id not in (select id from tasks) OR project_view_id not in (select id from project_views)" + + if dryRun { + return s.Where(whereClause).Count(&TaskPosition{}) + } + + return s.Where(whereClause).Delete(&TaskPosition{}) } // createPositionsForTasksInView creates position records for tasks that don't have them. diff --git a/pkg/modules/migration/helpers_test.go b/pkg/modules/migration/helpers_test.go index 867168b71..0ee7da13e 100644 --- a/pkg/modules/migration/helpers_test.go +++ b/pkg/modules/migration/helpers_test.go @@ -71,13 +71,14 @@ func TestDoPostWithHeaders_GivesUpAfter3Retries(t *testing.T) { if !strings.Contains(err.Error(), expectedBody) { t.Errorf("expected error message to contain response body %q, got: %s", expectedBody, err.Error()) } - if resp == nil { + if resp != nil { + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", resp.StatusCode) + } + } else { t.Fatal("expected response to be returned with error, got nil") } - defer resp.Body.Close() - if resp.StatusCode != http.StatusInternalServerError { - t.Errorf("expected status 500, got %d", resp.StatusCode) - } if attempts.Load() != 3 { t.Errorf("expected 3 attempts, got %d", attempts.Load()) }