From 7bd89316bcc92642af6c17c9d6e7b89ad6807055 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Tue, 11 Feb 2025 15:29:16 +0000
Subject: [PATCH] Sorting: Updated sort set command, Changed sort timestamp
 handling

- Renamed AssignSortSetCommand to AssignSortRuleCommand, updated
  contents and testing.
- Updated sorting operations to not update timestamps if only priority
  is changed.
---
 ...tCommand.php => AssignSortRuleCommand.php} |  46 +++----
 app/Sorting/BookSorter.php                    |   7 +-
 lang/en/activities.php                        |  14 +--
 tests/Commands/AssignSortRuleCommandTest.php  | 112 ++++++++++++++++++
 tests/Commands/AssignSortSetCommandTest.php   | 112 ------------------
 tests/Sorting/BookSortTest.php                |  26 ++++
 tests/Sorting/SortRuleTest.php                |  21 ++++
 7 files changed, 193 insertions(+), 145 deletions(-)
 rename app/Console/Commands/{AssignSortSetCommand.php => AssignSortRuleCommand.php} (60%)
 create mode 100644 tests/Commands/AssignSortRuleCommandTest.php
 delete mode 100644 tests/Commands/AssignSortSetCommandTest.php

diff --git a/app/Console/Commands/AssignSortSetCommand.php b/app/Console/Commands/AssignSortRuleCommand.php
similarity index 60%
rename from app/Console/Commands/AssignSortSetCommand.php
rename to app/Console/Commands/AssignSortRuleCommand.php
index 6c9d3f764..c438d0783 100644
--- a/app/Console/Commands/AssignSortSetCommand.php
+++ b/app/Console/Commands/AssignSortRuleCommand.php
@@ -7,60 +7,60 @@ use BookStack\Sorting\BookSorter;
 use BookStack\Sorting\SortRule;
 use Illuminate\Console\Command;
 
