PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/wpcli/WPCLIImportExportCommandService.php

311 lines 12.1 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 // The file handle must close even if detectCsvDelimiterFromFile(),
123 // the deferred-invalidation callback, or loadDataArrayFromFile()
124 // (called per-row below with no per-row exception guard, unlike the
125 // browser-upload path in ImportService::processDataArray()) throws.
126 // See includes/import/ImportService.php::doImportFile() for the
127 // reference fix of this same resource-lifecycle shape.
128 try {
129 $delimiter = $svc->detectCsvDelimiterFromFile($fileHandle);
130 rewind($fileHandle);
131
132 $rowResult = $this->viewReadService()->runWithDeferredInvalidation(function () use (
133 $svc, $fileHandle, $delimiter, $dryRun) {
134 $local = array(
135 'headerColumns' => null,
136 'processedRows' => 0,
137 'validRows' => 0,
138 'invalidRows' => 0,
139 'anyIssuesToNote' => array(),
140 'error' => null,
141 );
142
143 while (($row = fgetcsv($fileHandle, 0, $delimiter, '"', '\\')) !== false) {
144 if (!is_array($row)) {
145 $local['error'] = 'Could not parse CSV row.';
146 return $local;
147 }
148 $data = array_map(function($value) {
149 return trim((string)$value);
150 }, $row);
151
152 if (count($data) === 1 && $data[0] === '') {
153 continue;
154 }
155
156 if ($local['headerColumns'] === null && $svc->isCompatibleImportHeaderRow($data)) {
157 $local['headerColumns'] = $svc->normalizeImportHeaders($data);
158 continue;
159 }
160
161 $dataArray = ($local['headerColumns'] !== null)
162 ? $svc->mapImportRowByHeaders($data, $local['headerColumns'])
163 : $svc->mapImportRowWithoutHeaders($data);
164
165 if (isset($dataArray['error'])) {
166 $local['error'] = $dataArray['error'];
167 return $local;
168 }
169
170 if (isset($dataArray['from_url']) &&
171 ($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request')) {
172 continue;
173 }
174
175 $local['processedRows']++;
176 $issues = $svc->loadDataArrayFromFile($dataArray, $dryRun);
177 if (count($issues) > 0) {
178 $local['invalidRows']++;
179 } else {
180 $local['validRows']++;
181 }
182 $local['anyIssuesToNote'] = array_merge($local['anyIssuesToNote'], $issues);
183 }
184 return $local;
185 });
186 } finally {
187 fclose($fileHandle);
188 }
189
190 if (!empty($rowResult['error'])) {
191 return $this->error((string)$rowResult['error']);
192 }
193
194 $validRows = (int)$rowResult['validRows'];
195 $invalidRows = (int)$rowResult['invalidRows'];
196 $processedRows = (int)$rowResult['processedRows'];
197 $warnings = array_map('strval', array_slice($rowResult['anyIssuesToNote'], 0, 20));
198
199 if ($dryRun) {
200 return $this->line(
201 "Dry run: valid={$validRows}, invalid={$invalidRows}, total={$processedRows}",
202 $warnings
203 );
204 }
205
206 return $this->success(
207 "Import complete. Valid={$validRows}, invalid={$invalidRows}, total={$processedRows}",
208 $warnings
209 );
210 }
211
212 /**
213 * @param string $format
214 * @param string $output
215 * @return array<string, mixed>
216 */
217 public function exportRedirects(string $format, string $output): array {
218 $svc = new ABJ_404_Solution_ExportService(
219 $this->viewReadService(),
220 $this->loggingService(),
221 $this->redirectsRepository()
222 );
223
224 $serverGenerators = array(
225 'htaccess' => 'generateHtaccessRules',
226 'nginx' => 'generateNginxRules',
227 'cloudflare' => 'generateCloudflareWorkerScript',
228 'netlify' => 'generateNetlifyRedirects',
229 'vercel' => 'generateVercelRedirects',
230 );
231 if (isset($serverGenerators[$format])) {
232 $content = $svc->{$serverGenerators[$format]}();
233 if ($output !== '') {
234 if (file_put_contents($output, $content) === false) {
235 return $this->error("Could not write to file: {$output}");
236 }
237 return $this->success("Exported {$format} rules to: {$output}");
238 }
239 return array('type' => 'content', 'content' => $content);
240 }
241
242 $tempFile = sys_get_temp_dir() . '/abj404_export_' . $this->clock->now() . '.csv';
243 if ($format === 'redirection') {
244 $nativeTemp = sys_get_temp_dir() . '/abj404_export_native_' . $this->clock->now() . '.csv';
245 $this->viewReadService()->doRedirectsExport($nativeTemp);
246 $error = $svc->convertExportCsvToRedirectionFormat($nativeTemp, $tempFile);
247 @unlink($nativeTemp);
248 if ($error !== '') {
249 return $this->error("Export conversion failed: {$error}");
250 }
251 } else {
252 $this->viewReadService()->doRedirectsExport($tempFile);
253 }
254
255 if (!file_exists($tempFile)) {
256 @unlink($tempFile);
257 return $this->line('No redirects to export.');
258 }
259
260 if ($output !== '') {
261 if (!rename($tempFile, $output)) {
262 if (!copy($tempFile, $output)) {
263 @unlink($tempFile);
264 return $this->error("Could not write to file: {$output}");
265 }
266 @unlink($tempFile);
267 }
268 return $this->success("Exported {$format} redirects to: {$output}");
269 }
270
271 $csv = file_get_contents($tempFile);
272 @unlink($tempFile);
273 if ($csv === false) {
274 return $this->error('Could not read export temp file.');
275 }
276 return array('type' => 'content', 'content' => $csv);
277 }
278
279 private function defaultClock(): ABJ_404_Solution_Clock {
280 $clock = ABJ_404_Solution_ServiceContainer::safeGet('clock');
281 if ($clock instanceof ABJ_404_Solution_Clock) {
282 return $clock;
283 }
284 return new ABJ_404_Solution_SystemClock();
285 }
286
287 /**
288 * @param array<int, string> $warnings
289 * @return array{type: string, message: string, warnings: array<int, string>}
290 */
291 private function error(string $message, array $warnings = array()): array {
292 return array('type' => 'error', 'message' => $message, 'warnings' => $warnings);
293 }
294
295 /**
296 * @param array<int, string> $warnings
297 * @return array{type: string, message: string, warnings: array<int, string>}
298 */
299 private function line(string $message, array $warnings = array()): array {
300 return array('type' => 'line', 'message' => $message, 'warnings' => $warnings);
301 }
302
303 /**
304 * @param array<int, string> $warnings
305 * @return array{type: string, message: string, warnings: array<int, string>}
306 */
307 private function success(string $message, array $warnings = array()): array {
308 return array('type' => 'success', 'message' => $message, 'warnings' => $warnings);
309 }
310 }
311