Skip to content

Commit 02e5a72

Browse files
authored
Merge branch 'main' into new/front-cursor-pagination
2 parents fe09af6 + 82e65d1 commit 02e5a72

File tree

112 files changed

+426
-110
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

112 files changed

+426
-110
lines changed

config/packages/framework.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ framework:
3333
http_client:
3434
default_options:
3535
headers:
36-
'User-Agent': 'Mbin/1.9.0 (+https://%kbin_domain%/agent)'
36+
'User-Agent': 'Mbin/1.9.1 (+https://%kbin_domain%/agent)'
3737

3838
#esi: true
3939
#fragments: true

docs/02-admin/04-running-mbin/05-cli.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,17 @@ php bin/console mbin:cache:build
366366

367367
## Miscellaneous
368368

369+
### Search for duplicate magazines or users and remove them
370+
371+
This command provides a guided tour to search for, and remove duplicate magazines or users.
372+
This has been added to make the creation of unique indexes easier if the migration failed.
373+
374+
Usage:
375+
376+
```bash
377+
php bin/console mbin:check:duplicates-users-magazines
378+
```
379+
369380
### Users-Remove-Marked-For-Deletion
370381

371382
> [!NOTE]
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Command;
6+
7+
use App\Entity\Magazine;
8+
use App\Entity\User;
9+
use App\Service\MagazineManager;
10+
use App\Service\UserManager;
11+
use Doctrine\ORM\EntityManagerInterface;
12+
use Symfony\Component\Console\Attribute\AsCommand;
13+
use Symfony\Component\Console\Command\Command;
14+
use Symfony\Component\Console\Input\InputInterface;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
use Symfony\Component\Console\Style\SymfonyStyle;
17+
18+
#[AsCommand(
19+
name: 'mbin:check:duplicates-users-magazines',
20+
description: 'Check for duplicate users and magazines.',
21+
)]
22+
class CheckDuplicatesUsersMagazines extends Command
23+
{
24+
public function __construct(
25+
private readonly EntityManagerInterface $entityManager,
26+
private readonly UserManager $userManager,
27+
private readonly MagazineManager $magazineManager,
28+
) {
29+
parent::__construct();
30+
}
31+
32+
protected function configure(): void
33+
{
34+
$this
35+
->setDescription('Check for duplicate users and magazines with interactive deletion options.');
36+
}
37+
38+
protected function execute(InputInterface $input, OutputInterface $output): int
39+
{
40+
$io = new SymfonyStyle($input, $output);
41+
42+
$io->title('Duplicate Users and Magazines Checker');
43+
44+
// Let user choose entity type
45+
$entity = $io->choice(
46+
'What would you like to check for duplicates?',
47+
['users' => 'Users', 'magazines' => 'Magazines'],
48+
'users'
49+
);
50+
51+
// Check for duplicates
52+
$duplicates = $this->findDuplicates($io, $entity);
53+
54+
if (empty($duplicates)) {
55+
$entityName = ucfirst(substr($entity, 0, -1));
56+
$io->success("No duplicate {$entityName}s found.");
57+
58+
return Command::SUCCESS;
59+
}
60+
61+
// Display duplicates table
62+
$entityName = ucfirst($entity);
63+
$nameField = 'users' === $entity ? 'username' : 'name';
64+
$this->displayDuplicatesTable($io, $duplicates, $entityName, $nameField);
65+
66+
// Ask if user wants to delete any duplicates
67+
$deleteChoice = $io->confirm('Would you like to delete any of these duplicates?', false);
68+
69+
if (!$deleteChoice) {
70+
$io->success('Operation completed. No deletions performed.');
71+
72+
return Command::SUCCESS;
73+
}
74+
75+
// Get IDs to delete
76+
$idsInput = $io->ask(
77+
'Enter the IDs to delete (comma-separated, e.g., 1,2,3)',
78+
null,
79+
function ($input) {
80+
if (empty($input)) {
81+
throw new \InvalidArgumentException('Please provide at least one ID');
82+
}
83+
84+
$ids = array_map('trim', explode(',', $input));
85+
foreach ($ids as $id) {
86+
if (!is_numeric($id)) {
87+
throw new \InvalidArgumentException("Invalid ID: $id");
88+
}
89+
}
90+
91+
return $ids;
92+
}
93+
);
94+
95+
return $this->deleteEntities($io, $entity, $idsInput);
96+
}
97+
98+
private function findDuplicates(SymfonyStyle $io, string $entity): array
99+
{
100+
$conn = $this->entityManager->getConnection();
101+
102+
if ('users' === $entity) {
103+
$sql = '
104+
SELECT id, username, ap_public_url, created_at, last_active FROM
105+
"user" WHERE ap_public_url IN
106+
(SELECT ap_public_url FROM "user" WHERE ap_public_url IS NOT NULL GROUP BY ap_public_url HAVING COUNT(*) > 1)
107+
ORDER BY ap_public_url;
108+
';
109+
} else { // magazines
110+
$sql = '
111+
SELECT id, name, ap_public_url, created_at, last_active FROM
112+
"magazine" WHERE ap_public_url IN
113+
(SELECT ap_public_url FROM "magazine" WHERE ap_public_url IS NOT NULL GROUP BY ap_public_url HAVING COUNT(*) > 1)
114+
ORDER BY ap_public_url;
115+
';
116+
}
117+
118+
$stmt = $conn->prepare($sql);
119+
$stmt = $stmt->executeQuery();
120+
$results = $stmt->fetchAllAssociative();
121+
122+
return $results;
123+
}
124+
125+
private function displayDuplicatesTable(SymfonyStyle $io, array $results, string $entityName, string $nameField): void
126+
{
127+
$io->section("Duplicate {$entityName}s Found");
128+
129+
// Group by ap_public_url
130+
$duplicates = [];
131+
foreach ($results as $item) {
132+
$url = $item['ap_public_url'];
133+
if (!isset($duplicates[$url])) {
134+
$duplicates[$url] = [];
135+
}
136+
$duplicates[$url][] = $item;
137+
}
138+
139+
foreach ($duplicates as $url => $items) {
140+
$io->text("\n".str_repeat('=', 30));
141+
$io->text('Duplicate Group: '.$url);
142+
143+
// Prepare table data
144+
$headers = ['ID', ucfirst($nameField), 'Created At', 'Last Active'];
145+
$rows = [];
146+
147+
foreach ($items as $item) {
148+
$rows[] = [
149+
$item['id'],
150+
$item[$nameField],
151+
$item['created_at'] ? substr($item['created_at'], 0, 19) : 'N/A',
152+
$item['last_active'] ? substr($item['last_active'], 0, 19) : 'N/A',
153+
];
154+
}
155+
156+
$io->table($headers, $rows);
157+
}
158+
159+
$io->text(\sprintf("\nTotal duplicate {$entityName}s: %d", \count($results)));
160+
}
161+
162+
private function deleteEntities(SymfonyStyle $io, string $entity, array $ids): int
163+
{
164+
try {
165+
foreach ($ids as $id) {
166+
if ('users' === $entity) {
167+
// Check if user exists first
168+
$existingUser = $this->entityManager->getRepository(User::class)->find($id);
169+
if (!$existingUser) {
170+
$io->warning("User with ID $id not found, skipping...");
171+
continue;
172+
}
173+
174+
$this->userManager->delete($existingUser);
175+
$io->success("Deleted user: {$existingUser->getUsername()} (ID: $id)");
176+
} else { // magazines
177+
// Check if magazine exists first
178+
$magazine = $this->entityManager->getRepository(Magazine::class)->find($id);
179+
if (!$magazine) {
180+
$io->warning("Magazine with ID $id not found, skipping...");
181+
continue;
182+
}
183+
184+
$this->magazineManager->purge($magazine);
185+
$io->success("Deleted magazine: {$magazine->getApName()} (ID: $id)");
186+
}
187+
}
188+
189+
$entityName = ucfirst(substr($entity, 0, -1));
190+
$io->success("{$entityName} deletion completed successfully.");
191+
} catch (\Exception $e) {
192+
$io->error('Error during deletion: '.$e->getMessage());
193+
194+
return Command::FAILURE;
195+
}
196+
197+
return Command::SUCCESS;
198+
}
199+
}

