Subversion Repositories web.active

Rev

Rev 22 | Details | Compare with Previous | Last modification | View Log

Rev Author Line No. Line
1 mjordaan 1
<?php namespace ProcessWire;
2
 
3
/**
4
 * ProcessWire Pages Editor
5
 * 
6
 * Implements page manipulation methods of the $pages API variable
7
 *
22 mjordaan 8
 * ProcessWire 3.x, Copyright 2021 by Ryan Cramer
1 mjordaan 9
 * https://processwire.com
10
 * 
11
 */ 
12
 
13
class PagesEditor extends Wire {
14
 
15
	/**
16
	 * Are we currently cloning a page?
17
	 *
18
	 * This is greater than 0 only when the clone() method is currently in progress.
19
	 *
20
	 * @var int
21
	 *
22
	 */
23
	protected $cloning = 0;
24
 
25
	/**
26
	 * @var Pages
27
	 * 
28
	 */
29
	protected $pages;
30
 
31
	/**
32
	 * Construct
33
	 * 
34
	 * @param Pages $pages
35
	 *
36
	 */
37
	public function __construct(Pages $pages) {
45 mjordaan 38
		parent::__construct();
1 mjordaan 39
		$this->pages = $pages;
40
 
22 mjordaan 41
		$config = $pages->wire()->config;
45 mjordaan 42
		if($config->dbStripMB4 && strtolower($config->dbCharset) != 'utf8mb4') {
1 mjordaan 43
			$this->addHookAfter('Fieldtype::sleepValue', $this, 'hookFieldtypeSleepValueStripMB4');
44
		}
45
	}
46
 
47
	/**
48
	 * Are we currently in a page clone?
49
	 * 
50
	 * @param bool $getDepth Get depth (int) rather than state (bool)?
51
	 * @return bool|int
52
	 * 
53
	 */
54
	public function isCloning($getDepth = false) {
55
		return $getDepth ? $this->cloning : $this->cloning > 0;
56
	}
57
 
58
	/**
59
	 * Add a new page using the given template to the given parent
60
	 *
61
	 * If no name is specified one will be assigned based on the current timestamp.
62
	 *
63
	 * @param string|Template $template Template name or Template object
64
	 * @param string|int|Page $parent Parent path, ID or Page object
65
	 * @param string $name Optional name or title of page. If none provided, one will be automatically assigned based on microtime stamp.
66
	 * 	If you want to specify a different name and title then specify the $name argument, and $values['title'].
67
	 * @param array $values Field values to assign to page (optional). If $name is omitted, this may also be 3rd param.
68
	 * @return Page Returned page has output formatting off.
69
	 * @throws WireException When some criteria prevents the page from being saved.
70
	 *
71
	 */
72
	public function add($template, $parent, $name = '', array $values = array()) {
73
 
74
		// the $values may optionally be the 3rd argument
75
		if(is_array($name)) {
76
			$values = $name;
77
			$name = isset($values['name']) ? $values['name'] : '';
78
		}
79
 
80
		if(!is_object($template)) {
22 mjordaan 81
			$template = $this->wire()->templates->get($template);
1 mjordaan 82
			if(!$template) throw new WireException("Unknown template");
83
		}
84
 
22 mjordaan 85
		$options = array('template' => $template, 'parent' => $parent);
86
		if(isset($values['pageClass'])) {
87
			$options['pageClass'] = $values['pageClass'];
88
			unset($values['pageClass']);
89
		}
90
		$page = $this->pages->newPage($options); 
1 mjordaan 91
 
92
		$exceptionMessage = "Unable to add new page using template '$template' and parent '{$page->parent->path}'.";
93
 
94
		if(empty($values['title'])) {
95
			// no title provided in $values, so we assume $name is $title
96
			// but if no name is provided, then we default to: Untitled Page
97
			if(!strlen($name)) $name = $this->_('Untitled Page');
98
			// the setupNew method will convert $page->title to a unique $page->name
99
			$page->title = $name;
100
 
101
		} else {
102
			// title was provided
103
			$page->title = $values['title'];
104
			// if name is provided we use it
105
			// otherwise setupNew will take care of assign it from title
106
			if(strlen($name)) $page->name = $name;
107
			unset($values['title']);
108
		}
109
 
22 mjordaan 110
		if(isset($values['status'])) {
111
			$page->status = $values['status'];
112
			unset($values['status']);
113
		}
114
 
1 mjordaan 115
		// save page before setting $values just in case any fieldtypes
116
		// require the page to have an ID already (like file-based)
117
		if(!$this->pages->save($page)) throw new WireException($exceptionMessage);
118
 
119
		// set field values, if provided
120
		if(!empty($values)) {
121
			unset($values['id'], $values['parent'], $values['template']); // fields that may not be set from this array
122
			foreach($values as $key => $value) $page->set($key, $value);
123
			$this->pages->save($page);
124
		}
22 mjordaan 125
 
126
		// get a fresh copy of the page
127
		if($page->id) {
128
			$inserted = $page->_inserted;
129
			$of = $this->pages->outputFormatting;
130
			if($of) $this->pages->setOutputFormatting(false);
131
			$p = $this->pages->getById($page->id, $template, $page->parent_id);
132
			if($p->id) $page = $p;
133
			if($of) $this->pages->setOutputFormatting(true);
134
			$page->setQuietly('_inserted', $inserted);
135
		}
1 mjordaan 136
 
137
		return $page;
138
	}
139
 
140
	/**
141
	 * Is the given page in a state where it can be saved from the API?
142
	 *
143
	 * @param Page $page
144
	 * @param string $reason Text containing the reason why it can't be saved (assuming it's not saveable)
145
	 * @param string|Field $fieldName Optional fieldname to limit check to.
146
	 * @param array $options Options array given to the original save method (optional)
147
	 * @return bool True if saveable, False if not
148
	 *
149
	 */
150
	public function isSaveable(Page $page, &$reason, $fieldName = '', array $options = array()) {
151
 
152
		$saveable = false;
153
		$outputFormattingReason = "Call \$page->of(false); before getting/setting values that will be modified and saved.";
154
		$corrupted = array();
155
 
156
		if($fieldName && is_object($fieldName)) {
157
			/** @var Field $fieldName */
158
			$fieldName = $fieldName->name;
159
			/** @var string $fieldName */
160
		}
161
 
162
		if($page->hasStatus(Page::statusCorrupted)) {
163
			$corruptedFields = $page->_statusCorruptedFields;
164
			foreach($page->getChanges() as $change) {
165
				if(isset($corruptedFields[$change])) $corrupted[] = $change;
166
			}
167
			// if focused on a specific field... 
168
			if($fieldName && !in_array($fieldName, $corrupted)) $corrupted = array();
169
		}
170
 
171
		if($page instanceof NullPage) {
172
			$reason = "Pages of type NullPage are not saveable";
173
		} else if(!$page->parent_id && $page->id !== 1 && (!$page->parent || $page->parent instanceof NullPage)) {
174
			$reason = "It has no parent assigned";
175
		} else if(!$page->template) {
176
			$reason = "It has no template assigned";
177
		} else if(!strlen(trim($page->name)) && $page->id != 1) {
178
			$reason = "It has an empty 'name' field";
179
		} else if(count($corrupted)) {
180
			$reason = $outputFormattingReason . " [Page::statusCorrupted] fields: " . implode(', ', $corrupted);
181
		} else if($page->id == 1 && !$page->template->useRoles) {
182
			$reason = "Selected homepage template cannot be used because it does not define access.";
183
		} else if($page->id == 1 && !$page->template->hasRole('guest')) {
184
			$reason = "Selected homepage template cannot be used because it does not have required 'guest' role in its access settings.";
185
		} else {
186
			$saveable = true;
187
		}
188
 
189
		// check if they could corrupt a field by saving
190
		if($saveable && $page->outputFormatting) {
191
			// iternate through recorded changes to see if any custom fields involved
192
			foreach($page->getChanges() as $change) {
193
				if($fieldName && $change != $fieldName) continue;
194
				if($page->template->fieldgroup->getField($change) !== null) {
195
					$reason = $outputFormattingReason . " [$change]";
196
					$saveable = false;
197
					break;
198
				}
199
			}
200
			// iterate through already-loaded data to see if any are objects that have changed
201
			if($saveable) foreach($page->getArray() as $key => $value) {
202
				if($fieldName && $key != $fieldName) continue;
203
				if(!$page->template->fieldgroup->getField($key)) continue;
45 mjordaan 204
				if($value instanceof Wire && $value->isChanged()) {
1 mjordaan 205
					$reason = $outputFormattingReason . " [$key]";
206
					$saveable = false;
207
					break;
208
				}
209
			}
210
		}
211
 
212
		// check for a parent change and whether it is allowed
22 mjordaan 213
		if($saveable && $page->id && $page->parentPrevious && empty($options['ignoreFamily'])) {
1 mjordaan 214
			// parent has changed, check that the move is allowed
215
			$saveable = $this->isMoveable($page, $page->parentPrevious, $page->parent, $reason); 
216
		}
217
 
218
		return $saveable;
219
	}
220
 
221
	/**
222
	 * Return whether given Page is moveable from $oldParent to $newParent
223
	 * 
224
	 * @param Page $page Page to move
225
	 * @param Page $oldParent Current/old parent page
226
	 * @param Page $newParent New requested parent page
227
	 * @param string $reason Populated with reason why page is not moveable, if return false is false. 
228
	 * @return bool
229
	 * 
230
	 */
231
	public function isMoveable(Page $page, Page $oldParent, Page $newParent, &$reason) {
232
 
233
		if($oldParent->id == $newParent->id) return true; 
234
 
22 mjordaan 235
		$config = $this->wire()->config;
1 mjordaan 236
		$moveable = false;
237
		$isSystem = $page->hasStatus(Page::statusSystem) || $page->hasStatus(Page::statusSystemID);
238
		$toTrash = $newParent->id > 0 && $newParent->isTrash();
239
		$wasTrash = $oldParent->id > 0 && $oldParent->isTrash();
240
 
241
		// page was moved
242
		if($page->template->noMove && ($isSystem || (!$toTrash && !$wasTrash))) {
243
			// make sure the page template allows moves.
244
			// only move always allowed is to the trash (or out of it), unless page has system status
245
			$reason = 
246
				sprintf($this->_('Page using template “%s” is not moveable.'), $page->template->name) . ' ' . 
247
				"(Template::noMove) [{$oldParent->path} => {$newParent->path}]";
248
 
249
		} else if($newParent->template->noChildren) {
250
			// check if new parent disallows children
251
			$reason = sprintf(
252
				$this->_('Chosen parent “%1$s” uses template “%2$s” that does not allow children.'), 
253
				$newParent->path, 
254
				$newParent->template->name
255
			);
256
 
257
		} else if($newParent->id && $newParent->id != $config->trashPageID && count($newParent->template->childTemplates)
258
			&& !in_array($page->template->id, $newParent->template->childTemplates)) {
259
			// make sure the new parent's template allows pages with this template
260
			$reason = sprintf(
261
				$this->_('Cannot move “%1$s” because template “%2$s” used by page “%3$s” does not allow children using template “%4$s”.'), 
262
				$page->name, 
263
				$newParent->template->name, 
264
				$newParent->path,
265
				$page->template->name
266
			);
267
 
268
		} else if(count($page->template->parentTemplates) && $newParent->id != $config->trashPageID
269
			&& !in_array($newParent->template->id, $page->template->parentTemplates)) {
270
			// check for allowed parentTemplates setting
271
			$reason = sprintf(
272
				$this->_('Cannot move “%1$s” because template “%2$s” used by new parent “%3$s” is not allowed by moved page template “%4$s”.'),
273
				$page->name, 
274
				$newParent->template->name, 
275
				$newParent->path, 
276
				$page->template->name
277
			);
278
 
279
		} else if(count($newParent->children("name=$page->name, id!=$page->id, include=all"))) {
280
			// check for page name collision
281
			$reason = sprintf(
282
				$this->_('Chosen parent “%1$s” already has a page named “%2$s”.'),
283
				$newParent->path,
284
				$page->name
285
			);
286
 
287
		} else {
288
			$moveable = true;
289
		}
290
 
291
		return $moveable;
292
	}
293
 
294
	/**
295
	 * Is the given page deleteable from the API?
296
	 *
297
	 * Note: this does not account for user permission checking. It only checks if the page is in a state to be saveable via the API.
298
	 *
299
	 * @param Page $page
300
	 * @param bool $throw Throw WireException with additional details? 
301
	 * @return bool True if deleteable, False if not
302
	 * @throws WireException If requested to do so via $throw argument
303
	 *
304
	 */
305
	public function isDeleteable(Page $page, $throw = false) {
306
 
307
		$error = false;
308
 
309
		if($page instanceof NullPage) {
310
			$error = "it is a NullPage";
311
		} else if(!$page->id) {
312
			$error = "it has no id";
313
		} else if($page->hasStatus(Page::statusSystemID) || $page->hasStatus(Page::statusSystem)) {
314
			$error = "it has “system” and/or “systemID” status";
315
		} else if($page->hasStatus(Page::statusLocked)) {
316
			$error = "it has “locked” status";
22 mjordaan 317
		} else if($page->id === $this->wire()->page->id && $this->wire()->config->installedAfter('2019-04-04')) {
1 mjordaan 318
			$error = "it is the current page being viewed, try \$pages->trash() instead";
319
		}
320
 
321
		if($error === false) return true;
322
		if($throw) throw new WireException("Page $page->path ($page->id) cannot be deleted: $error"); 
323
 
324
		return false;
325
	}
326
 
327
	/**
328
	 * Auto-populate some fields for a new page that does not yet exist
329
	 *
330
	 * Currently it does this:
331
	 * 
332
	 * - Assigns a parent if one is not already assigned.
333
	 * - Sets up a unique page->name based on the format or title if one isn't provided already.
334
	 * - Assigns a sort value.
335
	 * - Populates any default values for fields. 
336
	 *
337
	 * @param Page $page
338
	 * @throws \Exception|WireException|\PDOException if failure occurs while in DB transaction
339
	 *
340
	 */
341
	public function setupNew(Page $page) {
342
 
343
		$parent = $page->parent();
344
 
345
		//  assign parent
346
		if(!$parent->id) {
347
			$parentTemplates = $page->template->parentTemplates;
348
			$parent = null;
349
 
350
			if(!empty($parentTemplates)) {
351
				$idStr = implode('|', $parentTemplates);
352
				$parent = $this->pages->get("include=hidden, template=$idStr");
353
				if(!$parent->id) $parent = $this->pages->get("include=all, template=$idStr");
354
			}
355
 
356
			if($parent->id) $page->parent = $parent;
357
		}
358
 
359
		// assign page name
360
		if(!strlen($page->name)) {
361
			$this->pages->setupPageName($page); // call through $pages intended, so it can be hooked
362
		}
363
 
364
		// assign sort order
365
		if($page->sort < 0) {
45 mjordaan 366
			$page->sort = ($parent->id ? $parent->numChildren() : 0);
1 mjordaan 367
		}
368
 
369
		// assign any default values for fields
370
		foreach($page->template->fieldgroup as $field) {
45 mjordaan 371
			/** @var Field $field */
1 mjordaan 372
			if($page->isLoaded($field->name)) continue; // value already set
373
			if(!$page->hasField($field)) continue; // field not valid for page
45 mjordaan 374
			if(!strlen((string) $field->get('defaultValue'))) continue; // no defaultValue property defined with Fieldtype config inputfields
1 mjordaan 375
			try {
376
				$blankValue = $field->type->getBlankValue($page, $field);
377
				if(is_object($blankValue) || is_array($blankValue)) continue; // we don't currently handle complex types
378
				$defaultValue = $field->type->getDefaultValue($page, $field);
379
				if(is_object($defaultValue) || is_array($defaultValue)) continue; // we don't currently handle complex types
380
				if("$blankValue" !== "$defaultValue") {
381
					$page->set($field->name, $defaultValue);
382
				}
383
			} catch(\Exception $e) {
384
				$this->trackException($e, false, true);
22 mjordaan 385
				if($this->wire()->database->inTransaction()) throw $e;
1 mjordaan 386
			}
387
		}
388
	}
389
 
390
	/**
391
	 * Auto-assign a page name to this page
392
	 *
393
	 * Typically this would be used only if page had no name or if it had a temporary untitled name.
394
	 *
395
	 * Page will be populated with the name given. This method will not populate names to pages that
396
	 * already have a name, unless the name is "untitled"
397
	 *
398
	 * @param Page $page
399
	 * @param array $options
400
	 * 	- format: Optionally specify the format to use, or leave blank to auto-determine.
401
	 * @return string If a name was generated it is returned. If no name was generated blank is returned.
402
	 *
403
	 */
404
	public function setupPageName(Page $page, array $options = array()) {
405
		return $this->pages->names()->setupNewPageName($page, isset($options['format']) ? $options['format'] : '');
406
	}
407
 
408
	/**
409
	 * Save a page object and it's fields to database.
410
	 *
411
	 * If the page is new, it will be inserted. If existing, it will be updated.
412
	 *
413
	 * This is the same as calling $page->save()
414
	 *
415
	 * If you want to just save a particular field in a Page, use $page->save($fieldName) instead.
416
	 *
417
	 * @param Page $page
418
	 * @param array $options Optional array with the following optional elements:
419
	 * 	- `uncacheAll` (boolean): Whether the memory cache should be cleared (default=true)
420
	 * 	- `resetTrackChanges` (boolean): Whether the page's change tracking should be reset (default=true)
421
	 * 	- `quiet` (boolean): When true, created/modified time+user will use values from $page rather than current user+time (default=false)
45 mjordaan 422
	 *	- `adjustName` (boolean): Adjust page name to ensure it is unique within its parent (default=true)
1 mjordaan 423
	 * 	- `forceID` (integer): Use this ID instead of an auto-assigned on (new page) or current ID (existing page)
424
	 * 	- `ignoreFamily` (boolean): Bypass check of allowed family/parent settings when saving (default=false)
425
	 *  - `noHooks` (boolean): Prevent before/after save hooks from being called (default=false)
426
	 *  - `noFields` (boolean): Bypass saving of custom fields (default=false)
427
	 * @return bool True on success, false on failure
428
	 * @throws WireException
429
	 *
430
	 */
431
	public function save(Page $page, $options = array()) {
432
 
433
		$defaultOptions = array(
434
			'uncacheAll' => true,
435
			'resetTrackChanges' => true,
45 mjordaan 436
			'adjustName' => true,
1 mjordaan 437
			'forceID' => 0,
438
			'ignoreFamily' => false,
439
			'noHooks' => false, 
440
			'noFields' => false, 
441
		);
442
 
443
		if(is_string($options)) $options = Selectors::keyValueStringToArray($options);
444
		$options = array_merge($defaultOptions, $options);
22 mjordaan 445
		$user = $this->wire()->user;
446
		$languages = $this->wire()->languages;
1 mjordaan 447
		$language = null;
448
 
449
		// if language support active, switch to default language so that saved fields and hooks don't need to be aware of language
45 mjordaan 450
		if($languages && $page->id != $user->id && "$user->language") {
451
			$language = $user->language;
452
			$user->setLanguage($languages->getDefault());
1 mjordaan 453
		}
454
 
455
		$reason = '';
456
		$isNew = $page->isNew();
457
		if($isNew) $this->pages->setupNew($page);
458
 
459
		if(!$this->isSaveable($page, $reason, '', $options)) {
45 mjordaan 460
			if($language) $user->setLanguage($language);
461
			throw new WireException(rtrim("Can’t save page (id=$page->id): $page->path", ": ") . ": $reason");
1 mjordaan 462
		}
463
 
464
		if($page->hasStatus(Page::statusUnpublished) && $page->template->noUnpublish) {
465
			$page->removeStatus(Page::statusUnpublished);
466
		}
467
 
468
		if($page->parentPrevious && !$isNew) {
469
			if($page->isTrash() && !$page->parentPrevious->isTrash()) {
470
				$this->pages->trash($page, false);
471
			} else if($page->parentPrevious->isTrash() && !$page->parent->isTrash()) {
472
				$this->pages->restore($page, false);
473
			}
474
		}
475
 
45 mjordaan 476
		if($options['adjustName']) $this->pages->names()->checkNameConflicts($page);
1 mjordaan 477
		if(!$this->savePageQuery($page, $options)) return false;
478
		$result = $this->savePageFinish($page, $isNew, $options);
45 mjordaan 479
		if($language) $user->setLanguage($language); // restore language
1 mjordaan 480
 
481
		return $result;
482
	}
483
 
484
	/**
485
	 * Execute query to save to pages table
486
	 *
487
	 * triggers hooks: saveReady, statusChangeReady (when status changed)
488
	 *
489
	 * @param Page $page
490
	 * @param array $options
491
	 * @return bool
492
	 * @throws WireException|\Exception
493
	 *
494
	 */
495
	protected function savePageQuery(Page $page, array $options) {
496
 
497
		$isNew = $page->isNew();
22 mjordaan 498
		$database = $this->wire()->database;
499
		$sanitizer = $this->wire()->sanitizer;
500
		$config = $this->wire()->config;
501
		$user = $this->wire()->user;
1 mjordaan 502
		$userID = $user ? $user->id : $config->superUserPageID;
503
		$systemVersion = $config->systemVersion;
22 mjordaan 504
		$sql = '';
505
 
1 mjordaan 506
		if(!$page->created_users_id) $page->created_users_id = $userID;
22 mjordaan 507
 
508
		if($page->isChanged('status') && empty($options['noHooks'])) {
509
			$this->pages->statusChangeReady($page);
510
		}
511
 
1 mjordaan 512
		if(empty($options['noHooks'])) {
513
			$extraData = $this->pages->saveReady($page); 
514
			$this->pages->savePageOrFieldReady($page);
515
		} else {
516
			$extraData = array();
517
		}
518
 
519
		if($this->pages->names()->isUntitledPageName($page->name)) {
520
			$this->pages->setupPageName($page);
521
		}
522
 
523
		$data = array(
524
			'parent_id' => (int) $page->parent_id,
525
			'templates_id' => (int) $page->template->id,
22 mjordaan 526
			'name' => $sanitizer->pageName($page->name, Sanitizer::toAscii),
1 mjordaan 527
			'status' => (int) $page->status,
528
			'sort' =>  ($page->sort > -1 ? (int) $page->sort : 0)
529
		);
530
 
531
		if(is_array($extraData)) foreach($extraData as $column => $value) {
532
			$column = $database->escapeCol($column);
533
			$data[$column] = (strtoupper($value) === 'NULL' ? NULL : $value);
534
		}
535
 
536
		if($isNew) {
537
			if($page->id) $data['id'] = (int) $page->id;
538
			$data['created_users_id'] = (int) $userID;
539
		}
540
 
541
		if($options['forceID']) $data['id'] = (int) $options['forceID'];
542
 
543
		if($page->template->allowChangeUser) {
544
			$data['created_users_id'] = (int) $page->created_users_id;
545
		}
546
 
547
		if(empty($options['quiet'])) {
548
			$sql = 'modified=NOW()';
549
			$data['modified_users_id'] = (int) $userID;
550
		} else {
551
			// quiet option, use existing values already populated to page, when present
552
			$data['modified_users_id'] = (int) ($page->modified_users_id ? $page->modified_users_id : $userID);
553
			$data['created_users_id'] = (int) ($page->created_users_id ? $page->created_users_id : $userID);
554
			if($page->modified > 0) {
555
				$data['modified'] = date('Y-m-d H:i:s', $page->modified);
556
			} else if($isNew) {
557
				$sql = 'modified=NOW()';
558
			}
559
			if($page->created > 0) {
560
				$data['created'] = date('Y-m-d H:i:s', $page->created);
561
			}
562
		}
563
 
45 mjordaan 564
		$page->modified_users_id = $data['modified_users_id'];
1 mjordaan 565
		if(isset($data['created_users_id'])) $page->created_users_id = $data['created_users_id'];
566
 
567
		if(!$page->isUnpublished() && ($isNew || ($page->statusPrevious && ($page->statusPrevious & Page::statusUnpublished)))) {
568
			// page is being published
569
			if($systemVersion >= 12) {
570
				$sql .= ($sql ? ', ' : '') . 'published=NOW()';
571
			}
572
		}
573
 
574
		foreach($data as $column => $value) {
575
			$sql .= ", $column=" . (is_null($value) ? "NULL" : ":$column");
576
		}
577
 
578
		$sql = trim($sql, ", ");
579
 
580
		if($isNew) { 
581
			if(empty($data['created'])) $sql .= ', created=NOW()';
582
			$query = $database->prepare("INSERT INTO pages SET $sql");
583
		}  else {
584
			$query = $database->prepare("UPDATE pages SET $sql WHERE id=:page_id");
585
			$query->bindValue(":page_id", (int) $page->id, \PDO::PARAM_INT);
586
		}
587
 
588
		foreach($data as $column => $value) {
589
			if(is_null($value)) continue; // already bound above
590
			$query->bindValue(":$column", $value, is_int($value) ? \PDO::PARAM_INT : \PDO::PARAM_STR);
591
		}
592
 
593
		$tries = 0;
594
		$maxTries = 100;
595
 
596
		do {
597
			$result = false;
598
			$keepTrying = false;
599
			try {
600
				$result = $database->execute($query);
601
			} catch(\Exception $e) {
602
				$keepTrying = $this->savePageQueryException($page, $query, $e, $options);
603
				if(!$keepTrying) throw $e;
604
			}
605
		} while($keepTrying && (++$tries < $maxTries));
606
 
22 mjordaan 607
		if($result && ($isNew || !$page->id)) {
608
			$page->id = (int) $database->lastInsertId();
609
			$page->setQuietly('_inserted', time());
610
		}
611
 
1 mjordaan 612
		if($options['forceID']) $page->id = (int) $options['forceID'];
613
 
614
		return $result;
615
	}
616
 
617
	/**
618
	 * Handle Exception for savePageQuery()
619
	 * 
620
	 * While setupNew() already attempts to uniqify a page name with an incrementing
621
	 * number, there is a chance that two processes running at once might end up with
622
	 * the same number, so we account for the possibility here by re-trying queries
623
	 * that trigger duplicate-entry exceptions.
624
	 * 
625
	 * Example of actual exception text, for reference:
626
	 * Integrity constraint violation: 1062 Duplicate entry 'background-3552' for key 'name3894_parent_id'
627
	 * 
628
	 * @param Page $page
629
	 * @param \PDOStatement $query
630
	 * @param \PDOException|\Exception $exception
631
	 * @param array $options
632
	 * @return bool True if it should give $query another shot, false if not
633
	 * 
634
	 */
635
	protected function savePageQueryException(Page $page, $query, $exception, array $options) {
636
 
637
		$errorCode = $exception->getCode();
638
 
639
		// 23000=integrity constraint violation, duplicate entry
640
		if($errorCode != 23000) return false; 
641
 
642
		if(!$this->pages->names()->hasAutogenName($page) && !$options['adjustName']) return false;
22 mjordaan 643
 
644
		$languages = $this->wire()->languages;
645
		$sanitizer = $this->wire()->sanitizer;
1 mjordaan 646
 
647
		// account for the duplicate possibly being a multi-language name field
648
		// i.e. “Duplicate entry 'bienvenido-2-1001' for key 'name1013_parent_id'”
22 mjordaan 649
		if($languages && preg_match('/\b(name\d*)_parent_id\b/', $exception->getMessage(), $matches)) {
1 mjordaan 650
			$nameField = $matches[1];
651
		} else {
652
			$nameField = 'name';
653
		}
654
 
655
		// get either 'name' or 'name123' (where 123 is language ID)
656
		$pageName = $page->get($nameField);
657
		$pageName = $this->pages->names()->incrementName($pageName);
658
		$page->set($nameField, $pageName);
22 mjordaan 659
		$query->bindValue(":$nameField", $sanitizer->pageName($pageName, Sanitizer::toAscii));
1 mjordaan 660
 
661
		// indicate that page has a modified name 
662
		$this->pages->names()->hasAdjustedName($page, true);
663
 
664
		return true;
665
	}
666
 
667
	/**
668
	 * Save individual Page fields and supporting actions
669
	 *
670
	 * triggers hooks: saved, added, moved, renamed, templateChanged
671
	 *
672
	 * @param Page $page
673
	 * @param bool $isNew
674
	 * @param array $options
675
	 * @return bool
676
	 * @throws \Exception|WireException|\PDOException If any field-saving failure occurs while in a DB transaction
677
	 *
678
	 */
679
	protected function savePageFinish(Page $page, $isNew, array $options) {
680
 
681
		$changes = $page->getChanges(2);
682
		$changesValues = $page->getChanges(true);
683
 
684
		// update children counts for current/previous parent
685
		if($isNew) {
686
			// new page
687
			$page->parent->numChildren++;
688
 
689
		} else if($page->parentPrevious && $page->parentPrevious->id != $page->parent->id) {
690
			// parent changed
691
			$page->parentPrevious->numChildren--;
692
			$page->parent->numChildren++;
693
		}
694
 
695
		// save any needed updates to pages_parents table
696
		$this->pages->parents()->save($page);
697
 
698
		// if page hasn't changed, don't continue further
699
		if(!$page->isChanged() && !$isNew) {
700
			$this->pages->debugLog('save', '[not-changed]', true);
701
			if(empty($options['noHooks'])) {
702
				$this->pages->saved($page, array());
703
				$this->pages->savedPageOrField($page, array());
704
			}
705
			return true;
706
		}
707
 
708
		// if page has a files path (or might have previously), trigger filesManager's save
709
		if(PagefilesManager::hasPath($page)) $page->filesManager->save();
710
 
711
		// disable outputFormatting and save state
712
		$of = $page->of();
713
		$page->of(false);
714
 
715
		// when a page is statusCorrupted, it records what fields are corrupted in _statusCorruptedFields array
716
		$corruptedFields = $page->hasStatus(Page::statusCorrupted) ? $page->_statusCorruptedFields : array();
717
 
718
		// save each individual Fieldtype data in the fields_* tables
719
		foreach($page->fieldgroup as $field) {
45 mjordaan 720
			/** @var Field $field */
22 mjordaan 721
			$fieldtype = $field->type;
1 mjordaan 722
			$name = $field->name;
22 mjordaan 723
			if($options['noFields'] || isset($corruptedFields[$name]) || !$fieldtype || !$page->hasField($field)) {
1 mjordaan 724
				unset($changes[$name]);
725
				unset($changesValues[$name]); 
726
			} else {
727
				try {
22 mjordaan 728
					$fieldtype->savePageField($page, $field);
1 mjordaan 729
				} catch(\Exception $e) {
730
					$label = $field->getLabel();
731
					$message = $e->getMessage();
732
					if(strpos($message, $label) !== false) $label = $name;
733
					$error = sprintf($this->_('Error saving field "%s"'), $label) . ' — ' . $message;
734
					$this->trackException($e, true, $error);
22 mjordaan 735
					if($this->wire()->database->inTransaction()) throw $e;
1 mjordaan 736
				}
737
			}
738
		}
739
 
740
		// return outputFormatting state
741
		$page->of($of);
742
 
22 mjordaan 743
		// sortfield for children
744
		$templateSortfield = $page->template->sortfield;
745
		if(empty($templateSortfield)) $this->pages->sortfields()->save($page);
1 mjordaan 746
 
747
		if($options['resetTrackChanges']) {
748
			if($options['noFields']) {
749
				// reset for only fields that were saved
750
				foreach($changes as $change) $page->untrackChange($change);
751
				$page->setTrackChanges(true);
752
			} else {
753
				// reset all changes
754
				$page->resetTrackChanges();
755
			}
756
		}
757
 
758
		// determine whether we'll trigger the added() hook
759
		if($isNew) {
760
			$page->setIsNew(false);
761
			$triggerAddedPage = $page;
762
		} else {
763
			$triggerAddedPage = null;
764
		}
765
 
766
		// check for template changes
767
		if($page->templatePrevious && $page->templatePrevious->id != $page->template->id) {
768
			// the template was changed, so we may have data in the DB that is no longer applicable
769
			// find unused data and delete it
770
			foreach($page->templatePrevious->fieldgroup as $field) {
771
				if($page->hasField($field)) continue;
772
				$field->type->deletePageField($page, $field);
773
				$this->message("Deleted field '$field' on page {$page->url}", Notice::debug);
774
			}
775
		}
776
 
777
		if($options['uncacheAll']) $this->pages->uncacheAll($page);
778
 
779
		// determine whether the pages_access table needs to be updated so that pages->find()
780
		// operations can be access controlled. 
781
		if($isNew || $page->parentPrevious || $page->templatePrevious) $this->wire(new PagesAccess($page));
782
 
783
		// trigger hooks
784
		if(empty($options['noHooks'])) {
785
			$this->pages->saved($page, $changes, $changesValues);
786
			$this->pages->savedPageOrField($page, $changes);
787
			if($triggerAddedPage) $this->pages->added($triggerAddedPage);
788
			if($page->namePrevious && $page->namePrevious != $page->name) $this->pages->renamed($page);
789
			if($page->parentPrevious) $this->pages->moved($page);
790
			if($page->templatePrevious) $this->pages->templateChanged($page);
791
			if(in_array('status', $changes)) $this->pages->statusChanged($page);
792
		}
793
 
794
		$this->pages->debugLog('save', $page, true);
795
 
796
		return true;
797
	}
798
 
799
	/**
800
	 * TBD Identify if parent changed and call saveParentsTable() where appropriate
801
	 *
802
	 * @param Page $page Page to save parent(s) for
803
	 * @param bool $isNew If page is newly created during this save this should be true, otherwise false
804
	 *
805
	protected function savePageParent(Page $page, $isNew) {
806
 
807
		if($page->parentPrevious || $page->_forceSaveParents || $isNew) {
808
			$this->pages->parents()->rebuild($page);
809
		}
810
 
811
		// saveParentsTable option is always true unless manually disabled from a hook
812
		if($page->parentPrevious && !$isNew && $page->numChildren > 0) {
813
			// existing page was moved and it has children
814
			if($page->parent->numChildren == 1) {
815
				// first child of new parent
816
				$this->pages->parents()->rebuildPage($page->parent);
817
			} else {
818
				$this->pages->parents()->rebuildPage($page);
819
			}
820
 
821
		} else if(($page->parentPrevious && $page->parent->numChildren == 1) ||
822
			($isNew && $page->parent->numChildren == 1) ||
823
			($page->_forceSaveParents)) {
824
			// page is moved and is the first child of its new parent
825
			// OR page is NEW and is the first child of its parent
826
			// OR $page->_forceSaveParents is set (debug/debug, can be removed later)
827
			$this->pages->parents()->rebuildPage($page->parent);
828
 
829
		} else if($page->parentPrevious && $page->parent->numChildren > 1 && $page->parent->parent_id > 1) {
830
			$this->pages->parents()->rebuildPage($page->parent->parent);
831
		}
832
 
833
		if($page->parentPrevious && $page->parentPrevious->numChildren == 0) {
834
			// $page was moved and its previous parent is now left with no children, this ensures the old entries get deleted
835
			$this->pages->parents()->rebuild($page->parentPrevious->id);
836
		}
837
	}
838
	 */
839
 
840
	/**
841
	 * Save just a field from the given page as used by Page::save($field)
842
	 *
843
	 * This function is public, but the preferred manner to call it is with $page->save($field)
844
	 *
845
	 * @param Page $page
846
	 * @param string|Field $field Field object or name (string)
847
	 * @param array|string $options Specify options: 
848
	 *  - `quiet` (boolean): Specify true to bypass updating of modified_users_id and modified time (default=false). 
849
	 *  - `noHooks` (boolean): Specify true to bypass calling of before/after save hooks (default=false). 
850
	 * @return bool True on success
851
	 * @throws WireException
852
	 *
853
	 */
854
	public function saveField(Page $page, $field, $options = array()) {
855
 
856
		$reason = '';
857
		if(is_string($options)) $options = Selectors::keyValueStringToArray($options);
858
 
859
		if($page->isNew()) {
860
			throw new WireException("Can't save field from a new page - please save the entire page first");
861
		}
862
 
863
		if(!$this->isSaveable($page, $reason, $field, $options)) {
864
			throw new WireException("Can't save field from page {$page->id}: {$page->path}: $reason");
865
		}
866
 
867
		if($field && (is_string($field) || is_int($field))) {
22 mjordaan 868
			$field = $this->wire()->fields->get($field);
1 mjordaan 869
		}
870
 
871
		if(!$field instanceof Field) {
872
			throw new WireException("Unknown field supplied to saveField for page {$page->id}");
873
		}
874
 
875
		if(!$page->fieldgroup->hasField($field)) {
876
			throw new WireException("Page {$page->id} does not have field {$field->name}");
877
		}
878
 
879
		$value = $page->get($field->name);
880
		if($value instanceof Pagefiles || $value instanceof Pagefile) $page->filesManager()->save();
881
		$page->trackChange($field->name);
882
 
883
		if(empty($options['noHooks'])) {
884
			$this->pages->saveFieldReady($page, $field);
885
			$this->pages->savePageOrFieldReady($page, $field->name);
886
		}
887
 
888
		if($field->type->savePageField($page, $field)) {
45 mjordaan 889
			// if page has a files path (or might have previously), trigger filesManager's save
890
			if(PagefilesManager::hasPath($page)) $page->filesManager->save();
1 mjordaan 891
			$page->untrackChange($field->name);
892
			if(empty($options['quiet'])) {
22 mjordaan 893
				$user = $this->wire()->user;
894
				$userID = (int) ($user ? $user->id : $this->wire()->config->superUserPageID);
895
				$database = $this->wire()->database;
1 mjordaan 896
				$query = $database->prepare("UPDATE pages SET modified_users_id=:userID, modified=NOW() WHERE id=:pageID");
897
				$query->bindValue(':userID', $userID, \PDO::PARAM_INT);
898
				$query->bindValue(':pageID', $page->id, \PDO::PARAM_INT);
899
				$database->execute($query);
900
			}
901
			$return = true;
902
			if(empty($options['noHooks'])) {
903
				$this->pages->savedField($page, $field);
904
				$this->pages->savedPageOrField($page, array($field->name));
905
			}
906
		} else {
907
			$return = false;
908
		}
909
 
910
		$this->pages->debugLog('saveField', "$page:$field", $return);
911
 
912
		return $return;
913
	}
914
 
915
	/**
916
	 * Silently add status flag to a Page and save
917
	 * 
918
	 * This action does not update the Page modified date. 
919
	 * It updates the status for both the given instantiated Page object and the value in the DB. 
920
	 * 
921
	 * @param Page $page 
922
	 * @param int $status Use Page::status* constants
923
	 * @return bool
924
	 * @since 3.0.146
925
	 * @see PagesEditor::setStatus(), PagesEditor::removeStatus()
926
	 * 
927
	 */
928
	public function addStatus(Page $page, $status) {
929
		if(!$page->hasStatus($status)) $page->addStatus($status);
930
		return $this->savePageStatus($page, $status) > 0;
931
	}
932
 
933
	/**
934
	 * Silently remove status flag from a Page and save
935
	 * 
936
	 * This action does not update the Page modified date.
937
	 * It updates the status for both the given instantiated Page object and the value in the DB. 
938
	 * 
939
	 * @param Page $page
940
	 * @param int $status Use Page::status* constants
941
	 * @return bool
942
	 * @since 3.0.146
943
	 * @see PagesEditor::setStatus(), PagesEditor::addStatus(), PagesEditor::saveStatus()
944
	 * 
945
	 */
946
	public function removeStatus(Page $page, $status) {
947
		if($page->hasStatus($status)) $page->removeStatus($status);
948
		return $this->savePageStatus($page, $status, false, true) > 0; 
949
	}
950
 
951
	/**
952
	 * Silently save whatever the given Page’s status currently is
953
	 * 
954
	 * This action does not update the Page modified date.
955
	 * 
956
	 * @param Page $page
957
	 * @return bool
958
	 * @since 3.0.146
959
	 * 
960
	 */
961
	public function saveStatus(Page $page) {
962
		return $this->savePageStatus($page, $page->status) > 0;
963
	}
964
 
965
	/**
966
	 * Add or remove a Page status and commit to DB, optionally recursive with the children, grandchildren, and so on.
967
	 *
968
	 * While this can be performed with other methods, this is here just to make it fast for internal/non-api use.
969
	 * See the trash and restore methods for an example.
970
	 * 
971
	 * This action does not update the Page modified date. If given a Page or PageArray, also note that it does not update
972
	 * the status properties of those instantiated Page objects, it only updates the DB value. 
973
	 * 
974
	 * #pw-internal Please use addStatus() or removeStatus() instead, unless you need to perform a recursive add/remove status.
975
	 *
976
	 * @param int|array|Page|PageArray $pageID Page ID, Page, array of page IDs, or PageArray
977
	 * @param int $status Status per flags in Page::status* constants. Status will be OR'd with existing status, unless $remove is used. 
978
	 * @param bool $recursive Should the status descend into the page's children, and grandchildren, etc? (default=false)
979
	 * @param bool|int $remove Should the status be removed rather than added? Use integer 2 to overwrite (default=false)
980
	 * @return int Number of pages updated
981
	 *
982
	 */
983
	public function savePageStatus($pageID, $status, $recursive = false, $remove = false) {
984
 
22 mjordaan 985
		$database = $this->wire()->database;
1 mjordaan 986
		$rowCount = 0;
987
		$multi = is_array($pageID) || $pageID instanceof PageArray;
988
		$status = (int) $status;
989
 
990
		if($status < 0 || $status > Page::statusMax) {
991
			throw new WireException("status must be between 0 and " . Page::statusMax);
992
		}
993
 
994
		$sql = "UPDATE pages SET status=";
995
 
996
		if($remove === 2) {
997
			// overwrite status (internal/undocumented)
998
			$sql .= "status=$status";
999
		} else if($remove) {
1000
			// remove status
1001
			$sql .= "status & ~$status";
1002
		} else {
1003
			// add status
1004
			$sql .= "status|$status";
1005
		}
1006
 
1007
		if($multi && $recursive) {
1008
			// multiple page IDs combined with recursive option, must be handled individually
1009
			foreach($pageID as $id) {
1010
				$rowCount += $this->savePageStatus((int) "$id", $status, $recursive, $remove);
1011
			}
1012
			// exit early in this case
1013
			return $rowCount; 
1014
 
1015
		} else if($multi) {
1016
			// multiple page IDs without recursive option, can be handled in one query
1017
			$ids = array();
1018
			foreach($pageID as $id) {
1019
				$id = (int) "$id";
1020
				if($id > 0) $ids[$id] = $id;
1021
			}
1022
			if(!count($ids)) $ids[] = 0;
1023
			$query = $database->prepare("$sql WHERE id IN(" . implode(',', $ids) . ")");
1024
			$database->execute($query);
1025
			return $query->rowCount();
1026
 
1027
		} else {
1028
			// single page ID or Page object
1029
			$pageID = (int) "$pageID";
1030
			$query = $database->prepare("$sql WHERE id=:page_id");
1031
			$query->bindValue(":page_id", $pageID, \PDO::PARAM_INT);
1032
			$database->execute($query);
1033
			$rowCount = $query->rowCount();
1034
		}
1035
 
1036
		if(!$recursive) return $rowCount;
1037
 
1038
		// recursive mode assumed from this point forward
1039
		$parentIDs = array($pageID);
1040
 
1041
		do {
1042
			$parentID = array_shift($parentIDs);
1043
 
1044
			// update all children to have the same status
1045
			$query = $database->prepare("$sql WHERE parent_id=:parent_id");
1046
			$query->bindValue(":parent_id", $parentID, \PDO::PARAM_INT);
1047
			$database->execute($query);
1048
			$rowCount += $query->rowCount();
1049
			$query->closeCursor();
1050
 
1051
			// locate children that themselves have children
1052
			$query = $database->prepare(
1053
				"SELECT pages.id FROM pages " .
1054
				"JOIN pages AS pages2 ON pages2.parent_id=pages.id " .
1055
				"WHERE pages.parent_id=:parent_id " .
1056
				"GROUP BY pages.id " .
1057
				"ORDER BY pages.sort"
1058
			);
1059
 
1060
			$query->bindValue(':parent_id', $parentID, \PDO::PARAM_INT);
1061
			$database->execute($query);
1062
 
1063
			/** @noinspection PhpAssignmentInConditionInspection */
1064
			while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
1065
				$parentIDs[] = (int) $row['id'];
1066
			}
1067
 
1068
			$query->closeCursor();
1069
 
1070
		} while(count($parentIDs));
1071
 
1072
		return $rowCount;
1073
	}
