From 9303e51bc50d730a8026f58984e83b840360ee88 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Fri, 14 Jan 2022 17:42:55 +0000
Subject: [PATCH] feat: add task to housekeeping setting for resetting all
 instance counts

set two toggle switches to run housekeeping tasks seperately if needed
---
 app/Controllers/PostController.php            |  13 +-
 app/Models/EpisodeCommentModel.php            |  36 ++++-
 app/Models/EpisodeModel.php                   |  52 ++++++
 app/Models/PostModel.php                      |  25 +++
 .../Admin/Controllers/SettingsController.php  | 151 ++++++++++--------
 modules/Admin/Language/en/Settings.php        |   6 +-
 modules/Admin/Language/fr/Settings.php        |   4 +
 .../Fediverse/Controllers/PostController.php  |   5 +-
 modules/Fediverse/Models/ActorModel.php       |  41 +++++
 modules/Fediverse/Models/PostModel.php        |  67 +++++++-
 themes/cp_admin/settings/general.php          |   3 +
 .../episode/_partials/comment_actions.php     |   4 +-
 .../_partials/comment_actions_from_post.php   |  16 +-
 .../cp_app/episode/_partials/comment_card.php |   4 +-
 .../_partials/comment_reply_actions.php       |   4 +-
 themes/cp_app/post/_partials/card.php         |   2 +-
 16 files changed, 348 insertions(+), 85 deletions(-)

diff --git a/app/Controllers/PostController.php b/app/Controllers/PostController.php
index ee5320418e..430a1ca061 100644
--- a/app/Controllers/PostController.php
+++ b/app/Controllers/PostController.php
@@ -22,7 +22,6 @@ use CodeIgniter\HTTP\URI;
 use CodeIgniter\I18n\Time;
 use Modules\Analytics\AnalyticsTrait;
 use Modules\Fediverse\Controllers\PostController as FediversePostController;
-use Modules\Fediverse\Entities\Post as FediversePost;
 use Modules\Fediverse\Models\FavouriteModel;
 
 class PostController extends FediversePostController
@@ -33,6 +32,11 @@ class PostController extends FediversePostController
 
     protected Actor $actor;
 
+    /**
+     * @var CastopodPost
+     */
+    protected $post;
+
     /**
      * @var string[]
      */
@@ -53,6 +57,7 @@ class PostController extends FediversePostController
             count($params) > 1 &&
             ($post = (new PostModel())->getPostById($params[1])) !== null
         ) {
+            /** @var CastopodPost $post */
             $this->post = $post;
 
             unset($params[0]);
@@ -163,7 +168,7 @@ class PostController extends FediversePostController
                 ->with('errors', $this->validator->getErrors());
         }
 