src/Service/ActivityPub/ContextsProvider.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ public static function embeddedContexts(): array
2828
public function referencedContexts(): array
2929
{
3030
return [
31-
ActivityPubActivityInterface::CONTEXT_URL,
3231
$this->urlGenerator->generate('ap_contexts', [], UrlGeneratorInterface::ABSOLUTE_URL),
3332
];
3433
}

src/Service/ProjectInfoService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class ProjectInfoService
1111
{
1212
// If updating version, please also update http client UA in [/config/packages/framework.yaml]
13-
private const VERSION = '1.9.0'; // TODO: Retrieve the version from git tags or getenv()?
13+
private const VERSION = '1.9.1'; // TODO: Retrieve the version from git tags or getenv()?
1414
private const NAME = 'mbin';
1515
private const CANONICAL_NAME = 'Mbin';
1616
private const REPOSITORY_URL = 'https://github.com/MbinOrg/mbin';

tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testAddModerator__1.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"@context": [
3-
"https://www.w3.org/ns/activitystreams",
43
"https://kbin.test/contexts"
54
],
65
"id": "SCRUBBED_ID",

tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testAddPinnedPost__1.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"@context": [
3-
"https://www.w3.org/ns/activitystreams",
43
"https://kbin.test/contexts"
54
],
65
"id": "SCRUBBED_ID",

tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testRemoveModerator__1.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"@context": [
3-
"https://www.w3.org/ns/activitystreams",
43
"https://kbin.test/contexts"
54
],
65
"id": "SCRUBBED_ID",

tests/Unit/ActivityPub/Outbox/JsonSnapshots/AddHandlerTest__testRemovePinnedPost__1.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"@context": [
3-
"https://www.w3.org/ns/activitystreams",
43
"https://kbin.test/contexts"
54
],
65
"id": "SCRUBBED_ID",

tests/Unit/ActivityPub/Outbox/JsonSnapshots/AnnounceTest__testAnnounceAddModerator__1.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"@context": [
3-
"https://www.w3.org/ns/activitystreams",
43
"https://kbin.test/contexts"
54
],
65
"id": "SCRUBBED_ID",

0 commit comments

Comments
 (0)