1074
 
1075
	/**
1076
	 * Permanently delete a page and it's fields.
1077
	 *
1078
	 * Unlike trash(), pages deleted here are not restorable.
1079
	 *
1080
	 * If you attempt to delete a page with children, and don't specifically set the $recursive param to True, then
1081
	 * this method will throw an exception. If a recursive delete fails for any reason, an exception will be thrown.
1082
	 *
1083
	 * @param Page $page
1084
	 * @param bool|array $recursive If set to true, then this will attempt to delete all children too.
1085
	 *   If you don't need this argument, optionally provide $options array instead. 
1086
	 * @param array $options Optional settings to change behavior:
1087
	 * - `uncacheAll` (bool): Whether to clear memory cache after delete (default=false)
1088
	 * - `recursive` (bool): Same as $recursive argument, may be specified in $options array if preferred.
1089
	 * @return bool|int Returns true (success), or integer of quantity deleted if recursive mode requested.
1090
	 * @throws WireException on fatal error
1091
	 *
1092
	 */
1093
	public function delete(Page $page, $recursive = false, array $options = array()) {
1094
 
1095
		$defaults = array(
1096
			'uncacheAll' => false, 
1097
			'recursive' => is_bool($recursive) ? $recursive : false,
1098
			// internal use properties:
1099
			'_level' => 0,
1100
			'_deleteBranch' => false,
1101
		);
1102
 
1103
		if(is_array($recursive)) $options = $recursive; 	
1104
		$options = array_merge($defaults, $options);
1105
 
1106
		$this->isDeleteable($page, true); // throws WireException
1107
		$numDeleted = 0;
1108
		$numChildren = $page->numChildren;
1109
		$deleteBranch = false;
1110
 
1111
		if($numChildren) {
1112
			if(!$options['recursive']) {
1113
				throw new WireException("Can't delete Page $page because it has one or more children.");
1114
			}
1115
			if($options['_level'] === 0) {
1116
				$deleteBranch = true;
1117
				$options['_deleteBranch'] = $page;
1118
				$this->pages->deleteBranchReady($page, $options);
1119
			}
1120
			foreach($page->children('include=all') as $child) {
1121
				/** @var Page $child */
1122
				$options['_level']++;
1123
				$result = $this->pages->delete($child, true, $options);
1124
				$options['_level']--;
1125
				if(!$result) throw new WireException("Error doing recursive page delete, stopped by page $child");
1126
				$numDeleted += $result;
1127
			}
1128
		}
1129
 
1130
		// trigger a hook to indicate delete is ready and WILL occur
1131
		$this->pages->deleteReady($page, $options);
1132
 
22 mjordaan 1133
		$this->clear($page);
1 mjordaan 1134
 
1135
		$database = $this->wire()->database;
1136
		$query = $database->prepare("DELETE FROM pages WHERE id=:page_id LIMIT 1"); // QA
1137
		$query->bindValue(":page_id", $page->id, \PDO::PARAM_INT);
1138
		$query->execute();
1139
 
1140
		$this->pages->sortfields()->delete($page);
1141
		$page->setTrackChanges(false);
1142
		$page->status = Page::statusDeleted; // no need for bitwise addition here, as this page is no longer relevant
1143
		$this->pages->deleted($page, $options);
1144
		$numDeleted++;
1145
		if($deleteBranch) $this->pages->deletedBranch($page, $options, $numDeleted);
1146
		if($options['uncacheAll']) $this->pages->uncacheAll($page);
1147
		$this->pages->debugLog('delete', $page, true);
1148
 
1149
		return $options['recursive'] ? $numDeleted : true;
1150
	}
1151
 
1152
	/**
1153
	 * Clone an entire page (including fields, file assets, and optionally children) and return it.
1154
	 *
1155
	 * @param Page $page Page that you want to clone
1156
	 * @param Page $parent New parent, if different (default=same parent)
1157
	 * @param bool $recursive Clone the children too? (default=true)
1158
	 * @param array|string $options Optional options that can be passed to clone or save
1159
	 * 	- forceID (int): force a specific ID
1160
	 * 	- set (array): Array of properties to set to the clone (you can also do this later)
1161
	 * 	- recursionLevel (int): recursion level, for internal use only.
45 mjordaan 1162
	 * @return Page|NullPage the newly cloned page or a NullPage() with id=0 if unsuccessful.
1 mjordaan 1163
	 * @throws WireException|\Exception on fatal error
1164
	 *
1165
	 */
1166
	public function _clone(Page $page, Page $parent = null, $recursive = true, $options = array()) {
1167
 
1168
		$defaults = array(
1169
			'forceID' => 0, 
1170
			'set' => array(), 
1171
			'recursionLevel' => 0, // recursion level (internal use only)
1172
		);
1173
 
1174
		if(is_string($options)) $options = Selectors::keyValueStringToArray($options);
1175
		$options = array_merge($defaults, $options);
1176
		if($parent === null) $parent = $page->parent; 
1177
 
1178
		if(count($options['set']) && !empty($options['set']['name'])) {
1179
			$name = $options['set']['name'];
1180
		} else {
1181
			$name = $this->pages->names()->uniquePageName(array(
1182
				'name' => $page->name, 
1183
				'parent' => $parent
1184
			));
1185
		}
1186
 
1187
		$of = $page->of();
1188
		$page->of(false);
1189
 
1190
		// Ensure all data is loaded for the page
1191
		foreach($page->fieldgroup as $field) {
45 mjordaan 1192
			/** @var Field $field */
1 mjordaan 1193
			if($page->hasField($field->name)) $page->get($field->name);
1194
		}
1195
 
1196
		/** @var User $user */
1197
		$user = $this->wire('user');
1198
 
1199
		// clone in memory
1200
		$copy = clone $page;
1201
		$copy->setIsNew(true);
1202
		$copy->of(false);
1203
		$copy->setQuietly('_cloning', $page);
1204
		$copy->setQuietly('id', $options['forceID'] > 1 ? (int) $options['forceID'] : 0);
1205
		$copy->setQuietly('numChildren', 0);
1206
		$copy->setQuietly('created', time());
1207
		$copy->setQuietly('modified', time());
1208
		$copy->name = $name;
1209
		$copy->parent = $parent;
1210
 
1211
		if(!isset($options['quiet']) || $options['quiet']) {
1212
			$options['quiet'] = true;
1213
			$copy->setQuietly('created_users_id', $user->id);
1214
			$copy->setQuietly('modified_users_id', $user->id);
1215
		}
1216
 
1217
		// set any properties indicated in options	
1218
		if(count($options['set'])) {
1219
			foreach($options['set'] as $key => $value) {
1220
				$copy->set($key, $value);
1221
				// quiet option required for setting modified time or user
1222
				if($key === 'modified' || $key === 'modified_users_id') $options['quiet'] = true; 
1223
			}
1224
		}
1225
 
1226
		// tell PW that all the data needs to be saved
1227
		foreach($copy->fieldgroup as $field) {
1228
			if($copy->hasField($field)) $copy->trackChange($field->name);
1229
		}
1230
 
1231
		$this->pages->cloneReady($page, $copy);
1232
		$this->cloning++;
1233
		$options['ignoreFamily'] = true; // skip family checks during clone
1234
		try {
1235
			$this->pages->save($copy, $options);
1236
		} catch(\Exception $e) {
1237
			$this->cloning--;
1238
			$copy->setQuietly('_cloning', null); 
1239
			$page->of($of);
1240
			throw $e;
1241
		}
1242
		$this->cloning--;
1243
 
1244
		// check to make sure the clone has worked so far
1245
		if(!$copy->id || $copy->id == $page->id) {
1246
			$copy->setQuietly('_cloning', null);
1247
			$page->of($of);
1248
			return $this->pages->newNullPage();
1249
		}
1250
 
1251
		// copy $page's files over to new page
1252
		if(PagefilesManager::hasFiles($page)) {
1253
			$copy->filesManager->init($copy);
1254
			$page->filesManager->copyFiles($copy->filesManager->path());
1255
		}
1256
 
1257
		// if there are children, then recursively clone them too
1258
		if($page->numChildren && $recursive) {
1259
			$start = 0;
1260
			$limit = 200;
1261
			$numChildrenCopied = 0;
1262
			do {
1263
				$children = $page->children("include=all, start=$start, limit=$limit");
1264
				$numChildren = $children->count();
1265
				foreach($children as $child) {
1266
					/** @var Page $child */
1267
					$childCopy = $this->pages->clone($child, $copy, true, array(
1268
						'recursionLevel' => $options['recursionLevel'] + 1,
1269
					));
1270
					if($childCopy->id) $numChildrenCopied++;
1271
				}
1272
				$start += $limit;
1273
				$this->pages->uncacheAll();
1274
			} while($numChildren);
1275
			$copy->setQuietly('numChildren', $numChildrenCopied); 
1276
		}
1277
 
1278
		$copy->parentPrevious = null;
1279
		$copy->setQuietly('_cloning', null);
1280
 
1281
		if($options['recursionLevel'] === 0) {
1282
			// update pages_parents table, only when at recursionLevel 0 since parents()->rebuild() already descends 
45 mjordaan 1283
			/*
1 mjordaan 1284
			if($copy->numChildren) {
1285
				$copy->setIsNew(true);
1286
				$this->pages->parents()->rebuild($copy);
1287
				$copy->setIsNew(false);
1288
			}
45 mjordaan 1289
			*/
1 mjordaan 1290
			// update sort
1291
			if($copy->parent()->sortfield() == 'sort') {
1292
				$this->sortPage($copy, $copy->sort, true);
1293
			}
1294
		}
1295
 
1296
		$copy->of($of);
1297
		$page->of($of);
1298
		$page->meta()->copyTo($copy->id); 
1299
		$copy->resetTrackChanges();
1300
		$this->pages->cloned($page, $copy);
1301
		$this->pages->debugLog('clone', "page=$page, parent=$parent", $copy);
1302
 
1303
		return $copy;
1304
	}
1305
 
1306
	/**
1307
	 * Update page modified/created/published time to now (or given time)
1308
	 * 
1309
	 * @param Page|PageArray|array $pages May be Page, PageArray or array of page IDs (integers)
22 mjordaan 1310
	 * @param null|int|string|array $options Omit (null) to update to now, or unix timestamp or strtotime() recognized time string, 
1311
	 *  or if you do not need this argument, you may optionally substitute the $type argument here, 
1312
	 *  or in 3.0.183+ you can also specify array of options here instead:
1313
	 *  - `time` (string|int|null): Unix timestamp or strtotime() recognized string to use, omit for use current time (default=null)
1314
	 *  - `type` (string): One of 'modified', 'created', 'published' (default='modified')
1315
	 *  - `user` (bool|User): True to also update modified/created user to current user, or specify User object to use (default=false)
1 mjordaan 1316
	 * @param string $type Date type to update, one of 'modified', 'created' or 'published' (default='modified') Added 3.0.147
22 mjordaan 1317
	 *  Skip this argument if using options array for previous argument or if using the default type 'modified'.
1 mjordaan 1318
	 * @throws WireException|\PDOException if given invalid format for $modified argument or failed database query
1319
	 * @return bool True on success, false on fail
1320
	 * 
1321
	 */