-        $newPost = new FediversePost([
+        $newPost = new CastopodPost([
             'actor_id' => interact_as_actor_id(),
             'in_reply_to_id' => $this->post->id,
             'message' => $this->request->getPost('message'),
@@ -171,6 +176,10 @@ class PostController extends FediversePostController
             'created_by' => user_id(),
         ]);
 
+        if ($this->post->in_reply_to_id === null && $this->post->episode_id !== null) {
+            $newPost->episode_id = $this->post->episode_id;
+        }
+
         $postModel = new PostModel();
         if (! $postModel->addReply($newPost)) {
             return redirect()
diff --git a/app/Models/EpisodeCommentModel.php b/app/Models/EpisodeCommentModel.php
index 7102e5e456..8310f02c3d 100644
--- a/app/Models/EpisodeCommentModel.php
+++ b/app/Models/EpisodeCommentModel.php
@@ -149,7 +149,10 @@ class EpisodeCommentModel extends UuidModel
             ->whereIn('in_reply_to_id', function (BaseBuilder $builder) use (&$episodeId): BaseBuilder {
                 return $builder->select('id')
                     ->from(config('Fediverse')->tablesPrefix . 'posts')
-                    ->where('episode_id', $episodeId);
+                    ->where([
+                        'episode_id' => $episodeId,
+                        'in_reply_to_id' => null,
+                    ]);
             })
             ->where('`created_at` <= NOW()', null, false)
             ->getCompiledSelect();
@@ -179,6 +182,37 @@ class EpisodeCommentModel extends UuidModel
             ->findAll();
     }
 
+    public function resetLikesCount(): int | false
+    {
+        $commentsLikesCount = $this->db->table('likes')
+            ->select('comment_id as id, COUNT(*) as `likes_count`')
+            ->groupBy('id')
+            ->get()
+            ->getResultArray();
+
+        if ($commentsLikesCount !== []) {
+            $this->uuidUseBytes = false;
+            return $this->updateBatch($commentsLikesCount, 'id');
+        }
+        return 0;
+    }
+
+    public function resetRepliesCount(): int | false
+    {
+        $commentsRepliesCount = $this->select('episode_comments.id, COUNT(*) as `replies_count`')
+            ->join('episode_comments as c2', 'episode_comments.id = c2.in_reply_to_id')
+            ->groupBy('episode_comments.id')
+            ->get()
+            ->getResultArray();
+
+        if ($commentsRepliesCount !== []) {
+            $this->uuidUseBytes = false;
+            return $this->updateBatch($commentsRepliesCount, 'id');
+        }
+
+        return 0;
+    }
+
     /**
      * @param array<string, array<string|int, mixed>> $data
      * @return array<string, array<string|int, mixed>>
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 0e4fa9cf39..10277643b6 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -322,6 +322,58 @@ class EpisodeModel extends Model
         return $stats;
     }
 
+    public function resetCommentsCount(): int | false
+    {
+        $episodeCommentsCount = $this->select('episodes.id, COUNT(*) as `comments_count`')
+            ->join('episode_comments', 'episodes.id = episode_comments.episode_id')
+            ->where('in_reply_to_id', null)
+            ->groupBy('episodes.id')
+            ->getCompiledSelect();
+
+        $episodePostsRepliesCount = $this
+            ->select('episodes.id, COUNT(*) as `comments_count`')
+            ->join(
+                config('Fediverse')
+                    ->tablesPrefix . 'posts',
+                'episodes.id = ' . config('Fediverse')->tablesPrefix . 'posts.episode_id'
+            )
+            ->where('in_reply_to_id IS NOT', null)
+            ->groupBy('episodes.id')
+            ->getCompiledSelect();
+
+        $query = $this->db->query(
+            'SELECT `id`, SUM(`comments_count`) as `comments_count` FROM (' . $episodeCommentsCount . ' UNION ALL ' . $episodePostsRepliesCount . ') x GROUP BY `id`'
+        );
+
+        $countsPerEpisodeId = $query->getResultArray();
+
+        if ($countsPerEpisodeId !== []) {
+            return $this->updateBatch($countsPerEpisodeId, 'id');
+        }
+
+        return 0;
+    }
+
+    public function resetPostsCount(): int | false
+    {
+        $episodePostsCount = $this->select('episodes.id, COUNT(*) as `posts_count`')
+            ->join(
+                config('Fediverse')
+                    ->tablesPrefix . 'posts',
+                'episodes.id = ' . config('Fediverse')->tablesPrefix . 'posts.episode_id'
+            )
+            ->where('in_reply_to_id', null)
+            ->groupBy('episodes.id')
+            ->get()
+            ->getResultArray();
+
+        if ($episodePostsCount !== []) {
+            return $this->updateBatch($episodePostsCount, 'id');
+        }
+
+        return 0;
+    }
+
     /**
      * @param mixed[] $data
      *
diff --git a/app/Models/PostModel.php b/app/Models/PostModel.php
index 9d6cd911a1..12d0e82600 100644
--- a/app/Models/PostModel.php
+++ b/app/Models/PostModel.php
@@ -49,8 +49,33 @@ class PostModel extends FediversePostModel
         return $this->where([
             'episode_id' => $episodeId,
         ])
+            ->where('in_reply_to_id', null)
             ->where('`published_at` <= NOW()', null, false)
             ->orderBy('published_at', 'DESC')
             ->findAll();
     }
+
+    public function setEpisodeIdForRepliesOfEpisodePosts(): int | false
+    {
+        // make sure that posts in reply to episode activities have an episode id
+        $postsToUpdate = $this->db->table(config('Fediverse')->tablesPrefix . 'posts as p1')
+            ->join(config('Fediverse')->tablesPrefix . 'posts as p2', 'p1.id = p2.in_reply_to_id')
+            ->select('p2.id, p1.episode_id')
+            ->where([
+                'p1.in_reply_to_id' => null,
+                'p2.in_reply_to_id IS NOT' => null,
+                'p2.episode_id' => null,
+                'p1.episode_id IS NOT' => null,
+            ])
+            ->get()
+            ->getResultArray();
+
+        if ($postsToUpdate !== []) {
+            $postModel = new self();
+            $postModel->uuidUseBytes = false;
+            return $postModel->updateBatch($postsToUpdate, 'id');
+        }
+
+        return 0;
+    }
 }
diff --git a/modules/Admin/Controllers/SettingsController.php b/modules/Admin/Controllers/SettingsController.php
index 9ec90e7c06..7054b0e0d1 100644
--- a/modules/Admin/Controllers/SettingsController.php
+++ b/modules/Admin/Controllers/SettingsController.php
@@ -10,11 +10,13 @@ declare(strict_types=1);
 
 namespace Modules\Admin\Controllers;
 
-use App\Entities\Media\Image;
 use App\Models\ActorModel;
+use App\Models\EpisodeCommentModel;
+use App\Models\EpisodeModel;
 use App\Models\MediaModel;
 use App\Models\PersonModel;
 use App\Models\PodcastModel;
+use App\Models\PostModel;
 use CodeIgniter\Files\File;
 use CodeIgniter\HTTP\RedirectResponse;
 use PHP_ICO;
@@ -158,93 +160,112 @@ class SettingsController extends BaseController
 
     public function runHousekeeping(): RedirectResponse
     {
+        if ($this->request->getPost('reset_counts') === 'yes') {
+            // recalculate fediverse counts
+            (new ActorModel())->resetFollowersCount();
+            (new ActorModel())->resetPostsCount();
+            (new PostModel())->setEpisodeIdForRepliesOfEpisodePosts();
+            (new PostModel())->resetFavouritesCount();
+            (new PostModel())->resetReblogsCount();
+            (new PostModel())->resetRepliesCount();
+            (new EpisodeModel())->resetCommentsCount();
+            (new EpisodeModel())->resetPostsCount();
+            (new EpisodeCommentModel())->resetLikesCount();
+            (new EpisodeCommentModel())->resetRepliesCount();
+        }
         helper('media');
 
-        // Delete all podcast image sizes to recreate them
-        $allPodcasts = (new PodcastModel())->findAll();
-        foreach ($allPodcasts as $podcast) {
-            $podcastImages = glob(media_path("/podcasts/{$podcast->handle}/*_*{jpg,png,webp}"), GLOB_BRACE);
+        if ($this->request->getPost('rewrite_media') === 'yes') {
 
-            if ($podcastImages) {
-                foreach ($podcastImages as $podcastImage) {
-                    if (is_file($podcastImage)) {
-                        unlink($podcastImage);
+            // Delete all podcast image sizes to recreate them
+            $allPodcasts = (new PodcastModel())->findAll();
+            foreach ($allPodcasts as $podcast) {
+                $podcastImages = glob(media_path("/podcasts/{$podcast->handle}/*_*{jpg,png,webp}"), GLOB_BRACE);
+
+                if ($podcastImages) {
+                    foreach ($podcastImages as $podcastImage) {
+                        if (is_file($podcastImage)) {
+                            unlink($podcastImage);
+                        }
                     }
                 }
             }
-        }
 
-        // Delete all person image sizes to recreate them
-        $personsImages = glob(media_path('/persons/*_*{jpg,png,webp}'), GLOB_BRACE);
-        if ($personsImages) {
-            foreach ($personsImages as $personsImage) {
-                if (is_file($personsImage)) {
-                    unlink($personsImage);
+            // Delete all person image sizes to recreate them
+            $personsImages = glob(media_path('/persons/*_*{jpg,png,webp}'), GLOB_BRACE);
+            if ($personsImages) {
+                foreach ($personsImages as $personsImage) {
+                    if (is_file($personsImage)) {
+                        unlink($personsImage);
+                    }
                 }
             }
-        }
 
-        $allImages = (new MediaModel('image'))->getAllOfType();
-        foreach ($allImages as $image) {
-            if (str_starts_with($image->file_path, 'podcasts')) {
-                if (str_ends_with($image->file_path, 'banner.jpg') || str_ends_with($image->file_path, 'banner.png')) {
-                    $image->sizes = config('Images')
-                        ->podcastBannerSizes;
-                } else {
+            $allImages = (new MediaModel('image'))->getAllOfType();
+            foreach ($allImages as $image) {
+                if (str_starts_with($image->file_path, 'podcasts')) {
+                    if (str_ends_with($image->file_path, 'banner.jpg') || str_ends_with(
+                        $image->file_path,
+                        'banner.png'
+                    )) {
+                        $image->sizes = config('Images')
+                            ->podcastBannerSizes;
+                    } else {
+                        $image->sizes = config('Images')
+                            ->podcastCoverSizes;
+                    }
+                } elseif (str_starts_with($image->file_path, 'persons')) {
                     $image->sizes = config('Images')
-                        ->podcastCoverSizes;
+                        ->personAvatarSizes;
                 }
-            } elseif (str_starts_with($image->file_path, 'persons')) {
-                $image->sizes = config('Images')
-                    ->personAvatarSizes;
+                $image->setFile(new File(media_path($image->file_path)));
+
+                (new MediaModel('image'))->updateMedia($image);
             }
-            $image->setFile(new File(media_path($image->file_path)));
 
-            (new MediaModel('image'))->updateMedia($image);
-        }
+            $allAudio = (new MediaModel('audio'))->getAllOfType();
+            foreach ($allAudio as $audio) {
+                $audio->setFile(new File(media_path($audio->file_path)));
+
+                (new MediaModel('audio'))->updateMedia($audio);
+            }
 
-        $allAudio = (new MediaModel('audio'))->getAllOfType();
-        foreach ($allAudio as $audio) {
-            $audio->setFile(new File(media_path($audio->file_path)));
+            $allTranscripts = (new MediaModel('transcript'))->getAllOfType();
+            foreach ($allTranscripts as $transcript) {
+                $transcript->setFile(new File(media_path($transcript->file_path)));
 
-            (new MediaModel('audio'))->updateMedia($audio);
-        }
+                (new MediaModel('transcript'))->updateMedia($transcript);
+            }
 
-        $allTranscripts = (new MediaModel('transcript'))->getAllOfType();
-        foreach ($allTranscripts as $transcript) {
-            $transcript->setFile(new File(media_path($transcript->file_path)));
+            $allChapters = (new MediaModel('chapters'))->getAllOfType();
+            foreach ($allChapters as $chapters) {
+                $chapters->setFile(new File(media_path($chapters->file_path)));
 
-            (new MediaModel('transcript'))->updateMedia($transcript);
-        }
+                (new MediaModel('chapters'))->updateMedia($chapters);
+            }
 
-        $allChapters = (new MediaModel('chapters'))->getAllOfType();
-        foreach ($allChapters as $chapters) {
-            $chapters->setFile(new File(media_path($chapters->file_path)));
+            $allVideos = (new MediaModel('video'))->getAllOfType();
+            foreach ($allVideos as $video) {
+                $video->setFile(new File(media_path($video->file_path)));
 
-            (new MediaModel('chapters'))->updateMedia($chapters);
-        }
+                (new MediaModel('video'))->updateMedia($video);
+            }
 
-        $allVideos = (new MediaModel('video'))->getAllOfType();
-        foreach ($allVideos as $video) {
-            $video->setFile(new File(media_path($video->file_path)));
+            // reset avatar and banner image urls for each podcast actor
+            foreach ($allPodcasts as $podcast) {
+                $actorModel = new ActorModel();
+                $actor = $actorModel->getActorById($podcast->actor_id);
 
-            (new MediaModel('video'))->updateMedia($video);
-        }
+                if ($actor !== null) {
+                    // update values
+                    $actor->avatar_image_url = $podcast->cover->federation_url;
+                    $actor->avatar_image_mimetype = $podcast->cover->file_mimetype;
+                    $actor->cover_image_url = $podcast->banner->federation_url;
+                    $actor->cover_image_mimetype = $podcast->banner->file_mimetype;
 
-        // reset avatar and banner image urls for each podcast actor
-        foreach ($allPodcasts as $podcast) {
-            $actorModel = new ActorModel();
-            $actor = $actorModel->getActorById($podcast->actor_id);
-
-            if ($actor !== null) {
-                // update values
-                $actor->avatar_image_url = $podcast->cover->federation_url;
-                $actor->avatar_image_mimetype = $podcast->cover->file_mimetype;
-                $actor->cover_image_url = $podcast->banner->federation_url;
-                $actor->cover_image_mimetype = $podcast->banner->file_mimetype;
-
-                if ($actor->hasChanged()) {
-                    $actorModel->update($actor->id, $actor);
+                    if ($actor->hasChanged()) {
+                        $actorModel->update($actor->id, $actor);
+                    }
                 }
             }
         }
diff --git a/modules/Admin/Language/en/Settings.php b/modules/Admin/Language/en/Settings.php
index 8d0ece529e..ea79101b72 100644
--- a/modules/Admin/Language/en/Settings.php
+++ b/modules/Admin/Language/en/Settings.php
@@ -30,7 +30,11 @@ return [
     ],
     'housekeeping' => [
         'title' => 'Housekeeping',
-        'subtitle' => 'Runs various housekeeping tasks, such as rewriting media files metadata (images, audio files, transcripts, chapters, …).',
+        'subtitle' => 'Runs various housekeeping tasks, such as resetting counts and rewriting media files metadata. This may take a while.',
+        'reset_counts' => 'Reset counts',
+        'reset_counts_helper' => 'This option will recalculate and reset all data counts (number of followers, posts, comments, …).',
+        'rewrite_media' => 'Rewrite media metadata',
+        'rewrite_media_helper' => 'This option will delete all superfluous media files and recreate them (images, audio files, transcripts, chapters, …)',
         'run' => 'Run housekeeping',
         'runSuccess' => 'Housekeeping has been run successfully!',
     ],
diff --git a/modules/Admin/Language/fr/Settings.php b/modules/Admin/Language/fr/Settings.php
index 0d0fe8df0f..7c52f2d408 100644
--- a/modules/Admin/Language/fr/Settings.php
+++ b/modules/Admin/Language/fr/Settings.php
@@ -31,6 +31,10 @@ return [
     'housekeeping' => [
         'title' => 'Ménage',
         'subtitle' => 'Exécute un nombre de tâches de nettoyage, comme la réécriture de métadonnées des fichiers media (images, fichiers audio, transcript, chapitres, …).',
+        'reset_counts' => 'Réinitialiser les compteurs',
+        'reset_counts_helper' => 'Cette option recalcul et réinitialise les compteurs de données (nombre d’abonné·e·s, de publications, de commentaires, …).',
+        'rewrite_media' => 'Réécrire les métadonnées des fichiers média',
+        'rewrite_media_helper' => 'Cette option supprimera tous les fichiers média superflus et les recréera (images, fichiers audio, transcripts, chapitrages, …)',
         'run' => 'Faire le ménage',
         'runSuccess' => 'Le ménage a été effectué avec succès !',
     ],
diff --git a/modules/Fediverse/Controllers/PostController.php b/modules/Fediverse/Controllers/PostController.php
index c375111c8e..8d47006582 100644
--- a/modules/Fediverse/Controllers/PostController.php
+++ b/modules/Fediverse/Controllers/PostController.php
@@ -28,7 +28,10 @@ class PostController extends Controller
      */
     protected $helpers = ['fediverse'];
 
-    protected Post $post;
+    /**
+     * @var Post
+     */
+    protected $post;
 
     protected Fediverse $config;
 
diff --git a/modules/Fediverse/Models/ActorModel.php b/modules/Fediverse/Models/ActorModel.php
index 628cca0560..2608df67bf 100644
--- a/modules/Fediverse/Models/ActorModel.php
+++ b/modules/Fediverse/Models/ActorModel.php
@@ -272,6 +272,47 @@ class ActorModel extends BaseModel
         return $found;
     }
 
+    public function resetFollowersCount(): int | false
+    {
+        $tablePrefix = config('Fediverse')
+            ->tablesPrefix;
+
+        $actorsFollowersCount = $this->db->table($tablePrefix . 'follows')->select(
+            'target_actor_id as id, COUNT(*) as `followers_count`'
+        )
+            ->groupBy('id')
+            ->get()
+            ->getResultArray();
+
+        if ($actorsFollowersCount !== []) {
+            return $this->updateBatch($actorsFollowersCount, 'id');
+        }
+
+        return 0;
+    }
+
+    public function resetPostsCount(): int | false
+    {
+        $tablePrefix = config('Fediverse')
+            ->tablesPrefix;
+
+        $actorsFollowersCount = $this->db->table($tablePrefix . 'posts')->select(
+            'actor_id as id, COUNT(*) as `posts_count`'
+        )
+            ->where([
+                'in_reply_to_id' => null,
+            ])
+            ->groupBy('actor_id')
+            ->get()
+            ->getResultArray();
+
+        if ($actorsFollowersCount !== []) {
+            return $this->updateBatch($actorsFollowersCount, 'id');
+        }
+
+        return 0;
+    }
+
     public function clearCache(Actor $actor): void
     {
         $cachePrefix = config('Fediverse')
diff --git a/modules/Fediverse/Models/PostModel.php b/modules/Fediverse/Models/PostModel.php
index b3f162f8e7..dd1a9f4b99 100644
--- a/modules/Fediverse/Models/PostModel.php
+++ b/modules/Fediverse/Models/PostModel.php
@@ -276,9 +276,12 @@ class PostModel extends BaseUuidModel
             }
         }
 
