Skip to content

Commit e862e7e

Browse files
author
Mr Alexandre ELISÉ
committed
Add smart-add-or-edit-joomla-articles-from-streamed-csv-url.php which is about 200loc and enhance the developer experience when using the Joomla! API code sample
1 parent f3137d7 commit e862e7e

File tree

1 file changed

+232
-0
lines changed

1 file changed

+232
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Add or Edit Joomla! Articles Via API Using Streamed CSV
6+
* - When id = 0 in csv it's doing a POST. If alias exists it add a random slug at the end of your alias and do POST again
7+
* - When id > 0 in csv it's doing a PATCH. If alias exists it add a random slug at the end of your alias and do PATCH again
8+
*
9+
* @author Alexandre ELISÉ <[email protected]>
10+
* @copyright (c) 2009 - present. Alexandre ELISÉ. All rights reserved.
11+
* @license GPL-2.0-and-later GNU General Public License v2.0 or later
12+
* @link https://alexandree.io
13+
*/
14+
15+
// Public url of the sample csv used in this example (CHANGE WITH YOUR OWN CSV URL IF YOU WISH)
16+
$csvUrl = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vTO8DC8xzEEyP754B0kBu1sa2P9Rn3I8OLmq_RJYHwOwTlY8OGvpjp1yvaE84Imj0HYQeJcNKT2TOFR/pub?output=csv';
17+
18+
// Your Joomla! 4.x website base url
19+
$baseUrl = '';
20+
// Your Joomla! 4.x Api Token (DO NOT STORE IT IN YOUR REPO USE A VAULT OR A PASSWORD MANAGER)
21+
$token = '';
22+
$basePath = 'api/index.php/v1';
23+
24+
25+
// Request timeout
26+
$timeout = 10;
27+
28+
// Add custom fields support (shout-out to Marc DECHÈVRE : CUSTOM KING)
29+
// The keys are the columns in the csv with the custom fields names (that's how Joomla! Web Services Api work as of today)
30+
// For the custom fields to work they need to be added in the csv and to exists in the Joomla! site.
31+
$customFieldKeys = []; //['with-coffee','with-dessert','extra-water-bottle'];
32+
33+
34+
// This time we need endpoint to be a function to make it more dynamic
35+
$endpoint = function (string $givenBaseUrl, string $givenBasePath, int $givenResourceId = 0): string {
36+
return $givenResourceId ? sprintf('%s/%s/%s/%d', $givenBaseUrl, $givenBasePath, 'content/articles', $givenResourceId)
37+
: sprintf('%s/%s/%s', $givenBaseUrl, $givenBasePath, 'content/articles');
38+
};
39+
40+
// PHP Generator to efficiently read the csv file
41+
$generator = function (string $url, array $keys = []): Generator {
42+
43+
if (empty($url))
44+
{
45+
yield new RuntimeException('Url MUST NOT be empty', 422);
46+
}
47+
48+
$defaultKeys = [
49+
'id',
50+
'title',
51+
'alias',
52+
'catid',
53+
'articletext',
54+
'introtext',
55+
'fulltext',
56+
'language',
57+
'metadesc',
58+
'metakey',
59+
'state',
60+
'featured',
61+
'publish_up',
62+
'publish_down',
63+
'featured_up',
64+
'featured_down',
65+
];
66+
67+
$mergedKeys = array_unique(array_merge($defaultKeys, $keys));
68+
69+
// Assess robustness of the code by trying random key order
70+
//shuffle($mergedKeys);
71+
72+
$resource = fopen($url, 'r');
73+
74+
if ($resource === false)
75+
{
76+
yield new RuntimeException('Could not read csv file', 500);
77+
}
78+
79+
try
80+
{
81+
//NON-BLOCKING I/O (Does not wait before processing next line.)
82+
stream_set_blocking($resource, false);
83+
84+
$firstLine = stream_get_line(
85+
$resource,
86+
0,
87+
"\r\n"
88+
);
89+
90+
if (empty($firstLine))
91+
{
92+
yield new RuntimeException('First line MUST NOT be empty. It is the header', 422);
93+
}
94+
95+
$csvHeaderKeys = str_getcsv($firstLine);
96+
$commonKeys = array_intersect($csvHeaderKeys, $mergedKeys);
97+
98+
do
99+
{
100+
$currentLine = stream_get_line(
101+
$resource,
102+
0,
103+
"\r\n"
104+
);
105+
106+
if (empty($currentLine))
107+
{
108+
yield new RuntimeException('Current line MUST NOT be empty', 422);
109+
}
110+
111+
$extractedContent = str_getcsv($currentLine);
112+
113+
// Allow using csv keys in any order
114+
$commonValues = array_intersect_key($extractedContent, $commonKeys);
115+
116+
$encodedContent = json_encode(array_combine($commonKeys, $commonValues));
117+
if ($encodedContent !== false)
118+
{
119+
yield $encodedContent;
120+
}
121+
122+
yield new RuntimeException('Current line seem to be invalid', 422);
123+
} while (!feof($resource));
124+
} finally
125+
{
126+
fclose($resource);
127+
}
128+
};
129+
130+
// Process data returned by the PHP Generator
131+
$process = function (string $givenHttpVerb, string $endpoint, string $dataString, array $headers, int $timeout, $transport) {
132+
curl_setopt_array($transport, [
133+
CURLOPT_URL => $endpoint,
134+
CURLOPT_RETURNTRANSFER => true,
135+
CURLOPT_ENCODING => 'utf-8',
136+
CURLOPT_MAXREDIRS => 10,
137+
CURLOPT_TIMEOUT => $timeout,
138+
CURLOPT_FOLLOWLOCATION => true,
139+
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
140+
CURLOPT_CUSTOMREQUEST => $givenHttpVerb,
141+
CURLOPT_POSTFIELDS => $dataString,
142+
CURLOPT_HTTPHEADER => $headers,
143+
]
144+
);
145+
146+
$response = curl_exec($transport);
147+
// Continue even on partial failure
148+
if (empty($response))
149+
{
150+
throw new RuntimeException('Empty output', 422);
151+
}
152+
153+
return $response;
154+
};
155+
// Read CSV in a PHP Generator using streams in non-blocking I/O mode
156+
$streamCsv = $generator($csvUrl, $customFieldKeys);
157+
$storage = [];
158+
foreach ($streamCsv as $dataKey => $dataString)
159+
{
160+
if (!is_string($dataString))
161+
{
162+
continue;
163+
}
164+
$curl = curl_init();
165+
try
166+
{
167+
// HTTP request headers
168+
$headers = [
169+
'Accept: application/vnd.api+json',
170+
'Content-Type: application/json',
171+
'Content-Length: ' . mb_strlen($dataString),
172+
sprintf('X-Joomla-Token: %s', trim($token)),
173+
];
174+
$decodedDataString = json_decode($dataString, true);
175+
// Article primary key. Usually 'id'
176+
$pk = (int) $decodedDataString['id'];
177+
$output = $process($pk ? 'PATCH' : 'POST', $endpoint($baseUrl, $basePath, $pk), $dataString, $headers, $timeout, $curl);
178+
179+
$decodedJsonOutput = json_decode($output, true);
180+
181+
// don't show errors, handle them gracefully
182+
if (isset($decodedJsonOutput['errors']))
183+
{
184+
// If article is potentially a duplicate (already exists with same alias)
185+
$storage[$dataKey] = ['mightExists' => $decodedJsonOutput['errors'][0]['code'] === 400, 'decodedDataString' => $decodedDataString];
186+
continue;
187+
}
188+
echo $output . PHP_EOL;
189+
}
190+
catch (Throwable $e)
191+
{
192+
echo $e->getMessage() . PHP_EOL;
193+
continue;
194+
} finally
195+
{
196+
curl_close($curl);
197+
}
198+
}
199+
// Handle errors and retries
200+
foreach ($storage as $item)
201+
{
202+
$curl = curl_init();
203+
try
204+
{
205+
if ($item['mightExists'])
206+
{
207+
$pk = (int) $item['decodedDataString']['id'];
208+
$item['decodedDataString']['alias'] = sprintf('%s-%s', $item['decodedDataString']['alias'], bin2hex(random_bytes(4)));
209+
// back to json string after changing alias
210+
$dataString = json_encode($item['decodedDataString']);
211+
212+
// HTTP request headers
213+
$headers = [
214+
'Accept: application/vnd.api+json',
215+
'Content-Type: application/json',
216+
'Content-Length: ' . mb_strlen($dataString),
217+
sprintf('X-Joomla-Token: %s', trim($token)),
218+
];
219+
220+
$output = $process($pk ? 'PATCH' : 'POST', $endpoint($baseUrl, $basePath, $pk), $dataString, $headers, $timeout, $curl);
221+
echo $output . PHP_EOL;
222+
}
223+
}
224+
catch (Throwable $e)
225+
{
226+
echo $e->getMessage() . PHP_EOL;
227+
continue;
228+
} finally
229+
{
230+
curl_close($curl);
231+
}
232+
}

0 commit comments

Comments
 (0)