PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / wpcli / WPCLIImportExportCommandService.php

WPCLIImportExportCommandService.php in 404 Solution 4.3.0, at includes/wpcli/WPCLIImportExportCommandService.php

302 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 // allow-no-test-found: covered through public WP-CLI command entry points in tests/WPCLICommandsTest.php
8
9 /**
10 * Application service for WP-CLI import and export file workflows.
11 */
12 class ABJ_404_Solution_WPCLIImportExportCommandService {
13
14 /** @var ABJ_404_Solution_Clock */
15 private $clock;
16
17 /** @var ABJ_404_Solution_DataAccess|null Injected aggregate root; null => resolve via the service locator. */
18 private $dataAccess;
19
20 /** @var ABJ_404_Solution_Logging|null Injected logger; null => resolve via the service locator. */
21 private $logging;
22
23 /**
24 * @param ABJ_404_Solution_Clock|null $clock
25 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data-access aggregate root. When provided, the
26 * import/export collaborators (redirects + content repositories, view read service) are taken
27 * from it instead of the global service locator. Defaults to
28 * abj_service('data_access') so production wiring is unchanged. This injection seam exists so
29 * callers (WP-CLI, tests) can run the import/export workflow against an explicit DataAccess
30 * without mutating global container state.
31 * @param ABJ_404_Solution_Logging|null $logging Logger. Defaults to abj_service('logging').
32 */
33 public function __construct($clock = null, $dataAccess = null, $logging = null) {
34 $this->clock = $clock instanceof ABJ_404_Solution_Clock
35 ? $clock
36 : $this->defaultClock();
37 $this->dataAccess = $dataAccess instanceof ABJ_404_Solution_DataAccess ? $dataAccess : null;
38 $this->logging = $logging instanceof ABJ_404_Solution_Logging ? $logging : null;
39 }
40
41 /**
42 * Resolve the redirects repository: from the injected DataAccess when present, else the
43 * service-locator singleton (the exact resolution the original code used). Resolving the
44 * individual collaborator rather than the whole DataAccess aggregate is deliberate: building
45 * the full aggregate would also construct unrelated sub-services (retention/cleanup) that this
46 * workflow never uses, changing construction-time behavior.
47 * @return ABJ_404_Solution_RedirectsRepository
48 */
49 private function redirectsRepository() {
50 if ($this->dataAccess instanceof ABJ_404_Solution_DataAccess) {
51 return $this->dataAccess->getRedirectsRepo();
52 }
53 /** @var ABJ_404_Solution_RedirectsRepository $repo */
54 $repo = abj_service('redirects_repository');
55 return $repo;
56 }
57
58 /**
59 * Resolve the content repository: from the injected DataAccess when present, else the
60 * service-locator singleton.
61 * @return ABJ_404_Solution_ContentRepository
62 */
63 private function contentRepository() {
64 if ($this->dataAccess instanceof ABJ_404_Solution_DataAccess) {
65 return $this->dataAccess->getContentRepo();
66 }
67 /** @var ABJ_404_Solution_ContentRepository $repo */
68 $repo = abj_service('content_repository');
69 return $repo;
70 }
71
72 /**
73 * Resolve the view read service: from the injected DataAccess when present, else the
74 * service-locator singleton.
75 * @return ABJ_404_Solution_ViewReadService
76 */
77 private function viewReadService() {
78 if ($this->dataAccess instanceof ABJ_404_Solution_DataAccess) {
79 return $this->dataAccess->getViewReadService();
80 }
81 /** @var ABJ_404_Solution_ViewReadService $svc */
82 $svc = abj_service('view_read_service');
83 return $svc;
84 }
85
86 /**
87 * Resolve the logger: the injected instance when present, else the service-locator singleton.
88 * @return ABJ_404_Solution_Logging
89 */
90 private function loggingService() {
91 if ($this->logging instanceof ABJ_404_Solution_Logging) {
92 return $this->logging;
93 }
94 /** @var ABJ_404_Solution_Logging $logging */
95 $logging = abj_service('logging');
96 return $logging;
97 }
98
99 /**
100 * @param string $filePath
101 * @param bool $dryRun
102 * @return array<string, mixed>
103 */
104 public function importRedirects(string $filePath, bool $dryRun): array {
105 if ($filePath === '') {
106 return $this->error('Please provide a path to the CSV file. Usage: wp abj404 import <file>');
107 }
108 if (!file_exists($filePath)) {
109 return $this->error("File not found: {$filePath}");
110 }
111
112 $fileHandle = fopen($filePath, 'r');
113 if ($fileHandle === false) {
114 return $this->error("Could not open file: {$filePath}");
115 }
116
117 $svc = new ABJ_404_Solution_ImportService(
118 $this->redirectsRepository(),
119 $this->contentRepository(),
120 $this->loggingService()
121 );
122 $delimiter = $svc->detectCsvDelimiterFromFile($fileHandle);
123 rewind($fileHandle);
124
125 $rowResult = $this->viewReadService()->runWithDeferredInvalidation(function () use (
126 $svc, $fileHandle, $delimiter, $dryRun) {
127 $local = array(
128 'headerColumns' => null,
129 'processedRows' => 0,
130 'validRows' => 0,
131 'invalidRows' => 0,
132 'anyIssuesToNote' => array(),
133 'error' => null,
134 );
135
136 while (($row = fgetcsv($fileHandle, 0, $delimiter, '"', '\\')) !== false) {
137 if (!is_array($row)) {
138 $local['error'] = 'Could not parse CSV row.';
139 return $local;
140 }
141 $data = array_map(function($value) {
142 return trim((string)$value);
143 }, $row);
144
145 if (count($data) === 1 && $data[0] === '') {
146 continue;
147 }
148
149 if ($local['headerColumns'] === null && $svc->isCompatibleImportHeaderRow($data)) {
150 $local['headerColumns'] = $svc->normalizeImportHeaders($data);
151 continue;
152 }
153
154 $dataArray = ($local['headerColumns'] !== null)
155 ? $svc->mapImportRowByHeaders($data, $local['headerColumns'])
156 : $svc->mapImportRowWithoutHeaders($data);
157
158 if (isset($dataArray['error'])) {
159 $local['error'] = $dataArray['error'];
160 return $local;
161 }
162
163 if (isset($dataArray['from_url']) &&
164 ($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request')) {
165 continue;
166 }
167
168 $local['processedRows']++;
169 $issues = $svc->loadDataArrayFromFile($dataArray, $dryRun);
170 if (count($issues) > 0) {
171 $local['invalidRows']++;
172 } else {
173 $local['validRows']++;
174 }
175 $local['anyIssuesToNote'] = array_merge($local['anyIssuesToNote'], $issues);
176 }
177 return $local;
178 });
179 fclose($fileHandle);
180
181 if (!empty($rowResult['error'])) {
182 return $this->error((string)$rowResult['error']);
183 }
184
185 $validRows = (int)$rowResult['validRows'];
186 $invalidRows = (int)$rowResult['invalidRows'];
187 $processedRows = (int)$rowResult['processedRows'];
188 $warnings = array_map('strval', array_slice($rowResult['anyIssuesToNote'], 0, 20));
189
190 if ($dryRun) {
191 return $this->line(
192 "Dry run: valid={$validRows}, invalid={$invalidRows}, total={$processedRows}",
193 $warnings
194 );
195 }
196
197 return $this->success(
198 "Import complete. Valid={$validRows}, invalid={$invalidRows}, total={$processedRows}",
199 $warnings
200 );
201 }
202
203 /**
204 * @param string $format
205 * @param string $output
206 * @return array<string, mixed>
207 */
208 public function exportRedirects(string $format, string $output): array {
209 $svc = new ABJ_404_Solution_ExportService(
210 $this->viewReadService(),
211 $this->loggingService(),
212 $this->redirectsRepository()
213 );
214
215 $serverGenerators = array(
216 'htaccess' => 'generateHtaccessRules',
217 'nginx' => 'generateNginxRules',
218 'cloudflare' => 'generateCloudflareWorkerScript',
219 'netlify' => 'generateNetlifyRedirects',
220 'vercel' => 'generateVercelRedirects',
221 );
222 if (isset($serverGenerators[$format])) {
223 $content = $svc->{$serverGenerators[$format]}();
224 if ($output !== '') {
225 if (file_put_contents($output, $content) === false) {
226 return $this->error("Could not write to file: {$output}");
227 }
228 return $this->success("Exported {$format} rules to: {$output}");
229 }
230 return array('type' => 'content', 'content' => $content);
231 }
232
233 $tempFile = sys_get_temp_dir() . '/abj404_export_' . $this->clock->now() . '.csv';
234 if ($format === 'redirection') {
235 $nativeTemp = sys_get_temp_dir() . '/abj404_export_native_' . $this->clock->now() . '.csv';
236 $this->viewReadService()->doRedirectsExport($nativeTemp);
237 $error = $svc->convertExportCsvToRedirectionFormat($nativeTemp, $tempFile);
238 @unlink($nativeTemp);
239 if ($error !== '') {
240 return $this->error("Export conversion failed: {$error}");
241 }
242 } else {
243 $this->viewReadService()->doRedirectsExport($tempFile);
244 }
245
246 if (!file_exists($tempFile)) {
247 @unlink($tempFile);
248 return $this->line('No redirects to export.');
249 }
250
251 if ($output !== '') {
252 if (!rename($tempFile, $output)) {
253 if (!copy($tempFile, $output)) {
254 @unlink($tempFile);
255 return $this->error("Could not write to file: {$output}");
256 }
257 @unlink($tempFile);
258 }
259 return $this->success("Exported {$format} redirects to: {$output}");
260 }
261
262 $csv = file_get_contents($tempFile);
263 @unlink($tempFile);
264 if ($csv === false) {
265 return $this->error('Could not read export temp file.');
266 }
267 return array('type' => 'content', 'content' => $csv);
268 }
269
270 private function defaultClock(): ABJ_404_Solution_Clock {
271 $clock = ABJ_404_Solution_ServiceContainer::safeGet('clock');
272 if ($clock instanceof ABJ_404_Solution_Clock) {
273 return $clock;
274 }
275 return new ABJ_404_Solution_SystemClock();
276 }
277
278 /**
279 * @param array<int, string> $warnings
280 * @return array{type: string, message: string, warnings: array<int, string>}
281 */
282 private function error(string $message, array $warnings = array()): array {
283 return array('type' => 'error', 'message' => $message, 'warnings' => $warnings);
284 }
285
286 /**
287 * @param array<int, string> $warnings
288 * @return array{type: string, message: string, warnings: array<int, string>}
289 */
290 private function line(string $message, array $warnings = array()): array {
291 return array('type' => 'line', 'message' => $message, 'warnings' => $warnings);
292 }
293
294 /**
295 * @param array<int, string> $warnings
296 * @return array{type: string, message: string, warnings: array<int, string>}
297 */
298 private function success(string $message, array $warnings = array()): array {
299 return array('type' => 'success', 'message' => $message, 'warnings' => $warnings);
300 }
301 }
302