-        model('ActorModel', false)
-            ->where('id', $post->actor_id)
-            ->increment('posts_count');
+        if ($post->in_reply_to_id === null) {
+            // increment posts_count only if not reply
+            model('ActorModel', false)
+                ->where('id', $post->actor_id)
+                ->increment('posts_count');
+        }
 
         if ($registerActivity) {
             // set post id and uri to construct NoteObject
@@ -619,6 +622,64 @@ class PostModel extends BaseUuidModel
         return $found;
     }
 
+    public function resetFavouritesCount(): int | false
+    {
+        $tablePrefix = config('Fediverse')
+            ->tablesPrefix;
+
+        $postsFavouritesCount = $this->db->table($tablePrefix . 'favourites')->select(
+            'post_id as id, COUNT(*) as `favourites_count`'
+        )
+            ->groupBy('id')
+            ->get()
+            ->getResultArray();
+
+        if ($postsFavouritesCount !== []) {
+            $this->uuidUseBytes = false;
+            return $this->updateBatch($postsFavouritesCount, 'id');
+        }
+
+        return 0;
+    }
+
+    public function resetReblogsCount(): int | false
+    {
+        $tablePrefix = config('Fediverse')
+            ->tablesPrefix;
+
+        $postsReblogsCount = $this->select($tablePrefix . 'posts.id, COUNT(*) as `replies_count`')
+            ->join($tablePrefix . 'posts as p2', $tablePrefix . 'posts.id = p2.reblog_of_id')
+            ->groupBy($tablePrefix . 'posts.id')
+            ->get()
+            ->getResultArray();
+
+        if ($postsReblogsCount !== []) {
+            $this->uuidUseBytes = false;
+            return $this->updateBatch($postsReblogsCount, 'id');
+        }
+
+        return 0;
+    }
+
+    public function resetRepliesCount(): int | false
+    {
+        $tablePrefix = config('Fediverse')
+            ->tablesPrefix;
+
+        $postsRepliesCount = $this->select($tablePrefix . 'posts.id, COUNT(*) as `replies_count`')
+            ->join($tablePrefix . 'posts as p2', $tablePrefix . 'posts.id = p2.in_reply_to_id')
+            ->groupBy($tablePrefix . 'posts.id')
+            ->get()
+            ->getResultArray();
+
+        if ($postsRepliesCount !== []) {
+            $this->uuidUseBytes = false;
+            return $this->updateBatch($postsRepliesCount, 'id');
+        }
+
+        return 0;
+    }
+
     public function clearCache(Post $post): void
     {
         $cachePrefix = config('Fediverse')
diff --git a/themes/cp_admin/settings/general.php b/themes/cp_admin/settings/general.php
index bd806aa240..3394731fed 100644
--- a/themes/cp_admin/settings/general.php
+++ b/themes/cp_admin/settings/general.php
@@ -77,6 +77,9 @@
     title="<?= lang('Settings.housekeeping.title') ?>"
     subtitle="<?= lang('Settings.housekeeping.subtitle') ?>" >
 
+    <Forms.Toggler name="reset_counts" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.reset_counts_helper') ?>"><?= lang('Settings.housekeeping.reset_counts') ?></Forms.Toggler>
+    <Forms.Toggler name="rewrite_media" value="yes" size="small" checked="true" hint="<?= lang('Settings.housekeeping.rewrite_media_helper') ?>"><?= lang('Settings.housekeeping.rewrite_media') ?></Forms.Toggler>
+
     <Button variant="primary" type="submit" iconLeft="home-gear"><?= lang('Settings.housekeeping.run') ?></Button>
 
 </Forms.Section>
diff --git a/themes/cp_app/episode/_partials/comment_actions.php b/themes/cp_app/episode/_partials/comment_actions.php
index b0630b74ef..0ca8c8c5a1 100644
--- a/themes/cp_app/episode/_partials/comment_actions.php
+++ b/themes/cp_app/episode/_partials/comment_actions.php
@@ -1,12 +1,12 @@
 <footer>
     <?php if (can_user_interact()): ?>
-        <form action="<?= route_to('comment-attempt-like', interact_as_actor()->username, $comment->episode->slug, $comment->id) ?>" method="POST" class="flex items-center gap-x-4">
+        <form action="<?= route_to('episode-comment-attempt-like', interact_as_actor()->username, $comment->episode->slug, $comment->id) ?>" method="POST" class="flex items-center gap-x-4">
             <button type="submit" name="action" class="inline-flex items-center hover:underline group" title="<?= lang(
     'Comment.likes',
     [
         'numberOfLikes' => $comment->likes_count,
     ],
-) ?>"><?= icon('heart', 'text-xl mr-1 opacity-40 group-hover:text-red-600 group-hover:opacity-100') . $comment->likes_count ?></button>
+) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $comment->likes_count ?></button>
             <Button uri="<?= route_to('episode-comment', $comment->episode->podcast->handle, $comment->episode->slug, $comment->id) ?>" size="small"><?= lang('Comment.reply') ?></Button>
         </form>
         <?php if ($comment->replies_count): ?>
