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:
parent
a5b1a90c42
commit
b6155d525c
|
|
@ -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.`,
|
||||||
|
}
|
||||||
|
|
@ -26,33 +26,44 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(repairFileMimeTypesCmd)
|
repairFileMimeTypesCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes")
|
||||||
|
repairCmd.AddCommand(repairFileMimeTypesCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var repairFileMimeTypesCmd = &cobra.Command{
|
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",
|
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,
|
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.
|
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
|
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) {
|
PreRun: func(_ *cobra.Command, _ []string) {
|
||||||
initialize.FullInitWithoutAsync()
|
initialize.FullInitWithoutAsync()
|
||||||
},
|
},
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
defer s.Close()
|
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 {
|
if err != nil {
|
||||||
log.Errorf("Failed to repair file MIME types: %s", err)
|
log.Errorf("Failed to repair file MIME types: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Commit(); err != nil {
|
if !dryRun {
|
||||||
log.Errorf("Failed to commit changes: %s", err)
|
if err := s.Commit(); err != nil {
|
||||||
return
|
log.Errorf("Failed to commit changes: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Repair complete:")
|
log.Infof("Repair complete:")
|
||||||
|
|
|
||||||
|
|
@ -26,29 +26,44 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(deleteOrphanTaskPositions)
|
repairOrphanPositionsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes")
|
||||||
|
repairCmd.AddCommand(repairOrphanPositionsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var deleteOrphanTaskPositions = &cobra.Command{
|
var repairOrphanPositionsCmd = &cobra.Command{
|
||||||
Use: "delete-orphan-task-positions",
|
Use: "orphan-positions",
|
||||||
Short: "Removes all task positions for tasks or project views which don't exist anymore.",
|
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) {
|
PreRun: func(_ *cobra.Command, _ []string) {
|
||||||
initialize.FullInitWithoutAsync()
|
initialize.FullInitWithoutAsync()
|
||||||
},
|
},
|
||||||
Run: func(_ *cobra.Command, _ []string) {
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
|
||||||
s := db.NewSession()
|
s := db.NewSession()
|
||||||
defer s.Close()
|
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 {
|
if err != nil {
|
||||||
log.Errorf("Could not delete orphaned task positions: %s", err)
|
log.Errorf("Could not delete orphaned task positions: %s", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.Commit(); err != nil {
|
if !dryRun {
|
||||||
log.Errorf("Could not commit orphaned task position deletion: %s", err)
|
if err := s.Commit(); err != nil {
|
||||||
return
|
log.Errorf("Could not commit orphaned task position deletion: %s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
|
|
@ -56,6 +71,10 @@ var deleteOrphanTaskPositions = &cobra.Command{
|
||||||
return
|
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)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -27,11 +27,11 @@ import (
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
repairProjectsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes")
|
repairProjectsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes")
|
||||||
rootCmd.AddCommand(repairProjectsCmd)
|
repairCmd.AddCommand(repairProjectsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var repairProjectsCmd = &cobra.Command{
|
var repairProjectsCmd = &cobra.Command{
|
||||||
Use: "repair-projects",
|
Use: "projects",
|
||||||
Short: "Repair orphaned projects whose parent project no longer exists",
|
Short: "Repair orphaned projects whose parent project no longer exists",
|
||||||
Long: `Finds projects whose parent_project_id references a project that no longer
|
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).
|
exists in the database and re-parents them to the top level (parent_project_id = 0).
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,11 @@ import (
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
repairTaskPositionsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes")
|
repairTaskPositionsCmd.Flags().Bool("dry-run", false, "Preview repairs without making changes")
|
||||||
rootCmd.AddCommand(repairTaskPositionsCmd)
|
repairCmd.AddCommand(repairTaskPositionsCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
var repairTaskPositionsCmd = &cobra.Command{
|
var repairTaskPositionsCmd = &cobra.Command{
|
||||||
Use: "repair-task-positions",
|
Use: "task-positions",
|
||||||
Short: "Detect and repair duplicate task positions across all views",
|
Short: "Detect and repair duplicate task positions across all views",
|
||||||
Long: `Scans all project views for tasks with duplicate position values and repairs them.
|
Long: `Scans all project views for tasks with duplicate position values and repairs them.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ type RepairMimeTypesResult struct {
|
||||||
|
|
||||||
// RepairFileMimeTypes finds all files with no MIME type set, detects it from
|
// RepairFileMimeTypes finds all files with no MIME type set, detects it from
|
||||||
// the stored file content, and updates the database.
|
// 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
|
var files []*File
|
||||||
err := s.Where("mime = '' OR mime IS NULL").Find(&files)
|
err := s.Where("mime = '' OR mime IS NULL").Find(&files)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -73,13 +74,15 @@ func RepairFileMimeTypes(s *xorm.Session) (*RepairMimeTypesResult, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
f.Mime = mime.String()
|
f.Mime = mime.String()
|
||||||
_, err = s.ID(f.ID).Cols("mime").Update(f)
|
if !dryRun {
|
||||||
if err != nil {
|
_, err = s.ID(f.ID).Cols("mime").Update(f)
|
||||||
msg := fmt.Sprintf("file %d: failed to update mime type: %s", f.ID, err)
|
if err != nil {
|
||||||
log.Errorf("file %d: failed to update mime type: %s", f.ID, err)
|
msg := fmt.Sprintf("file %d: failed to update mime type: %s", f.ID, err)
|
||||||
result.Errors = append(result.Errors, msg)
|
log.Errorf("file %d: failed to update mime type: %s", f.ID, err)
|
||||||
_ = bar.Add(1)
|
result.Errors = append(result.Errors, msg)
|
||||||
continue
|
_ = bar.Add(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Updated++
|
result.Updated++
|
||||||
|
|
|
||||||
|
|
@ -338,10 +338,17 @@ func calculateNewPositionForTask(s *xorm.Session, a web.Auth, t *Task, view *Pro
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DeleteOrphanedTaskPositions(s *xorm.Session) (count int64, err error) {
|
// DeleteOrphanedTaskPositions removes task position records that reference
|
||||||
return s.
|
// tasks or project views that no longer exist.
|
||||||
Where("task_id not in (select id from tasks) OR project_view_id not in (select id from project_views)").
|
// If dryRun is true, it counts the orphaned records without deleting them.
|
||||||
Delete(&TaskPosition{})
|
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.
|
// createPositionsForTasksInView creates position records for tasks that don't have them.
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,14 @@ func TestDoPostWithHeaders_GivesUpAfter3Retries(t *testing.T) {
|
||||||
if !strings.Contains(err.Error(), expectedBody) {
|
if !strings.Contains(err.Error(), expectedBody) {
|
||||||
t.Errorf("expected error message to contain response body %q, got: %s", expectedBody, err.Error())
|
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")
|
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 {
|
if attempts.Load() != 3 {
|
||||||
t.Errorf("expected 3 attempts, got %d", attempts.Load())
|
t.Errorf("expected 3 attempts, got %d", attempts.Load())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue