‪TYPO3CMS  ‪main
WorkspaceVersionRecordsCommand.php
Go to the documentation of this file.
1 <?php
2 
3 declare(strict_types=1);
4 
5 /*
6  * This file is part of the TYPO3 CMS project.
7  *
8  * It is free software; you can redistribute it and/or modify it under
9  * the terms of the GNU General Public License, either version 2
10  * of the License, or any later version.
11  *
12  * For the full copyright and license information, please read the
13  * LICENSE.txt file that was distributed with this source code.
14  *
15  * The TYPO3 project - inspiring people to share!
16  */
17 
19 
20 use Symfony\Component\Console\Command\Command;
21 use Symfony\Component\Console\Input\InputInterface;
22 use Symfony\Component\Console\Input\InputOption;
23 use Symfony\Component\Console\Output\OutputInterface;
24 use Symfony\Component\Console\Style\SymfonyStyle;
25 use TYPO3\CMS\Backend\Utility\BackendUtility;
33 
37 class ‪WorkspaceVersionRecordsCommand extends Command
38 {
43  protected ‪$allWorkspaces = [0 => 'Live Workspace'];
44 
45  public function ‪__construct(private readonly ‪ConnectionPool $connectionPool)
46  {
47  parent::__construct();
48  }
49 
54  protected ‪$foundRecords = [
55  // All versions of records found
56  // Subset of "all" which are offline versions (t3ver_oid > 0) [Informational]
57  'all_versioned_records' => [],
58  // All records that has been published and can therefore be removed permanently
59  // Subset of "versions" that is a count of 1 or more (has been published) [Informational]
60  'published_versions' => [],
61  // All versions that are offline versions in the Live workspace. You may wish to flush these if you only use
62  // workspaces for versioning since then you might find lots of versions piling up in the live workspace which
63  // have simply been disconnected from the workspace before they were published.
64  'versions_in_live' => [],
65  // Versions that has lost their connection to a workspace in TYPO3.
66  // Subset of "versions" that doesn't belong to an existing workspace [Warning: Fix by move to live workspace]
67  'invalid_workspace' => [],
68  ];
69 
73  public function ‪configure()
74  {
75  $this
76  ->setHelp('Traverse page tree and find versioned records. Also list all versioned records, additionally with some inconsistencies in the database, which can cleaned up with the "action" option. If you want to get more detailed information, use the --verbose option.')
77  ->addOption(
78  'pid',
79  'p',
80  InputOption::VALUE_REQUIRED,
81  'Setting start page in page tree. Default is the page tree root, 0 (zero)'
82  )
83  ->addOption(
84  'depth',
85  'd',
86  InputOption::VALUE_REQUIRED,
87  'Setting traversal depth. 0 (zero) will only analyze start page (see --pid), 1 will traverse one level of subpages etc.'
88  )
89  ->addOption(
90  'dry-run',
91  null,
92  InputOption::VALUE_NONE,
93  'If this option is set, the records will not actually be deleted/modified, but just the output which records would be touched are shown'
94  )
95  ->addOption(
96  'action',
97  null,
98  InputOption::VALUE_OPTIONAL,
99  'Specify which action should be taken. Set it to "versions_in_live", "published_versions" or "invalid_workspace"'
100  );
101  }
102 
106  protected function ‪execute(InputInterface $input, OutputInterface ‪$output): int
107  {
108  // Make sure the _cli_ user is loaded
110 
111  $io = new SymfonyStyle($input, ‪$output);
112  $io->title($this->getDescription());
113 
114  $startingPoint = 0;
115  if ($input->hasOption('pid') && ‪MathUtility::canBeInterpretedAsInteger($input->getOption('pid'))) {
116  $startingPoint = ‪MathUtility::forceIntegerInRange((int)$input->getOption('pid'), 0);
117  }
118 
119  $depth = 1000;
120  if ($input->hasOption('depth') && ‪MathUtility::canBeInterpretedAsInteger($input->getOption('depth'))) {
121  $depth = ‪MathUtility::forceIntegerInRange((int)$input->getOption('depth'), 0);
122  }
123 
124  $action = '';
125  if ($input->hasOption('action') && !empty($input->getOption('action'))) {
126  $action = $input->getOption('action');
127  }
128 
129  // type unsafe comparison and explicit boolean setting on purpose
130  $dryRun = $input->hasOption('dry-run') && $input->getOption('dry-run') != false ? true : false;
131 
132  if ($io->isVerbose()) {
133  $io->section('Searching the database now for versioned records.');
134  }
135 
137 
138  // Find all records that are versioned
139  $this->‪traversePageTreeForVersionedRecords($startingPoint, $depth);
140  // Sort recStats (for diff'able displays)
141  foreach ($this->foundRecords as $kk => $vv) {
142  foreach ($this->foundRecords[$kk] as $tables => $recArrays) {
143  ksort($this->foundRecords[$kk][$tables]);
144  }
145  ksort($this->foundRecords[$kk]);
146  }
147 
148  if (!$io->isQuiet()) {
149  $numberOfVersionedRecords = 0;
150  foreach ($this->foundRecords['all_versioned_records'] as $records) {
151  $numberOfVersionedRecords += count($records);
152  }
153 
154  $io->section('Found ' . $numberOfVersionedRecords . ' versioned records in the database.');
155  if ($io->isVeryVerbose()) {
156  foreach ($this->foundRecords['all_versioned_records'] as $table => $records) {
157  $io->writeln('Table "' . $table . '"');
158  $io->listing($records);
159  }
160  }
161 
162  $numberOfPublishedVersions = 0;
163  foreach ($this->foundRecords['published_versions'] as $records) {
164  $numberOfPublishedVersions += count($records);
165  }
166  $io->section('Found ' . $numberOfPublishedVersions . ' versioned records that have been published.');
167  if ($io->isVeryVerbose()) {
168  foreach ($this->foundRecords['published_versions'] as $table => $records) {
169  $io->writeln('Table "' . $table . '"');
170  $io->listing($records);
171  }
172  }
173 
174  $numberOfVersionsInLiveWorkspace = 0;
175  foreach ($this->foundRecords['versions_in_live'] as $records) {
176  $numberOfVersionsInLiveWorkspace += count($records);
177  }
178  $io->section('Found ' . $numberOfVersionsInLiveWorkspace . ' versioned records that are in the live workspace.');
179  if ($io->isVeryVerbose()) {
180  foreach ($this->foundRecords['versions_in_live'] as $table => $records) {
181  $io->writeln('Table "' . $table . '"');
182  $io->listing($records);
183  }
184  }
185 
186  $numberOfVersionsWithInvalidWorkspace = 0;
187  foreach ($this->foundRecords['invalid_workspace'] as $records) {
188  $numberOfVersionsWithInvalidWorkspace += count($records);
189  }
190  $io->section('Found ' . $numberOfVersionsWithInvalidWorkspace . ' versioned records with an invalid workspace.');
191  if ($io->isVeryVerbose()) {
192  foreach ($this->foundRecords['invalid_workspace'] as $table => $records) {
193  $io->writeln('Table "' . $table . '"');
194  $io->listing($records);
195  }
196  }
197  }
198 
199  // Actually permanently delete / update records
200  switch ($action) {
201  // All versions that are offline versions in the Live workspace. You may wish to flush these if you only use
202  // workspaces for versioning since then you might find lots of versions piling up in the live workspace which
203  // have simply been disconnected from the workspace before they were published.
204  case 'versions_in_live':
205  $io->section('Deleting versioned records in live workspace now. ' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
206  $this->‪deleteRecords($this->foundRecords['versions_in_live'], $dryRun, $io);
207  break;
208 
209  // All records that has been published and can therefore be removed permanently
210  // Subset of "versions" that is a count of 1 or more (has been published)
211  case 'published_versions':
212  $io->section('Deleting published records in live workspace now. ' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
213  $this->‪deleteRecords($this->foundRecords['published_versions'], $dryRun, $io);
214  break;
215 
216  // Versions that has lost their connection to a workspace in TYPO3.
217  // Subset of "versions" that doesn't belong to an existing workspace [Warning: Fix by move to live workspace]
218  case 'invalid_workspace':
219  $io->section('Moving versions in invalid workspaces to live workspace now. ' . ($dryRun ? ' (Not deleting now, just a dry run)' : ''));
220  $this->‪resetRecordsWithoutValidWorkspace($this->foundRecords['invalid_workspace'], $dryRun, $io);
221  break;
222 
223  default:
224  $io->note('No action specified, just displaying statistics. See --action option for details.');
225  break;
226  }
227  $io->success('All done!');
228  return Command::SUCCESS;
229  }
230 
239  protected function ‪traversePageTreeForVersionedRecords(int $rootID, int $depth, bool $isInsideVersionedPage = false, bool $rootIsVersion = false)
240  {
241  $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages');
242  $queryBuilder->getRestrictions()->removeAll();
243 
244  $pageRecord = $queryBuilder
245  ->select(
246  'deleted',
247  'title',
248  't3ver_wsid'
249  )
250  ->from('pages')
251  ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($rootID, ‪Connection::PARAM_INT)))
252  ->executeQuery()
253  ->fetchAssociative();
254 
255  // If rootIsVersion is set it means that the input rootID is that of a version of a page. See below where the recursive call is made.
256  if ($rootIsVersion) {
257  $workspaceId = (int)$pageRecord['t3ver_wsid'];
258  $this->foundRecords['all_versioned_records']['pages'][$rootID] = $rootID;
259  // If it has been published and is in archive now...
260  if ($workspaceId === 0) {
261  $this->foundRecords['versions_in_live']['pages'][$rootID] = $rootID;
262  }
263  // If it doesn't belong to a workspace...
264  if (!isset($this->allWorkspaces[$workspaceId])) {
265  $this->foundRecords['invalid_workspace']['pages'][$rootID] = $rootID;
266  }
267  }
268  // Only check for records if not inside a version
269  if (!$isInsideVersionedPage) {
270  // Traverse tables of records that belongs to page
271  $tableNames = $this->‪getAllVersionableTables();
272  foreach ($tableNames as $tableName) {
273  if ($tableName !== 'pages') {
274  // Select all records belonging to page:
275  $queryBuilder = $this->connectionPool
276  ->getQueryBuilderForTable($tableName);
277 
278  $queryBuilder->getRestrictions()->removeAll();
279 
280  $result = $queryBuilder
281  ->select('uid')
282  ->from($tableName)
283  ->where(
284  $queryBuilder->expr()->eq(
285  'pid',
286  $queryBuilder->createNamedParameter($rootID, ‪Connection::PARAM_INT)
287  )
288  )
289  ->executeQuery();
290  while ($rowSub = $result->fetchAssociative()) {
291  // Add any versions of those records
292  $versions = BackendUtility::selectVersionsOfRecord($tableName, $rowSub['uid'], 'uid,t3ver_wsid' . ((‪$GLOBALS['TCA'][$tableName]['ctrl']['delete'] ?? false) ? ',' . ‪$GLOBALS['TCA'][$tableName]['ctrl']['delete'] : ''), null, true);
293  if (is_array($versions)) {
294  foreach ($versions as $verRec) {
295  if (!($verRec['_CURRENT_VERSION'] ?? false)) {
296  // Register version
297  $this->foundRecords['all_versioned_records'][$tableName][$verRec['uid']] = $verRec['uid'];
298  $workspaceId = (int)$verRec['t3ver_wsid'];
299  if ($workspaceId === 0) {
300  $this->foundRecords['versions_in_live'][$tableName][$verRec['uid']] = $verRec['uid'];
301  }
302  if (!isset($this->allWorkspaces[$workspaceId])) {
303  $this->foundRecords['invalid_workspace'][$tableName][$verRec['uid']] = $verRec['uid'];
304  }
305  }
306  }
307  }
308  }
309  }
310  }
311  }
312  // Find subpages to root ID and traverse (only when rootID is not a version or is a branch-version):
313  if ($depth > 0) {
314  $depth--;
315  $queryBuilder = $this->connectionPool
316  ->getQueryBuilderForTable('pages');
317 
318  $queryBuilder->getRestrictions()->removeAll();
319  $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
320 
321  $queryBuilder
322  ->select('uid')
323  ->from('pages')
324  ->where(
325  $queryBuilder->expr()->eq(
326  'pid',
327  $queryBuilder->createNamedParameter($rootID, ‪Connection::PARAM_INT)
328  )
329  )
330  ->orderBy('sorting');
331 
332  $result = $queryBuilder->executeQuery();
333  while ($row = $result->fetchAssociative()) {
334  $this->‪traversePageTreeForVersionedRecords((int)$row['uid'], $depth, $isInsideVersionedPage, false);
335  }
336  }
337  // Add any versions of pages
338  if ($rootID > 0) {
339  $versions = BackendUtility::selectVersionsOfRecord('pages', $rootID, 'uid,t3ver_oid,t3ver_wsid', null, true);
340  if (is_array($versions)) {
341  foreach ($versions as $verRec) {
342  if (!($verRec['_CURRENT_VERSION'] ?? false)) {
343  $this->‪traversePageTreeForVersionedRecords((int)$verRec['uid'], $depth, true, true);
344  }
345  }
346  }
347  }
348  }
349 
350  /**************************
351  * actions / delete methods
352  **************************/
359  protected function ‪deleteRecords(array $records, bool $dryRun, SymfonyStyle $io)
360  {
361  // Putting "pages" table in the bottom
362  if (isset($records['pages'])) {
363  $_pages = $records['pages'];
364  unset($records['pages']);
365  // To delete sub pages first assuming they are accumulated from top of page tree.
366  $records['pages'] = array_reverse($_pages);
367  }
368 
369  // Set up the data handler instance
370  $dataHandler = GeneralUtility::makeInstance(DataHandler::class);
371  $dataHandler->start([], []);
372 
373  // Traversing records
374  foreach ($records as $table => $uidsInTable) {
375  if ($io->isVerbose()) {
376  $io->writeln('Flushing published records from table "' . $table . '"');
377  }
378  foreach ($uidsInTable as ‪$uid) {
379  if ($io->isVeryVerbose()) {
380  $io->writeln('Flushing record "' . $table . ':' . ‪$uid . '"');
381  }
382  if (!$dryRun) {
383  $dataHandler->deleteEl($table, ‪$uid, true, true);
384  if (!empty($dataHandler->errorLog)) {
385  $errorMessage = array_merge(['DataHandler reported an error'], $dataHandler->errorLog);
386  $io->error($errorMessage);
387  } elseif (!$io->isQuiet()) {
388  $io->writeln('Flushed published record "' . $table . ':' . ‪$uid . '".');
389  }
390  }
391  }
392  }
393  }
394 
402  protected function ‪resetRecordsWithoutValidWorkspace(array $records, bool $dryRun, SymfonyStyle $io)
403  {
404  foreach ($records as $table => $uidsInTable) {
405  if ($io->isVerbose()) {
406  $io->writeln('Resetting workspace to zero for records from table "' . $table . '"');
407  }
408  foreach ($uidsInTable as ‪$uid) {
409  if ($io->isVeryVerbose()) {
410  $io->writeln('Flushing record "' . $table . ':' . ‪$uid . '"');
411  }
412  if (!$dryRun) {
413  $queryBuilder = $this->connectionPool
414  ->getQueryBuilderForTable($table);
415 
416  $queryBuilder
417  ->update($table)
418  ->where(
419  $queryBuilder->expr()->eq(
420  'uid',
421  $queryBuilder->createNamedParameter(‪$uid, ‪Connection::PARAM_INT)
422  )
423  )
424  ->set('t3ver_wsid', 0)
425  ->executeStatement();
426  if (!$io->isQuiet()) {
427  $io->writeln('Flushed record "' . $table . ':' . ‪$uid . '".');
428  }
429  }
430  }
431  }
432  }
433 
443  protected function ‪loadAllWorkspaceRecords(): array
444  {
445  $queryBuilder = $this->connectionPool
446  ->getQueryBuilderForTable('sys_workspace');
447 
448  $queryBuilder->getRestrictions()
449  ->removeAll()
450  ->add(GeneralUtility::makeInstance(DeletedRestriction::class));
451 
452  $result = $queryBuilder
453  ->select('uid', 'title')
454  ->from('sys_workspace')
455  ->executeQuery();
456 
457  while ($workspaceRecord = $result->fetchAssociative()) {
458  $this->allWorkspaces[(int)$workspaceRecord['uid']] = $workspaceRecord['title'];
459  }
461  }
462 
466  protected function ‪getAllVersionableTables(): array
467  {
468  $tables = [];
469  foreach (‪$GLOBALS['TCA'] as $tableName => $config) {
470  if (BackendUtility::isTableWorkspaceEnabled($tableName)) {
471  $tables[] = $tableName;
472  }
473  }
474  return $tables;
475  }
476 }
‪TYPO3\CMS\Core\DataHandling\DataHandler
Definition: DataHandler.php:95
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\execute
‪execute(InputInterface $input, OutputInterface $output)
Definition: WorkspaceVersionRecordsCommand.php:104
‪TYPO3\CMS\Core\Database\Connection\PARAM_INT
‪const PARAM_INT
Definition: Connection.php:50
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\__construct
‪__construct(private readonly ConnectionPool $connectionPool)
Definition: WorkspaceVersionRecordsCommand.php:44
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand
Definition: WorkspaceVersionRecordsCommand.php:38
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\deleteRecords
‪deleteRecords(array $records, bool $dryRun, SymfonyStyle $io)
Definition: WorkspaceVersionRecordsCommand.php:357
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\$allWorkspaces
‪array $allWorkspaces
Definition: WorkspaceVersionRecordsCommand.php:42
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\loadAllWorkspaceRecords
‪array loadAllWorkspaceRecords()
Definition: WorkspaceVersionRecordsCommand.php:441
‪TYPO3\CMS\Workspaces\Command
Definition: AutoPublishCommand.php:18
‪TYPO3\CMS\Core\Utility\MathUtility\canBeInterpretedAsInteger
‪static bool canBeInterpretedAsInteger(mixed $var)
Definition: MathUtility.php:69
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\$foundRecords
‪array $foundRecords
Definition: WorkspaceVersionRecordsCommand.php:52
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\traversePageTreeForVersionedRecords
‪traversePageTreeForVersionedRecords(int $rootID, int $depth, bool $isInsideVersionedPage=false, bool $rootIsVersion=false)
Definition: WorkspaceVersionRecordsCommand.php:237
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\getAllVersionableTables
‪getAllVersionableTables()
Definition: WorkspaceVersionRecordsCommand.php:464
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\configure
‪configure()
Definition: WorkspaceVersionRecordsCommand.php:71
‪$output
‪$output
Definition: annotationChecker.php:119
‪TYPO3\CMS\Core\Database\Connection
Definition: Connection.php:39
‪TYPO3\CMS\Webhooks\Message\$uid
‪identifier readonly int $uid
Definition: PageModificationMessage.php:35
‪$GLOBALS
‪$GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['adminpanel']['modules']
Definition: ext_localconf.php:25
‪TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction
Definition: DeletedRestriction.php:28
‪TYPO3\CMS\Core\Core\Bootstrap
Definition: Bootstrap.php:64
‪TYPO3\CMS\Core\Utility\MathUtility
Definition: MathUtility.php:24
‪TYPO3\CMS\Core\Database\ConnectionPool
Definition: ConnectionPool.php:48
‪TYPO3\CMS\Core\Utility\MathUtility\forceIntegerInRange
‪static int forceIntegerInRange(mixed $theInt, int $min, int $max=2000000000, int $defaultValue=0)
Definition: MathUtility.php:34
‪TYPO3\CMS\Core\Utility\GeneralUtility
Definition: GeneralUtility.php:51
‪TYPO3\CMS\Workspaces\Command\WorkspaceVersionRecordsCommand\resetRecordsWithoutValidWorkspace
‪resetRecordsWithoutValidWorkspace(array $records, bool $dryRun, SymfonyStyle $io)
Definition: WorkspaceVersionRecordsCommand.php:400
‪TYPO3\CMS\Core\Core\Bootstrap\initializeBackendAuthentication
‪static initializeBackendAuthentication()
Definition: Bootstrap.php:529