diff --git a/themes/cp_app/episode/_partials/comment_actions_from_post.php b/themes/cp_app/episode/_partials/comment_actions_from_post.php
index f45e112a5d..bba0f41242 100644
--- a/themes/cp_app/episode/_partials/comment_actions_from_post.php
+++ b/themes/cp_app/episode/_partials/comment_actions_from_post.php
@@ -6,7 +6,7 @@
     [
         'numberOfLikes' => $comment->likes_count,
     ],
-) ?>"><?= icon('heart', 'text-xl mr-1 opacity-40 group-hover:text-red-600 group-hover:opacity-100') . $comment->likes_count ?></button>
+) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . $comment->likes_count ?></button>
             <Button uri="<?= route_to('post', $podcast->handle, $comment->id) ?>" size="small"><?= lang('Comment.reply') ?></Button>
         </form>
         <?php if ($comment->replies_count): ?>
@@ -21,12 +21,18 @@
 ) ?>
         <?php endif; ?>
     <?php else: ?>
-        <button class="inline-flex items-center opacity-50 cursor-not-allowed hover:underline" title="<?= lang(
-    'Comment.like',
+        <?= anchor_popup(
+    route_to('post-remote-action', $podcast->handle, $comment->id, 'favourite'),
+    icon('heart', 'text-xl mr-1 opacity-40') . $comment->likes_count,
     [
-        'numberOfLikes' => $comment->likes_count,
+        'class' => 'inline-flex items-center hover:underline',
+        'width' => 420,
+        'height' => 620,
+        'title' => lang('Post.favourites', [
+            'numberOfFavourites' => $comment->likes_count,
+        ]),
     ],
-) ?>"><?= icon('heart', 'text-xl mr-1 text-skin-muted') . $comment->likes_count ?></button>
+) ?>
         <?php if ($comment->replies_count): ?>
             <?= anchor(
     route_to('post', $podcast->handle, $comment->id),
diff --git a/themes/cp_app/episode/_partials/comment_card.php b/themes/cp_app/episode/_partials/comment_card.php
index 9cea928d3d..1bfb091c05 100644
--- a/themes/cp_app/episode/_partials/comment_card.php
+++ b/themes/cp_app/episode/_partials/comment_card.php
@@ -21,13 +21,13 @@
         <?php else: ?>
             <footer>
                 <?php if (can_user_interact()): ?>
-                    <form action="<?= route_to('comment-attempt-like', interact_as_actor()->username, $episode->slug, $comment->id) ?>" method="POST" class="flex items-center gap-x-4">
+                    <form action="<?= route_to('episode-comment-attempt-like', interact_as_actor()->username, $episode->slug, $comment->id) ?>" method="POST" class="flex items-center gap-x-4">
                     <button type="submit" name="action" class="inline-flex items-center hover:underline group" title="<?= lang(
                             'Comment.likes',
                             [
                                 'numberOfLikes' => $comment->likes_count,
                             ],
-                        ) ?>"><?= icon('heart', 'text-xl mr-1 opacity-40 group-hover:text-red-600 group-hover:opacity-100') . lang(
+                        ) ?>"><?= icon('heart', 'text-xl mr-1 text-gray-400 group-hover:text-red-600') . lang(
                             'Comment.likes',
                             [
                                 'numberOfLikes' => $comment->likes_count,
diff --git a/themes/cp_app/episode/_partials/comment_reply_actions.php b/themes/cp_app/episode/_partials/comment_reply_actions.php
index d878ec7e1f..a727b66619 100644
--- a/themes/cp_app/episode/_partials/comment_reply_actions.php
+++ b/themes/cp_app/episode/_partials/comment_reply_actions.php
@@ -1,12 +1,12 @@
 <footer>
     <?php if (can_user_interact()): ?>
-        <form action="<?= route_to('comment-attempt-like', interact_as_actor()->username, $reply->episode->slug, $reply->id) ?>" method="POST" class="flex items-center gap-x-4">
+        <form action="<?= route_to('episode-comment-attempt-like', interact_as_actor()->username, $reply->episode->slug, $reply->id) ?>" method="POST" class="flex items-center gap-x-4">
             <button type="submit" name="action" class="inline-flex items-center hover:underline group" title="<?= lang(
     'Comment.likes',
     [
         'numberOfLikes' => $reply->likes_count,
     ],
-) ?>"><?= icon('heart', 'text-lg mr-1 opacity-40 group-hover:text-red-600') . $reply->likes_count ?></button>
+) ?>"><?= icon('heart', 'text-lg mr-1 text-gray-400 group-hover:text-red-600') . $reply->likes_count ?></button>
             <Button uri="<?= route_to('episode-comment', $reply->episode->podcast->handle, $reply->episode->slug, $reply->id) ?>" size="small"><?= lang('Comment.reply') ?></Button>
         </form>
     <?php else: ?>
diff --git a/themes/cp_app/post/_partials/card.php b/themes/cp_app/post/_partials/card.php
index 0a6c45d893..efe7a952b1 100644
--- a/themes/cp_app/post/_partials/card.php
+++ b/themes/cp_app/post/_partials/card.php
@@ -27,7 +27,7 @@
     <?php else: ?>
         <div class="px-6 mb-4 post-content"><?= $post->message_html ?></div>
     <?php endif; ?>
-    <?php if ($post->episode_id): ?>
+    <?php if ($post->episode_id && $post->in_reply_to_id === null): ?>
         <?= view('episode/_partials/preview_card', [
     'index' => $index,
             'episode' => $post->episode,
-- 
GitLab