Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
GenerateArticlesCommand
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 7
870
0.00% covered (danger)
0.00%
0 / 1
 initialize
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 buildOptionParser
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 ensureTopLevelTags
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 generateArticle
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 generateRandomText
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
72
 loadAdminUser
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare(strict_types=1);
3
4namespace App\Command;
5
6use App\Model\Entity\Article;
7use App\Model\Table\ArticlesTable;
8use Cake\Command\Command;
9use Cake\Console\Arguments;
10use Cake\Console\ConsoleIo;
11use Cake\Console\ConsoleOptionParser;
12use Cake\I18n\DateTime;
13use Cake\ORM\TableRegistry;
14use RuntimeException;
15
16/**
17 * GenerateArticles command.
18 */
19class 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}