PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.10.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.10.0
4.10.0 4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Framework / Onboarding / OnboardingJourney.php
wp-staging / Framework / Onboarding Last commit date
BackupPluginsDetector.php 5 days ago FirstInstall.php 5 days ago FreeOnboarding.php 5 days ago NextStepRenderer.php 5 days ago OnboardingAjax.php 5 days ago OnboardingJourney.php 5 days ago QueuedBackup.php 5 days ago
OnboardingJourney.php
531 lines
1 <?php
2
3 namespace WPStaging\Framework\Onboarding;
4
5 use WPStaging\Core\WPStaging;
6 use WPStaging\Framework\Analytics\Actions\AnalyticsGenericEvent;
7 use WPStaging\Framework\Security\Auth;
8
9 /**
10 * The lifecycle of a first run, in options so it survives reloads and job hooks.
11 *
12 * Picking a card is not finishing: only completeOnExplicitExit() and a second
13 * completed capability end it.
14 */
15 class OnboardingJourney
16 {
17 const OPTION_STATE = 'wpstg_onboarding_journey';
18
19 /** Holds the completion reason; its presence ends the first run. */
20 const OPTION_COMPLETED = 'wpstg_onboarding_completed';
21
22 /**
23 * Set by the footer's restart link. An admin asking for the first run back
24 * has said what the eligibility rules are there to infer, so it overrides
25 * them — otherwise a site that already has a staging site could never
26 * replay the journey it just finished.
27 */
28 const OPTION_RESTARTED = 'wpstg_onboarding_restarted';
29
30 const STEP_SELECT = 'select';
31 const STEP_RUNNING = 'running';
32 const STEP_NEXT = 'next';
33 const STEP_DONE = 'done';
34
35 const POSITION_FIRST = 'first';
36 const POSITION_SECOND = 'second';
37
38 const CAPABILITY_STAGING = 'staging';
39 const CAPABILITY_BACKUP = 'backup';
40 const CAPABILITIES = [self::CAPABILITY_STAGING, self::CAPABILITY_BACKUP];
41
42 const REASON_SKIPPED_INITIAL = 'skipped_initial';
43 const REASON_DONE_AFTER_FIRST = 'done_after_first_capability';
44 const REASON_TWO_CAPABILITIES = 'two_capabilities_completed';
45 const REASON_OTHER_EXPLICIT_EXIT = 'other_explicit_exit';
46
47 /** The state that follows the first success. */
48 const SURFACE_POST_SUCCESS = 'post_success';
49
50 /** The offer that rides along with the staging progress modal. */
51 const SURFACE_STAGING_PROGRESS = 'staging_progress';
52
53 const EVENT_ACTION_STARTED = 'onboarding_action_started';
54 const EVENT_ACTION_COMPLETED = 'onboarding_action_completed';
55 const EVENT_ACTION_CANCELLED = 'onboarding_action_cancelled';
56 const EVENT_NEXT_OFFER_SHOWN = 'onboarding_next_offer_shown';
57 const EVENT_COMPLETED = 'onboarding_completed';
58
59 /** Past this, a job that stopped reporting no longer holds focus mode. */
60 const MAX_RUNNING_AGE_IN_SECONDS = DAY_IN_SECONDS;
61
62 /** @var array|null */
63 private $state = null;
64
65 /** @var string|null */
66 private $completionReason = null;
67
68 /**
69 * @return string One of the STEP_* constants.
70 */
71 public function getStep(): string
72 {
73 if ($this->isCompleted()) {
74 return self::STEP_DONE;
75 }
76
77 if ($this->getAction(self::POSITION_FIRST) === '') {
78 return self::STEP_SELECT;
79 }
80
81 return $this->activePosition() === '' ? self::STEP_NEXT : self::STEP_RUNNING;
82 }
83
84 /**
85 * @param string $position POSITION_FIRST or POSITION_SECOND.
86 * @return string The capability chosen for that position, or an empty string.
87 */
88 public function getAction(string $position): string
89 {
90 return $this->read($position, 'action');
91 }
92
93 /**
94 * @return string The capability the user did not start with, or empty.
95 */
96 public function getNextCapability(): string
97 {
98 $first = $this->getAction(self::POSITION_FIRST);
99
100 if ($first === '') {
101 return '';
102 }
103
104 return $first === self::CAPABILITY_BACKUP ? self::CAPABILITY_STAGING : self::CAPABILITY_BACKUP;
105 }
106
107 /**
108 * @return string The capability the journey is on, started or not. What
109 * decides which surface to render.
110 */
111 public function getActiveCapability(): string
112 {
113 return $this->getAction($this->activePosition());
114 }
115
116 /**
117 * @return string The capability with a job in flight. What decides the
118 * wording, so configuring one does not read as progress.
119 */
120 public function getRunningCapability(): string
121 {
122 $position = $this->activePosition();
123
124 return $this->read($position, 'started_at') === '' ? '' : $this->getAction($position);
125 }
126
127 /**
128 * Whether a first run is in progress. The deferred-backup queue is shared
129 * with ordinary staging jobs, so its endpoint has to ask before writing any
130 * journey state.
131 */
132 public function isUnderWay(): bool
133 {
134 return !$this->isCompleted() && $this->getAction(self::POSITION_FIRST) !== '';
135 }
136
137 public function isFirstCapabilityCompleted(): bool
138 {
139 return $this->hasSucceeded(self::POSITION_FIRST);
140 }
141
142 /**
143 * @param string $capability CAPABILITY_STAGING or CAPABILITY_BACKUP.
144 * @return string The position it was recorded as, or empty when untracked.
145 */
146 public function selectAction(string $capability): string
147 {
148 if (!in_array($capability, self::CAPABILITIES, true) || $this->isCompleted()) {
149 return '';
150 }
151
152 $position = $this->positionFor($capability);
153
154 if ($this->getAction($position) === '') {
155 $this->stamp($position, 'selected_at', ['action' => $capability]);
156 }
157
158 return $position;
159 }
160
161 public function startAction()
162 {
163 $position = $this->activePosition();
164
165 if ($position === '' || $this->read($position, 'started_at') !== '') {
166 return;
167 }
168
169 $this->stamp($position, 'started_at');
170 $this->logStep(self::EVENT_ACTION_STARTED, $position);
171 }
172
173 /**
174 * @action wpstg_staging_site_created
175 */
176 public function completeStaging()
177 {
178 $this->completeCapability(self::CAPABILITY_STAGING);
179 }
180
181 /**
182 * The hook hands over the job it finished, which is the only place the file
183 * it wrote is known without asking for it again. Recorded on the journey so
184 * the success state can name it, and still name it after a reload.
185 *
186 * @action wpstg.backup.created
187 * @param mixed $jobDataDto
188 */
189 public function completeBackup($jobDataDto = null)
190 {
191 $position = $this->activePosition();
192
193 $this->completeCapability(self::CAPABILITY_BACKUP);
194
195 if ($position === '' || !is_object($jobDataDto) || !method_exists($jobDataDto, 'getBackupFilePath')) {
196 return;
197 }
198
199 $path = (string)$jobDataDto->getBackupFilePath();
200
201 if ($path === '' || !is_readable($path)) {
202 return;
203 }
204
205 $state = $this->readState();
206 $state[$position]['backup_file'] = basename($path);
207 $state[$position]['backup_size'] = (int)filesize($path);
208
209 $this->write($state);
210 }
211
212 /**
213 * @return array `name` and `size` of the backup this run created, empty when
214 * none was recorded.
215 */
216 public function getCompletedBackupDetails(): array
217 {
218 foreach ([self::POSITION_FIRST, self::POSITION_SECOND] as $position) {
219 $name = $this->read($position, 'backup_file');
220
221 if ($name !== '') {
222 return ['name' => $name, 'size' => (int)$this->read($position, 'backup_size')];
223 }
224 }
225
226 return [];
227 }
228
229 /**
230 * Counts a success against the outstanding capability. Not gated on the
231 * start being reported: a dropped request must not lose a real completion.
232 *
233 * @param string $capability CAPABILITY_STAGING or CAPABILITY_BACKUP.
234 */
235 public function completeCapability(string $capability)
236 {
237 $position = $this->activePosition();
238
239 if ($position === '' || $this->getAction($position) !== $capability) {
240 return;
241 }
242
243 $this->stamp($position, 'completed_at');
244 $this->logStep(self::EVENT_ACTION_COMPLETED, $position);
245
246 if ($position === self::POSITION_SECOND) {
247 $this->complete(self::REASON_TWO_CAPABILITIES);
248 }
249 }
250
251 /**
252 * Takes back the capability the user just cancelled, started or not.
253 *
254 * cancelAction() refuses once a job has begun, because a request that was
255 * dropped in flight is not the same as one never made. A cancel is: the user
256 * said no to this capability, and the run has to offer the choice again
257 * rather than hold the screen with a job that is no longer running.
258 *
259 * @action wpstg_cancel_clone
260 * @action wpstg--job--cancel
261 *
262 * @return void
263 */
264 public function abandonActionOnRequest()
265 {
266 if (!WPStaging::make(Auth::class)->isAuthenticatedRequest('', 'manage_options')) {
267 return;
268 }
269
270 $this->abandonAction();
271 }
272
273 public function abandonAction()
274 {
275 $position = $this->activePosition();
276
277 if ($position === '' || $this->isCompleted()) {
278 return;
279 }
280
281 $this->logStep(self::EVENT_ACTION_CANCELLED, $position);
282 $this->forget($position);
283 }
284
285 /**
286 * Takes back a capability whose job never started, so backing out of the
287 * settings dialog returns to the step the user chose it from.
288 */
289 public function cancelAction()
290 {
291 $position = $this->activePosition();
292
293 if ($position === '' || $this->read($position, 'started_at') !== '') {
294 return;
295 }
296
297 $this->forget($position);
298 }
299
300 /**
301 * Puts a second capability that will not succeed back on offer. The first is
302 * left alone: the user is in front of its own error and retry.
303 */
304 public function cancelSecondAction()
305 {
306 if ($this->getAction(self::POSITION_SECOND) === '' || $this->hasSucceeded(self::POSITION_SECOND)) {
307 return;
308 }
309
310 $this->forget(self::POSITION_SECOND);
311 }
312
313 /**
314 * Reports the second capability being put in front of the user, once per
315 * surface. Both surfaces report through here so the event means the same
316 * thing however it is counted.
317 *
318 * @param string $surface SURFACE_POST_SUCCESS or SURFACE_STAGING_PROGRESS.
319 */
320 public function recordNextOfferShown(string $surface = self::SURFACE_POST_SUCCESS)
321 {
322 $capability = $this->getNextCapability();
323 $stamp = $surface === self::SURFACE_STAGING_PROGRESS ? 'offered_progress_at' : 'offered_at';
324
325 if ($capability === '' || $this->read(self::POSITION_SECOND, $stamp) !== '') {
326 return;
327 }
328
329 $this->stamp(self::POSITION_SECOND, $stamp);
330
331 AnalyticsGenericEvent::logEvent(self::EVENT_NEXT_OFFER_SHOWN, FreeOnboarding::ANALYTICS_GROUP, [
332 'action' => $capability,
333 'surface' => $surface,
334 ]);
335 }
336
337 /**
338 * Ends the first run, naming the reason from where the user was standing.
339 */
340 public function completeOnExplicitExit()
341 {
342 $reasons = [
343 self::STEP_SELECT => self::REASON_SKIPPED_INITIAL,
344 self::STEP_NEXT => self::REASON_DONE_AFTER_FIRST,
345 ];
346
347 $step = $this->getStep();
348
349 $this->complete(isset($reasons[$step]) ? $reasons[$step] : self::REASON_OTHER_EXPLICIT_EXIT);
350 }
351
352 /**
353 * @param string $reason One of the REASON_* constants.
354 */
355 public function complete(string $reason)
356 {
357 if ($this->isCompleted()) {
358 return;
359 }
360
361 AnalyticsGenericEvent::logEvent(self::EVENT_COMPLETED, FreeOnboarding::ANALYTICS_GROUP, [
362 'reason' => $reason,
363 'first_action' => $this->getAction(self::POSITION_FIRST),
364 'second_action' => $this->getAction(self::POSITION_SECOND),
365 ]);
366
367 update_option(self::OPTION_COMPLETED, $reason, false);
368 $this->completionReason = $reason;
369
370 delete_option(self::OPTION_STATE);
371 delete_option(self::OPTION_RESTARTED);
372 delete_option(QueuedBackup::OPTION_STATE);
373 $this->state = null;
374 }
375
376 public function isCompleted(): bool
377 {
378 return $this->getCompletionReason() !== '';
379 }
380
381 /**
382 * Puts the installation back on the three-card selector.
383 */
384 public function restart()
385 {
386 delete_option(self::OPTION_COMPLETED);
387 delete_option(self::OPTION_STATE);
388 delete_option(FreeOnboarding::OPTION_EXPOSURE);
389 delete_option(QueuedBackup::OPTION_STATE);
390 update_option(self::OPTION_RESTARTED, (string)time(), false);
391
392 $this->state = null;
393 $this->completionReason = null;
394 }
395
396 public function wasRestarted(): bool
397 {
398 return get_option(self::OPTION_RESTARTED) !== false;
399 }
400
401 /**
402 * @return string One of the REASON_* constants, or an empty string while the
403 * first run is still going.
404 */
405 public function getCompletionReason(): string
406 {
407 if ($this->completionReason === null) {
408 $this->completionReason = (string)get_option(self::OPTION_COMPLETED, '');
409 }
410
411 return $this->completionReason;
412 }
413
414 private function hasSucceeded(string $position): bool
415 {
416 return $this->read($position, 'completed_at') !== '';
417 }
418
419 /**
420 * @return string The position still waiting for its success, or an empty string.
421 */
422 private function activePosition(): string
423 {
424 foreach ([self::POSITION_FIRST, self::POSITION_SECOND] as $position) {
425 if ($this->getAction($position) !== '' && !$this->hasSucceeded($position)) {
426 return $position;
427 }
428 }
429
430 return '';
431 }
432
433 /**
434 * @return string Where this capability belongs, so that re-picking the one
435 * already in flight does not open a second slot.
436 */
437 private function positionFor(string $capability): string
438 {
439 $first = $this->getAction(self::POSITION_FIRST);
440
441 return ($first === '' || $first === $capability) ? self::POSITION_FIRST : self::POSITION_SECOND;
442 }
443
444 private function logStep(string $event, string $position)
445 {
446 AnalyticsGenericEvent::logEvent($event, FreeOnboarding::ANALYTICS_GROUP, [
447 'action' => $this->getAction($position),
448 'position' => $position,
449 ]);
450 }
451
452 private function stamp(string $position, string $key, array $extra = [])
453 {
454 $state = $this->readState();
455 $state[$position] = array_merge($state[$position], [$key => time()], $extra);
456
457 $this->write($state);
458 }
459
460 private function forget(string $position)
461 {
462 $state = $this->readState();
463 $state[$position] = [];
464
465 $this->write($state);
466 }
467
468 private function write(array $state)
469 {
470 $this->state = $state;
471 update_option(self::OPTION_STATE, $state, false);
472 }
473
474 /**
475 * Self-healing rather than pure, like QueuedBackup: the state is only read
476 * on a page render or a job hook, and neither has anywhere else to notice
477 * that a job stopped reporting.
478 */
479 private function dropStaleAction()
480 {
481 foreach ([self::POSITION_FIRST, self::POSITION_SECOND] as $position) {
482 $startedAt = empty($this->state[$position]['started_at']) ? 0 : (int)$this->state[$position]['started_at'];
483
484 if ($startedAt === 0 || !empty($this->state[$position]['completed_at'])) {
485 continue;
486 }
487
488 if ($startedAt < time() - self::MAX_RUNNING_AGE_IN_SECONDS) {
489 $this->state[$position] = [];
490 update_option(self::OPTION_STATE, $this->state, false);
491 }
492 }
493 }
494
495 /**
496 * @return string Empty when unset, so callers can test presence with `!== ''`.
497 */
498 private function read(string $position, string $key): string
499 {
500 $state = $this->readState();
501
502 return empty($state[$position][$key]) ? '' : (string)$state[$position][$key];
503 }
504
505 /**
506 * @return array Both positions always present, so writers never guard for shape.
507 */
508 private function readState(): array
509 {
510 if ($this->state !== null) {
511 return $this->state;
512 }
513
514 // A finished run has no state. Ending it deletes the record, but a delete
515 // is not the only thing that decides the answer: a request still holding
516 // the old state can write it back afterwards, and the run would then be
517 // both over and half-way through — which is a screen nobody can leave.
518 $stored = $this->isCompleted() ? [] : get_option(self::OPTION_STATE, []);
519 $stored = is_array($stored) ? $stored : [];
520 $this->state = [];
521
522 foreach ([self::POSITION_FIRST, self::POSITION_SECOND] as $position) {
523 $this->state[$position] = isset($stored[$position]) && is_array($stored[$position]) ? $stored[$position] : [];
524 }
525
526 $this->dropStaleAction();
527
528 return $this->state;
529 }
530 }
531