feat(cli): reorganize repair commands under unified 'vikunja repair' parent (#2300)

Consolidate four scattered repair/maintenance CLI commands into a unified `vikunja repair` parent command with subcommands.
This commit is contained in:
kolaente
2026-02-25 12:50:09 +01:00
committed by GitHub
parent a5b1a90c42
commit b6155d525c
8 changed files with 120 additions and 39 deletions

40
pkg/cmd/repair.go Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
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.`,
}

View File

@@ -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:")

View File

@@ -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)
}
},
}

View File

@@ -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).

View File

@@ -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.

View File

@@ -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++

View File

@@ -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.

View File

@@ -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())
}