-class AssignSortSetCommand extends Command
+class AssignSortRuleCommand extends Command
 {
     /**
      * The name and signature of the console command.
      *
      * @var string
      */
-    protected $signature = 'bookstack:assign-sort-set
-                            {sort-set=0: ID of the sort set to apply}
+    protected $signature = 'bookstack:assign-sort-rule
+                            {sort-rule=0: ID of the sort rule to apply}
                             {--all-books : Apply to all books in the system}
-                            {--books-without-sort : Apply to only books without a sort set already assigned}
-                            {--books-with-sort= : Apply to only books with the sort of given id}';
+                            {--books-without-sort : Apply to only books without a sort rule already assigned}
+                            {--books-with-sort= : Apply to only books with the sort rule of given id}';
 
     /**
      * The console command description.
      *
      * @var string
      */
-    protected $description = 'Assign a sort set to content in the system';
+    protected $description = 'Assign a sort rule to content in the system';
 
     /**
      * Execute the console command.
      */
     public function handle(BookSorter $sorter): int
     {
-        $sortSetId = intval($this->argument('sort-set')) ?? 0;
-        if ($sortSetId === 0) {
-            return $this->listSortSets();
+        $sortRuleId = intval($this->argument('sort-rule')) ?? 0;
+        if ($sortRuleId === 0) {
+            return $this->listSortRules();
         }
 
-        $set = SortRule::query()->find($sortSetId);
+        $rule = SortRule::query()->find($sortRuleId);
         if ($this->option('all-books')) {
             $query = Book::query();
         } else if ($this->option('books-without-sort')) {
-            $query = Book::query()->whereNull('sort_set_id');
+            $query = Book::query()->whereNull('sort_rule_id');
         } else if ($this->option('books-with-sort')) {
             $sortId = intval($this->option('books-with-sort')) ?: 0;
             if (!$sortId) {
                 $this->error("Provided --books-with-sort option value is invalid");
                 return 1;
             }
-            $query = Book::query()->where('sort_set_id', $sortId);
+            $query = Book::query()->where('sort_rule_id', $sortId);
         } else {
             $this->error("No option provided to specify target. Run with the -h option to see all available options.");
             return 1;
         }
 
-        if (!$set) {
-            $this->error("Sort set of provided id {$sortSetId} not found!");
+        if (!$rule) {
+            $this->error("Sort rule of provided id {$sortRuleId} not found!");
             return 1;
         }
 
         $count = $query->clone()->count();
-        $this->warn("This will apply sort set [{$set->id}: {$set->name}] to {$count} book(s) and run the sort on each.");
+        $this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each.");
         $confirmed = $this->confirm("Are you sure you want to continue?");
 
         if (!$confirmed) {
@@ -68,11 +68,11 @@ class AssignSortSetCommand extends Command
         }
 
         $processed = 0;
-        $query->chunkById(10, function ($books) use ($set, $sorter, $count, &$processed) {
+        $query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) {
             $max = min($count, ($processed + 10));
             $this->info("Applying to {$processed}-{$max} of {$count} books");
             foreach ($books as $book) {
-                $book->sort_set_id = $set->id;
+                $book->sort_rule_id = $rule->id;
                 $book->save();
                 $sorter->runBookAutoSort($book);
             }
@@ -84,14 +84,14 @@ class AssignSortSetCommand extends Command
         return 0;
     }
 
-    protected function listSortSets(): int
+    protected function listSortRules(): int
     {
 
-        $sets = SortRule::query()->orderBy('id', 'asc')->get();
-        $this->error("Sort set ID required!");
-        $this->warn("\nAvailable sort sets:");
-        foreach ($sets as $set) {
-            $this->info("{$set->id}: {$set->name}");
+        $rules = SortRule::query()->orderBy('id', 'asc')->get();
+        $this->error("Sort rule ID required!");
+        $this->warn("\nAvailable sort rules:");
+        foreach ($rules as $rule) {
+            $this->info("{$rule->id}: {$rule->name}");
         }
 
         return 1;
diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php
index 7bf1b63f4..6710f070a 100644
--- a/app/Sorting/BookSorter.php
+++ b/app/Sorting/BookSorter.php
@@ -2,6 +2,7 @@
 
 namespace BookStack\Sorting;
 
+use BookStack\App\Model;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\BookChild;
 use BookStack\Entities\Models\Chapter;
@@ -57,7 +58,7 @@ class BookSorter
 
         foreach ($topItems as $index => $topItem) {
             $topItem->priority = $index + 1;
-            $topItem->save();
+            $topItem::withoutTimestamps(fn () => $topItem->save());
         }
 
         foreach ($chapters as $chapter) {
@@ -68,7 +69,7 @@ class BookSorter
 
             foreach ($pages as $index => $page) {
                 $page->priority = $index + 1;
-                $page->save();
+                $page::withoutTimestamps(fn () => $page->save());
             }
         }
     }
@@ -166,7 +167,7 @@ class BookSorter
         }
 
         if ($chapterChanged || $priorityChanged) {
-            $model->save();
+            $model::withoutTimestamps(fn () => $model->save());
         }
     }
 
diff --git a/lang/en/activities.php b/lang/en/activities.php
index 7db872c0c..67df53e36 100644
--- a/lang/en/activities.php
+++ b/lang/en/activities.php
@@ -127,13 +127,13 @@ return [
     'comment_update'              => 'updated comment',
     'comment_delete'              => 'deleted comment',
 
-    // Sort Sets
-    'sort_set_create' => 'created sort set',
-    'sort_set_create_notification' => 'Sort set successfully created',
-    'sort_set_update' => 'updated sort set',
-    'sort_set_update_notification' => 'Sort set successfully update',
-    'sort_set_delete' => 'deleted sort set',
-    'sort_set_delete_notification' => 'Sort set successfully deleted',
+    // Sort Rules
+    'sort_rule_create' => 'created sort rule',
+    'sort_rule_create_notification' => 'Sort rule successfully created',
+    'sort_rule_update' => 'updated sort rule',
+    'sort_rule_update_notification' => 'Sort rule successfully update',
+    'sort_rule_delete' => 'deleted sort rule',
+    'sort_rule_delete_notification' => 'Sort rule successfully deleted',
 
     // Other
     'permissions_update'          => 'updated permissions',
diff --git a/tests/Commands/AssignSortRuleCommandTest.php b/tests/Commands/AssignSortRuleCommandTest.php
new file mode 100644
index 000000000..5b308cd7c
--- /dev/null
+++ b/tests/Commands/AssignSortRuleCommandTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Commands;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Sorting\SortRule;
+use Tests\TestCase;
+
+class AssignSortRuleCommandTest extends TestCase
+{
+    public function test_no_given_sort_rule_lists_options()
+    {
+        $sortRules = SortRule::factory()->createMany(10);
+
+        $commandRun = $this->artisan('bookstack:assign-sort-rule')
+            ->expectsOutputToContain('Sort rule ID required!')
+            ->assertExitCode(1);
+
+        foreach ($sortRules as $sortRule) {
+            $commandRun->expectsOutputToContain("{$sortRule->id}: {$sortRule->name}");
+        }
+    }
+
+    public function test_run_without_options_advises_help()
+    {
+        $this->artisan("bookstack:assign-sort-rule 100")
+            ->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.")
+            ->assertExitCode(1);
+    }
+
+    public function test_run_without_valid_sort_advises_help()
+    {
+        $this->artisan("bookstack:assign-sort-rule 100342 --all-books")
+            ->expectsOutput("Sort rule of provided id 100342 not found!")
+            ->assertExitCode(1);
+    }
+
+    public function test_confirmation_required()
+    {
+        $sortRule = SortRule::factory()->create();
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books")
+            ->expectsConfirmation('Are you sure you want to continue?', 'no')
+            ->assertExitCode(1);
+
+        $booksWithSort = Book::query()->whereNotNull('sort_rule_id')->count();
+        $this->assertEquals(0, $booksWithSort);
+    }
+
+    public function test_assign_to_all_books()
+    {
+        $sortRule = SortRule::factory()->create();
+        $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
+        $this->assertGreaterThan(0, $booksWithoutSort);
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRule->id} --all-books")
+            ->expectsOutputToContain("This will apply sort rule [{$sortRule->id}: {$sortRule->name}] to {$booksWithoutSort} book(s)")
+            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
+            ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)")
+            ->assertExitCode(0);
+
+        $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
+        $this->assertEquals(0, $booksWithoutSort);
+    }
+
+    public function test_assign_to_all_books_without_sort()
+    {
+        $totalBooks = Book::query()->count();
+        $book = $this->entities->book();
+        $sortRuleA = SortRule::factory()->create();
+        $sortRuleB = SortRule::factory()->create();
+        $book->sort_rule_id = $sortRuleA->id;
+        $book->save();
+
+        $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
+        $this->assertEquals($totalBooks, $booksWithoutSort + 1);
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-without-sort")
+            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
+            ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)")
+            ->assertExitCode(0);
+
+        $booksWithoutSort = Book::query()->whereNull('sort_rule_id')->count();
+        $this->assertEquals(0, $booksWithoutSort);
+        $this->assertEquals($totalBooks, $sortRuleB->books()->count() + 1);
+    }
+
+    public function test_assign_to_all_books_with_sort()
+    {
+        $book = $this->entities->book();
+        $sortRuleA = SortRule::factory()->create();
+        $sortRuleB = SortRule::factory()->create();
+        $book->sort_rule_id = $sortRuleA->id;
+        $book->save();
+
+        $this->artisan("bookstack:assign-sort-rule {$sortRuleB->id} --books-with-sort={$sortRuleA->id}")
+            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
+            ->expectsOutputToContain("Sort applied to 1 book(s)")
+            ->assertExitCode(0);
+
+        $book->refresh();
+        $this->assertEquals($sortRuleB->id, $book->sort_rule_id);
+        $this->assertEquals(1, $sortRuleB->books()->count());
+    }
+
+    public function test_assign_to_all_books_with_sort_id_is_validated()
+    {
+        $this->artisan("bookstack:assign-sort-rule 50 --books-with-sort=beans")
+            ->expectsOutputToContain("Provided --books-with-sort option value is invalid")
+            ->assertExitCode(1);
+    }
+}
diff --git a/tests/Commands/AssignSortSetCommandTest.php b/tests/Commands/AssignSortSetCommandTest.php
deleted file mode 100644
index 659840a9b..000000000
--- a/tests/Commands/AssignSortSetCommandTest.php
+++ /dev/null
@@ -1,112 +0,0 @@
-<?php
-
-namespace Commands;
-
-use BookStack\Entities\Models\Book;
-use BookStack\Sorting\SortRule;
-use Tests\TestCase;
-
-class AssignSortSetCommandTest extends TestCase
-{
-    public function test_no_given_sort_set_lists_options()
-    {
-        $sortSets = SortRule::factory()->createMany(10);
-
-        $commandRun = $this->artisan('bookstack:assign-sort-set')
-            ->expectsOutputToContain('Sort set ID required!')
-            ->assertExitCode(1);
-
-        foreach ($sortSets as $sortSet) {
-            $commandRun->expectsOutputToContain("{$sortSet->id}: {$sortSet->name}");
-        }
-    }
-
-    public function test_run_without_options_advises_help()
-    {
-        $this->artisan("bookstack:assign-sort-set 100")
-            ->expectsOutput("No option provided to specify target. Run with the -h option to see all available options.")
-            ->assertExitCode(1);
-    }
-
-    public function test_run_without_valid_sort_advises_help()
-    {
-        $this->artisan("bookstack:assign-sort-set 100342 --all-books")
-            ->expectsOutput("Sort set of provided id 100342 not found!")
-            ->assertExitCode(1);
-    }
-
-    public function test_confirmation_required()
-    {
-        $sortSet = SortRule::factory()->create();
-
-        $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books")
-            ->expectsConfirmation('Are you sure you want to continue?', 'no')
-            ->assertExitCode(1);
-
-        $booksWithSort = Book::query()->whereNotNull('sort_set_id')->count();
-        $this->assertEquals(0, $booksWithSort);
-    }
-
-    public function test_assign_to_all_books()
-    {
-        $sortSet = SortRule::factory()->create();
-        $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count();
-        $this->assertGreaterThan(0, $booksWithoutSort);
-
-        $this->artisan("bookstack:assign-sort-set {$sortSet->id} --all-books")
-            ->expectsOutputToContain("This will apply sort set [{$sortSet->id}: {$sortSet->name}] to {$booksWithoutSort} book(s)")
-            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
-            ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)")
-            ->assertExitCode(0);
-
-        $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count();
-        $this->assertEquals(0, $booksWithoutSort);
-    }
-
-    public function test_assign_to_all_books_without_sort()
-    {
-        $totalBooks = Book::query()->count();
-        $book = $this->entities->book();
-        $sortSetA = SortRule::factory()->create();
-        $sortSetB = SortRule::factory()->create();
-        $book->sort_rule_id = $sortSetA->id;
-        $book->save();
-
-        $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count();
-        $this->assertEquals($totalBooks, $booksWithoutSort + 1);
-
-        $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-without-sort")
-            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
-            ->expectsOutputToContain("Sort applied to {$booksWithoutSort} book(s)")
-            ->assertExitCode(0);
-
-        $booksWithoutSort = Book::query()->whereNull('sort_set_id')->count();
-        $this->assertEquals(0, $booksWithoutSort);
-        $this->assertEquals($totalBooks, $sortSetB->books()->count() + 1);
-    }
-
-    public function test_assign_to_all_books_with_sort()
-    {
-        $book = $this->entities->book();
-        $sortSetA = SortRule::factory()->create();
-        $sortSetB = SortRule::factory()->create();
-        $book->sort_rule_id = $sortSetA->id;
-        $book->save();
-
-        $this->artisan("bookstack:assign-sort-set {$sortSetB->id} --books-with-sort={$sortSetA->id}")
-            ->expectsConfirmation('Are you sure you want to continue?', 'yes')
-            ->expectsOutputToContain("Sort applied to 1 book(s)")
-            ->assertExitCode(0);
-
-        $book->refresh();
-        $this->assertEquals($sortSetB->id, $book->sort_rule_id);
-        $this->assertEquals(1, $sortSetB->books()->count());
-    }
-
-    public function test_assign_to_all_books_with_sort_id_is_validated()
-    {
-        $this->artisan("bookstack:assign-sort-set 50 --books-with-sort=beans")
-            ->expectsOutputToContain("Provided --books-with-sort option value is invalid")
-            ->assertExitCode(1);
-    }
-}
diff --git a/tests/Sorting/BookSortTest.php b/tests/Sorting/BookSortTest.php
index b30cec8b6..c4217a4cc 100644
--- a/tests/Sorting/BookSortTest.php
+++ b/tests/Sorting/BookSortTest.php
@@ -207,6 +207,32 @@ class BookSortTest extends TestCase
         ]);
     }
 