22 mjordaan 1322
	public function touch($pages, $options = null, $type = 'modified') {
1 mjordaan 1323
 
22 mjordaan 1324
		$defaults = array(
1325
			'time' => (is_string($options) || is_int($options) ? $options : null),
1326
			'type' => $type,
1327
			'user' => false,
1328
		);
1329
 
1330
		$options = is_array($options) ? array_merge($defaults, $options) : $defaults;
1331
		$database = $this->wire()->database;
1332
		$time = $options['time']; 
1333
		$type = $options['type'];
1334
		$user = $options['user'] === true ? $this->wire()->user : $options['user'];
1 mjordaan 1335
		$ids = array();
1336
 
1337
		if($time === 'modified' || $time === 'created' || $time === 'published') {
1338
			// time argument was omitted and type supplied here instead
1339
			$type = $time;	
1340
			$time = null;
1341
		}
1342
 
1343
		// ensure $col property is created in this method and not copied directly from $type
1344
		if($type === 'modified') {
1345
			$col = 'modified';
1346
		} else if($type === 'created') {
1347
			$col = 'created';
1348
		} else if($type === 'published') {
1349
			$col = 'published';
1350
		} else {
1351
			throw new WireException("Unrecognized date type '$type' for Pages::touch()");
1352
		}
1353
 
1354
		if($pages instanceof Page) {
1355
			$ids[] = (int) $pages->id;
1356
 
1357
		} else if(WireArray::iterable($pages)) {
1358
			foreach($pages as $page) {
1359
				if(is_int($page)) {
1360
					// page ID integer
1361
					$ids[] = (int) $page;
1362
				} else if($page instanceof Page) {
1363
					// Page object
1364
					$ids[] = (int) $page->id;
1365
				} else if(ctype_digit("$page")) {
1366
					// Page ID string
1367
					$ids[] = (int) "$page";
1368
				} else {
1369
					// invalid
1370
				}
1371
			}
1372
		}
1373
 
1374
		if(!count($ids)) return false;
1375
 
1376
		$sql = "UPDATE pages SET $col=";
1377
 
1378
		if(is_null($time)) {
1379
			$sql .= 'NOW() ';
1380
 
1381
		} else if(is_int($time) || ctype_digit($time)) {
1382
			$time = (int) $time;
1383
			$sql .= ':time ';
1384
 
1385
		} else if(is_string($time)) {
1386
			$time = strtotime($time);
1387
			if(!$time) throw new WireException("Unrecognized time format provided to Pages::touch()");
1388
			$sql .= ':time ';
1389
		}
22 mjordaan 1390
 
45 mjordaan 1391
		if($user instanceof User && ($col === 'modified' || $col === 'created')) {
22 mjordaan 1392
			$sql .= ", {$col}_users_id=:user ";
1393
		} 
1 mjordaan 1394
 
1395
		$sql .= 'WHERE id IN(' . implode(',', $ids) . ')';
1396
		$query = $database->prepare($sql);
1397
		if(strpos($sql, ':time')) $query->bindValue(':time', date('Y-m-d H:i:s', $time));
22 mjordaan 1398
		if(strpos($sql, ':user')) $query->bindValue(':user', $user->id, \PDO::PARAM_INT);
1 mjordaan 1399
 
1400
		return $database->execute($query);
1401
	}
