Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
47 / 47 |
|
100.00% |
6 / 6 |
CRAP | |
100.00% |
1 / 1 |
| Drafts | |
100.00% |
47 / 47 |
|
100.00% |
6 / 6 |
23 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
4 | |||
| save | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| matchesLive | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| essence | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
12 | |||
| delete | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Node; |
| 6 | |
| 7 | use Celema\Quma\Database; |
| 8 | |
| 9 | /** |
| 10 | * The working copies of published nodes: one row per node, content in |
| 11 | * the shape of `nodes.content`, plus the drafted handle and URL paths. |
| 12 | */ |
| 13 | final class Drafts |
| 14 | { |
| 15 | public function __construct( |
| 16 | private readonly Database $db, |
| 17 | ) {} |
| 18 | |
| 19 | /** |
| 20 | * @return ?array{ |
| 21 | * node: int, |
| 22 | * content: array<string, mixed>, |
| 23 | * settings: array{handle?: ?string, paths?: array<string, string>}, |
| 24 | * created: string, |
| 25 | * changed: string, |
| 26 | * editor: int, |
| 27 | * } |
| 28 | */ |
| 29 | public function get(int $node): ?array |
| 30 | { |
| 31 | $row = $this->db->drafts->get(['node' => $node])->first(); |
| 32 | |
| 33 | if (!$row) { |
| 34 | return null; |
| 35 | } |
| 36 | |
| 37 | $content = json_decode((string) $row['content'], true); |
| 38 | $settings = json_decode((string) $row['settings'], true); |
| 39 | |
| 40 | return [ |
| 41 | 'node' => (int) $row['node'], |
| 42 | 'content' => is_array($content) ? $content : [], |
| 43 | 'settings' => is_array($settings) ? $settings : [], |
| 44 | 'created' => (string) $row['created'], |
| 45 | 'changed' => (string) $row['changed'], |
| 46 | 'editor' => (int) $row['editor'], |
| 47 | ]; |
| 48 | } |
| 49 | |
| 50 | /** |
| 51 | * @param array<string, mixed> $content |
| 52 | * @param array{handle?: ?string, paths?: array<string, string>} $settings |
| 53 | */ |
| 54 | public function save(int $node, array $content, array $settings, int $editor): void |
| 55 | { |
| 56 | $this->db->drafts->upsert([ |
| 57 | 'node' => $node, |
| 58 | 'content' => json_encode($content), |
| 59 | 'settings' => json_encode((object) $settings), |
| 60 | 'editor' => $editor, |
| 61 | ])->run(); |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * Whether a working copy would leave the live node as it is: the same |
| 66 | * content, handle and active URL paths, compared by self::essence(). |
| 67 | * |
| 68 | * @param array<string, mixed> $content |
| 69 | * @param array<string, string> $paths |
| 70 | */ |
| 71 | public function matchesLive(int $node, array $content, ?string $handle, array $paths): bool |
| 72 | { |
| 73 | $live = $this->db->drafts->live(['node' => $node])->one(); |
| 74 | $livePaths = json_decode((string) $live['paths'], true); |
| 75 | $liveContent = json_decode((string) $live['content'], true); |
| 76 | |
| 77 | return ( |
| 78 | $live['handle'] === $handle |
| 79 | && self::essence($livePaths) === self::essence(array_map(trim(...), $paths)) |
| 80 | && self::essence($liveContent) === self::essence($content) |
| 81 | ); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * Content as far as an editor can tell it apart. The form submits |
| 86 | * every slot it shows, numbers as text, so empty values and fields |
| 87 | * holding nothing but empty values count as absent, and 1041, 1041.0 |
| 88 | * and '1041' are the same; key order does not count either. |
| 89 | */ |
| 90 | private static function essence(mixed $value): mixed |
| 91 | { |
| 92 | if (is_int($value) || is_float($value)) { |
| 93 | return (string) $value; |
| 94 | } |
| 95 | |
| 96 | if ($value === '') { |
| 97 | return null; |
| 98 | } |
| 99 | |
| 100 | if (!is_array($value)) { |
| 101 | return $value; |
| 102 | } |
| 103 | |
| 104 | if (array_is_list($value)) { |
| 105 | return $value === [] ? null : array_map(self::essence(...), $value); |
| 106 | } |
| 107 | |
| 108 | $essence = array_filter( |
| 109 | array_map(self::essence(...), $value), |
| 110 | static fn(mixed $item): bool => $item !== null, |
| 111 | ); |
| 112 | |
| 113 | $field = array_key_exists('type', $value) && array_key_exists('value', $value); |
| 114 | |
| 115 | if ($field && !isset($essence['value']) && !isset($essence['meta'])) { |
| 116 | return null; |
| 117 | } |
| 118 | |
| 119 | ksort($essence); |
| 120 | |
| 121 | return $essence === [] ? null : $essence; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Deletes the working copy; its history keeps the last state and, when |
| 126 | * the caller says the content went live, records it as published rather |
| 127 | * than discarded. Must run inside the caller's transaction: the reason |
| 128 | * travels as a transaction-local setting the history trigger reads. |
| 129 | */ |
| 130 | public function delete(int $node, bool $published = false): void |
| 131 | { |
| 132 | // Set either way: an earlier delete in the same transaction may have |
| 133 | // left `published` behind. |
| 134 | $this->db->drafts->outcome(['outcome' => $published ? 'published' : 'discarded'])->run(); |
| 135 | $this->db->drafts->delete(['node' => $node])->run(); |
| 136 | } |
| 137 | } |