Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 125 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
| GenerateArticlesCommand | |
0.00% |
0 / 125 |
|
0.00% |
0 / 7 |
870 | |
0.00% |
0 / 1 |
| initialize | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| buildOptionParser | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
| execute | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
56 | |||
| ensureTopLevelTags | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
42 | |||
| generateArticle | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
20 | |||
| generateRandomText | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
72 | |||
| loadAdminUser | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | namespace App\Command; |
| 5 | |
| 6 | use App\Model\Entity\Article; |
| 7 | use App\Model\Table\ArticlesTable; |
| 8 | use Cake\Command\Command; |
| 9 | use Cake\Console\Arguments; |
| 10 | use Cake\Console\ConsoleIo; |
| 11 | use Cake\Console\ConsoleOptionParser; |
| 12 | use Cake\I18n\DateTime; |
| 13 | use Cake\ORM\TableRegistry; |
| 14 | use RuntimeException; |
| 15 | |
| 16 | /** |
| 17 | * GenerateArticles command. |
| 18 | */ |
| 19 | class GenerateArticlesCommand extends Command |
| 20 | { |
| 21 | /** |
| 22 | * @var string UUID of the admin user |
| 23 | */ |
| 24 | private string $adminUserId; |
| 25 | |
| 26 | /** |
| 27 | * @var \App\Model\Table\ArticlesTable |
| 28 | */ |
| 29 | private ArticlesTable $Articles; |
| 30 | |
| 31 | /** |
| 32 | * Initialize method |
| 33 | * |
| 34 | * @return void |
| 35 | */ |
| 36 | public function initialize(): void |
| 37 | { |
| 38 | parent::initialize(); |
| 39 | $this->Articles = TableRegistry::getTableLocator()->get('Articles'); |
| 40 | $this->loadAdminUser(); |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Build option parser method. |
| 45 | * |
| 46 | * @param \Cake\Console\ConsoleOptionParser $parser The parser to be defined |
| 47 | * @return \Cake\Console\ConsoleOptionParser The built parser. |
| 48 | */ |
| 49 | protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser |
| 50 | { |
| 51 | $parser->addArgument('count', [ |
| 52 | 'help' => __('Number of articles to generate'), |
| 53 | 'required' => true, |
| 54 | ])->addOption('delete', [ |
| 55 | 'help' => __('Delete all articles before generating new ones'), |
| 56 | 'boolean' => true, |
| 57 | ]); |
| 58 | |
| 59 | return $parser; |
| 60 | } |
| 61 | |
| 62 | /** |
| 63 | * Execute the command |
| 64 | * |
| 65 | * @param \Cake\Console\Arguments $args The command arguments. |
| 66 | * @param \Cake\Console\ConsoleIo $io The console io |
| 67 | * @return int|null The exit code or null for success |
| 68 | */ |
| 69 | public function execute(Arguments $args, ConsoleIo $io): ?int |
| 70 | { |
| 71 | if ($args->getOption('delete')) { |
| 72 | $this->Articles->deleteAll([]); |
| 73 | $io->out(__('All articles have been deleted.')); |
| 74 | } |
| 75 | |
| 76 | // Check and create top-level tags if needed |
| 77 | $this->ensureTopLevelTags($io); |
| 78 | |
| 79 | $count = (int)$args->getArgument('count'); |
| 80 | $io->out(__('Generating {0} articles...', $count)); |
| 81 | |
| 82 | $successCount = 0; |
| 83 | $failCount = 0; |
| 84 | |
| 85 | for ($i = 0; $i < $count; $i++) { |
| 86 | $article = $this->generateArticle(); |
| 87 | |
| 88 | $publishedDate = $article->published; |
| 89 | |
| 90 | if ($this->Articles->save($article, ['associated' => ['Tags']])) { |
| 91 | $article->published = $publishedDate; |
| 92 | $this->Articles->save($article); |
| 93 | $io->out(__('Generated article: {0}', $article->title)); |
| 94 | $successCount++; |
| 95 | } else { |
| 96 | $io->error(__('Failed to generate article: {0}', $article->title)); |
| 97 | $errors = $article->getErrors(); |
| 98 | foreach ($errors as $field => $fieldErrors) { |
| 99 | foreach ($fieldErrors as $error) { |
| 100 | $io->error(__('Error in {0}: {1}', $field, $error)); |
| 101 | } |
| 102 | } |
| 103 | $failCount++; |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | $io->success(__('Generated {0} articles successfully.', $successCount)); |
| 108 | if ($failCount > 0) { |
| 109 | $io->warning(__('Failed to generate {0} articles.', $failCount)); |
| 110 | } |
| 111 | |
| 112 | return static::CODE_SUCCESS; |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * Ensures there are at least 10 top-level tags in the system |
| 117 | * |
| 118 | * @param \Cake\Console\ConsoleIo $io The console IO object |
| 119 | * @return void |
| 120 | */ |
| 121 | private function ensureTopLevelTags(ConsoleIo $io): void |
| 122 | { |
| 123 | $tagsTable = TableRegistry::getTableLocator()->get('Tags'); |
| 124 | |
| 125 | // Count existing top-level tags |
| 126 | $existingCount = $tagsTable->find() |
| 127 | ->where(['parent_id IS' => null]) |
| 128 | ->count(); |
| 129 | |
| 130 | if ($existingCount >= 10) { |
| 131 | return; |
| 132 | } |
| 133 | |
| 134 | $tagsToCreate = 10 - $existingCount; |
| 135 | $io->out(__('Creating {0} new top-level tags...', $tagsToCreate)); |
| 136 | |
| 137 | for ($i = 0; $i < $tagsToCreate; $i++) { |
| 138 | $tag = $tagsTable->newEmptyEntity(); |
| 139 | |
| 140 | // Generate a single word for name (max 10 characters) |
| 141 | $tag->title = substr($this->generateRandomText(10), 0, 10); |
| 142 | |
| 143 | // Generate description (max 10 words) |
| 144 | $tag->description = $this->generateRandomText(10, true); |
| 145 | |
| 146 | if ($tagsTable->save($tag)) { |
| 147 | $io->out(__('Created tag: {0}', $tag->name)); |
| 148 | } else { |
| 149 | $io->error(__('Failed to create tag: {0}', $tag->name)); |
| 150 | $errors = $tag->getErrors(); |
| 151 | foreach ($errors as $field => $fieldErrors) { |
| 152 | foreach ($fieldErrors as $error) { |
| 153 | $io->error(__('Error in {0}: {1}', $field, $error)); |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * Generate a single article with random tags |
| 162 | * |
| 163 | * @return \App\Model\Entity\Article |
| 164 | */ |
| 165 | private function generateArticle(): Article |
| 166 | { |
| 167 | // Generate shorter content to ensure it fits database constraints |
| 168 | $title = $this->generateRandomText(100); |
| 169 | $lede = $this->generateRandomText(200); |
| 170 | $summary = $this->generateRandomText(50, true); |
| 171 | $body = $this->generateRandomText(200, true); |
| 172 | |
| 173 | // Generate a random date between 2000 and now |
| 174 | $year = rand(2000, (int)date('Y')); |
| 175 | $month = str_pad((string)rand(1, 12), 2, '0', STR_PAD_LEFT); |
| 176 | $day = str_pad((string)rand(1, 28), 2, '0', STR_PAD_LEFT); |
| 177 | $hour = str_pad((string)rand(0, 23), 2, '0', STR_PAD_LEFT); |
| 178 | $minute = str_pad((string)rand(0, 59), 2, '0', STR_PAD_LEFT); |
| 179 | $second = str_pad((string)rand(0, 59), 2, '0', STR_PAD_LEFT); |
| 180 | |
| 181 | $publishedDate = new DateTime("{$year}-{$month}-{$day} {$hour}:{$minute}:{$second}"); |
| 182 | |
| 183 | // Create new article entity |
| 184 | $article = $this->Articles->newEmptyEntity(); |
| 185 | $article->title = $title; |
| 186 | $article->lede = $lede; |
| 187 | $article->summary = $summary; |
| 188 | $article->body = $body; |
| 189 | $article->slug = ''; // Will be auto-generated |
| 190 | $article->user_id = $this->adminUserId; |
| 191 | $article->kind = 'article'; |
| 192 | $article->is_published = true; |
| 193 | $article->published = $publishedDate; |
| 194 | |
| 195 | // Get all available tags |
| 196 | $tagsTable = TableRegistry::getTableLocator()->get('Tags'); |
| 197 | $allTags = $tagsTable->find() |
| 198 | ->select(['id', 'title']) |
| 199 | ->toArray(); |
| 200 | |
| 201 | if (!empty($allTags)) { |
| 202 | // Randomly select between 1 and 3 tags |
| 203 | $numTags = min(rand(1, 3), count($allTags)); |
| 204 | $selectedIndices = array_rand($allTags, $numTags); |
| 205 | |
| 206 | // Convert to array if only one tag selected |
| 207 | if (!is_array($selectedIndices)) { |
| 208 | $selectedIndices = [$selectedIndices]; |
| 209 | } |
| 210 | |
| 211 | // Create array of tag entities |
| 212 | $tags = []; |
| 213 | foreach ($selectedIndices as $index) { |
| 214 | $tags[] = $allTags[$index]; |
| 215 | } |
| 216 | |
| 217 | // Set the tags |
| 218 | $article->tags = $tags; |
| 219 | } |
| 220 | |
| 221 | return $article; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Generate random text |
| 226 | * |
| 227 | * @param int $maxLength Maximum length of the text |
| 228 | * @param bool $isWordCount Whether the length is in words |
| 229 | * @return string Random text |
| 230 | */ |
| 231 | private function generateRandomText(int $maxLength, bool $isWordCount = false): string |
| 232 | { |
| 233 | if ($isWordCount) { |
| 234 | // Generate by word count |
| 235 | $words = []; |
| 236 | for ($i = 0; $i < $maxLength; $i++) { |
| 237 | $wordLength = rand(3, 10); |
| 238 | $word = ''; |
| 239 | for ($j = 0; $j < $wordLength; $j++) { |
| 240 | $word .= chr(rand(97, 122)); |
| 241 | } |
| 242 | $words[] = $word; |
| 243 | } |
| 244 | |
| 245 | return implode(' ', $words); |
| 246 | } |
| 247 | |
| 248 | // Generate by character count |
| 249 | $text = ''; |
| 250 | $currentLength = 0; |
| 251 | |
| 252 | while ($currentLength < $maxLength) { |
| 253 | // Calculate remaining space |
| 254 | $remainingSpace = $maxLength - $currentLength; |
| 255 | |
| 256 | // If we have very limited space left, just add a few characters |
| 257 | if ($remainingSpace <= 4) { |
| 258 | $text .= substr(str_shuffle('abcdefghijklmnopqrstuvwxyz'), 0, $remainingSpace); |
| 259 | break; |
| 260 | } |
| 261 | |
| 262 | // Generate a word that will fit in remaining space (including a space character) |
| 263 | $maxWordLength = min(10, $remainingSpace - 1); |
| 264 | $wordLength = rand(3, $maxWordLength); |
| 265 | $word = ''; |
| 266 | for ($j = 0; $j < $wordLength; $j++) { |
| 267 | $word .= chr(rand(97, 122)); |
| 268 | } |
| 269 | |
| 270 | // Add word and space if it fits |
| 271 | if (strlen($text . $word . ' ') <= $maxLength) { |
| 272 | $text .= $word . ' '; |
| 273 | $currentLength = strlen($text); |
| 274 | } else { |
| 275 | break; |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | return trim($text); |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Load admin user ID |
| 284 | * |
| 285 | * @throws \RuntimeException When no admin user is found |
| 286 | * @return void |
| 287 | */ |
| 288 | private function loadAdminUser(): void |
| 289 | { |
| 290 | $usersTable = TableRegistry::getTableLocator()->get('Users'); |
| 291 | $adminUser = $usersTable->find() |
| 292 | ->select(['id']) |
| 293 | ->where(['is_admin' => true]) |
| 294 | ->first(); |
| 295 | |
| 296 | if ($adminUser) { |
| 297 | $this->adminUserId = $adminUser->id; |
| 298 | } else { |
| 299 | throw new RuntimeException(__('No admin user found.')); |
| 300 | } |
| 301 | } |
| 302 | } |