1402
 
1403
	/**
1404
	 * Move page to specified parent (work in progress)
1405
	 * 
1406
	 * This method is the same as changing a page parent and saving, but provides a useful shortcut
1407
	 * for some cases with less code. This method:
1408
	 * 
1409
	 * - Does not save the other custom fields of a page (if any are changed). 
1410
	 * - Does not require that output formatting be off (it manages that internally). 
1411
	 * 
1412
	 * @param Page $child Page that you want to move.
1413
	 * @param Page|int|string $parent Parent to move it under (may be Page object, path string, or ID integer).
1414
	 * @param array $options Options to modify behavior (see PagesEditor::save for options). 
45 mjordaan 1415
	 * @return bool True on success or false if not necessary.
1 mjordaan 1416
	 * @throws WireException if given parent does not exist, or move is not allowed
1417
	 *
1418
	 */
1419
	public function move(Page $child, $parent, array $options = array()) {
1420
 
1421
		if(is_string($parent) || is_int($parent)) $parent = $this->pages->get($parent); 
1422
		if(!$parent instanceof Page || !$parent->id) throw new WireException('Unable to locate parent for move');
1423
 
1424
		$options['noFields'] = true;
1425
		$of = $child->of();
1426
		$child->of(false);
1427
		$child->parent = $parent;
1428
		$result = $child->parentPrevious ? $this->pages->save($child, $options) : false;
1429
		if($of) $child->of(true);
1430
 
1431
		return $result;
1432
	}