+    public function test_book_sort_does_not_change_timestamps_on_just_order_changes()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $chapter = $book->chapters()->first();
+        \DB::table('chapters')->where('id', '=', $chapter->id)->update([
+            'priority' => 10001,
+            'updated_at' => \Carbon\Carbon::now()->subYear(5),
+        ]);
+
+        $chapter->refresh();
+        $oldUpdatedAt = $chapter->updated_at->unix();
+
+        $sortData = [
+            'id'            => $chapter->id,
+            'sort'          => 0,
+            'parentChapter' => false,
+            'type'          => 'chapter',
+            'book'          => $book->id,
+        ];
+        $this->asEditor()->put($book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect();
+
+        $chapter->refresh();
+        $this->assertNotEquals(10001, $chapter->priority);
+        $this->assertEquals($oldUpdatedAt, $chapter->updated_at->unix());
+    }
+
     public function test_book_sort_item_returns_book_content()
     {
         $bookToSort = $this->entities->book();
diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php
index d0ff263df..0f6f43cee 100644
--- a/tests/Sorting/SortRuleTest.php
+++ b/tests/Sorting/SortRuleTest.php
@@ -165,6 +165,27 @@ class SortRuleTest extends TestCase
         ]);
     }
 
+    public function test_auto_book_sort_does_not_touch_timestamps()
+    {
+        $book = $this->entities->bookHasChaptersAndPages();
+        $rule = SortRule::factory()->create(['sequence' => 'name_asc,chapters_first']);
+        $book->sort_rule_id = $rule->id;
+        $book->save();
+        $page = $book->pages()->first();
+        $chapter = $book->chapters()->first();
+
+        $resp = $this->actingAsApiEditor()->put("/api/pages/{$page->id}", [
+            'name' => '1111 page',
+        ]);
+        $resp->assertOk();
+
+        $oldTime = $chapter->updated_at->unix();
+        $oldPriority = $chapter->priority;
+        $chapter->refresh();
+        $this->assertEquals($oldTime, $chapter->updated_at->unix());
+        $this->assertNotEquals($oldPriority, $chapter->priority);
+    }
+
     public function test_name_numeric_ordering()
     {
         $book = Book::factory()->create();