Completed
Pull Request — master (#1574)
by Damian
03:03
created

RemoveOrphanedPagesTask::removeOrphans()   B

Complexity

Conditions 4
Paths 5

Size

Total Lines 32
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 32
rs 8.5806
c 0
b 0
f 0
cc 4
eloc 23
nc 5
nop 1
1
<?php
2
3
namespace SilverStripe\CMS\Tasks;
4
5
6
use SilverStripe\ORM\SS_List;
7
use SilverStripe\ORM\Versioning\Versioned;
8
use SilverStripe\ORM\DataObject;
9
use SilverStripe\ORM\ArrayList;
10
use SilverStripe\Security\Permission;
11
use SilverStripe\Security\Security;
12
use SilverStripe\CMS\Model\SiteTree;
13
use Controller;
14
use Requirements;
15
use FieldList;
16
use HeaderField;
17
use LiteralField;
18
use CheckboxSetField;
19
use OptionsetField;
20
use Form;
21
use FormAction;
22
23
24
25
26
27
/**
28
 * Identify "orphaned" pages which point to a parent
29
 * that no longer exists in a specific stage.
30
 * Shows the pages to an administrator, who can then
31
 * decide which pages to remove by ticking a checkbox
32
 * and manually executing the removal.
33
 *
34
 * Caution: Pages also count as orphans if they don't
35
 * have parents in this stage, even if the parent has a representation
36
 * in the other stage:
37
 * - A live child is orphaned if its parent was deleted from live, but still exists on stage
38
 * - A stage child is orphaned if its parent was deleted from stage, but still exists on live
39
 *
40
 * See {@link RemoveOrphanedPagesTaskTest} for an example sitetree
41
 * before and after orphan removal.
42
 *
43
 * @author Ingo Schommer (<firstname>@silverstripe.com), SilverStripe Ltd.
44
 *
45
 * @package cms
46
 * @subpackage tasks
47
 */
48
//class RemoveOrphanedPagesTask extends BuildTask {
49
class RemoveOrphanedPagesTask extends Controller {
50
51
	private static $allowed_actions = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
52
		'index' => 'ADMIN',
53
		'Form' => 'ADMIN',
54
		'run' => 'ADMIN',
55
		'handleAction' => 'ADMIN',
56
	);
57
58
	protected $title = 'Removed orphaned pages without existing parents from both stage and live';
59
60
	protected $description = "
61
<p>
62
Identify 'orphaned' pages which point to a parent
63
that no longer exists in a specific stage.
64
</p>
65
<p>
66
Caution: Pages also count as orphans if they don't
67
have parents in this stage, even if the parent has a representation
68
in the other stage:<br />
69
- A live child is orphaned if its parent was deleted from live, but still exists on stage<br />
70
- A stage child is orphaned if its parent was deleted from stage, but still exists on live
71
</p>
72
	";
73
74
	protected $orphanedSearchClass = 'SilverStripe\\CMS\\Model\\SiteTree';
75
76
	public function init() {
77
		parent::init();
78
79
		if(!Permission::check('ADMIN')) {
80
			return Security::permissionFailure($this);
81
		}
82
	}
83
84
	public function Link($action = null)
85
	{
86
		return Controller::join_links('RemoveOrphanedPagesTask', $action, '/');
87
	}
88
89
	public function index() {
90
		Requirements::javascript(FRAMEWORK_DIR . '/thirdparty/jquery/jquery.js');
91
		Requirements::customCSS('#OrphanIDs .middleColumn {width: auto;}');
92
		Requirements::customCSS('#OrphanIDs label {display: inline;}');
93
94
		return $this->renderWith('BlankPage');
95
	}
96
97
	public function Form() {
98
		$fields = new FieldList();
99
		$source = array();
100
101
		$fields->push(new HeaderField(
102
			'Header',
103
			_t('RemoveOrphanedPagesTask.HEADER', 'Remove all orphaned pages task')
104
		));
105
		$fields->push(new LiteralField(
106
			'Description',
107
			$this->description
108
		));
109
110
		$orphans = $this->getOrphanedPages($this->orphanedSearchClass);
111
		if($orphans) foreach($orphans as $orphan) {
112
			$latestVersion = Versioned::get_latest_version($this->orphanedSearchClass, $orphan->ID);
113
			$latestAuthor = DataObject::get_by_id('SilverStripe\\Security\\Member', $latestVersion->AuthorID);
114
			$orphanBaseTable = DataObject::getSchema()->baseDataTable($this->orphanedSearchClass);
115
			$liveRecord = Versioned::get_one_by_stage(
116
				$this->orphanedSearchClass,
117
				'Live',
118
				array("\"$orphanBaseTable\".\"ID\"" => $orphan->ID)
0 ignored issues
show
Documentation introduced by
array("\"{$orphanBaseTab...\"ID\"" => $orphan->ID) is of type array, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
119
			);
120
			$label = sprintf(
121
				'<a href="admin/pages/edit/show/%d">%s</a> <small>(#%d, Last Modified Date: %s, Last Modifier: %s, %s)</small>',
122
				$orphan->ID,
123
				$orphan->Title,
124
				$orphan->ID,
125
				$orphan->dbObject('LastEdited')->Nice(),
126
				($latestAuthor) ? $latestAuthor->Title : 'unknown',
127
				($liveRecord) ? 'is published' : 'not published'
128
			);
129
			$source[$orphan->ID] = $label;
130
		}
131
132
		if($orphans && $orphans->count()) {
133
			$fields->push(new CheckboxSetField('OrphanIDs', false, $source));
0 ignored issues
show
Documentation introduced by
false is of type boolean, but the function expects a string|null.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
134
			$fields->push(new LiteralField(
135
				'SelectAllLiteral',
136
				sprintf(
137
					'<p><a href="#" onclick="javascript:jQuery(\'#Form_Form_OrphanIDs :checkbox\').attr(\'checked\', \'checked\'); return false;">%s</a>&nbsp;',
138
					_t('RemoveOrphanedPagesTask.SELECTALL', 'select all')
139
				)
140
			));
141
			$fields->push(new LiteralField(
142
				'UnselectAllLiteral',
143
				sprintf(
144
					'<a href="#" onclick="javascript:jQuery(\'#Form_Form_OrphanIDs :checkbox\').attr(\'checked\', \'\'); return false;">%s</a></p>',
145
					_t('RemoveOrphanedPagesTask.UNSELECTALL', 'unselect all')
146
				)
147
			));
148
			$fields->push(new OptionsetField(
149
				'OrphanOperation',
150
				_t('RemoveOrphanedPagesTask.CHOOSEOPERATION', 'Choose operation:'),
151
				array(
152
					'rebase' => _t(
153
						'RemoveOrphanedPagesTask.OPERATION_REBASE',
154
						sprintf(
155
							'Rebase selected to a new holder page "%s" and unpublish. None of these pages will show up for website visitors.',
156
							$this->rebaseHolderTitle()
157
						)
158
					),
159
					'remove' => _t('RemoveOrphanedPagesTask.OPERATION_REMOVE', 'Remove selected from all stages (WARNING: Will destroy all selected pages from both stage and live)'),
160
				),
161
				'rebase'
162
			));
163
			$fields->push(new LiteralField(
164
				'Warning',
165
				sprintf('<p class="message">%s</p>',
166
					_t(
167
						'RemoveOrphanedPagesTask.DELETEWARNING',
168
						'Warning: These operations are not reversible. Please handle with care.'
169
					)
170
				)
171
			));
172
		} else {
173
			$fields->push(new LiteralField(
174
				'NotFoundLabel',
175
				sprintf(
176
					'<p class="message">%s</p>',
177
					_t('RemoveOrphanedPagesTask.NONEFOUND', 'No orphans found')
178
				)
179
			));
180
		}
181
182
		$form = new Form(
183
			$this,
184
			'Form',
185
			$fields,
186
			new FieldList(
187
				new FormAction('doSubmit', _t('RemoveOrphanedPagesTask.BUTTONRUN', 'Run'))
188
			)
189
		);
190
191
		if(!$orphans || !$orphans->count()) {
192
			$form->makeReadonly();
193
		}
194
195
		return $form;
196
	}
197
198
	public function run($request) {
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
199
		// @todo Merge with BuildTask functionality
200
	}
201
202
	public function doSubmit($data, $form) {
0 ignored issues
show
Unused Code introduced by
The parameter $form is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
203
		set_time_limit(60*10); // 10 minutes
204
205
		if(!isset($data['OrphanIDs']) || !isset($data['OrphanOperation'])) return false;
206
207
		$successIDs = null;
208
		switch($data['OrphanOperation']) {
209
			case 'remove':
210
				$successIDs = $this->removeOrphans($data['OrphanIDs']);
211
				break;
212
			case 'rebase':
213
				$successIDs = $this->rebaseOrphans($data['OrphanIDs']);
214
				break;
215
			default:
216
				user_error(sprintf("Unknown operation: '%s'", $data['OrphanOperation']), E_USER_ERROR);
217
		}
218
219
		$content = '';
220
		if($successIDs) {
221
			$content .= "<ul>";
222
			foreach($successIDs as $id => $label) {
223
				$content .= sprintf('<li>%s</li>', $label);
224
			}
225
			$content .= "</ul>";
226
		} else {
227
			$content = _t('RemoveOrphanedPagesTask.NONEREMOVED', 'None removed');
228
		}
229
230
		return $this->customise(array(
231
			'Content' => $content,
232
			'Form' => ' '
233
		))->renderWith('BlankPage');
234
	}
235
236
	protected function removeOrphans($orphanIDs) {
237
		$removedOrphans = array();
238
		$orphanBaseTable = DataObject::getSchema()->baseDataTable($this->orphanedSearchClass);
239
		foreach($orphanIDs as $id) {
240
			/** @var SiteTree $stageRecord */
241
			$stageRecord = Versioned::get_one_by_stage(
242
				$this->orphanedSearchClass,
243
				Versioned::DRAFT,
244
				array("\"$orphanBaseTable\".\"ID\"" => $id)
0 ignored issues
show
Documentation introduced by
array("\"{$orphanBaseTable}\".\"ID\"" => $id) is of type array, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
245
			);
246
			if($stageRecord) {
247
				$removedOrphans[$stageRecord->ID] = sprintf('Removed %s (#%d) from Stage', $stageRecord->Title, $stageRecord->ID);
248
				$stageRecord->delete();
249
				$stageRecord->destroy();
250
				unset($stageRecord);
251
			}
252
			/** @var SiteTree $liveRecord */
253
			$liveRecord = Versioned::get_one_by_stage(
254
				$this->orphanedSearchClass,
255
				Versioned::LIVE,
256
				array("\"$orphanBaseTable\".\"ID\"" => $id)
0 ignored issues
show
Documentation introduced by
array("\"{$orphanBaseTable}\".\"ID\"" => $id) is of type array, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
257
			);
258
			if($liveRecord) {
259
				$removedOrphans[$liveRecord->ID] = sprintf('Removed %s (#%d) from Live', $liveRecord->Title, $liveRecord->ID);
260
				$liveRecord->doUnpublish();
0 ignored issues
show
Documentation Bug introduced by
The method doUnpublish does not exist on object<SilverStripe\CMS\Model\SiteTree>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
261
				$liveRecord->destroy();
262
				unset($liveRecord);
263
			}
264
		}
265
266
		return $removedOrphans;
267
	}
268
269
	protected function rebaseHolderTitle() {
270
		return sprintf('Rebased Orphans (%s)', date('d/m/Y g:ia', time()));
271
	}
272
273
	protected function rebaseOrphans($orphanIDs) {
274
		$holder = new SiteTree();
275
		$holder->ShowInMenus = 0;
276
		$holder->ShowInSearch = 0;
277
		$holder->ParentID = 0;
0 ignored issues
show
Documentation introduced by
The property ParentID does not exist on object<SilverStripe\CMS\Model\SiteTree>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
278
		$holder->Title = $this->rebaseHolderTitle();
279
		$holder->write();
280
281
		$removedOrphans = array();
282
		$orphanBaseTable = DataObject::getSchema()->baseDataTable($this->orphanedSearchClass);
283
		foreach($orphanIDs as $id) {
284
			$stageRecord = Versioned::get_one_by_stage(
285
				$this->orphanedSearchClass,
286
				'Stage',
287
				array("\"$orphanBaseTable\".\"ID\"" => $id)
0 ignored issues
show
Documentation introduced by
array("\"{$orphanBaseTable}\".\"ID\"" => $id) is of type array, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
288
			);
289
			if($stageRecord) {
290
				$removedOrphans[$stageRecord->ID] = sprintf('Rebased %s (#%d)', $stageRecord->Title, $stageRecord->ID);
291
				$stageRecord->ParentID = $holder->ID;
292
				$stageRecord->ShowInMenus = 0;
293
				$stageRecord->ShowInSearch = 0;
294
				$stageRecord->write();
295
				$stageRecord->doUnpublish();
296
				$stageRecord->destroy();
297
				//unset($stageRecord);
298
			}
299
			$liveRecord = Versioned::get_one_by_stage(
300
				$this->orphanedSearchClass,
301
				'Live',
302
				array("\"$orphanBaseTable\".\"ID\"" => $id)
0 ignored issues
show
Documentation introduced by
array("\"{$orphanBaseTable}\".\"ID\"" => $id) is of type array, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
303
			);
304
			if($liveRecord) {
305
				$removedOrphans[$liveRecord->ID] = sprintf('Rebased %s (#%d)', $liveRecord->Title, $liveRecord->ID);
306
				$liveRecord->ParentID = $holder->ID;
307
				$liveRecord->ShowInMenus = 0;
308
				$liveRecord->ShowInSearch = 0;
309
				$liveRecord->write();
310
				if(!$stageRecord) $liveRecord->doRestoreToStage();
311
				$liveRecord->doUnpublish();
312
				$liveRecord->destroy();
313
				unset($liveRecord);
314
			}
315
			if($stageRecord) {
316
				unset($stageRecord);
317
			}
318
		}
319
320
		return $removedOrphans;
321
	}
322
323
	/**
324
	 * Gets all orphans from "Stage" and "Live" stages.
325
	 *
326
	 * @param string $class
327
	 * @param array $filter
328
	 * @param string $sort
329
	 * @param string $join
330
	 * @param int|array $limit
331
	 * @return SS_List
332
	 */
333
	public function getOrphanedPages($class = 'SilverStripe\\CMS\\Model\\SiteTree', $filter = array(), $sort = null, $join = null, $limit = null) {
0 ignored issues
show
Unused Code introduced by
The parameter $join is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
334
		// Alter condition
335
		$table = DataObject::getSchema()->tableName($class);
336
		if(empty($filter)) {
337
			$where = array();
338
		} elseif(is_array($filter)) {
339
			$where = $filter;
340
		} else {
341
			$where = array($filter);
342
		}
343
		$where[] = array("\"{$table}\".\"ParentID\" != ?" => 0);
344
		$where[] = '"Parents"."ID" IS NULL';
345
346
		$orphans = new ArrayList();
347
		foreach(array(Versioned::DRAFT, Versioned::LIVE) as $stage) {
348
			$table .= ($stage == Versioned::LIVE) ? '_Live' : '';
349
			$stageOrphans = Versioned::get_by_stage(
350
				$class,
351
				$stage,
352
				$where,
0 ignored issues
show
Documentation introduced by
$where is of type array<integer,array<stri...nteger,integer>|string>, but the function expects a string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
353
				$sort,
354
				null,
355
				$limit
0 ignored issues
show
Bug introduced by
It seems like $limit defined by parameter $limit on line 333 can also be of type array; however, SilverStripe\ORM\Version...rsioned::get_by_stage() does only seem to accept integer|null, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
356
			)->leftJoin($table, "\"$table\".\"ParentID\" = \"Parents\".\"ID\"", "Parents");
357
			$orphans->merge($stageOrphans);
358
		}
359
360
		$orphans->removeDuplicates();
361
362
		return $orphans;
363
	}
364
}
365