1433
 
1434
	/**
1435
	 * Set page $sort value and increment siblings having same or greater sort value 
1436
	 * 
1437
	 * - This method is primarily applicable if configured sortfield is manual “sort” (or “none”).
1438
	 * - This is typically used after a move, sort, clone or delete operation. 
1439
	 * 
1440
	 * @param Page $page Page that you want to set the sort value for
1441
	 * @param int|null $sort New sort value for page or null to pull from $page->sort
1442
	 * @param bool $after If another page already has the sort, make $page go after it rather than before it? (default=false)
1443
	 * @throws WireException if given invalid arguments
1444
	 * @return int Number of sibling pages that had to have sort adjusted
1445
	 * 
1446
	 */
1447
	public function sortPage(Page $page, $sort = null, $after = false) {
1448
 
22 mjordaan 1449
		$database = $this->wire()->database;
1 mjordaan 1450
 
1451
		// reorder siblings having same or greater sort value, when necessary
1452
		if($page->id <= 1) return 0;
1453
		if(is_null($sort)) $sort = $page->sort;
1454
 
1455
		// determine if any other siblings have same sort value
1456
		$sql = 'SELECT id FROM pages WHERE parent_id=:parent_id AND sort=:sort AND id!=:id';
1457
		$query = $database->prepare($sql);
1458
		$query->bindValue(':parent_id', $page->parent_id, \PDO::PARAM_INT);
1459
		$query->bindValue(':sort', $sort, \PDO::PARAM_INT);
1460
		$query->bindValue(':id', $page->id, \PDO::PARAM_INT);
1461
		$query->execute();
1462
		$rowCount = $query->rowCount();
1463
		$query->closeCursor();
1464
 
1465
		// move sort to after if requested
1466
		if($after && $rowCount) $sort += $rowCount;
1467
 
1468
		// update $page->sort property if needed
1469
		if($page->sort != $sort) $page->sort = $sort;
1470
 
1471
		// make sure that $page has the sort value indicated
1472
		$sql = 'UPDATE pages SET sort=:sort WHERE id=:id';
1473
		$query = $database->prepare($sql);
1474
		$query->bindValue(':sort', $sort, \PDO::PARAM_INT);
1475
		$query->bindValue(':id', $page->id, \PDO::PARAM_INT);
1476
		$query->execute();
1477
		$sortCnt = $query->rowCount();
1478
 
1479
		// no need for $page to have 'sort' indicated as a change, since we just updated it above
1480
		$page->untrackChange('sort');
1481
 
1482
		if($rowCount) {
1483
			// update order of all siblings 
1484
			$sql = 'UPDATE pages SET sort=sort+1 WHERE parent_id=:parent_id AND sort>=:sort AND id!=:id';
1485
			$query = $database->prepare($sql);
1486
			$query->bindValue(':parent_id', $page->parent_id, \PDO::PARAM_INT);
1487
			$query->bindValue(':sort', $sort, \PDO::PARAM_INT);
1488
			$query->bindValue(':id', $page->id, \PDO::PARAM_INT);
1489
			$query->execute();
1490
			$sortCnt += $query->rowCount();
1491
		}
1492
 
1493
		// call the sorted hook
1494
		$this->pages->sorted($page, false, $sortCnt);
1495
 
1496
		return $sortCnt;
1497
	}
1498
 
1499
	/**
1500
	 * Sort one page before another (for pages using manual sort)
1501
	 * 
1502
	 * Note that if given $sibling parent is different from `$page` parent, then the `$pages->save()`
1503
	 * method will also be called to perform that movement. 
1504
	 * 
1505
	 * @param Page $page Page to move/sort
1506
	 * @param Page $sibling Sibling that page will be moved/sorted before 
1507
	 * @param bool $after Specify true to make $page move after $sibling instead of before (default=false)
1508
	 * @throws WireException When conditions don't allow page insertions
1509
	 * 
1510
	 */
1511
	public function insertBefore(Page $page, Page $sibling, $after = false) {
1512
		$sortfield = $sibling->parent()->sortfield();
1513
		if($sortfield != 'sort') {
1514
			throw new WireException('Insert before/after operations can only be used with manually sorted pages');
1515
		}
1516
		if(!$sibling->id || !$page->id) {
1517
			throw new WireException('New pages must be saved before using insert before/after operations');
1518
		}
1519
		if($sibling->id == 1 || $page->id == 1) {
1520
			throw new WireException('Insert before/after operations cannot involve homepage');
1521
		}
1522
		$page->sort = $sibling->sort;
1523
		if($page->parent_id != $sibling->parent_id) {
1524
			// page needs to be moved first
1525
			$page->parent = $sibling->parent;
1526
			$page->save();
1527
		}
1528
		$this->sortPage($page, $page->sort, $after); 
1529
	}
1530
 
1531
	/**
1532
	 * Rebuild the “sort” values for all children of the given $parent page, fixing duplicates and gaps
1533
	 * 
1534
	 * If used on a $parent not currently sorted by by “sort” then it will update the “sort” index to be
1535
	 * consistent with whatever the pages are sorted by. 
1536
	 * 
1537
	 * @param Page $parent
1538
	 * @return int
1539
	 * 
1540
	 */
1541
	public function sortRebuild(Page $parent) {
1542
 
1543
		if(!$parent->id || !$parent->numChildren) return 0;
22 mjordaan 1544
		$database = $this->wire()->database;
1 mjordaan 1545
		$sorts = array();
1546
		$sort = 0;
1547
 
1548
		if($parent->sortfield() == 'sort') {
1549
			// pages are manually sorted, so we can find IDs directly from the database
1550
			$sql = 'SELECT id FROM pages WHERE parent_id=:parent_id ORDER BY sort, created';
1551
			$query = $database->prepare($sql);
1552
			$query->bindValue(':parent_id', $parent->id, \PDO::PARAM_INT);
1553
			$query->execute();
1554
 
1555
			// establish new sort values
1556
			do {
1557
				$id = (int) $query->fetch(\PDO::FETCH_COLUMN);
1558
				if(!$id) break;
1559
				$sorts[] = "($id,$sort)";
1560
			} while(++$sort);
1561
 
1562
			$query->closeCursor();
1563
 
1564
		} else {
1565
			// children of $parent don't currently use "sort" as sort property
1566
			// so we will update the "sort" of children to be consistent with that
1567
			// of whatever sort property is in use. 
1568
			$o = array('findIDs' => 1, 'cache' => false);
1569
			foreach($parent->children('include=all', $o) as $id) {
1570
				$id = (int) $id;
1571
				$sorts[] = "($id,$sort)";	
1572
				$sort++;
1573
			}
1574
		}
1575
 
1576
		// update sort values
1577
		$query = $database->prepare(
1578
			'INSERT INTO pages (id,sort) VALUES ' . implode(',', $sorts) . ' ' .
1579
			'ON DUPLICATE KEY UPDATE sort=VALUES(sort)'
1580
		);
1581
 
1582
		$query->execute();
1583
 
1584
		return count($sorts);
1585
	}
1586
 
1587
	/**
22 mjordaan 1588
	 * Replace one page with another (work in progress)
1589
	 * 
1590
	 * @param Page $oldPage
1591
	 * @param Page $newPage
1592
	 * @return Page
1593
	 * @throws WireException
1594
	 * @since 3.0.189 But not yet available in public API
1595
	 * 
1596
	 */
1597
	protected function replace(Page $oldPage, Page $newPage) {
1598
 
1599
		if($newPage->numChildren) {
1600
			throw new WireException('Page with children cannot replace another');
1601
		}
1602
 
1603
		$database = $this->wire()->database;
1604
 
1605
		$this->pages->cacher()->uncache($oldPage);
1606
		$this->pages->cacher()->uncache($newPage);
1607
 
1608
		$prevId = $newPage->id;
1609
		$id = $oldPage->id;
1610
		$parent = $oldPage->parent;
1611
		$prevTemplate = $oldPage->template;
1612
 
1613
		$newPage->parent = $parent;
1614
		$newPage->templatePrevious = $prevTemplate;
1615
 
1616
		$this->clear($oldPage, array(
1617
			'clearParents' => false, 
1618
			'clearAccess' => $prevTemplate->id != $newPage->template->id, 
1619
			'clearSortfield' => false,
1620
		)); 
1621
 
1622
		$binds = array(
1623
			':id' => $id, 
1624
			':parent_id' => $parent->id, 
1625
			':prev_id' => $prevId, 
1626
		);
1627
 
1628
		$sqls = array();
1629
		$sqls[] = 'UPDATE pages SET id=:id, parent_id=:parent_id WHERE id=:prev_id';
1630
 
1631
		foreach($newPage->template->fieldgroup as $field) {
1632
			/** @var Field $field */
1633
			$field->type->replacePageField($newPage, $oldPage, $field);
1634
		}
1635
 
1636
		foreach($sqls as $sql) {
1637
			$query = $database->prepare($sql);
1638
			foreach($binds as $bindKey => $bindValue) {
1639
				if(strpos($sql, $bindKey) === false) continue;
1640
				$query->bindValue($bindKey, $bindValue);
1641
				$query->execute();
1642
			}
1643
		}
1644
 
1645
		$newPage->id = $id;
1646
 
1647
		$this->save($newPage);
1648
 
1649
		$page = $this->pages->getById($id, $newPage->template, $parent->id);
1650
 
1651
		return $page;
1652
	}
1653
 
1654
	/**
1655
	 * Clear a page of its data
1656
	 * 
1657
	 * @param Page $page
1658
	 * @param array $options
1659
	 * @return bool
1660
	 * @throws WireException
1661
	 * @since 3.0.189
1662
	 * 
1663
	 */
1664
	public function clear(Page $page, array $options = array()) {
1665
 
1666
		$defaults = array(
1667
			'clearMethod' => 'delete', // 'delete' or 'empty'
1668
			'haltOnError' => false,
1669
			'clearFields' => true,
1670
			'clearFiles' => true, 
1671
			'clearMeta' => true, 
1672
			'clearAccess' => true, 
1673
			'clearSortfield' => true,
1674
			'clearParents' => true,
1675
		);
1676
 
1677
		$options = array_merge($defaults, $options);
1678
		$errors = array();
1679
		$halt = false;
1680
 
1681
		if($options['clearFields']) {
1682
			foreach($page->fieldgroup as $field) {
1683
				/** @var Field $field  */
45 mjordaan 1684
				/** @var Fieldtype $fieldtype */
1685
				$fieldtype = $field->type;
22 mjordaan 1686
				if($options['clearMethod'] === 'delete') {
45 mjordaan 1687
						$result = $fieldtype->deletePageField($page, $field);
22 mjordaan 1688
					} else {
45 mjordaan 1689
						$result = $fieldtype->emptyPageField($page, $field);
22 mjordaan 1690
					}
1691
				if(!$result) {	
1692
					$errors[] = "Unable to clear field '$field' from page $page";
1693
					$halt = $options['haltOnError'];
1694
					if($halt) break;
1695
				}
1696
			}
1697
		}
1698
 
1699
		if($options['clearFiles'] && !$halt) {
1700
			$error = "Error clearing files for page $page"; 
1701
			try {
1702
				if(PagefilesManager::hasPath($page)) {
45 mjordaan 1703
					$filesManager = $page->filesManager();
1704
					if(!$filesManager) {
1705
						// $filesManager will be null if page has deleted status
1706
						// so create our own instance
1707
						$filesManager = new PagefilesManager($page);
1708
					}
1709
					if(!$filesManager->emptyAllPaths()) {
22 mjordaan 1710
						$errors[] = $error;
1711
						$halt = $options['haltOnError'];
1712
					}
1713
				}
1714
			} catch(\Exception $e) {
1715
				$errors[] = $error . ' - ' . $e->getMessage();
1716
				$halt = $options['haltOnError'];
1717
			}
1718
		}
1719
 
1720
		if($options['clearMeta'] && !$halt) {
1721
			try {
1722
				$page->meta()->removeAll();
1723
			} catch(\Exception $e) {
1724
				$errors[] = "Error clearing meta for page $page";
1725
				$halt = $options['haltOnError'];
1726
			}
1727
		}
1728
 
1729
		if($options['clearAccess'] && !$halt) {
1730
			/** @var PagesAccess $access */
1731
			$access = $this->wire(new PagesAccess());
1732
			$access->deletePage($page);
1733
		}
1734
 
1735
		if($options['clearParents'] && !$halt) {
1736
			// delete entirely from pages_parents table
1737
			$this->pages->parents()->delete($page);
1738
		}
1739
 
1740
		if($options['clearSortfield'] && !$halt) {
1741
			$this->pages->sortfields()->delete($page);
1742
		}
1743
 
1744
		if(count($errors) || $halt) {
1745
			foreach($errors as $error) {
1746
				$this->error($error);
1747
			}
1748
			return false;
1749
		}
1750
 
1751
		return true;
1752
	}
1753
 
1754
	/**
1755
	 * Prepare options for Pages::new(), Pages::newPage() 
1756
	 * 
1757
	 * Converts given array, selector string, template name, object or int to array of options. 
1758
	 * 
1759
	 * #pw-internal
1760
	 *
1761
	 * @param array|string|int $options
1762
	 * @return array
1763
	 * @since 3.0.191
1764
	 *
1765
	 */
1766
	public function newPageOptions($options) {
1767
 
1768
		if(empty($options)) return array(); 
1769
 
1770
		$template = null; /** @var Template|null $template */
1771
		$parent = null;
1772
		$class = '';
1773
 
1774
		if(is_array($options)) {
1775
			// ok
1776
		} else if(is_string($options)) {
1777
			if(strpos($options, '=') !== false) {
1778
				$selectors = new Selectors($options);
1779
				$this->wire($selectors);
1780
				$options = array();
1781
				foreach($selectors as $selector) {
1782
					$options[$selector->field()] = $selector->value;
1783
				}
1784
			} else if(strpos($options, '/') === 0) {
1785
				$options = array('path' => $options);
1786
			} else {
1787
				$options = array('template' => $options);
1788
			}
1789
		} else if(is_object($options)) {
1790
			$options = $options instanceof Template ? array('template' => $options) : array();
1791
		} else if(is_int($options)) {
1792
			$template = $this->wire()->templates->get($options);
1793
			$options = $template ? array('template' => $template) : array();
1794
		} else {
1795
			$options = array();
1796
		}
1797
 
1798
		// only use property 'parent' rather than 'parent_id'
1799
		if(!empty($options['parent_id']) && empty($options['parent'])) {
1800
			$options['parent'] = $options['parent_id'];
1801
			unset($options['parent_id']);
1802
		}
1803
 
1804
		// only use property 'template' rather than 'templates_id'
1805
		if(!empty($options['templates_id']) && empty($options['template'])) {
1806
			$options['template'] = $options['templates_id'];
1807
			unset($options['templates_id']);
1808
		}
1809
 
1810
		// page class (pageClass)
1811
		if(!empty($options['pageClass'])) {
1812
			// ok
1813
			$class = $options['pageClass'];
1814
			unset($options['pageClass']); 
1815
		} else if(!empty($options['class']) && !$this->wire()->fields->get('class')) {
1816
			// alias for pageClass, so long as there is not a field named 'class'
1817
			$class = $options['class'];
1818
			unset($options['class']);
1819
		}
1820
 
1821
		// identify requested template
1822
		if(isset($options['template'])) {
1823
			$template = $options['template'];
1824
			if(!is_object($template)) {
1825
				$template = empty($template) ? null : $this->wire()->templates->get($template);
1826
			}
1827
			unset($options['template']);
1828
		}
1829
 
1830
		// convert parent path to parent page object
1831
		if(!empty($options['parent'])) {
1832
			if(is_object($options['parent'])) {
1833
				$parent = $options['parent'];
1834
			} else if(ctype_digit("$options[parent]")) {
1835
				$parent = (int) $options['parent'];
1836
			} else {
1837
				$parent = $this->pages->getByPath($options['parent']);
1838
				if(!$parent->id) $parent = null;
1839
			}
1840
			unset($options['parent']);
1841
		}
1842
 
1843
		// name and parent can be detected from path, when specified
1844
		if(!empty($options['path'])) {
1845
			$path = trim($options['path'], '/');
1846
			if(strpos($path, '/') === false) $path = "/$path";
1847
			$parts = explode('/', $path); // note index[0] is blank
1848
			$name = array_pop($parts);
1849
			if(empty($options['name']) && !empty($name)) {
1850
				// detect name from path
1851
				$options['name'] = $name;
1852
			}
1853
			if(empty($parent) && !$this->pages->loader()->isLoading()) {
1854
				// detect parent from path
1855
				$parentPath = count($parts) ? implode('/', $parts) : '/';
1856
				$parent = $this->pages->getByPath($parentPath);
1857
				if(!$parent->id) $parent = null;
1858
			}
1859
			unset($options['path']);
1860
		}
1861
 
1862
		// detect template from parent (when possible)
1863
		if(!$template && !empty($parent) && empty($options['id']) && !$this->pages->loader()->isLoading()) {
1864
			$parent = is_object($parent) ? $parent : $this->pages->get($parent);
1865
			if($parent->id) {
1866
				if(count($parent->template->childTemplates) === 1) {
1867
					$template = $parent->template->childTemplates()->first();
1868
				}
1869
			} else {
1870
				$parent = null;
1871
			}
1872
		}
1873
 
1874
		// detect parent from template (when possible)
1875
		if($template && empty($parent) && empty($options['id']) && !$this->pages->loader()->isLoading()) { 
1876
			if(count($template->parentTemplates) === 1) {
1877
				$parentTemplates = $template->parentTemplates();
1878
				if($parentTemplates->count()) {
1879
					$numParents = $this->pages->count("template=$parentTemplates, include=all");
1880
					if($numParents === 1) {
1881
						$parent = $this->pages->get("template=$parentTemplates");
1882
						if(!$parent->id) $parent = null;
1883
					}
1884
				}
1885
			}	
1886
		}
1887
 
1888
		// detect class from template
1889
		if(empty($class) && $template) $class = $template->getPageClass();
1890
 
1891
		if($parent) $options['parent'] = $parent;
1892
		if($template) $options['template'] = $template;
1893
		if($class) $options['pageClass'] = $class;
1894
 
1895
		if(isset($options['id'])) {
1896
			if(ctype_digit("$options[id]") && (int) $options['id'] > 0) {
1897
				$options['id'] = (int) $options['id'];
1898
				if($parent && "$options[id]" === "$parent") unset($options['parent']);
1899
			} else if(((int) $options['id']) === -1) {
1900
				$options['id'] = (int) $options['id']; // special case allowed for access control tests
1901
			} else {
1902
				unset($options['id']);
1903
			}
1904
		}
1905
 
1906
		return $options;
1907
	}
1908
 
1909
	/**
1 mjordaan 1910
	 * Hook after Fieldtype::sleepValue to remove MB4 characters when present and applicable
1911
	 * 
1912
	 * This hook is only used if $config->dbStripMB4 is true and $config->dbEngine is not “utf8mb4”. 
1913
	 * 
1914
	 * @param HookEvent $event
1915
	 * 
1916
	 */
22 mjordaan 1917
	public function hookFieldtypeSleepValueStripMB4(HookEvent $event) {
1918
		$event->return = $this->wire()->sanitizer->removeMB4($event->return); 
1 mjordaan 1919
	}
1920
}